Two email systems, one in each process, configured independently. They are separate because they run in separate processes, not because they do different things to the mail: point both at the same SMTP provider in production if you like.
1. Magic-link emails (frontend / NextAuth)
NextAuth sends sign-in emails through its built-in Nodemailer provider,
configured in frontend/auth.ts from EMAIL_SERVER_HOST,
EMAIL_SERVER_PORT, EMAIL_SERVER_USER, EMAIL_SERVER_PASSWORD and
EMAIL_FROM. You never call it directly; NextAuth runs it when a user
submits their email, and the send is synchronous because it blocks the
sign-in flow.
Email sign-in is optional as a whole: leave EMAIL_SERVER_HOST unset and
the signin form hides the option. A partial config fails boot in
production instead of silently hiding it. See
Auth Flow for the full sign-in sequence.
2. App emails (backend / EmailService)
backend/src/email/email.service.ts handles transactional and bulk mail. It
builds one pooled Nodemailer transporter (maxConnections: 5,
maxMessages: 100) at construction and exposes three entry points:
send()- send one email immediately, bypassing the queue.queue()- queue one email for batch processing.queueBulk()- queue many at once, for campaigns.
Two boot-time guards, both there to stop a production deploy from quietly misbehaving:
SMTP_FROMis required. The constructor throws if it is unset rather than sending mail under the wrong identity, or having SMTP servers reject a sender-less envelope.SMTP_PORTgoes throughparseSmtpPort(email/smtp-port.ts). Unset or empty defaults to1025, but a non-numeric or out-of-range value throws instead of falling back. A silent fallback would put a real deployment into MailDev mode and drop every outgoing email.
SMTP_USER, when set, adds auth credentials automatically.
Nodemailer runs at 9.x while
@types/nodemailerstays on 8, because DefinitelyTyped has published no 9.x. Bump the types when one appears.
Queue and processing
Queued emails become email_jobs rows. EmailProcessor runs on
@Cron(EVERY_30_SECONDS) and calls processBatch(50), which finds pending
or retryable-failed jobs, claims them as PROCESSING so no two workers send
the same one, sends each, then marks SENT or FAILED with exponential
backoff. An isProcessing flag prevents overlapping batch runs within a
process; the claim is what makes it safe across replicas (see
Architecture).
Admin dashboard
AdminEmailController, guarded by SessionGuard + AdminGuard:
| Route | Description |
|---|---|
GET /admin/email/stats |
Overall stats + per-campaign breakdown + 30-day daily volume + recent failures, as one bundle |
GET /admin/email/campaigns/:id |
Per-campaign detail: send/fail counts, success rate, recent attempts |
Backend SMTP variables are SMTP_HOST, SMTP_PORT, SMTP_SECURE,
SMTP_USER, SMTP_PASS and SMTP_FROM; defaults and requirements are in
Environment Variables.
Sending an email from a service
Import EmailModule in your feature module, then inject EmailService:
import { EmailModule } from 'src/email/email.module';
@Module({ imports: [EmailModule] })
export class YourModule {}
@Injectable()
export class YourService {
constructor(private readonly emailService: EmailService) {}
async doSomething() {
await this.emailService.send({
to: '[email protected]',
subject: 'Something happened',
html: '<h1>Hello</h1><p>Your thing is ready.</p>',
});
}
}
EmailModule exports EmailService and EmailStatsService.
SMTP providers
For local development the template ships MailDev:
cd backend
yarn dev:email # SMTP on :1025, web UI on http://localhost:1080
The backend defaults (localhost:1025, no auth) already point at it, so local email testing needs no env vars. Mailtrap and Mailhog work the same way.
For production, any standards-compliant SMTP provider works. Verify your sending domain (SPF and DKIM) before sending to real addresses.
Related
- Auth Flow - how magic-link sign-in works
- Environment Variables - full SMTP config reference
- Architecture - worker claim and recovery mechanics