Task Breakdown: Mail Providers
Feature ID: mail-providers
Status: Done (Retrospective)
Last updated: 2026-05-08
Phase 1 — Provider Wiring
- T1.
MailerModule(apps/api/src/mail/mail.module.ts) wires@nestjs-modules/mailerwith the SMTP transport (config.mail.smtpHost/Port/Secure/IgnoreTLS/User/Password+tls.rejectUnauthorized: false),defaults.from = config.mail.from(), and the Handlebars adapter (inlineCssEnabled: true,strict: true) reading from${process.cwd()}/src/templates. - T2.
RESEND_CLIENTprovider built viauseFactory:config.mail.resend.apiKey()truthy →new Resend(apiKey), elseundefined. - T3.
FakerMailerService,MailerService, andMailServiceregistered as module providers.
Phase 2 — Provider Router
- T4.
MailerService.sendMail(data)switch onconfig.mail.provider()forsmtp/resend/ default branches. - T5. SMTP branch logs "Sending …" + "Email sent …" lines and
delegates to
SmtpMailerService.sendMail(data). - T6. Resend branch fallback when
RESEND_CLIENTis undefined: warn-log and route throughFakerMailerService. - T7. Resend branch resolves
fromviaconfig.mail.resend.emailFrom(), buildshtmlviareadHtmlTemplate(data), and forwards{to, from, subject, html}toResend.emails.send. - T8. Default branch routes to
FakerMailerServicewith a debug log. - T9.
getDestination(destination)normalisesstring | Address | (string|Address)[]tostring[](string pass-through,addressfield extraction, fallthrough totoString). - T10.
readHtmlTemplate(data)resolves${cwd}/src/templates/${template}.hbswith{encoding: 'utf8'}and compiles viaHandlebars.compile; falls back todata.html(Buffer or string), thendata.text(Buffer or string), then empty string. - T11. Constructor log line:
Mailer service initialized with provider: <provider>.
Phase 3 — Faker Fallback
- T12.
FakerMailerService.sendMail(data)debug-logsFakerMailerService:sendMail to=<to> subject="<subject>"and resolves immediately.
Phase 4 — Domain Event Listeners
- T13.
MailServicesubscribes via@OnEventto all seven user-lifecycle events: -UserCreatedEvent→signup-confirmation. -UserConfirmedEvent→welcome(defaultdashboardUrl = ${webAppUrl}/works/new). -UserPasswordChangedEvent→password-changed(formatDateTime(changedAt)). -UserForgotPasswordEvent→forgot-password(defaultexpiresIn = '1 hour'). -UserNewDeviceLoginEvent→new-device-login(formatDateTime(loginTime)+ device + browser + IP + verifyUrl/Token + secureAccountUrl). -UserAccountDeletionEvent→account-deletion(defaultexpiresIn = '24 hours'). -MemberInvitedEvent→member-invitation(formatRoleName(role)). - T14.
getBrandingContext()returns{appName, companyOwner, platformWebsite, currentYear}and is merged into every template context. - T15. Each handler wraps its body in
try/catch+logger.error('Failed to send …', err?.stack ?? err)so delivery failures DO NOT propagate back to the originating event emitter. - T16.
formatDateTime(date)usesIntl.DateTimeFormat('en-US', {year:numeric, month:long, day:numeric, hour:'2-digit', minute:'2-digit', timeZoneName:'short'}). - T17.
formatRoleName(role)capitalises the first character and lowercases the rest.
Phase 5 — Templates
- T18. Seven Handlebars templates committed at
apps/api/src/templates/:signup-confirmation.hbs.welcome.hbs.password-changed.hbs.forgot-password.hbs.new-device-login.hbs.account-deletion.hbs.member-invitation.hbs.
Phase 6 — Tests
- T19.
mailer.service.spec.ts(14 tests):- SMTP path (single recipient string, mixed string + Address
array log format,
to=unknownlog when omitted, object withoutaddresskey falls through totoString). - Resend path (no client → faker fallback w/ warn, html-string
body, Buffer html / Buffer text / empty body, Handlebars
template via
fs.readFilew/ correct path +utf8opts, undefinedresult.data?.id→id=unknown, documents the existing unguardedgetDestination(undefined)bug inresend.emails.send). - Faker fallback (MAILER_PROVIDER unset /
none). - Constructor log line via
Logger.prototypespy. - Mocks
fs/promises.readFileat module scope.
- SMTP path (single recipient string, mixed string + Address
array log format,
- T20.
faker-mailer.service.spec.ts(2 tests): debug log shape + undefined-recipient tolerance. - T21.
mail.service.spec.ts(apps/api/src/mail) — 28 listener-side unit tests covering all seven@OnEventhandlers (sendSignupConfirmation/sendForgotPassword/sendPasswordChanged/sendWelcomeEmail/sendNewDeviceAlert/sendAccountDeletionConfirmation/sendMemberInvitation),getBrandingContextmerge across all four branding fields (appName/companyOwner/platformWebsite/currentYear) withAPP_NAME/NEXT_PUBLIC_APP_NAMEfallback chain pinned, defaultexpiresIn = '1 hour'(forgot-password) /'24 hours'(account-deletion), defaultdashboardUrl = ${webAppUrl}/works/newwithWEB_URLenv override ANDhttp://localhost:3000last-resort fallback,formatDateTimeIntl.DateTimeFormat shape (year + long month assertions, TZ-agnostic),formatRoleNamecapitalise-first/lowercase-rest including mid-word casing, single-letter input, and empty-string no-crash, the per-handlertry/catch + logger.error('Failed to send …', err?.stack ?? err)swallowing policy (sendMail rejection MUST NOT propagate back to the event-bus), and the member-invitation log routing pinned against the INVITEE's email (NOT the inviter's). Closes the listener-side coverage gap called out inspec.md§9 / Constitution Gate VI.
Phase 7 — Bug Fixes
- T22. (FR-9 / OQ-1):
MailerService.sendMailResend branch no longer crashes whentois omitted. The call siteto: this.getDestination(data.to)is now short-circuited toto: data.to ? this.getDestination(data.to) : [], mirroring the log line'sdata.to ? this.getDestination(data.to).join(', ') : 'unknown'gate. The corresponding assertion inmailer.service.spec.tsflipped from "rejects with'address' inTypeError" to "forwardsto: []toresend.emails.send". The "to=unknown" log line still fires, mirroring the SMTP / faker branches' tolerance of a missingtofield.
Phase 8 — Docs
- T23. This Spec Kit folder (spec / plan / tasks).
- T24. Follow-up: developer-facing doc at
docs/devops/mail-providers.md— env-var reference, SMTP vs Resend trade-offs, thetls.rejectUnauthorizedgate, and how to add a new template.
Definition of Done
- All seven user-lifecycle events trigger the right template with the right context, branding-merged.
- Provider switching is purely env-var driven and degrades
gracefully (Resend without API key → Faker; unrecognised
MAILER_PROVIDER→ Faker). - Per-handler
try/catch+logger.errormeans a delivery failure does NOT propagate back to the originating event. - Both providers are unit-tested with branching coverage; the
Resend-
to-undefined edge case now forwardsto: []instead of crashing.
Follow-ups discovered
- T24 — operator-facing devops doc covering SMTP vs Resend
configuration,
tls.rejectUnauthorizedaudit guidance, and the new-template workflow. - OQ-2 — codegen template-key types from the
.hbsfiles so drift is caught at compile time rather than at Handlebars render time. - OQ-3 — production-mode
tls.rejectUnauthorized: truetoggle for SMTP; today the code accepts self-signed certs unconditionally. - Localisation — if user feedback requests email copy in the user's UI locale, this is the spec to amend (currently English only).