Skip to content

feat(mail): consolidate outbound email onto one mail module and send path - #130

Open
klaudia-blazyczek-blurify wants to merge 16 commits into
devfrom
feat/mail-module
Open

feat(mail): consolidate outbound email onto one mail module and send path#130
klaudia-blazyczek-blurify wants to merge 16 commits into
devfrom
feat/mail-module

Conversation

@klaudia-blazyczek-blurify

@klaudia-blazyczek-blurify klaudia-blazyczek-blurify commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

Consolidates all outbound email onto one mail module and one send path: callers hand MAIL_DISPATCH a { key, data } template, it enqueues onto mail-send, and the worker resolves the recipient, renders once, and sends off the request path. Breaking: SEND_EMAIL and NOTIFICATION_DELIVERY_ADAPTER are removed in favour of EMAIL_SENDER, and the renderer contract changes shape.

Why

Email left the platform three different ways - the identity OTP hook rendered and sent inside the request, compliance/rg.service rendered and swallowed the error in a warn log, and the notifications module carried a sendEmail: boolean flag and mailed the in-app notification's English body. None of the three had a delivery guarantee; several template keys never saw the recipient's language and formatted dates as en-GB; rendering ran on the request path. The two ports it went through (SEND_EMAIL, NOTIFICATION_DELIVERY_ADAPTER) both existed only to move an email, with different argument shapes, and an operator had to bind both.

Alternatives considered

  • Reuse NOTIFICATION_DELIVERY_ADAPTER under a new argument shape - rejected: a silent change of contract under a familiar name; a compile error on a renamed port is the safer break.
  • Bind the send path in identity or notifications - rejected: notifications depends on identity and identity needs to send, so a mail binding in either closes a load-order cycle. The mail module declares no dependsOn and consumers resolve MAIL_DISPATCH lazily.
  • Keep sendEmail: boolean on the notification input - rejected: a flag can't carry the template key or its data, which is why four mails used to go out as the notification's plain English body.
  • Change the broker to ack only after a successful enqueue - rejected here: it touches every subscriber on the platform. Interim guard is a short enqueue-retry; the durable fix (an outbox row in the caller's transaction) is deferred.

Risks

  • Breaking: SEND_EMAIL / NOTIFICATION_DELIVERY_ADAPTER removed (bind EMAIL_SENDER); the notification-delivery port loses its email method; EMAIL_TEMPLATE_RENDERER.render now takes the { key, data } template whole and returns { subject, html, text }; createAuth swaps sendEmail / templateRenderer / getUserLanguage for one dispatchOtpMail hook. ADR-0038 and docs/adapters/mail.md cover the move.
  • Rollout: the notifications-dispatch and kyc-resubmission-notify job payload shapes changed (mail template instead of a bool; eventId now carried). The new schemas accept the previous release's shape on a rolling redeploy - the in-app notification still lands and the mail is skipped rather than dead-lettering.
  • New boot dependency: the mail-send payload is authenticated-encrypted with AUTH_SECRET, so the mail plugin now asserts AUTH_SECRET is at least 32 characters at register time.
  • Infra: the Redis service in docker-compose.yml and the consumer template gets an fsync'd append-only journal and a volume so queued work survives a restart. A dedicated queue instance is deferred.
  • Deferred: the ack gap between an event handler and its enqueue (interim retry only), and a dedicated Redis instance for the queue - both tracked for a follow-up.

…path

Add a thin mail module that owns the outbound-mail seams and a mail-send
queue worker. Rendering and transport move off the request path.

- MAIL_DISPATCH facade (toUser / toAddress), template as a { key, data }
  tagged union
- EMAIL_SENDER ({ to, subject, html, text }) replaces SEND_EMAIL and
  NOTIFICATION_DELIVERY_ADAPTER
- EMAIL_TEMPLATE_RENDERER.render takes the template whole, returns
  { subject, html, text }; four new keys; locale-aware date formatting
- notification email intent carries the template, not a bool; withdrawal
  mail dates from envelope.occurredAt
- regulatory audit entry when delivery of an RG / KYC-resubmission mail is
  exhausted
- auth OTP hook, rg.service, iam.inviteAdmin, notifications dispatch all
  switch to MAIL_DISPATCH
- AdminUserRow.language; Redis compose gains an fsync'd journal + volume

BREAKING CHANGE: SEND_EMAIL and NOTIFICATION_DELIVERY_ADAPTER are removed;
the notification-delivery port loses its email method; the renderer return
and input shapes change.

Refs: BF-484
@klaudia-blazyczek-blurify klaudia-blazyczek-blurify changed the title feat(mail): consolidate outbound email onto one mail module and send … feat(mail): consolidate outbound email onto one mail module and send path Sep 1, 2026
EMAIL_TEMPLATE_RENDERER.render takes a third argument, recipientName: the
account display name for a toUser send (resolved from ADMIN_USER_DIRECTORY at
delivery time), or null for a toAddress send (OTP, admin invitation - no
account behind the address). The platform default renderer accepts and ignores
it; an operator overlay uses it for a greeting.

Refs: BF-484

@marek-chmielowski-blurify marek-chmielowski-blurify left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review findings (see inline comments for the four tied to this diff). One additional finding has no line in this diff to anchor to:

packages/core/generators/src/config.ts:373 — the adapter scaffold generator's token-choice list still offers the deleted SEND_EMAIL and NOTIFICATION_DELIVERY_ADAPTER tokens and wasn't updated to offer the new EMAIL_SENDER token. A consumer who runs the generator and picks either old token gets a generated overlay plugin importing a token/type this PR deletes (packages/core/src/contracts/adapters/email.ts and notification.ts are both removed) — guaranteed compile failure with no working replacement offered.

Comment thread packages/core/src/mail/plugin.ts
Comment thread packages/core/src/pam/identity/plugin.ts
Comment thread packages/core/src/contracts/adapters/email-template.ts Outdated
Comment thread packages/core/src/contracts/schemas/mail.ts Outdated
Resolves conflicts between the mail-module consolidation (MAIL_DISPATCH,
recipient name) and dev's fx/exchange-rate + RG-limits work:
- audit.ts: keep both new DirectAuditAction members
- compliance/plugin.ts: keep lazy MAIL_DISPATCH, add RG_LIMITS/EXCHANGE_RATE_READER
- rg.service.ts: RgServiceDeps carries mailDispatch + rates; fixes
  notifyLimitUpdated's call to notify() to match the retained signature
  (was passing a bare key/data instead of a MailTemplate + idempotency key)
- rg-self-service.service.ts: two call sites updated to the row-based
  notifyLimitUpdated signature
- rg.router.int.test.ts, rg.service.int.test.ts: drop dead imports, add the
  missing initiatedBy arg to setPlayerLimit, fix a raise/lower ordering bug
  the merge would otherwise have hidden
- docs/platform/system-design.md: regenerated via pnpm gen:catalog instead
  of hand-merging the reference table
- packages/testing rg.e2e.test.ts: CapturedEmail has html/text, not body

Verified: pnpm check:types, check:lint, check:boundaries, build all green
across every package; full unit suite (620/620) and compliance integration
suite (194/194) pass.
klaudia-blazyczek-blurify added a commit that referenced this pull request Sep 3, 2026
- mail/plugin.ts: register an empty router so the MailService is built during
  the boot-time router loop - the mail-send worker starts consuming before
  consumer router factories run, and a mail-only split deployment has no
  consumer resolving MAIL_DISPATCH at all
- identity + iam: declare requiresPorts: [MAIL_DISPATCH] - a split deployment
  omitting `mail` now fails fast with a descriptive boot error (ADR-0024)
  instead of a bare "no provider" crash on the first send
- extract formatMoneyAmount to contracts/schemas/common.ts and use it in the
  email renderer's formatMoney - a $10,000 withdrawal read "10,000.00" in the
  in-app notification but "10000.00" in the email
- drop the redundant `status` literal from withdrawalApproved/withdrawalRejected
  template data - the `key` is already the discriminant, callers were
  hand-writing a matching value with nothing catching a mismatch
- wallet-ledger-auto-withdrawal.e2e: assert the notifications<->mail coupling
  end-to-end - one approve/reject event drives both the in-app notification row
  and the withdrawalApproved/withdrawalRejected email

The generator token list (SEND_EMAIL/NOTIFICATION_DELIVERY_ADAPTER -> EMAIL_SENDER)
was already fixed on this branch in 7b41341.

Refs: BF-484

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKWbhmKuvYiUAqEzYaNHH2
- mail/plugin.ts: register an empty router so the MailService is built during
  the boot-time router loop - the mail-send worker starts consuming before
  consumer router factories run, and a mail-only split deployment has no
  consumer resolving MAIL_DISPATCH at all
- identity + iam: declare requiresPorts: [MAIL_DISPATCH] - a split deployment
  omitting `mail` now fails fast with a descriptive boot error (ADR-0024)
  instead of a bare "no provider" crash on the first send
- extract formatMoneyAmount to contracts/schemas/common.ts and use it in the
  email renderer's formatMoney - a $10,000 withdrawal read "10,000.00" in the
  in-app notification but "10000.00" in the email
- drop the redundant `status` literal from withdrawalApproved/withdrawalRejected
  template data - the `key` is already the discriminant, callers were
  hand-writing a matching value with nothing catching a mismatch
- wallet-ledger-auto-withdrawal.e2e: assert the notifications<->mail coupling
  end-to-end - one approve/reject event drives both the in-app notification row
  and the withdrawalApproved/withdrawalRejected email

The generator token list (SEND_EMAIL/NOTIFICATION_DELIVERY_ADAPTER -> EMAIL_SENDER)
was already fixed on this branch in 7b41341.

Refs: BF-484
- mail.service: record only error.name on an exhausted regulatory delivery -
  a provider bounce message routinely embeds the recipient address and
  audit_log is append-only
- notifications: mail-dispatch/kyc job schemas accept the prior release's shape
  (`email` nullish, `eventId` optional) so a rolling redeploy across #110's
  queue lands the in-app notification and skips the mail rather than
  dead-lettering
- mail/plugin: assert AUTH_SECRET length at register() with a clear message
  instead of failing deep in the MailService factory

Refs: BF-484
Drop rationale, load-order narration and edit-announcement comments added
across this PR (comments.md: a comment must state a fact the code cannot
carry). Kept: the regulatory notes on the audit action and RG mail keys, the
"must not confirm the account exists" copy constraint, the occurredAt/date
constraint, the variance-cast note, and JSDoc on non-obvious params.

Refs: BF-484
Brings in CMS scheduled banners (#133) and the exchange-rate/swap work.
Only conflict was docs/platform/system-design.md (generated) - resolved via
pnpm regen.
Comment thread .changeset/mail-module-single-send-path.md Outdated
Comment thread docker-compose.yml
Comment thread packages/core/src/mail/service/mail.service.ts
Comment thread packages/core/src/compliance/service/rg.service.ts Outdated
Comment thread packages/core/src/iam/service/iam.service.ts
- audit both outcomes of a regulatory send: mail.regulatory_delivery.sent on
  success and .failed on exhaustion, each carrying templateKey, locale and
  attempt - the delivered case is the "player was notified" evidence the
  regulator asks for
- rgLimitUpdated template data carries raw amount/currency/minutes instead of
  a pre-composed English "description", so a consumer renderer can translate
  and money-format it
- add the fsync'd journal + volume to tools/templates/consumer/docker-compose.yml
  (the file an operator copies to production)
- drop the per-PR changeset
- renumber the ADR 0036 -> 0038 (0036/0037 taken by the RG-limits work merged
  from dev)
The sendGlobalMessage / sendRoomMessage "publishes ..." tests subscribe and
then immediately publish; on a slow runner the SUBSCRIBE has not registered
yet and the message is lost (Redis Pub/Sub has no buffering). Same fix as
f250122 - settle() after the subscribe. Unrelated to the mail change; the
CI failure surfaced here.

@zaxovaiko zaxovaiko left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-checked the fixes, the four earlier threads look good. Three things left, mostly about where the copy lives and who can change it.

export const DEFAULT_EMAIL_TEMPLATES: {
[K in EmailTemplateKey]: (data: EmailTemplateData[K]) => { subject: string; body: string };
} = {
const PLAIN_EMAIL_TEMPLATES: { [K in EmailTemplateKey]: PlainTemplate<K> } = {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, the English copy is still baked in here - the rg fix moved it out of rg.service.ts into PLAIN_EMAIL_TEMPLATES, so it moved rather than went away, and locale still only reaches Intl for the date. I'm fine with the default renderer shipping English, what I'm missing is the configurable part: today the only way for a consumer to change a subject or a body is to write a TS overlay and redeploy. We want the operator to edit the templates per key and per locale from the back office, the way betfeel does it - so an email_template table (key + locale + subject + body), admin CRUD, and a renderer that reads it and falls back to renderDefaultEmail. Doesn't have to be this PR, but let's have the ticket before we call the mail module done.

Also, should this copy sit in adapters/email-template.ts at all? It's the published contract surface, so every consumer ships English strings it never renders - mail/default-email-template-renderer.ts feels like the better home.

@@ -110,7 +103,17 @@ export const notificationEventMap: NotificationMapEntry[] = [
body: `Your withdrawal of ${formatMoneyAmount(p.amount)} ${p.currency} has been approved and is being processed.`,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The in-app side is in worse shape than the mail one - title and body are hardcoded English here with no port at all, so a consumer can't override them even with an overlay. The same applies to buildKycResubmissionNotification and the rg.limit.set copy further down (describeLimitValue still returns ${minutes} minutes, plus no prior limit). Can we give the in-app notifications the same seam as the mail templates, so both channels render through something the operator controls? Fine as a follow-up, but I'd keep it in the same ticket as the email templates.

Comment thread docs/adapters/mail.md Outdated
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants