There are two separate email systems in this project. They serve different purposes and are configured independently.
Two Email Systems
1. Magic Link Emails (Frontend / NextAuth)
NextAuth sends magic link sign-in emails using its built-in Nodemailer provider. This is configured in frontend/auth.ts:
Nodemailer({
server: {
host: process.env.EMAIL_SERVER_HOST,
port: Number(process.env.EMAIL_SERVER_PORT),
auth: {
user: process.env.EMAIL_SERVER_USER,
pass: process.env.EMAIL_SERVER_PASSWORD,
},
},
from: process.env.EMAIL_FROM,
}),
You don't call this directly. NextAuth handles it when a user signs in with their email address. The email contains a one-time link that authenticates the user.
Frontend env vars (in frontend/.env):
EMAIL_SERVER_HOSTEMAIL_SERVER_PORTEMAIL_SERVER_USEREMAIL_SERVER_PASSWORDEMAIL_FROM
2. App Emails (Backend / EmailService)
The backend has its own EmailService for sending transactional emails (welcome emails, notifications, password resets, whatever your app needs). This is in backend/src/email/email.service.ts.
It creates a pooled Nodemailer transporter on initialization and exposes three ways to send:
send()-- Send a single email immediately (bypasses the queue)queue()-- Queue a single email for batch processingqueueBulk()-- Queue many emails at once (for campaigns, bulk sends)
The transporter defaults to MailDev settings for local development (localhost:1025, no auth, no TLS). SMTP_FROM is required and checked at boot: the constructor throws if it's unset rather than silently sending mail under the wrong identity. The port is parsed by an explicit parseSmtpPort guard (B53): an empty/unset value defaults to 1025, but a non-numeric or out-of-range value throws instead of silently falling back (the old Number(x) || 1025 masked deploy-config bugs as "MailDev mode" in prod). If SMTP_USER is set, auth credentials are included automatically:
function parseSmtpPort(raw: string | undefined): number {
if (raw === undefined || raw === '') return 1025;
const port = Number.parseInt(raw, 10);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(
`SMTP_PORT must be an integer between 1 and 65535 (got ${JSON.stringify(raw)})`,
);
}
return port;
}
@Injectable()
export class EmailService {
private transporter: Transporter;
constructor(@Inject(KNEX_CONNECTION) private db: Knex) {
if (!process.env.SMTP_FROM) {
// Fail fast at boot rather than silently sending mail under the
// wrong identity (or having SMTP servers reject sender-less envelopes).
throw new Error('SMTP_FROM environment variable is required');
}
this.transporter = createTransport({
host: process.env.SMTP_HOST || 'localhost',
port: parseSmtpPort(process.env.SMTP_PORT),
secure: (process.env.SMTP_SECURE ?? 'false') === 'true',
...(process.env.SMTP_USER
? {
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
}
: {}),
pool: true,
maxConnections: 5,
maxMessages: 100,
});
}
async send(options: SendEmailOptions): Promise<void> {
await this.transporter.sendMail({
from: process.env.SMTP_FROM,
to: options.to,
subject: options.subject,
html: options.html,
});
}
async queue(options: QueueEmailOptions): Promise<EmailJobRow> {
const rows = await this.db<EmailJobRow>('email_jobs')
.insert({ ... })
.returning('*');
return rows[0];
}
}
Email Queue and Processing
Queued emails are stored in the email_jobs table as EmailJobRow rows. A cron-based EmailProcessor runs every 30 seconds and calls processBatch(), which:
- Finds pending or retryable-failed jobs
- Marks them as
PROCESSINGto prevent double-sends - Sends each email via the transporter
- Updates status to
SENTorFAILEDwith exponential backoff for retries
The processor uses a mutex (isProcessing flag) to prevent overlapping batch runs.
Admin Email Dashboard
AdminEmailController exposes admin-only REST routes (guarded by SessionGuard + AdminGuard):
| Route | Description |
|---|---|
GET /admin/email/stats |
Overall stats + per-campaign breakdown + 30-day daily volume + recent failures, returned as one bundle |
GET /admin/email/campaigns/:id |
Per-campaign detail: send/fail counts, success rate, recent attempts |
Backend env vars (in backend/.env):
SMTP_HOST-- defaults tolocalhost(MailDev)SMTP_PORT-- defaults to1025(MailDev)SMTP_SECURE-- defaults tofalseSMTP_USER-- optional; if set, auth is includedSMTP_PASS-- optional; paired with SMTP_USERSMTP_FROM-- required; the service throws at boot if it is unset
Sending an Email from a Service
- Import
EmailModulein your feature module:
import { EmailModule } from 'src/email/email.module';
@Module({
imports: [EmailModule],
// ...
})
export class YourModule {}
- Inject
EmailServicein your service:
import { EmailService } from 'src/email/email.service';
@Injectable()
export class YourService {
constructor(private readonly emailService: EmailService) {}
async doSomething() {
await this.emailService.send({
to: 'user@example.com',
subject: 'Something happened',
html: '<h1>Hello</h1><p>Your thing is ready.</p>',
});
}
}
That's it. The EmailModule exports the service, so any module that imports it gets access.
SMTP Providers
For local development, the project includes MailDev as a dev dependency. Start it with:
cd backend
yarn dev:email # SMTP on :1025, web UI on http://localhost:1080
The backend defaults (localhost:1025, no auth) are pre-configured for MailDev, so no env vars are needed for local email testing.
Other local options:
For production, any standards-compliant SMTP provider works with the config above (SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS).
Both the frontend and backend can use the same SMTP server or different ones. They're completely independent configurations.
Why Two Systems?
The frontend needs SMTP for NextAuth magic links, and that runs in the Next.js server process. The backend needs SMTP for app emails, and that runs in the NestJS process. Since they're separate processes, they each need their own SMTP configuration.
You could point both at the same SMTP server with the same credentials. Just set the matching env vars in both .env files.
Related
- [[Auth Flow]] - How magic link sign-in works
- [[Environment Variables]] - Full list of SMTP config vars
- [[Adding a Module]] - How to import EmailModule in new features