Mail System
The mail system handles all transactional email delivery for the platform, supporting SMTP, Resend, and a development faker provider. Emails are rendered using Handlebars templates and triggered by domain events via NestJS @OnEvent decorators.
Architecture
apps/api/src/mail/
mail.service.ts # Event listeners and email orchestration
mail.module.ts # Module config (SMTP, Resend, templates)
templates.ts # Template dir resolution + packaging checks
types.ts # SendMailOptions interface
providers/
mailer.service.ts # Multi-provider dispatch (SMTP, Resend, faker)
faker-mailer.service.ts # Development-only logger provider
apps/api/src/templates/
signup-confirmation.hbs # Account signup verification
forgot-password.hbs # Password reset link
password-changed.hbs # Password change confirmation
welcome.hbs # Post-confirmation welcome email
new-device-login.hbs # New device login alert
account-deletion.hbs # Account deletion confirmation
member-invitation.hbs # Work collaboration invitation
Mail Providers
The MailerService selects a provider based on config.mail.provider():
| Provider | Value | Description |
|---|---|---|
| SMTP | smtp | Standard SMTP transport via @nestjs-modules/mailer |
| Resend | resend | Resend API client, falls back to faker if API key is missing |
| Faker | (default) | Logs email data to console; used in development |
SMTP Configuration
| Config Method | Environment Variable | Description |
|---|---|---|
config.mail.smtpHost() | MAIL_SMTP_HOST | SMTP server hostname |
config.mail.smtpPort() | MAIL_SMTP_PORT | SMTP server port |
config.mail.smtpSecure() | MAIL_SMTP_SECURE | Use TLS |
config.mail.smtpIgnoreTLS() | MAIL_SMTP_IGNORE_TLS | Ignore TLS errors |
config.mail.smtpUser() | MAIL_SMTP_USER | SMTP username |
config.mail.smtpPassword() | MAIL_SMTP_PASSWORD | SMTP password |
config.mail.from() | MAIL_FROM | Default "From" address |
config.mail.provider() | MAIL_PROVIDER | smtp, resend, or omit |
Resend Configuration
| Config Method | Environment Variable | Description |
|---|---|---|
config.mail.resend.apiKey() | RESEND_APIKEY | Resend API key |
config.mail.resend.emailFrom() | RESEND_FROM | Resend sender address |
Email Templates
Templates use Handlebars (.hbs) with inline CSS enabled. All templates receive common branding context:
{
appName: string, // Application name from config
companyOwner: string, // Company owner name
platformWebsite: string, // Platform URL
currentYear: number // Current year for footer
}
Available Templates
| Template | Event | Additional Context |
|---|---|---|
signup-confirmation | UserCreatedEvent | firstName, confirmationUrl, confirmationToken |
forgot-password | UserForgotPasswordEvent | firstName, resetUrl, resetToken, expiresIn |
password-changed | UserPasswordChangedEvent | firstName, changedAt, ipAddress, location, device, browser, secureAccountUrl |
welcome | UserConfirmedEvent | firstName, dashboardUrl |
new-device-login | UserNewDeviceLoginEvent | firstName, loginTime, device, browser, location, ipAddress, verifyUrl, verifyToken, secureAccountUrl |
account-deletion | UserAccountDeletionEvent | firstName, deleteUrl, deleteToken, keepAccountUrl, expiresIn |
member-invitation | MemberInvitedEvent | inviteeName, inviterName, workName, roleName, workUrl |
Event-Driven Delivery
The MailService subscribes to domain events using @OnEvent():
@OnEvent(UserCreatedEvent.EVENT_NAME)
async sendSignupConfirmation(data: UserCreatedEvent): Promise<void> {
await this.mailerService.sendMail({
to: data.user.email,
subject: `Confirm your ${appName} account`,
template: 'signup-confirmation',
context: { ...brandingContext, firstName: data.user.username, ... },
});
}
All email sends are wrapped in try/catch blocks and errors are logged without throwing, ensuring email failures do not break the calling flow.
Template Packaging
The .hbs templates are not TypeScript, so they only reach the runtime
because apps/api/nest-cli.json lists them under compilerOptions.assets.
Two rules matter, and both were violated at once in the defect that made
every templated email fail in every environment:
- Asset globs are relative to
sourceRoot."templates/**/*"is correct;"src/templates/**/*"silently resolves toapps/api/src/src/templates/**/*, matches nothing and copies nothing — with no build error. - The runtime path must be relative to the compiled module, not to
process.cwd(). The container starts the process in/appwith the app in/app/dist; a cwd-relativesrc/templatesdoes not exist there. Both consumers now go throughTEMPLATES_DIRinapps/api/src/mail/templates.ts, which resolves<dirname>/../templatesand is therefore correct forsrc/,dist/and the image alike.
Three independent guards keep this from regressing: a Jest suite that renders
every template with the working directory moved elsewhere, a boot-time check
that logs EMAIL TEMPLATES MISSING and reports email: degraded on
/api/health/ready, and a RUN step in .deploy/docker/api/Dockerfile that
fails the image build unless every source template reached dist/templates.
Adding a New Template
-
Create a
.hbsfile inapps/api/src/templates/:Then register its slug in
KNOWN_EMAIL_TEMPLATES(apps/api/src/mail/templates.ts).templates.spec.tsasserts the list and the directory match in both directions, so an unregistered template fails the suite — that registry is what the boot-time check and the API Dockerfile's packaging gate compare against. -
Define a new event class in
apps/api/src/events/. -
Add an
@OnEvent()handler inMailService:@OnEvent(MyNewEvent.EVENT_NAME)async sendMyNewEmail(data: MyNewEvent): Promise<void> {await this.mailerService.sendMail({to: data.user.email,subject: 'My Subject',template: 'my-new-template',context: { ...this.getBrandingContext(), ...data },});} -
Emit the event from your service:
this.eventEmitter.emit(MyNewEvent.EVENT_NAME, new MyNewEvent({ ... }));
SendMailOptions
The types.ts file defines the SendMailOptions interface:
interface SendMailOptions {
to?: string | Address | Array<string | Address>;
cc?: string | Address | Array<string | Address>;
bcc?: string | Address | Array<string | Address>;
from?: string | Address;
subject?: string;
text?: string | Buffer;
html?: string | Buffer;
template?: string; // Handlebars template name (without .hbs)
context?: Record<string, any>; // Template variables
}
Module Registration
@Module({
imports: [
MailerModule.forRootAsync({
useFactory: () => ({
transport: { host, port, secure, auth: { user, pass } },
defaults: { from: config.mail.from() },
template: {
// Resolved from the module's own location (see
// `apps/api/src/mail/templates.ts`), NOT from
// `process.cwd()`. In the container the process starts in
// `/app` and the compiled app lives in `/app/dist`, so a
// cwd-relative `src/templates` does not exist and every
// templated email fails with ENOENT.
dir: TEMPLATES_DIR,
adapter: new HandlebarsAdapter(undefined, { inlineCssEnabled: true })
}
})
})
],
providers: [
{ provide: 'RESEND_CLIENT', useFactory: () => new Resend(apiKey) },
MailService,
MailerService,
FakerMailerService
]
})
export class MailModule {}