Email platform roadmap¶
Problem¶
The shared email package extraction and transactional parity work has shipped, but persistence, optional-notification policy, retry, provider feedback, deliverability operations, and one CMS account-discovery hardening item are not complete across the LMS and CMS.
The current baseline is:
- Shared, server-only, i18n-free
@open-learning-hub/emailpackage. - React Email HTML/plaintext rendering and Resend, SMTP, and console drivers.
- Apps inject environment, translations, event logic, locale, tenant host, and logging.
- CMS transactional email parity for the Phase 2 account flows: password-reset link, password-changed confirmation, and admin-created-user welcome.
- LMS transactional events use the shared package through app-owned adapters.
- Category tagging is supported and implemented transactional sends are tagged.
- LMS
courseActivityandmarketingpreferences persist as flatusers.preferencesJSON. - LMS sign-up and account-settings UI capture and update preferences, defaulting both to
true. - CMS
collaborationandmarketingpreferences persist underusers.preferences.notificationSettingsand are editable throughGET/PATCH /api/meand the profile UI, defaulting both totrue. - Shared
canSend(category, preferences)utility and policy tests. - LMS and CMS
email_eventsschemas and unique dedupe indexes. - Shared-package tests cover driver selection, category tags, policy, and HTML/plaintext rendering.
- Both apps consume the package from source, and the React Email preview server runs on port
3002. - Correct LMS invitation URL:
/invite/\[token\].
The canSend utility exists, but no optional application send currently
enforces it. The LMS has no course-activity or marketing sends yet, and the CMS
has no collaboration or marketing sends yet. Every future opt-outable event
must call canSend before dispatch.
The email_events table also exists only as schema. Neither app reads or
writes it, so event history, idempotency, and delivery-state tracking are not
active.
Progress by phase¶
- Phase 0 — Quick wins and groundwork: fixed the LMS invitation URL, corrected stale email documentation, and added CMS email environment validation and template variables.
- Phase 1 — Shared package extraction: extracted rendering and delivery into
@open-learning-hub/email, retained app-owned adapters and i18n, and added source transpilation and the preview server. - Phase 2 — CMS auth email parity: implemented the three CMS transactional account flows listed above. Provider/configuration exceptions in forgot-password still require the Phase 3b security hardening below.
- Phase 3a — Data model and preferences: added categories, tags,
canSend, bothemail_eventsschemas, LMS registration/account preferences, and CMS profile preferences. - Phase 3b — Runtime notification infrastructure: add event persistence, active idempotency, preference enforcement, unsubscribe, asynchronous dispatch, and bounded retry.
- Phase 4 — Unblocked LMS notifications: implement the currently actionable course and account events listed below.
- Phase 5 — Deliverability and operations: implement Resend feedback, suppression, domain verification, monitoring, and incident guidance.
- Phase 6 — Feature-blocked notifications: implement course completion, certificate, content-change, and CMS collaboration events only when their source features exist.
- Phase 7 — Fuller tenant branding: add app-owned sender identity, reply-to, logo, accent, and eventually verified per-tenant domains.
Proposal¶
Complete the remaining work in app-owned layers while retaining these design decisions:
- Package boundary: the shared package owns render and delivery only. It has no database or environment reads.
- Application ownership: each app owns i18n, event classification, recipient selection, preferences, persistence, and tenant context.
- Categories:
transactional: required account/security mail; no opt-out;course-activity: LMS learner notifications; opt-outable;collaboration: CMS authoring notifications; opt-outable;marketing: opt-outable in both apps.
- Preference scope: preferences are per account, not per course, and optional categories default to opted in.
- Preference storage: LMS stores its settings as flat
users.preferencesJSON; CMS stores them underusers.preferences.notificationSettings. Each app maps its own shape to the sharedcanSendinput. - Dispatch: auth/security mail remains awaited inline. Optional notifications use Next.js
after()with bounded retry initially. - Persistence: each app owns its own
email_eventstable and idempotency logic. - Webhooks: each app owns its own signature-verified Resend webhook because databases are separate.
- Sender domains: use a shared verified sending domain first; per-tenant sender domains are a later enhancement.
- Queueing:
after()plus bounded retry is sufficient initially; revisit a durable queue only if volume or reliability requires it. - Logging: normal application logs record recipient domain and sanitized context only. They never record recipient addresses, tokens, links, provider payloads, or other sensitive values.
Acceptance criteria¶
Event persistence and idempotency¶
- Choose and document the persistence model before wiring sends: either one mutable logical-event row plus separate attempt history, or an event row with explicit attempt metadata sufficient for retries and provider feedback.
- Migrate both schemas for the chosen model, including status timestamps and retry metadata where required.
- Define the nullable
tenant_idpolicy so tenantless rows cannot bypass the unique dedupe contract through SQLNULLsemantics. - Add app-owned
email_eventsquery helpers. - Persist each logical event, send attempt, and outcome according to the chosen model.
- Define status transitions and message-ID updates.
- Consult the unique
(tenant_id, user_id, event, subject_id)key before optional notification sends. - Make retry behavior safe when a provider result is ambiguous.
- Add retention guidance for event records.
Transactional auth mail may need a different dedupe policy from optional notifications; do not suppress a requested password reset solely because an earlier reset message exists.
Account-discovery security hardening¶
- Make CMS forgot-password return the same user-safe status and body for matched and unmatched accounts when template rendering, provider configuration, or delivery throws.
- Keep reset tokens, recipient addresses, rendered links, and provider details out of responses and structured logs.
- Test matched, unmatched, invalid-input, provider-failure, missing-provider, and template-failure paths without asserting user-enumerable differences.
Preference enforcement and unsubscribe¶
- Require
canSendat every course-activity, collaboration, and marketing dispatch boundary. - Add signed unsubscribe links for opt-outable categories.
- Add an unsubscribe endpoint in each owning app that updates the same account preferences.
- Keep transactional messages exempt from preference checks and unsubscribe.
- Test preference suppression and unsubscribe round trips.
Async dispatch and retry¶
- Add an app-level optional-notification dispatcher using
after(). - Add bounded retry for transient transport failures.
- Keep user responses independent of optional delivery outcomes.
- Record attempts and terminal outcomes in
email_events. - Add tests for success, transient retry, terminal failure, and dedupe.
Resend webhooks and suppression¶
- Add a signature-verified Resend webhook to each app.
- Record delivered, bounced, and complained states against that app's events.
- Suppress future optional sends to hard-bounced or complained addresses.
- Define how required transactional mail behaves for suppressed recipients.
- Protect webhook logs from payload, token, and PII leakage.
- Treat feedback from any future provider as a separate, explicitly designed adapter contract rather than assuming SMTP exposes Resend-style events.
LMS course and account notifications¶
| Event | Category | Status / dependency |
|---|---|---|
| Self-enrollment confirmation | Course activity | Gap |
| Admin enrollment notice | Course activity | Gap |
| Enrollment cancellation | Course activity | Gap |
| Course completion | Course activity | Blocked until completion transition is authoritative |
| Certificate ready | Course activity | Blocked until certificates ship |
| New course content | Course activity | Blocked until a change-detection event exists |
| Quiz passed / entry complete | Course activity | Optional; product decision required |
| Account deactivated/reactivated | Transactional | Gap |
| Password changed | Transactional | Gap |
| Sign-out-everywhere security alert | Transactional | Gap |
| Invitation accepted welcome | Transactional | Gap |
| Role or course-admin assignment changed | Transactional or audit-only | Product classification required |
For every course-activity event:
- Use the recipient's persisted locale and tenant host.
- Call
canSend("course-activity", preferences). - Include a signed course-activity unsubscribe link.
- Persist and dedupe the event.
- Dispatch asynchronously with bounded retry.
CMS collaboration notifications¶
These remain blocked by their underlying authoring features:
| Event | Category | Dependency |
|---|---|---|
| Project shared | Collaboration | Project-sharing feature |
| Peer-review invitation | Collaboration | Peer-review workflow |
| Peer-review feedback | Collaboration | Peer-review workflow |
When implemented, each event must use CMS-owned locale/context, enforce
canSend("collaboration", preferences), provide unsubscribe, and use CMS-owned
persistence and webhook state.
Deliverability and monitoring¶
- Document and verify SPF, DKIM, and DMARC for the shared sending domain.
- Add alerts for send-failure, bounce, and complaint spikes.
- Define operational dashboards or provider queries keyed by message ID and event.
- Document event-record retention and incident-response steps.
- Add periodic verification that production has a provider configured.
The LMS operator baseline is documented in Email Delivery.
Fuller tenant branding¶
Current CMS and LMS templates pass tenant host only. Remaining work:
- Resolve app-owned sender name and reply-to per tenant.
- Pass tenant logo and accent color into the shared base template.
- Define safe image hosting and fallback behavior.
- Support per-tenant verified sender domains only after domain lifecycle and failure handling are designed.
Existing test coverage¶
- Shared package tests cover render output, driver selection, category tags, and
canSendpolicy. - Existing LMS and CMS tests cover Phase ⅔a happy-path sends, preference persistence, profile controls, and migration creation.
- Locale key-parity tests keep current email strings present in every app-supported locale.
Remaining test coverage¶
- Add CMS password-changed and welcome template tests.
- Verify CMS reset-confirmation and welcome send failures remain best-effort, and invalid forgot-password input never sends.
- Add CMS
GET/PATCH /api/meauthorization tests. - Assert that the LMS account Notifications section renders its persisted values.
- Add LMS
email_eventsmigration rollback coverage. - App action tests for new events cover translated props, tenant links, category, recipient selection, and preference decisions.
- Persistence tests cover unique-key behavior, status transitions, attempt recording, and idempotent retry.
- Preference tests cover suppression and unsubscribe round trips.
- Webhook tests cover signature validation, event updates, and bounce suppression.
- Each implementation change keeps
npm run checkgreen.
Out of scope¶
- Attachments, including certificate PDFs; links to hosted assets remain the preferred initial approach.
- Durable queue adoption until
after()and bounded retry no longer meet volume or reliability needs. - Marketing campaign tooling; no marketing event exists today.
- Per-tenant verified sender domains until domain lifecycle and failure handling are designed.
- Adopting
@react-email/editor(TipTap/ProseMirror visual builder) as an optional template-authoring path without (or alongside) hand-written JSX. Current templates remain engineer-authored with app-owned i18n; revisit only if self-service or per-tenant template authoring becomes a product requirement.
Notes / decisions log¶
- 2026-08-01: Shipped shared-template UX hardening across LMS and CMS: centered CTA alignment, Title Case English CTA labels, visible fallback URL copy, locale
langpropagation into email HTML, dark-mode meta tags, optional logo support, and accent/text contrast handling. Also fixed CMS send paths to pass request locale to template adapters instead of always using default English. - 2026-07-22: Migrated
@open-learning-hub/emailfrom deprecated@react-email/*component/render/tailwind packages to unifiedreact-emailv6, keeping the preview CLI workflow (npm run email:dev --workspace @open-learning-hub/email) unchanged. - 2026-07-16: Moved the maintainer email roadmap into the platform ticket system as one umbrella ticket in commit
1059fff; Phases 0–3a form the shipped baseline and Phases 3b–7 remain tracked here.
Transactional event inventory¶
| App | Event | Status |
|---|---|---|
| LMS | Sign-up verification | Implemented; inline |
| LMS | Duplicate-sign-up notice | Implemented; inline |
| LMS | Forgot password | Implemented; inline |
| LMS | Resend verification | Implemented; inline |
| LMS | Magic-link sign-in | Implemented; inline |
| LMS | Email-change confirmation | Implemented; inline |
| LMS | Admin resend verification | Implemented; inline |
| LMS | Admin password reset | Implemented; inline |
| LMS | Admin invitation | Implemented; inline |
| CMS | Password-reset link | Implemented; inline; exception hardening pending |
| CMS | Password-reset complete confirmation | Implemented; best-effort |
| CMS | Admin-created-user welcome | Implemented; best-effort |