partial — Contract Graph migration complete; browser and live-provider evidence remain explicit gaps

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-XXXX short 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.
  • 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.
  • 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.
  • 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), EmailLog delivery 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 — extends UUIDModel, organization FK, member FK (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 — extends UUIDModel, organization FK (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_bounce today). New sends check it before touching the provider, and a matching row yields an honest EmailLog.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

MethodPathAuth/RoleRequestResponseErrors
POST/api/auth/password-reset/anon{email}200 always (no enumeration)429 on abuse
POST/api/auth/password-reset/confirm/anon{token, new_password}200400 invalid/expired
POST/api/members/invitation-links/<id>/send-email/manager+{email}202 queued403, 404 cross-tenant
GET/PATCH/api/notification-preferences/me/member— / partial togglespreference object401

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, shadcn Switch + 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) — en and vi together; 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 en and the footer stayed vi. 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.language via the notification i18n catalog (apps/notifications/i18n.py + apps/notifications/locales/<locale>.json), covering all SUPPORTED_LOCALES (~62 locales, the frontend’s set) with a deterministic … → base language → en → vi fallback. vi (wedge base) and en ship complete; the other ~60 are filled by the docs/translation-agents-workflow.md pipeline used for the frontend. A multi-recipient digest renders per recipient (a CEO whose profile is en/ms/ar never receives a Vietnamese email). This is the backend twin of frontend Rule 3 — see backend/AGENTS.md → Localized Notifications. The pure builder helpers take a locale argument (defaulting to the vi base).
  • 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 keys footer.brief.*); built by the pure build_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 pure build_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 keys footer.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 pure build_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-org EmailLog check still gates a single send. Copy is localized in the recipient’s language (catalog keys footer.grace.*).
  • Earned digest subject (retention — a notification that earns the next open). A flat … — {N} items count 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 pure build_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 pure build_brief_digest_subject(date_label, items, locale, org_name=None) helper with a defensive org_name?.strip() trim — absent / empty / whitespace falls back to the existing anonymous subject and body.brief.header paths (bit-for-bit identical artifact when org.name is 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.invitation interpolates {org, link, code}); base URL drawn from settings.CONFIRMATION_PUBLIC_BASE_URL (the same field the password-reset link uses), so all environments resolve correctly.

7. Test Plan

#CaseTypeWhere
1Reset request → token → confirm happy path; unknown email returns identical 200backendbackend/tests/test_email_notifications.py
2Expired/used token rejectedbackendsame
3Invitation email send (manager OK, member 403, cross-tenant 404)backendsame + test_multitenancy.py
4Brief digest: enabled sends once with ranked items; disabled sends nothing; quiet day per preferencebackendsame
5Billing receipt on activation; single grace warningbackendsame
6Preferences GET/PATCH; security category not disableablebackendsame
7EmailLog rows written for every send/failurebackendsame
8Settings preference toggles + forgot-password flowe2enew frontend/tests/e2e/notifications.spec.ts
9Brief 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 notbackendbackend/tests/test_email_notifications.py
10Earned 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ụcbackendbackend/tests/test_email_notifications.py
11Billing 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)backendbackend/tests/test_email_notifications.py
12Billing 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 unchangedbackendbackend/tests/test_email_notifications.py
13i18n 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 raisesbackendbackend/tests/test_notification_i18n.py
14Localized 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 verbatimbackendbackend/tests/test_email_notifications.py, test_push_notifications.py, test_billing_management.py
15Invitation 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 bodiesbackendbackend/tests/test_email_notifications.py
16Identity-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 chainbackendbackend/tests/test_email_notifications.py, backend/tests/test_notification_i18n.py
17Render 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 destinationsbackendbackend/tests/test_email_notifications.py
18Localization 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 shellbackendbackend/tests/test_notification_i18n.py
19Footer rules: password reset omits manage-preferences/referral footer; invitation includes help/manage-context footer; digest and billing categories include the correct quiet footer variantbackendbackend/tests/test_email_notifications.py
20Hard-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 suppressbackendbackend/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=failed with 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 write EmailLog.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)

#ClaimStatusEvidence
1NotificationPreference, EmailLog, PasswordResetToken models in backend/apps/notifications/✅ matchapps/notifications/; shared spine with F36 per spec §4
2Password reset: POST /api/auth/password-reset/ + confirm; unknown email returns identical 200✅ matchbackend/tests/test_email_notifications.py test cases 1–2
3Invitation email send (manager+, 403 on member, 404 cross-tenant)✅ matchtest_email_notifications.py test 3; test_multitenancy.py
4Brief digest: send_brief_digest_emails wired into generate_daily_briefs_for_time✅ matchbackend/apps/briefs/tasks.py; §13 changelog “Wired send_brief_digest_emails”
5Billing receipt on activation; single grace warning (not one per day)✅ matchF24 webhook → _process_payment_code path; test_email_notifications.py test 5
6Preferences GET/PATCH; security email (reset) not disableable✅ matchGET/PATCH /api/notification-preferences/me/; test 6
7EmailLog rows written for every send/failure✅ matchtest_email_notifications.py test 7
8Frontend: /password-reset, /password-reset/confirm, “Forgot password?”, preferences card, “Send by email”✅ match§13 changelog; E2E in notifications.spec.ts + organization.spec.ts
9Preference changes respected at send time, not enqueue time✅ match2026-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
10Hard-bounced addresses moved to suppression (not retried forever)✅ matchEmailSuppression + 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
11Shared product-email shell (text/plain + text/html, preheader, CTA, fallback URL, footer) exists✅ matchapps/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
12Non-security categories have a consistent “why you got this” line + manage-preferences/help footer✅ matchshared shell + footer link helpers in apps/notifications/tasks.py / apps/billing/services/__init__.py; tests row 19
13Localization is proven at the rendered-message level (single locale bundle, formatting, RTL support)✅ matchresolve_catalog_locale() in apps/notifications/i18n.py; tests test_resolve_catalog_locale_picks_one_complete_bundle + test_invitation_email_rtl_shell_uses_requested_direction
14Storyboard/lifecycle-triggered emails are required to use the F35 product shell instead of ad-hoc copy rules✅ matchapps/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_BACKEND setting 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.md owns 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.app for 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.

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 EmailSuppression and EmailLog.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 NotificationPreference rows 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.

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.

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).

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_KEY in prod is not authorized to send from DEFAULT_FROM_EMAIL (RESEND_FROM_EMAIL, default Lumi <noreply@runlumi.app>) — the runlumi.app sending 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_log already caught all send exceptions, marked the EmailLog row failed, and returned — so the endpoint already returned its generic 200 (no account-enumeration leak; the 403 never reached the client). Hardening: it now also calls sentry_sdk.capture_exception inside a scope tagged with email_category and email_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 a failed EmailLog row, and captures to Sentry — never raises; plus a unit guard that send_email_with_log swallows provider errors and returns a failed log.
  • Config remediation (required — code alone does not stop the 403): in the Resend dashboard, verify the runlumi.app sending domain (add the SPF/DKIM/Return-Path DNS records Resend provides and confirm) for the API key configured as RESEND_API_KEY in prod; OR set RESEND_FROM_EMAIL to 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).

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/PasswordResetToken models — all under backend/apps/notifications/.
  • Wired send_brief_digest_emails into generate_daily_briefs_for_time so the digest actually fires after each org’s morning Brief generates (previously defined but never called).
  • Frontend: /password-reset + /password-reset/confirm pages, “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 NotificationPreference spine with F36, EmailLog audit, 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.