Skip to main content

Implementation Plan: Mail Providers

Feature ID: mail-providers Spec: ./spec.md Status: Done (Retrospective) Last updated: 2026-05-08


1. Architecture

2. Tech Choices

ConcernChoiceRationale
Event ingestionNestJS @OnEvent listeners (@nestjs/event-emitter)Decouples mail delivery from domain code; event handler runs off the request hot path
Provider abstractionMailerService switch keyed on config.mail.provider()Capability-driven (Constitution Principle II); env-var driven swap, zero code change
SMTP transport@nestjs-modules/mailer + Handlebars adapterBattle-tested wrapper around Nodemailer; inlineCssEnabled: true + strict: true
Resend transportresend SDK (Resend.emails.send)First-class TypeScript SDK; RESEND_CLIENT is a Nest provider so we can swap it in tests
Local-dev fallbackFakerMailerServiceLets local developers run the API without configuring a real provider; logs payloads at debug level
Template engineHandlebarsRequired by @nestjs-modules/mailer's adapter; works for the Resend path via direct Handlebars.compile
Templates locationapps/api/src/templates/*.hbsLives next to the API code; path.join(process.cwd(), 'src/templates', '<slug>.hbs')
Branding contextconfig.branding.{appName, companyOwner, platformWebsite} + currentYearSingle source of truth; merged into every template render
Error policytry/catch + logger.error per handlerMail delivery failures MUST NOT propagate back to the originating event emitter (Constitution Principle VI tests pin this)

3. Data Model

No persistent storage. The feature is stateless — each mail delivery is event-driven and synchronous-from-the-listener. The RESEND_CLIENT NestJS provider is a singleton per process.

// apps/api/src/mail/types.ts
export interface Address {
name: string;
address: string;
}

export interface SendMailOptions {
to?: string | Address | Array<string | Address>;
cc?: string | Address | Array<string | Address>;
bcc?: string | Address | Array<string | Address>;
replyTo?: string | Address | Array<string | Address>;
inReplyTo?: string | Address;
from?: string | Address;
subject?: string;
text?: string | Buffer;
html?: string | Buffer;
sender?: string | Address;
raw?: string | Buffer;
references?: string | string[];
encoding?: string;
date?: Date | string;
context?: { [name: string]: any };
transporterName?: string;
template?: string;
}

4. API Surface

The mail-providers feature is internal — it exposes no HTTP endpoints. Public callers interact via emitting one of the seven domain events. Internal services interact via MailerService.sendMail(options: SendMailOptions): Promise<void>.

Trigger eventTemplate slugNotable defaults
UserCreatedEventsignup-confirmation
UserConfirmedEventwelcomedashboardUrl defaults to ${webAppUrl}/works/new
UserPasswordChangedEventpassword-changedformatDateTime(changedAt)
UserForgotPasswordEventforgot-passwordexpiresIn defaults to '1 hour'
UserNewDeviceLoginEventnew-device-loginformatDateTime(loginTime); passes device + browser + IP
UserAccountDeletionEventaccount-deletionexpiresIn defaults to '24 hours'
MemberInvitedEventmember-invitationformatRoleName(role) (capitalise first char, lowercase rest)

5. Plugin / Web / CLI

  • Plugin: no plugin surface today. Adding a new transactional email is an in-repo change: drop a <slug>.hbs file, add a domain event class, and wire a new @OnEvent handler in MailService.
  • Web: indirect — actions in the web app emit the underlying events (forgot password, invite member, etc.). The web app never calls MailerService directly.
  • CLI: indirect — CLI commands that mutate user state emit the same events through the API and inherit the same delivery paths.

6. Background Jobs

There are no dedicated background jobs. Each delivery runs inside the originating @OnEvent handler. The provider's own upstream queue (Nodemailer pool, Resend's API queue) absorbs bursty volume.

7. Security & Permissions

  • Provider credentials: SMTP user/password and Resend API key live exclusively in env vars (SMTP_USER, SMTP_PASSWORD, RESEND_APIKEY). They are never logged.
  • TLS: the SMTP transport sets tls.rejectUnauthorized: false to support self-signed certs in dev. Production deployments MUST audit this gate per FR-? in the spec (OQ-3).
  • Email body content: templates render only safe placeholders — usernames, work names, URLs, device strings. They never embed passwords, hashes, OAuth tokens, or API keys. Reviewers MUST flag any template that does.
  • Logging: recipient email addresses appear in INFO-level logs to support delivery diagnostics; body content is never logged.
  • Token expiry: confirmation, reset, and account-deletion links carry one-shot tokens whose expiries are computed by the caller (AuthService for password reset / signup confirm; account module for deletion). Mail delivery does not cache tokens.

8. Observability

  • Construction log: Mailer service initialized with provider: <provider> (info, once at boot).
  • Per-call SMTP:
    • Sending email via SMTP to=<recipient> subject="<subject>"
    • Email sent via SMTP to=<recipient>
  • Per-call Resend: - Sending email via Resend to=<recipient> from="<from>" subject="<subject>" - Email sent via Resend to=<recipient> id=<resendId | "unknown">
  • Resend fallback: - Resend client not initialized (missing RESEND_APIKEY?), falling back to faker for to=<recipient> (warn).
  • Faker default:
    • No mail provider configured, using faker for to=<recipient> (debug).
  • Faker handler:
    • FakerMailerService:sendMail to=<to> subject="<subject>" (debug).
  • MailService delivery failure:
    • Failed to send <kind> to <email> + stack (error).

9. Risks & Mitigations

RiskMitigation
MAILER_PROVIDER=resend with missing RESEND_APIKEY crashes APIRESEND_CLIENT factory returns undefined; runtime fallback in MailerService.sendMail warns and routes through Faker
Garbage MAILER_PROVIDER value crashes APIdefault switch arm routes through Faker
Mail delivery throws and breaks signup / password reset flowPer-handler try/catch + logger.error — listener swallows the rejection
Template referenced placeholder missing from contextHandlebarsAdapter is built with {strict: true} so missing keys throw at render time — caught by per-handler try/catch (OQ-2 follow-up)
Resend.emails.send called with to=undefinedCurrently throws TypeError: Cannot use 'in' operator … — pinned by current tests; FR-9 / OQ-1 follow-up tracks the proper guard
SMTP auth or network failureCaught by MailService per-handler try/catch and logged; upstream provider handles its own retry/backoff
Self-signed cert in productiontls.rejectUnauthorized: false is intentional for dev — operators MUST audit per OQ-3

10. Constitution Reconciliation

See spec.md §9.

11. References

  • Spec: ./spec.md
  • Service: apps/api/src/mail/mail.service.ts
  • Provider router: apps/api/src/mail/providers/mailer.service.ts
  • Faker fallback: apps/api/src/mail/providers/faker-mailer.service.ts
  • Module: apps/api/src/mail/mail.module.ts
  • Types: apps/api/src/mail/types.ts
  • Templates: apps/api/src/templates/*.hbs
  • Tests:
    • apps/api/src/mail/providers/mailer.service.spec.ts (14)
    • apps/api/src/mail/providers/faker-mailer.service.spec.ts (2)
  • Config: apps/api/src/config/constants.ts (config.mail.*, config.branding.*).