Skip to main content

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():

ProviderValueDescription
SMTPsmtpStandard SMTP transport via @nestjs-modules/mailer
ResendresendResend API client, falls back to faker if API key is missing
Faker(default)Logs email data to console; used in development

SMTP Configuration

Config MethodEnvironment VariableDescription
config.mail.smtpHost()MAIL_SMTP_HOSTSMTP server hostname
config.mail.smtpPort()MAIL_SMTP_PORTSMTP server port
config.mail.smtpSecure()MAIL_SMTP_SECUREUse TLS
config.mail.smtpIgnoreTLS()MAIL_SMTP_IGNORE_TLSIgnore TLS errors
config.mail.smtpUser()MAIL_SMTP_USERSMTP username
config.mail.smtpPassword()MAIL_SMTP_PASSWORDSMTP password
config.mail.from()MAIL_FROMDefault "From" address
config.mail.provider()MAIL_PROVIDERsmtp, resend, or omit

Resend Configuration

Config MethodEnvironment VariableDescription
config.mail.resend.apiKey()RESEND_APIKEYResend API key
config.mail.resend.emailFrom()RESEND_FROMResend 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

TemplateEventAdditional Context
signup-confirmationUserCreatedEventfirstName, confirmationUrl, confirmationToken
forgot-passwordUserForgotPasswordEventfirstName, resetUrl, resetToken, expiresIn
password-changedUserPasswordChangedEventfirstName, changedAt, ipAddress, location, device, browser, secureAccountUrl
welcomeUserConfirmedEventfirstName, dashboardUrl
new-device-loginUserNewDeviceLoginEventfirstName, loginTime, device, browser, location, ipAddress, verifyUrl, verifyToken, secureAccountUrl
account-deletionUserAccountDeletionEventfirstName, deleteUrl, deleteToken, keepAccountUrl, expiresIn
member-invitationMemberInvitedEventinviteeName, 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 to apps/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 /app with the app in /app/dist; a cwd-relative src/templates does not exist there. Both consumers now go through TEMPLATES_DIR in apps/api/src/mail/templates.ts, which resolves <dirname>/../templates and is therefore correct for src/, 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

  1. Create a .hbs file in apps/api/src/templates/:

    <h1>Hello {{firstName}},</h1>
    <p>Your notification from {{appName}}.</p>
    <p>&copy; {{currentYear}} {{companyOwner}}</p>

    Then register its slug in KNOWN_EMAIL_TEMPLATES (apps/api/src/mail/templates.ts). templates.spec.ts asserts 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.

  2. Define a new event class in apps/api/src/events/.

  3. Add an @OnEvent() handler in MailService:

    @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 },
    });
    }
  4. 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 {}