Skip to content

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/email package.
  • 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 courseActivity and marketing preferences persist as flat users.preferences JSON.
  • LMS sign-up and account-settings UI capture and update preferences, defaulting both to true.
  • CMS collaboration and marketing preferences persist under users.preferences.notificationSettings and are editable through GET/PATCH /api/me and the profile UI, defaulting both to true.
  • Shared canSend(category, preferences) utility and policy tests.
  • LMS and CMS email_events schemas 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, both email_events schemas, 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:

  1. Package boundary: the shared package owns render and delivery only. It has no database or environment reads.
  2. Application ownership: each app owns i18n, event classification, recipient selection, preferences, persistence, and tenant context.
  3. 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.
  4. Preference scope: preferences are per account, not per course, and optional categories default to opted in.
  5. Preference storage: LMS stores its settings as flat users.preferences JSON; CMS stores them under users.preferences.notificationSettings. Each app maps its own shape to the shared canSend input.
  6. Dispatch: auth/security mail remains awaited inline. Optional notifications use Next.js after() with bounded retry initially.
  7. Persistence: each app owns its own email_events table and idempotency logic.
  8. Webhooks: each app owns its own signature-verified Resend webhook because databases are separate.
  9. Sender domains: use a shared verified sending domain first; per-tenant sender domains are a later enhancement.
  10. Queueing: after() plus bounded retry is sufficient initially; revisit a durable queue only if volume or reliability requires it.
  11. 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_id policy so tenantless rows cannot bypass the unique dedupe contract through SQL NULL semantics.
  • Add app-owned email_events query 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 canSend at 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 canSend policy.
  • 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/me authorization tests.
  • Assert that the LMS account Notifications section renders its persisted values.
  • Add LMS email_events migration 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 check green.

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 lang propagation 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/email from deprecated @react-email/* component/render/tailwind packages to unified react-email v6, 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

References