Spec: F35 — Email Notifications
Status: partial — Contract Graph migration complete; browser and live-provider evidence remain explicit gaps
F77 relationship: F35 is the email channel spec under F77 Unified notification center. Email is a fanout rail; the canonical user-visible notification ledger now lives in F77.
Contract Graph: contracts/features/F35.cue · implementation audit. The repository has backend/API, worker, rendering, localization, and focused test evidence. This does not establish browser completion, live provider delivery, recipient inbox, or production evidence.
1. Problem & CEO Job
See clearly + trust the signal. Telegram (F21) is Lumi’s primary notification rail, but it cannot carry password resets or invitations (F01’s first post-MVP auth item), and email-first CEOs and approvers otherwise miss ready Briefs and waiting approvals. Email is the fallback rail that reaches them without becoming a newsletter machine.
F21’s restraint doctrine applies: at most one digest per user per day; transactional otherwise. Nagging email is filtered; a single morning chief-of-staff memo is read.
2. User Stories & Acceptance Criteria
-
As a user who forgot their password, I want a reset link by email, so that lockout doesn’t require an admin.
- Given a registered email, when I request a reset, then a single-use, expiring reset link is sent and the API responds identically for unknown emails (no account enumeration).
- Given an expired or used reset token, when I open the link, then I see a clear “request a new link” state, never a stack trace.
-
As an admin, I want invitations optionally delivered by email, so that onboarding works for teams not on Zalo/Telegram.
- Given an invitation with an email address, when I choose “send by email”, then the invite link (and
LUMI-XXXXshort code) is delivered to that address; the existing copy-link flow is unchanged. - Given a member without email-send permission (below manager), when they attempt it, then 403.
- Given an invitation with an email address, when I choose “send by email”, then the invite link (and
-
As a CEO, I want my morning Brief as an email digest, so that the day’s queue reaches me in the inbox I actually open.
- Given email digest enabled in my preferences, when the Brief generates (org
brief_time), then I receive one email with the same ranked items, deep-linking into/intel/brief. - Given a quiet day (0 items), when the digest would send, then it sends the positive quiet state or nothing per my preference — never a padded email.
- Given digest disabled (default), when Briefs generate, then no email is sent.
- Given email digest enabled in my preferences, when the Brief generates (org
-
As an owner, I want billing receipts and subscription-expiry warnings by email, so that payment problems never surprise me.
- Given a successful payment (any gateway), when the subscription activates, then a receipt email is sent to the payer.
- Given a subscription entering grace, when the state changes, then one warning email is sent (not one per day).
-
As any user, I want per-category email controls, so that I decide what reaches my inbox.
- Given
/settings, when I open notification preferences, then I can toggle email per category (digest, approvals, billing); security email (reset) is not disableable. - Given a disabled category, when its trigger fires, then no email is sent and nothing errors.
- Given
-
As any recipient, I want every Lumi email to be immediately usable, so that I can trust it, scan it, and act in one tap instead of hunting through a wall of text.
- Given any non-security F35 email, when it renders, then it includes a localized subject, preheader, one explicit “why you got this” line, one primary CTA deep-link, one fallback plain URL, and a quiet footer with manage-preferences/support links.
- Given any F35 email, when the rich version renders, then a plain-text part with the same meaning and same destinations is also present; the email must never mix locales inside one message.
3. Scope Boundaries
- In: transactional email (password reset, invitation delivery, billing receipts/grace warnings), opt-in daily Brief digest, per-member notification preferences (shared model with F36),
EmailLogdelivery audit, SES (prod) / console (dev) backends, and the reusable F35 email presentation contract (multipart HTML + plain-text twin, deep-link CTA, fallback URL, footer, localization bundle). - Out: marketing/lifecycle campaigns, per-event task/risk/commitment emails (the digest is the aggregation point — no event spam), email-reply-to-act flows, HTML template designer, direct ownership of the in-app notification center (F77 owns that), F36 push (own spec), Telegram (F21).
4. Data Model
NotificationPreference— extendsUUIDModel,organizationFK,memberFK (one per member, created lazily). Boolean columns per channel×category:email_digest,email_approvals,email_billing, plus F36’s push columns. Shared with F36 — one preference spine, never two.EmailLog— extendsUUIDModel,organizationFK (nullable for pre-auth reset emails), recipient, category, subject, provider message id, status (queued/sent/failed/suppressed), error text. Append-only; the deliverability audit trail.EmailSuppression— global do-not-send list for permanently bad recipients (hard_bouncetoday). New sends check it before touching the provider, and a matching row yields an honestEmailLog.status="suppressed"audit entry instead of another doomed retry.PasswordResetToken— single-use, expiring (owned by F01’s auth app; listed here because this spec introduces it).
No FKs from hot business tables into these; logs reference by id strings where the target may be huge (scale-check per AGENTS.md).
5. API Contract
| Method | Path | Auth/Role | Request | Response | Errors |
|---|---|---|---|---|---|
| POST | /api/auth/password-reset/ | anon | {email} | 200 always (no enumeration) | 429 on abuse |
| POST | /api/auth/password-reset/confirm/ | anon | {token, new_password} | 200 | 400 invalid/expired |
| POST | /api/members/invitation-links/<id>/send-email/ | manager+ | {email} | 202 queued | 403, 404 cross-tenant |
| GET/PATCH | /api/notification-preferences/me/ | member | — / partial toggles | preference object | 401 |
Tenant scoping: preferences and logs filter by organization_id=request.organization_id; reset endpoints are anon by design and rate-limited (F20). Email sending runs in Celery tasks (existing worker), never inline in request handlers.
No AI output — no EvidenceSource involvement.
6. UI / UX
- Notification preferences card inside
/settings(SettingsPage.tsx) — channel × category toggle grid, shadcnSwitch+Card. - “Quên mật khẩu?” link on
/login→ reset request + confirm pages (reuse auth shell). - “Gửi qua email” affordance on the existing invitation-link UI.
- i18n namespaces:
settings(new keys),auth(reset keys) —enandvitogether; all 55+ locales before release per F18 process. - States: saved/saving toggles, reset-sent confirmation (identical for unknown emails), expired-token page.
- Reusable product-email shell. Every F35 email category renders as multipart
text/plain+text/html. The HTML shell is intentionally simple, not marketing-glossy: preheader, Lumi/org identity line, one-sentence reason, primary headline, supporting context, one primary CTA button, fallback plain URL, and a quiet footer. The plain-text twin preserves the same meaning, same CTA destination, same fallback URL, and same footer intent in linear form. Security flows may omit manage-preferences controls; all other member-facing emails include them. - Category-specific structure rules.
- Password reset: urgency without drama. Explain the request, show expiry, primary reset CTA, fallback raw URL, support note. No marketing footer, no referral footer.
- Invitation: lead with inviter identity, org, role, and why joining matters; primary CTA is the magic link; short code remains the fallback path; footer explains why the recipient got the email and where to ask for help.
- Brief digest: preheader names the day’s top stakes; body shows quiet-day or ranked summary; every item cluster deep-links to
/intel/brief; footer includes manage-preferences and the existing F59 forwardable growth doorway. - Billing receipt / grace: show the concrete business fact first (paid, expiring, amount/date if present), then one action or reassurance path, then support/manage-billing links; referral footer stays email-only, never mirrored into F77.
- Localization contract (stronger than “string translation exists”). F35 owns the rule that subject, preheader, body, CTA, footer, date/time/number formatting, and link labels resolve as one coherent recipient locale. Fallback is deterministic, but it must happen at the message-bundle level: do not ship a mixed-language email where one paragraph fell back to
enand the footer stayedvi. RTL locales render with the correct direction in the HTML shell; the plain-text part remains readable linearly. - Localized in the recipient’s language (i18n — no hardcoded copy). Every email and push string is rendered in the recipient’s
User.languagevia the notification i18n catalog (apps/notifications/i18n.py+apps/notifications/locales/<locale>.json), covering allSUPPORTED_LOCALES(~62 locales, the frontend’s set) with a deterministic… → base language → en → vifallback.vi(wedge base) andenship complete; the other ~60 are filled by thedocs/translation-agents-workflow.mdpipeline used for the frontend. A multi-recipient digest renders per recipient (a CEO whose profile isen/ms/arnever receives a Vietnamese email). This is the backend twin of frontend Rule 3 — see backend/AGENTS.md → Localized Notifications. The pure builder helpers take alocaleargument (defaulting to thevibase). - Brief digest as a distribution object (growth, F59 loop). The digest email is the brief’s most-forwarded form — a CEO forwards it to a co-founder, board member, or investor. Every digest body ends with a branded footer that turns each forward into a signup doorway: when the recipient has an F59 referral code the footer carries their attributed
…/register?ref=<code>link (referrer + new signup each earn 1 month premium — same mechanic as the on-screen brief share, F12), falling back to a generic branded signup link when they have no code yet, so a forwarded brief is never a dead end. Footer copy is localized in the recipient’s language (catalog keysfooter.brief.*); built by the purebuild_brief_digest_footer(code, locale)helper. - Billing receipt as a distribution object (growth, F59 loop). The receipt is the most-forwarded transactional email in B2B SaaS — finance teams, bookkeepers, board members, and peer CEOs all see it (“here’s the latest tool we’re paying for”). The receipt body now ends with the same branded F59 footer: when the payer has an F59 referral code the footer carries their attributed
…/register?ref=<code>doorway (referrer + new signup each earn 1 month premium), falling back to a generic branded signup link otherwise — built by the purebuild_billing_receipt_footer(code, locale)helper, mirroring the digest pattern. The footer is appended to the email body only, never the in-app notification event (the in-app reader is already a user). Copy is localized in the payer’s language (catalog keysfooter.billing.*). - Billing grace warning as a distribution object (growth, F59 loop). The grace email (“subscription expiring — please renew”) is the most-action-forwarded billing email: the CEO sends it to finance/CFO to renew, so the recipient is a decision-maker themselves. The body now ends with the same branded F59 footer — when the recipient has an F59 referral code the footer carries their attributed
…/register?ref=<code>doorway (same mechanic as the receipt and digest footers), falling back to a generic branded signup link otherwise — built by the purebuild_billing_grace_footer(code, locale)helper. The footer is appended to the email body only, never the in-app notification event. Idempotency is preserved — the existing once-per-orgEmailLogcheck still gates a single send. Copy is localized in the recipient’s language (catalog keysfooter.grace.*). - Earned digest subject (retention — a notification that earns the next open). A flat
… — {N} itemscount is identical in shape every morning and trains the CEO’s eye to skip the digest. The subject instead names the day’s actual stakes, leading with the two highest-stakes non-zero buckets — decisions (decision/approval), risks (risk/cash/customer), overdue (overdue/commitment) — e.g.[Lumi] Sáng 21/06 — 2 quyết định · 1 rủi ro cần bạn(vi) /[Lumi] Morning 21/06 — 2 decisions · 1 risk need you(en). When only routine items exist it stays honest (no manufactured urgency); a quiet day stays the normal-day line. Built by the purebuild_brief_digest_subject(date_label, items, locale)helper; rendered per recipient in their own language (the stakes reflect the brief, the words reflect the reader). - Identity-stamped brief digest (distribution-object growth — the email reads as the sender’s company, not anonymous Lumi). The brief digest is the most-frequent forwardable form of the brief: a CEO forwards their morning digest to a board member, co-founder, or investor — the receiver scans the inbox and decides “this is from someone I know” vs “this is spam” from the subject preview alone. The subject now leads with the sender’s organization name when present (
[Lumi] Acme Corp · Sáng 21/06 — 2 quyết định · 1 rủi ro cần bạn/[Lumi] Acme Corp · Morning 21/06 — 2 decisions · 1 risk need you) and the body header stamps the same letterhead (Acme Corp · CEO Brief — 21/06/2026), mirroring the identifiable letterhead invariant the six in-product share artifacts already carry (whole-brief text/image, per-item brief, per-decision/risk/commitment text). Built by the purebuild_brief_digest_subject(date_label, items, locale, org_name=None)helper with a defensiveorg_name?.strip()trim — absent / empty / whitespace falls back to the existing anonymous subject andbody.brief.headerpaths (bit-for-bit identical artifact whenorg.nameis somehow empty). The brief-digest email category now hits 100% identity-stamp coverage across subject + body + footer (the F59 referral footer was added in #310). - Invitation email is one-tap activation (the magic link). The invitation email’s body leads with the recipient’s personal join link —
{base}/auth/accept-invitation/{token}— pointing at the existing frontend route that auto-accepts the invite once the user is signed in. The short code (LUMI-XXXX) is retained as a fallback for users on a different device or browser. Previously the body promised “use this link to join” but never interpolated a link — the highest-intent activation surface in the product (a CEO/admin personally invites a future user) forced multi-step friction (find the app, find the invite screen, type a 9-character code). The magic link compresses this to one tap from inbox → joined org while preserving the code-entry path for any user who can’t follow the link directly. Localized via the i18n catalog (body.invitationinterpolates{org, link, code}); base URL drawn fromsettings.CONFIRMATION_PUBLIC_BASE_URL(the same field the password-reset link uses), so all environments resolve correctly.
7. Test Plan
| # | Case | Type | Where |
|---|---|---|---|
| 1 | Reset request → token → confirm happy path; unknown email returns identical 200 | backend | backend/tests/test_email_notifications.py |
| 2 | Expired/used token rejected | backend | same |
| 3 | Invitation email send (manager OK, member 403, cross-tenant 404) | backend | same + test_multitenancy.py |
| 4 | Brief digest: enabled sends once with ranked items; disabled sends nothing; quiet day per preference | backend | same |
| 5 | Billing receipt on activation; single grace warning | backend | same |
| 6 | Preferences GET/PATCH; security category not disableable | backend | same |
| 7 | EmailLog rows written for every send/failure | backend | same |
| 8 | Settings preference toggles + forgot-password flow | e2e | new frontend/tests/e2e/notifications.spec.ts |
| 9 | Brief digest footer (F59 growth): pure build_brief_digest_footer with/without code; digest body carries the recipient’s attributed ?ref=<code> link when they have a code, generic branded link when not | backend | backend/tests/test_email_notifications.py |
| 10 | Earned digest subject (retention): pure build_brief_digest_subject leads with decisions+risks, stays honest on routine-only items, Ngày bình thường when empty, caps to top 2 buckets; digest mail.outbox[0].subject names the stakes, not a flat {N} mục | backend | backend/tests/test_email_notifications.py |
| 11 | Billing receipt footer (F59 growth): pure build_billing_receipt_footer with/without code; receipt email body carries the payer’s attributed ?ref=<code> link when they have a code, generic branded link when not; the in-app NotificationEvent body excludes the footer (reader is already a user) | backend | backend/tests/test_email_notifications.py |
| 12 | Billing grace footer (F59 growth): pure build_billing_grace_footer with/without code; grace email body carries the recipient’s attributed ?ref=<code> link when they have a code, generic branded link when not; the in-app NotificationEvent body excludes the footer (reader is already a user); idempotency unchanged | backend | backend/tests/test_email_notifications.py |
| 13 | i18n catalog (localization): every key resolves to a non-empty, fully-interpolated string for every SUPPORTED_LOCALE via the fallback chain; vi/en key sets are identical; unknown/region locale falls back correctly; missing key raises | backend | backend/tests/test_notification_i18n.py |
| 14 | Localized send paths: a non-vi recipient (User.language="en") receives English subject + body for billing receipt, billing grace (+ localized footer), billing cancellation, password reset, invitation, brief digest (per recipient), and the earned brief push; vi default preserved verbatim | backend | backend/tests/test_email_notifications.py, test_push_notifications.py, test_billing_management.py |
| 15 | Invitation email magic link (activation): body contains …/auth/accept-invitation/<inv.token> built from settings.CONFIRMATION_PUBLIC_BASE_URL; short code retained as fallback; link present in both vi and en localized bodies | backend | backend/tests/test_email_notifications.py |
| 16 | Identity-stamped digest (distribution-object growth): pure build_brief_digest_subject(..., org_name="Acme Corp") stamps "Acme Corp ·" into each of the three subject templates (normal day · stakes · routine updates); absent / empty / whitespace org_name preserves the existing anonymous templates verbatim; end-to-end send_brief_digest_emails carries the org name in both mail.outbox[0].subject and mail.outbox[0].body (header line); body.brief.header_with_org resolves across every SUPPORTED_LOCALE via the fallback chain | backend | backend/tests/test_email_notifications.py, backend/tests/test_notification_i18n.py |
| 17 | Render contract: every non-security email category emits multipart text/plain + text/html; the HTML part carries preheader + primary CTA + fallback URL + footer; both parts point to the same destinations | backend | backend/tests/test_email_notifications.py |
| 18 | Localization render contract: one email never mixes locales; subject/preheader/body/footer/CTA resolve from the same locale bundle; RTL locales set dir=\"rtl\" in the HTML shell | backend | backend/tests/test_notification_i18n.py |
| 19 | Footer rules: password reset omits manage-preferences/referral footer; invitation includes help/manage-context footer; digest and billing categories include the correct quiet footer variant | backend | backend/tests/test_email_notifications.py |
| 20 | Hard-bounce suppression: a permanent recipient failure creates EmailSuppression; later sends to that address are skipped with EmailLog.status=\"suppressed\"; provider 403/429 errors do not suppress | backend | backend/tests/test_email_notifications.py |
Backend coverage stays ≥99%; every new endpoint gets a cross-tenant test.
8. Current Behavior
Shipped core delivery plus the richer product-email shell. apps/notifications/ now has NotificationPreference, EmailLog, EmailSuppression, and PasswordResetToken; password reset, invitation send, brief digest, billing receipt, grace warning, and billing cancellation all deliver through the shared shell with multipart text/plain + text/html, explicit CTA/fallback URL, and localized footer treatment. Frontend: /password-reset, /password-reset/confirm, “Forgot password?” link, preferences card, and “Send by email”. E2E remains notifications.spec.ts + organization.spec.ts.
9. Error States
- Provider failure (SES error): Celery retry with backoff (max 3);
EmailLog.status=failedwith error text; never block the triggering transaction (payment still activates if its receipt email fails). - Unknown reset email: identical success response; internal log only.
- Rate limit: reset endpoints throttled per F20; 429 with
Retry-After. - Bad address on invitation send: 400 with
invite.error_invalid_email. - Hard-bounced address: first permanent recipient failure creates
EmailSuppression; later sends skip the provider and writeEmailLog.status="suppressed"instead of retrying forever. - Logging: every send attempt →
EmailLog; failures also to Sentry.
10. Diagrams & Docs Touched
- backend/DIAGRAMS.md: notification flow diagram (new) + ER additions.
- deploy/AGENTS.md: SES env vars (
EMAIL_BACKEND,AWS_SES_REGION,DEFAULT_FROM_EMAIL). - GLOSSARY:
NotificationPreference,EmailLog. - F01 spec: password reset closes its audit gap — update in same PR.
- README env table.
11. Implementation Audit (2026-06-12)
| # | Claim | Status | Evidence |
|---|---|---|---|
| 1 | NotificationPreference, EmailLog, PasswordResetToken models in backend/apps/notifications/ | ✅ match | apps/notifications/; shared spine with F36 per spec §4 |
| 2 | Password reset: POST /api/auth/password-reset/ + confirm; unknown email returns identical 200 | ✅ match | backend/tests/test_email_notifications.py test cases 1–2 |
| 3 | Invitation email send (manager+, 403 on member, 404 cross-tenant) | ✅ match | test_email_notifications.py test 3; test_multitenancy.py |
| 4 | Brief digest: send_brief_digest_emails wired into generate_daily_briefs_for_time | ✅ match | backend/apps/briefs/tasks.py; §13 changelog “Wired send_brief_digest_emails” |
| 5 | Billing receipt on activation; single grace warning (not one per day) | ✅ match | F24 webhook → _process_payment_code path; test_email_notifications.py test 5 |
| 6 | Preferences GET/PATCH; security email (reset) not disableable | ✅ match | GET/PATCH /api/notification-preferences/me/; test 6 |
| 7 | EmailLog rows written for every send/failure | ✅ match | test_email_notifications.py test 7 |
| 8 | Frontend: /password-reset, /password-reset/confirm, “Forgot password?”, preferences card, “Send by email” | ✅ match | §13 changelog; E2E in notifications.spec.ts + organization.spec.ts |
| 9 | Preference changes respected at send time, not enqueue time | ✅ match | 2026-07-01 test_brief_digest_preference_change_before_send_is_respected; send_brief_digest_emails() queries live NotificationPreference(email_digest=True) rows at execution time |
| 10 | Hard-bounced addresses moved to suppression (not retried forever) | ✅ match | EmailSuppression + EmailLog.status="suppressed" in apps/notifications/models.py / services.py; tests test_hard_bounce_creates_email_suppression_and_future_send_is_skipped + test_provider_403_does_not_create_email_suppression |
| 11 | Shared product-email shell (text/plain + text/html, preheader, CTA, fallback URL, footer) exists | ✅ match | apps/notifications/email_render.py + send_email_with_log(..., html_message=...); tests test_password_reset_email_is_multipart_with_html_shell, test_brief_digest_email_html_includes_preferences_and_referral_footer |
| 12 | Non-security categories have a consistent “why you got this” line + manage-preferences/help footer | ✅ match | shared shell + footer link helpers in apps/notifications/tasks.py / apps/billing/services/__init__.py; tests row 19 |
| 13 | Localization is proven at the rendered-message level (single locale bundle, formatting, RTL support) | ✅ match | resolve_catalog_locale() in apps/notifications/i18n.py; tests test_resolve_catalog_locale_picks_one_complete_bundle + test_invitation_email_rtl_shell_uses_requested_direction |
| 14 | Storyboard/lifecycle-triggered emails are required to use the F35 product shell instead of ad-hoc copy rules | ✅ match | apps/notifications/orchestrator.py::_dispatch_email now renders through render_product_email; test tests/assistant/test_f93_orchestrator.py::TestChannelSelection::test_no_token_falls_through_to_email |
Summary: F35 now matches the spec: shared multipart shell, coherent locale bundles, footer/CTA discipline, hard-bounce suppression, and F93 email adoption are implemented and covered by targeted tests.
Edge Cases Catalog (formerly §9, 6 items): E1 preference changed after queueing (closed); E2 shared inbox reused by multiple members — duplicate digest for same address (no dedup test yet); E3 digest scheduling near tenant midnight must follow org timezone, not worker-host time (implicit in Organization.brief_time + timezone per F12/F21 pattern); E4 hard-bounced addresses move to suppression (closed); E5 locale fallback must not create mixed-language emails (closed via bundle resolution); E6 lifecycle/storyboard emails reuse the same footer/CTA shell as other F35 product emails (closed). Full catalog in git history.
12. Open Questions / Decisions
- Provider: SES assumed (AWS deploy already); if a different ESP is chosen, only the Django
EMAIL_BACKENDsetting changes — no code shape change. Decision at implementation. - Digest send time: reuse org
brief_time(decided — no second schedule field). - Locale of email body: member’s chosen language (same rule as Telegram digests). Implemented 2026-06-22 via the notification i18n catalog (
apps/notifications/i18n.py) — see §13 and backend/AGENTS.md → Localized Notifications. - Decision: product notifications and lifecycle beats use the F35 shell.
docs/marketing/email.mdowns welcome-sequence voice, not whether a product email may omit deep links, footer, or localization rules. - Open question: whether the long-term sender identity stays
noreply@runlumi.appfor all product emails or splits into product (noreply) vs human-replyable (founder@/ support) lanes once F82/F58 reply handling is mature.
13. Bug / Change Log
2026-07-16 — Contract Graph invitation alias ownership checkpoint
F35 now declares the shipped alias POST /api/organizations/invitations/{id}/send-email/ in
addition to /api/members/invitation-links/{id}/send-email/. The alias uses the same
send_invitation_email_view, with independent owner-allow, member-deny, and cross-tenant proofs
registered as F35.AC003.ORG_ALIAS_*. Backend execution remains explicit in F35.GAP004.
2026-07-08 — Email format/content revamp spec: shell, CTA, footer, localization bundle
Type: Documentation Change
Summary:
- Expanded F35 from “delivery rail” into a full product-email contract.
- Added new acceptance criteria for usable emails: one explicit reason, one primary CTA, fallback URL, quiet footer, and multipart
text/plain+text/html. - Added stronger localization rules: one coherent locale bundle per message, render-level proof, and RTL support requirements.
- Marked the current plain-text-only sender path truthfully as a rework gap rather than pretending the richer shell exists.
Reason:
- The shipped emails deliver, but the format is still too weak: plain text only, inconsistent footer/CTA framing, and no durable render contract for localization.
Impact:
- Future F35/F93 email work now has a sharp target instead of scattered one-off improvements.
Tests:
- Planned in §7 rows 17–19; no code changes in this spec pass.
2026-07-08 — Shared multipart shell + suppression + storyboard-email adoption
Type: Feature
Summary:
- Added a shared F35 renderer/transport path: multipart
text/plain+text/html, hidden preheader, explicit reason line, primary CTA, fallback URL, quiet footer links, and RTL-aware HTML shell. - Migrated password reset, invitation, brief digest, billing receipt, billing grace, and billing cancellation onto that shell.
- Added
EmailSuppressionandEmailLog.status="suppressed"so permanently bad recipients stop being retried forever. - Moved F93 email beats onto the same shell instead of ad-hoc plain-text sends.
Reason:
- The old system delivered email, but it still behaved like scattered plain-text notifications rather than a durable product-email platform.
Impact:
- F35 now has a coherent presentation layer and an honest suppression path; lifecycle emails no longer lag behind transactional ones.
Tests:
cd backend && ./.venv/bin/python -m pytest tests/notifications/test_email_notifications.py tests/notifications/test_notification_i18n.py tests/notifications/test_email_rate_limit.py tests/billing/test_billing_management.py tests/assistant/test_f93_orchestrator.py -q
2026-07-02 — Spec drift fix: checked shipped ACs in §2
Type: Documentation
Summary:
- 11 of 11
[ ]ACs in §2 flipped to[x]— confirmed shipped against §11 audit rows 1–9. - No code changes.
2026-07-01 — Close F35 send-time preference proof gap
Type: Backend test coverage + documentation truth fix
Summary:
- Closed the stale F35 row that treated send-time preference enforcement as unproven.
- The digest task already reads live
NotificationPreferencerows at execution time; this slice adds the explicit regression test showing that a member who turns digest off after queueing but before execution receives no email.
Tests:
cd backend && DJANGO_SETTINGS_MODULE=config.settings.test ./.venv/bin/python -m pytest tests/test_email_notifications.py -k "brief_digest_preference_change_before_send_is_respected or brief_digest_email_enabled or brief_digest_email_disabled_by_default" -x --tb=short
2026-06-23 — Brief digest email: identity-stamped subject + body header (distribution-object growth)
Type: Feature (growth) — additive, no migration, no API change.
The gap. Of the six F35 email categories — invitation, password reset, billing receipt, billing grace, billing cancellation, brief digest — the brief digest was the only category whose subject and body header rendered an anonymous artifact when forwarded. A CEO who forwarded their morning digest to a board member / co-founder / investor sent an email whose inbox preview read [Lumi] Morning brief 23/06 — 2 decisions · 1 risk need you and whose body began CEO Brief — 23/06/2026 — no {org} interpolation anywhere on the highest-volume forwardable form of the brief. The six in-product share artifacts (whole-brief text/image PRs #392/#437, per-item brief #442, per-decision/risk/commitment #461/#453/#448) had all flipped to identifiable letterhead by 2026-06-23; the email channel — the daily-touched twin of the in-product brief — was the missing third leg of the distribution-object identity-stamp invariant.
The fix. build_brief_digest_subject(date_label, items, locale="vi", org_name=None) (apps/notifications/tasks.py) now takes an optional org_name keyword. When org_name is truthy after .strip(), all three subject branches (subject.brief.normal_day / subject.brief.stakes / subject.brief.updates) route to their _with_org siblings — e.g. [Lumi] Acme Corp · Morning 23/06 — 2 decisions · 1 risk need you instead of [Lumi] Morning 23/06 — 2 decisions · 1 risk need you. When org_name is absent / empty / whitespace / None, the existing three templates run bit-for-bit unchanged (every existing test stays green; the defensive trim prevents any leading-separator artifact). send_brief_digest_emails extracts the org name once per task invocation (org_name = org.name.strip() if org.name else None), passes it to the subject helper, and routes the body header translation through body.brief.header_with_org ({org} · CEO Brief — {date}) when truthy — same defensive fallback to the anonymous body.brief.header otherwise. 4 new i18n keys × 2 base locales (en + vi); other ~60 locales degrade gracefully down the … → en → vi chain (apps/notifications/i18n.py BASE_LOCALE/FALLBACK_LOCALE design), so no translate-agents-workflow propagation is required for this PR. 6 new tests in test_email_notifications.py (4 pure-helper + 2 end-to-end via send_brief_digest_emails); _PARAMS fixture in test_notification_i18n.py extended with the 4 new keys so the parametrized test_every_supported_locale_resolves_every_key covers them across every SUPPORTED_LOCALE. Red→green TDD confirmed before commit; backend coverage ≥97%. The metric bet: forward-rate on the morning digest ↑ (the inbox preview now reads as a personal communication from a known company), forwarded-digest-attributed signups per forwarded brief ↑ (pairs with the F59 referral footer already in the body — identity stamp + branded body + per-recipient signup doorway = a fully-realized distribution object). The brief-digest email category now hits 100% identity-stamp coverage across subject + body + footer.
2026-06-22 — Invitation email magic link (activation — fix broken promise on the highest-intent surface)
Type: Feature (activation/growth) / correctness — additive, no migration, no API change.
The gap. The localized invitation email body (body.invitation in apps/notifications/locales/{vi,en}.json)
read “…hoặc dùng liên kết này để tham gia” / “…or use this link to join” — but
send_invitation_email only interpolated {org, code} and never passed a link. The
recipient — the highest-intent activation audience in the product (personally invited by a
CEO or admin) — was told a link existed, saw no link, and was left to type the 9-character
short code into the app, if they could find the invite screen. The frontend magic-link
route (/auth/accept-invitation/:token — see frontend/src/App.tsx) has existed
throughout; the backend simply never put the URL into the email. A broken promise on the
single surface where one-tap activation matters most.
The fix. send_invitation_email (apps/notifications/tasks.py) now reads
settings.CONFIRMATION_PUBLIC_BASE_URL once (the same field the password-reset link uses,
defaulting to FRONTEND_URL), composes join_link = f"{base}/auth/accept-invitation/{inv.token}"
against the existing InvitationLink.token UUID, and passes link=join_link into
translate("body.invitation", ...). The two locale catalog templates (vi.json, en.json)
now lead with the magic link as the primary call-to-action and retain the short code as a
fallback for users on a different device or browser — the existing "Invite code: …"
text remains, so prior tests stay green. All other locales fall back through the chain
(L → base-language → en → vi) per apps/notifications/i18n.py, so a partially-translated
locale never regresses to a link-less body. No new params beyond {link} — additive only.
Three new backend cases in test_email_notifications.py (magic-link present in body;
link uses configured base URL; localized en body carries link + English CTA copy);
red→green TDD confirmed before commit. The metric bet: invitation acceptance rate ↑ and
time-from-send-to-accept ↓; the recipient is the highest-intent audience Lumi has, and
reducing friction here moves more activations per email than any other transactional channel.
2026-06-22 — Localize all emails & pushes in the recipient’s language (i18n catalog)
Type: Feature / correctness — additive, no migration, no API change.
The gap. Every transactional email and push (apps/notifications/tasks.py, push.py, plus the
cancellation email in apps/billing/services.py) was hardcoded in Vietnamese — billing receipts,
the cancellation confirmation, password resets, invitations, brief digests, the grace warning, the
brief/quiet-hours pushes, and the Telegram brief header. Yet User.language
already spans all ~62 SUPPORTED_LOCALES (the frontend’s set), the AI pipeline already answers
in it, and §12 had already decided the email body locale should be “the member’s chosen
language”. The decision had simply never been implemented: a CEO whose profile is en/ms/ar
received Vietnamese chrome. This was the broken-promise instance behind the goal that prompted
the work (the build_billing_receipt_footer CTA).
The fix. Added a notification i18n catalog mirroring the frontend: apps/notifications/i18n.py
(translate(key, locale, **params) with a deterministic L → base-language → en → vi fallback)
and apps/notifications/locales/<locale>.json flat key→template maps. Shipped vi.json (the
current production copy, verbatim — no wedge-market regression) and en.json complete; the other
~60 locales are filled by the same docs/translation-agents-workflow.md pipeline used for the
frontend, and degrade gracefully down the fallback chain until then. Refactored every send path to
resolve the recipient’s User.language and render in it (digests render per recipient, inside
the loop; password-reset/invitation look the language up by email and fall back to vi for a
not-yet-a-user invitee). The pure builder helpers gained a locale argument (default vi), so the
existing unit tests stay green. The rule is now documented authoritatively in
backend/AGENTS.md → Localized Notifications (the backend twin of frontend
Rule 3) and §6 above. Test plan §7 rows 13–14 added: structural proof that every key resolves for
every SUPPORTED_LOCALE, plus end-to-end assertions that a non-vi recipient gets their language
across all email + push channels (including the merged-in grace footer and earned brief push).
Coverage ≥97%; all backend tests pass.
Tracked follow-up (not silent): the F21 Telegram interactive bot handlers
(apps/telegram/handlers.py, tasks.py) still carry hardcoded Vietnamese and must adopt the same
translate() catalog in an F21-scoped change (the pre-link /connect reply falls back to vi,
since the account isn’t linked yet). The mechanism and rule are now in place for it.
2026-06-22 — Billing grace email: branded, referral-attributed footer (F59 growth loop)
Type: Feature (growth) — additive, no migration, no API change.
The billing grace-warning email (send_billing_grace_email) is the most-action-forwarded
billing email — the CEO forwards it to finance/CFO to renew (“we’re about to lose access
— pay this”), so the recipient is a decision-maker themselves. It was a dead end: a
plain-text body with no branded footer and no signup doorway, so a forwarded grace email
gave a non-user no way to ask “what is this, how do I get it?”. Added a pure
build_billing_grace_footer(code) helper (mirrors the proven build_billing_receipt_footer
shipped earlier today in #312 and build_brief_digest_footer from #304) and appended its
output to the grace email body only. When the recipient has an F59 referral code the
footer carries their attributed https://runlumi.app/register?ref=<code> doorway
(referrer + new signup each earn 1 month premium — same mechanic as the on-screen brief
share, F12 / brief digest footer #304 / billing receipt footer #312); otherwise a generic
branded signup link, never a dead end. Recipient code read with
ReferralCode.objects.filter(owner=user).only("code").first() (per-user isolation, no
write side-effect, no new request). The in-app NotificationEvent body deliberately
excludes the footer — the reader is already a user; the doorway belongs to the forwarded
email only (same rule as the digest and receipt). Idempotency is preserved — the
existing once-per-org EmailLog check still gates a single send. Test plan §7 row 12
added (5 cases: unit with/without code; integration email-body with/without code;
integration in-app event excludes footer). Bet: shares-per-grace × attributed-signup-from-
grace — completes the F35 × F59 transactional footer trifecta (digest, receipt, grace)
on the highest-action-intent audience (the recipient opens it AND acts on it).
2026-06-22 — Billing receipt email: branded, referral-attributed footer (F59 growth loop)
Type: Feature (growth) — additive, no migration, no API change.
The billing receipt email (send_billing_receipt_email) is the most-forwarded transactional
email in B2B SaaS — finance, bookkeepers, board members, and peer CEOs all see the receipt
(“here’s the latest tool we’re paying for”). It was a dead end: a plain-text body with no
branded footer and no signup doorway, so a forwarded receipt gave a non-user no way to ask
“what is this, how do I get it?”. Added a pure build_billing_receipt_footer(code) helper
(mirrors the proven build_brief_digest_footer shipped 2026-06-21) and appended its output
to the receipt email body only. When the payer has an F59 referral code the footer carries
their attributed https://runlumi.app/register?ref=<code> doorway (referrer + new signup
each earn 1 month premium — same mechanic as the on-screen brief share, F12 / brief digest
footer, #304); otherwise a generic branded signup link, never a dead end. Payer code read
with ReferralCode.objects.filter(owner=user).only("code").first() (per-user isolation, no
write side-effect, no new request). The in-app NotificationEvent body deliberately
excludes the footer — the reader is already a user; the doorway belongs to the forwarded
email only (same rule as the digest). Test plan §7 row 11 added (5 cases: unit
with/without code; integration email-body with/without code; integration in-app event
excludes footer). Bet: shares-per-receipt × attributed-signup-from-receipt — extends the
F12 → F59 viral loop to the highest-quality transactional email audience (decision-makers
themselves: finance, board, peer CEOs).
2026-06-21 — BE-B: harden password-reset email against Resend 403 + Sentry capture
Type: Fix (resilience / observability) + Regression test. Plus a config remediation that must happen outside code (see below).
Summary:
- Symptom.
Email send failed: password_reset → <email>: Resend API response 403 (Forbidden)(Sentry lumi-be, 2 events / 2 users) on/api/auth/password-reset/. - Root cause. Provider-side: the
RESEND_API_KEYin prod is not authorized to send fromDEFAULT_FROM_EMAIL(RESEND_FROM_EMAIL, defaultLumi <noreply@runlumi.app>) — therunlumi.appsending domain is not verified for that key (or the key is restricted / test-mode). This is a config fix, not a code fix — see remediation. - Code (fail-soft contract made explicit + capture).
send_email_with_logalready caught all send exceptions, marked theEmailLogrowfailed, and returned — so the endpoint already returned its generic 200 (no account-enumeration leak; the 403 never reached the client). Hardening: it now also callssentry_sdk.capture_exceptioninside a scope tagged withemail_categoryandemail_from, so a provider mis-config surfaces as a grouped, actionable Sentry exception (not only a log line, which depended on the logging integration). The docstring now states the fail-soft contract that security-sensitive callers (password reset above all) rely on. No secrets in code. - Tests (
tests/test_email_notifications.py): a simulated Resend 403 on/api/auth/password-reset/still returns the generic 200, still creates the reset token, writes afailedEmailLog row, and captures to Sentry — never raises; plus a unit guard thatsend_email_with_logswallows provider errors and returns afailedlog. - Config remediation (required — code alone does not stop the 403): in the Resend
dashboard, verify the
runlumi.appsending domain (add the SPF/DKIM/Return-Path DNS records Resend provides and confirm) for the API key configured asRESEND_API_KEYin prod; OR setRESEND_FROM_EMAILto an address on an already-verified domain; OR replace a restricted/test-mode key with a full-send key whose authorized domain matches the from-address. Until then, every password-reset email continues to fail soft (logged + captured, generic 200) rather than 500.
2026-06-21 — Brief digest email: earned, stakes-naming subject line (retention)
Type: Feature (retention/growth) — additive, no migration, no API change.
The digest subject was a flat [Lumi] Bản tin sáng {date} — {N} mục (“morning brief — N items”):
a count, identical in shape every morning, that trains the CEO’s eye to skip the digest — exactly
the “notification that trains people to mute us” failure (growth Appendix A). Added a pure
build_brief_digest_subject(date_label, items) helper that names the day’s actual stakes, leading
with the two highest-stakes non-zero buckets — decisions (decision/approval), risks
(risk/cash/customer), overdue (overdue/commitment) — e.g. [Lumi] Sáng 21/06 — 2 quyết định · 1 rủi ro cần bạn. Only-routine days stay honest ({N} mục cập nhật); empty stays
Ngày bình thường. send_brief_digest_emails now computes the subject once before the
per-recipient loop (it reflects the brief, not the reader). Test plan §7 row 10 added (5 cases).
Bet: a stakes-naming subject earns the next open where a flat count does not (opens-per-notification
→ next-morning return). No frontend/i18n (backend-hardcoded VI transactional copy).
2026-06-21 — Brief digest email: branded, referral-attributed footer (F59 growth loop)
Type: Feature (growth) — additive, no migration, no API change.
The Brief digest email (send_brief_digest_emails) is the brief’s most-forwarded form but was a
dead end — a plain-text body with no branded footer and no signup doorway, so a forwarded brief
gave a non-user no way to ask “what is this, how do I get it?”. Added a pure
build_brief_digest_footer(code) helper and appended its output to every digest body (items and
quiet-day). When the recipient has an F59 referral code the footer carries their attributed
https://runlumi.app/register?ref=<code> doorway (referrer + new signup each earn 1 month
premium — same mechanic as the on-screen brief share, F12); otherwise a generic branded signup
link. Recipient code read with ReferralCode.objects.filter(owner=user).first() (per-user
isolation, no write side-effect, no new request). Test plan §7 row 9 added (4 cases). Extends the
F12→F59 viral loop to its highest-volume channel.
2026-06-15 — Frontend route namespaced
Frontend route namespaced: email deep-link target /brief -> /intel/brief (app-wide IA restructure; API paths unchanged).
2026-06-14 — Restructure to TEMPLATE.md section order; expand §11 audit (SP fleet)
Type: Documentation Change
Summary:
- Absorbed §9 Edge Cases (4 items) into §11 note; §10 Error States → §9; §11 Diagrams → §10; §12 Implementation Audit (narrative) → §11 (10-row table); §13 Open Questions → §12; §14 Bug/Change Log → §13.
- §11 audit: 10 rows, 8 ✅, 2 gaps (preference-at-send-time not tested, no bounce suppression list).
2026-06-12 — Shipped
Type: Feature
Summary:
- Backend: full transactional + digest implementation (password reset, invitation email, billing receipts/grace, Brief digest) plus
EmailLog/NotificationPreference/PasswordResetTokenmodels — all underbackend/apps/notifications/. - Wired
send_brief_digest_emailsintogenerate_daily_briefs_for_timeso the digest actually fires after each org’s morning Brief generates (previously defined but never called). - Frontend:
/password-reset+/password-reset/confirmpages, “Forgot password?” on/login, notification-preferences card in/settings, “Send by email” on invitation links in/invite. Full i18n across all 8 locale dirs. - E2E:
frontend/tests/e2e/notifications.spec.ts(reset flow, settings card) +frontend/tests/e2e/organization.spec.ts(send invitation by email).
2026-06-12 — Initial spec
Type: Feature
Summary:
- Drafted the email channel contract: transactional (reset, invitations, billing) + opt-in Brief digest, shared
NotificationPreferencespine with F36,EmailLogaudit, restraint doctrine (≤1 digest/day, no per-event spam).
Reason:
- Email and push were requested as notification channels; F01’s password-reset gap and billing receipts need a non-chat rail.
Impact:
- New registry entry F35 (🔲). No code yet.
Tests:
- Specified in §7; none written yet.
Notes:
- Implementation must reuse the existing Celery + brief_time infrastructure; a second scheduler is a spec violation.
The exact GET /api/notification-preferences/me/ route is represented by
notification_preferences_me and its marked default-preferences pytest proof; this records
repository ownership only and does not claim browser, provider, or production evidence.
The exact PATCH /api/notification-preferences/me/ route is separately represented by the
existing partial-update proof F35.AC002.PATCH; successful source/test ownership does not establish
backend execution, browser, provider, native, or production evidence.