Feature Specification: Mail Providers
Feature ID: mail-providers
Status: Retrospective
Created: 2026-05-08
Last updated: 2026-05-08
Owner: Ever Works Team
1. Overview
The mail-providers feature is the platform's transactional-email
delivery surface. It abstracts three concrete delivery backends
(SMTP via Nodemailer, Resend, and a no-op Faker for local
development) behind a single MailerService.sendMail(...) call,
and wires seven domain events
(UserCreatedEvent, UserConfirmedEvent, UserPasswordChangedEvent,
UserForgotPasswordEvent, UserNewDeviceLoginEvent,
UserAccountDeletionEvent, MemberInvitedEvent) to seven
Handlebars-templated emails (signup-confirmation, welcome,
password-changed, forgot-password, new-device-login,
account-deletion, member-invitation). Provider selection is
driven entirely by the MAILER_PROVIDER environment variable; a
missing/invalid value silently degrades to the Faker provider so the
API never crashes on a misconfigured mail backend, and template
context is consistently enriched with the platform's branding
(app name, company owner, platform website, current year).
2. User Scenarios
2.1 Primary scenarios
- Given a new user signs up, when the
UserCreatedEventfires, then theMailServicebuilds asignup-confirmationHandlebars context (firstName + confirmation URL + token + branding) and hands it to the configured provider. - Given I forget my password, when the
UserForgotPasswordEventfires with aresetUrlandresetToken, then I receive aforgot-passwordemail whose copy interpolates my username, reset URL, reset token, and a defaultexpiresInof'1 hour'when the event omits it. - Given I confirm my account, when the
UserConfirmedEventfires, then I receive awelcomeemail with adashboardUrlthat defaults to${config.webAppUrl()}/works/newwhen the event omits it. - Given I sign in from a new device, when the
UserNewDeviceLoginEventfires, then I receive anew-device-loginalert that includes the formatted login time (Intl.DateTimeFormat('en-US', {year:numeric, month:long, day:numeric, hour:'2-digit', minute:'2-digit', timeZoneName:'short'})),device,browser,location,ipAddress, theverifyUrl/verifyToken, and asecureAccountUrl. - Given I am invited to a work, when the
MemberInvitedEventfires, then I receive amember-invitationemail with the inviter and invitee names, the work name, the formatted role label (role.charAt(0).toUpperCase() + role.slice(1).toLowerCase()), and aworkUrl. - Given I request account deletion, when the
UserAccountDeletionEventfires, then I receive anaccount-deletionemail withdeleteUrl/deleteToken,keepAccountUrl, and a defaultexpiresInof'24 hours'when the event omits it. - Given an admin sets
MAILER_PROVIDER=smtp, whenMailerService.sendMailruns, then the SMTP transport from@nestjs-modules/mailerresolves the matching*.hbstemplate viaHandlebarsAdapter(withinlineCssEnabled: trueandstrict: true) and delivers the message — and the service emits two info-level log lines surrounding the call (Sending … to=<recipient> subject=…thenEmail sent via SMTP to=<recipient>). - Given an admin sets
MAILER_PROVIDER=resendandRESEND_APIKEY, whenMailerService.sendMailruns, then the Resend client receives{to, from, subject, html}(wherehtmlis the rendered Handlebars template) and the service logsEmail sent via Resend to=<recipient> id=<resendId>(orid=unknownwhen the response has nodata.id).
2.2 Edge cases & failures
- Given a domain event handler in
MailServicerejects, when the originating event is consumed, then the listener swallows the error viatry/catch+logger.error('Failed to send …', err?.stack ?? err)and the originating event MUST NOT see the rejection — a delivery failure does not block sign-up, password reset, etc. - Given
MAILER_PROVIDER=resendbutRESEND_APIKEYis unset, when the module factory builds theRESEND_CLIENTprovider, then the client isundefinedand the runtime falls through to the Faker delivery with a singleResend client not initialized (missing RESEND_APIKEY?), falling back to faker for to=<recipient>warn-level log. - Given
MAILER_PROVIDERis unset /'none'/'faker'/ any unrecognised string, whensendMailruns, then thedefaultswitch branch routes the call toFakerMailerService, which logs atdebuglevel and resolves immediately without touching any network. - Given the caller omits
data.to, whensendMailbuilds its log line, thenrecipientis the literal string'unknown'(the Resend branch still callsthis.getDestination(data.to)which currently throws onundefined— see follow-up FR-9 / open question OQ-1). - Given the caller passes
data.toasAddress[]mixing strings and{name, address}objects, whengetDestination(...)runs, then each item is normalised tostring(raw string passes through,Addressbecomes its.addressfield, anything else falls through to itstoString). - Given
data.template = 'forgot-password', whenreadHtmlTemplate(...)runs, then it reads${process.cwd()}/src/templates/forgot-password.hbswith{encoding: 'utf8'}, compiles viaHandlebars.compile, and evaluates withdata.context ?? {}. - Given no
templateis set butdata.htmlis aBuffer, whenreadHtmlTemplate(...)runs, then the buffer is converted via.toString(). Same fordata.text. - Given no
template,html, ortextis set, whenreadHtmlTemplate(...)runs, then it returns the empty string''(Resend will reject this — guard responsibility lies with the caller). - Given SMTP delivery throws (auth failure, connection refused,
4xx/5xx response), when the error propagates out of
MailerService.sendMail, then the callingMailServicehandler catches it, logs the error (with stack), and the originating event's flow continues normally.
3. Functional Requirements
- FR-1
MailServiceMUST subscribe to all seven user-lifecycle events (UserCreatedEvent,UserConfirmedEvent,UserPasswordChangedEvent,UserForgotPasswordEvent,UserNewDeviceLoginEvent,UserAccountDeletionEvent,MemberInvitedEvent) via@OnEventdecorators and translate each into aMailerService.sendMailcall with the matchingtemplateslug and a fully populatedcontextobject. - FR-2 Every template context MUST be merged with
getBrandingContext()returning{appName, companyOwner, platformWebsite, currentYear}so all emails can render the same branding placeholders. - FR-3
MailServiceMUST wrap each handler body intry/catchand log delivery failures vialogger.error('Failed to send …', err?.stack ?? err)so a mail delivery failure NEVER propagates back to the originating event emitter. - FR-4
MailService.sendWelcomeEmailMUST defaultdashboardUrlto${config.webAppUrl()}/works/newwhendata.dashboardUrlis omitted. - FR-5
MailService.sendForgotPasswordMUST defaultexpiresInto'1 hour'when the event omits it; analogouslyMailService.sendAccountDeletionConfirmationMUST defaultexpiresInto'24 hours'. - FR-6
MailService.formatDateTime(date)MUST format dates viaIntl.DateTimeFormat('en-US', {year:numeric, month:long, day:numeric, hour:'2-digit', minute:'2-digit', timeZoneName:'short'}).MailService.formatRoleName(role)MUST capitalise the first character and lowercase the rest. - FR-7
MailerService.sendMail(data)MUST switch onconfig.mail.provider()returning one of'smtp','resend', or any other value (which maps to the Faker default branch). Provider resolution MUST be per-call — a deployment can change the env var and the next call respects it. - FR-8 The Resend branch MUST resolve
fromviaconfig.mail.resend.emailFrom(), buildhtmlviareadHtmlTemplate(data), and pass{to, from, subject, html}toresend.emails.send. Thetois normalised throughgetDestination(data.to)returning astring[]. - FR-9
getDestination(destination)MUST acceptstring | Address | (string|Address)[], MUST wrap a single value in an array, and MUST map each entry through: -typeof to === 'string'→ return as-is. -'address' in to→ returnto.address. - else → fall through toto(which Node will coerce to itstoString). - FR-10 When
MAILER_PROVIDER=resendbutRESEND_APIKEYis unset, the module'sRESEND_CLIENTfactory MUST returnundefinedandMailerService.sendMailMUST log a warn-level fallback line and route the call throughFakerMailerServiceinstead of throwing. - FR-11
readHtmlTemplate(data)MUST resolve templates from${process.cwd()}/src/templates/${template}.hbswith{encoding: 'utf8'}and compile viaHandlebars.compile(content). Whentemplateis unset,Bufferandstringhtml/textfields MUST be normalised via.toString(). - FR-12
MailerServiceMUST emit a single info-level log line on construction (Mailer service initialized with provider: <provider>). - FR-13 Per-call logs MUST include the recipient (or
'unknown'whendata.tois omitted) and the subject. SMTP and Resend MUST log both a "Sending …" and an "Email sent …" line surrounding the outbound call. - FR-14 The
MailerModuleMUST configure@nestjs-modules/mailerwith the SMTP transport, theHandlebarsAdapter(withinlineCssEnabled: trueandstrict: true), the templates directory at${process.cwd()}/src/templates, and the defaultfromfromconfig.mail.from(). - FR-15
FakerMailerService.sendMailMUST resolve immediately without touching the network and MUST log atdebuglevel (FakerMailerService:sendMail to=<to> subject="<subject>").
4. Non-Functional Requirements
- Performance:
- The mail handler executes inside the
@OnEventlistener, off the request critical path. SMTP/Resend latency does not block the originating HTTP response. - Template files are read fresh on every send (no in-process
cache); typical handler latency is dominated by the upstream
provider's HTTP round-trip, not by
readFile.
- The mail handler executes inside the
- Reliability:
- Provider misconfiguration NEVER crashes the API — Resend
without
RESEND_APIKEYfalls back to Faker;MAILER_PROVIDER=garbagefalls back to Faker. - Each
MailServicehandler is independentlytry/catch-ed so one failed delivery type cannot wedge another. - Bounce / spam / quota handling is the responsibility of the configured upstream provider; the platform does not retry delivery itself.
- Provider misconfiguration NEVER crashes the API — Resend
without
- Security & privacy:
- Email subjects and bodies MUST NOT include passwords, password hashes, OAuth tokens, or other secrets; reset and confirmation links carry one-shot tokens with explicit expiries.
- SMTP credentials live in env vars (
SMTP_USER,SMTP_PASSWORD) and are never logged. RESEND_APIKEYlives in env vars and is never logged.- Recipient email addresses appear in INFO-level provider logs so operators can diagnose delivery; no body content is logged.
- The SMTP transport sets
tls.rejectUnauthorized: falseto allow self-signed certs in dev environments — operators deploying to prod MUST front the SMTP host with a properly signed certificate or revisit this gate.
- Observability: - Construction log:
Mailer service initialized with provider: <provider>. - Per-call info logs around SMTP and Resend sends as described in FR-13. - Faker-fallback warn log when Resend client is not initialised. - Per-handler error log on delivery failure (Failed to send <kind> to <email>+ stack). - Compatibility:
- Provider switching is a config change, not a code change (Constitution Principle II — capability-driven).
- Adding a new template means: drop a
<slug>.hbsfile underapps/api/src/templates/, add the matching event class, and add the matching@OnEventhandler — no schema or provider changes required.
5. Key Entities & Domain Concepts
| Entity / concept | Description |
|---|---|
MailService | apps/api/src/mail/mail.service.ts. Subscribes to seven user-lifecycle events and translates each into a MailerService.sendMail call with the matching template slug and a branding-enriched context. |
MailerService | apps/api/src/mail/providers/mailer.service.ts. The provider-routing facade. Switches on config.mail.provider() between SMTP (@nestjs-modules/mailer), Resend (resend SDK), or the Faker default branch. |
FakerMailerService | apps/api/src/mail/providers/faker-mailer.service.ts. No-op debug-logging delivery used when no real provider is configured or when Resend is selected without an API key. |
MailerModule | apps/api/src/mail/mail.module.ts. Wires MailerModule.forRootAsync with the SMTP transport (config.mail.smtpHost/Port/Secure/IgnoreTLS/User/Password, tls.rejectUnauthorized: false, defaults.from = config.mail.from(), Handlebars adapter with inlineCssEnabled: true + strict: true) and the RESEND_CLIENT factory provider. |
SendMailOptions | apps/api/src/mail/types.ts. {to?, cc?, bcc?, replyTo?, inReplyTo?, from?, subject?, text?, html?, sender?, raw?, references?, encoding?, date?, context?, transporterName?, template?}. Both single-recipient and array-of-recipient shapes are supported. |
Address | {name: string; address: string}. Used inside to/cc/bcc/etc. |
MAILER_PROVIDER | Env-var switch: smtp / resend / anything-else (faker). |
| Handlebars templates | Seven *.hbs files under apps/api/src/templates/: signup-confirmation, welcome, password-changed, forgot-password, new-device-login, account-deletion, member-invitation. Compiled at request time via Handlebars.compile. |
| Branding context | {appName, companyOwner, platformWebsite, currentYear} from config.branding.* and new Date().getFullYear(). |
RESEND_CLIENT token | NestJS provider injected via @Optional() @Inject('RESEND_CLIENT'). Built via useFactory that returns new Resend(apiKey) if config.mail.resend.apiKey() is truthy, else undefined. |
6. Out of Scope
- Provider-level retry / dead-letter queues. Bounces, soft failures, quota exceedances, and rate-limiting are owned by the configured upstream provider (SMTP server or Resend). The platform does not implement its own retry queue.
- Template versioning / A/B copy. Templates are committed to the repo; changing one requires a code deploy.
- Per-tenant from addresses. All outbound mail uses the global
config.mail.from()value — no per-work or per-org override. - Localisation / i18n of email copy. Templates render in English regardless of the user's UI locale.
- Inbound mail processing. The platform does not parse incoming mail (no IMAP/POP integration, no Resend webhook handler).
- Per-recipient personalisation beyond
{firstName, inviteeName, dashboardUrl, …}. Anything richer lives in the Handlebars template's own context. - Bulk / marketing email. This surface is strictly for transactional, event-triggered messages.
- In-app notifications. Covered by the
notificationsfeature, which is user-facing and runs independently.
7. Acceptance Criteria
- All seven
@OnEventhandlers inMailServicedeliver the right template with the right context, and each handler is independentlytry/catch-wrapped. - Branding context (appName / companyOwner / platformWebsite / currentYear) is merged into every template render.
-
MailerService.sendMailswitches on the env-drivenMAILER_PROVIDERper call and falls back to Faker on unrecognised values or missing Resend API key. - SMTP and Resend paths log a "Sending …" line before the provider call and an "Email sent …" line after.
-
getDestinationacceptsstring | Address | array-of-mixedand returns astring[]. -
readHtmlTemplatereads from${cwd}/src/templates/{slug}.hbswith utf-8, compiles via Handlebars, falls back todata.html/data.text(Buffer or string), else returns''. - Tests cover both providers + the constructor log + the
Handlebars template path — see
mailer.service.spec.ts(14 tests) andfaker-mailer.service.spec.ts(2 tests), #492.
8. Open Questions
- OQ-1 (FR-9 follow-up — Resend
to-undefined crash). As of 2026-05-08,MailerService.sendMailbuilds itsrecipientlog line viadata.to ? this.getDestination(data.to).join(', ') : 'unknown', but the Resend branch unconditionally callsthis.getDestination(data.to)— so when a caller omitsto, the log line saysto=unknownand thengetDestination(undefined)throwsTypeError: Cannot use 'in' operator to search for 'address' in undefined. The SMTP and Faker paths handle a missingtocleanly. Tracked as a follow-up intasks.md; not a regression — current behaviour is pinned by a test inmailer.service.spec.ts. A fix should short-circuit Resend the same way (e.g.to: data.to ? this.getDestination(data.to) : []), and the test must be updated to assert the new behaviour. - OQ-2 (template-context drift). Each
@OnEventhandler builds its context manually. There is no compile-time check that the*.hbstemplate's referenced placeholders match the keys the handler provides — drift is caught only at runtime when Handlebarsstrict: truerejects the missing key. A future improvement could codegen template-key types from the.hbsfiles. - OQ-3 (
tls.rejectUnauthorized: false). The SMTP transport accepts self-signed certs by default. This is fine for dev but questionable for production deployments that want strict certificate validation. Operators MUST audit this gate per deployment.
9. Constitution Gates
- I — Plugin-first: N/A. Mail delivery is a
platform-cross-cutting concern, not a per-work plugin. Adding
a new transactional email is an in-repo change to
MailService+apps/api/src/templates/. - II — Capability-driven: provider selection is purely
config-driven (
MAILER_PROVIDERenv var). Swapping SMTP for Resend is zero code change, and adding a third backend requires only a new switch arm inMailerServiceand a new@nestjs/commonprovider inMailerModule. - III — Source-of-truth repos: N/A — emails are outbound transactional messages, not user-repo state.
- IV — Trigger.dev / exclusive workers: N/A — delivery is synchronous-from-the-event-handler, off the request hot path. The platform deliberately keeps this simple; bursty volume lives in the upstream provider's queue.
- V — Forward-only migrations: no schema. Adding a template is a file-add; renaming a slug requires changing both the caller and the file.
- VI — Tests: 16 unit tests cover both providers (
mailer.service.spec.ts14 +faker-mailer.service.spec.ts2) including SMTP/Resend/faker branching, recipient log shape, Handlebars template read path, Buffer/text body resolution, and constructor log (#492). Listener-side coverage ofMailServiceis a documented follow-up (T15 intasks.md). - VII — Secret hygiene: SMTP credentials and Resend API key live in env vars and are never logged. Email bodies never include secrets.
- VIII — Plugin counts: N/A.
- IX — Behaviour-first: this spec describes user-observable mail delivery behaviour (when emails go out, what they contain, how delivery degrades).
- X — Backwards-compat: provider switching is non-breaking (env-var only); template / event additions are additive; existing emails MUST continue to deliver after every change.
10. References
- Implementation:
- 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 wiring:
apps/api/src/mail/mail.module.ts - Types:
apps/api/src/mail/types.ts - Templates:
apps/api/src/templates/{signup-confirmation,welcome,password-changed,forgot-password,new-device-login,account-deletion,member-invitation}.hbs
- Service:
- 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.*). - Related feature specs:
../notifications/spec.md— sibling surface (notifications are in-app, mail-providers is outbound email — both consume some of the same domain events but are decoupled).../activity-log/spec.md— listens to the same user-lifecycle events for the audit trail.
- PR:
#492 — added
the 16 unit tests covering both providers and pinned the
Resend-with-undefined-
tofollow-up bug (OQ-1).