Aller au contenu principal

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.*).