feat(mail): consolidate outbound email onto one mail module and send path - #130
feat(mail): consolidate outbound email onto one mail module and send path#130klaudia-blazyczek-blurify wants to merge 16 commits into
Conversation
…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
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
left a comment
There was a problem hiding this comment.
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.
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.
2cbcb4e to
40fc777
Compare
- 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.
21a9f3d to
8f2c3a6
Compare
- 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
left a comment
There was a problem hiding this comment.
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> } = { |
There was a problem hiding this comment.
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.`, | |||
There was a problem hiding this comment.
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.
Summary
Consolidates all outbound email onto one
mailmodule and one send path: callers handMAIL_DISPATCHa{ key, data }template, it enqueues ontomail-send, and the worker resolves the recipient, renders once, and sends off the request path. Breaking:SEND_EMAILandNOTIFICATION_DELIVERY_ADAPTERare removed in favour ofEMAIL_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.servicerendered and swallowed the error in a warn log, and the notifications module carried asendEmail: booleanflag 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 asen-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
NOTIFICATION_DELIVERY_ADAPTERunder 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.identityornotifications- rejected:notificationsdepends onidentityandidentityneeds to send, so a mail binding in either closes a load-order cycle. Themailmodule declares nodependsOnand consumers resolveMAIL_DISPATCHlazily.sendEmail: booleanon 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.Risks
SEND_EMAIL/NOTIFICATION_DELIVERY_ADAPTERremoved (bindEMAIL_SENDER); the notification-delivery port loses its email method;EMAIL_TEMPLATE_RENDERER.rendernow takes the{ key, data }template whole and returns{ subject, html, text };createAuthswapssendEmail/templateRenderer/getUserLanguagefor onedispatchOtpMailhook. ADR-0038 anddocs/adapters/mail.mdcover the move.notifications-dispatchandkyc-resubmission-notifyjob payload shapes changed (mail template instead of a bool;eventIdnow 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.mail-sendpayload is authenticated-encrypted withAUTH_SECRET, so the mail plugin now assertsAUTH_SECRETis at least 32 characters at register time.docker-compose.ymland 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.