Skip to content

Email System

Audience: Developers maintaining LMS email events, templates, delivery adapters, preferences, or persistence.

Scope: Current LMS email architecture and implemented event inventory. Provider setup and incident diagnosis live in Email Delivery; future work is tracked in T-026 Email Platform Roadmap.

Architecture

The LMS uses the shared, server-only @open-learning-hub/email package for React Email rendering and provider delivery. The package is deliberately i18n-free and has no database or environment-variable access.

The LMS owns:

  • environment resolution and provider configuration in src/lib/email/client.ts;
  • locale lookup, translated template props, tenant host, and app-specific subjects in src/lib/email/templates.ts;
  • event triggers, recipient selection, category assignment, and inline send timing;
  • user notification preferences and the email_events database table.

The shared package owns:

  • SendEmailInput, SendEmailResult, EmailCategory, EmailBranding, and driver types;
  • React Email components and HTML/plaintext rendering;
  • Resend, SMTP, and non-production console drivers;
  • provider-neutral sendEmail behavior, category tags, and recipient-domain logging;
  • the canSend(category, preferences) policy utility.
flowchart LR
  action["LMS server action"] --> appTemplate["LMS template adapter<br/>locale + tenant host"]
  appTemplate --> sharedTemplate["Shared React Email template<br/>HTML + plaintext"]
  action --> appClient["LMS email client<br/>injected environment + logger"]
  sharedTemplate --> appClient
  appClient --> driver{"Selected driver"}
  driver --> resend["Resend"]
  driver --> smtp["SMTP"]
  driver --> console["Console<br/>non-production only"]

All imports from the shared package and LMS adapters must remain server-only.

Provider Selection

src/lib/email/client.ts injects validated LMS environment values and the structured logger into the shared client. The client selects one driver and caches it for the process:

  1. NODE_ENV=test: console (ensures tests never deliver to external providers).
  2. Otherwise, RESEND_API_KEY present: Resend.
  3. Otherwise, SMTP_HOST present: SMTP.
  4. Otherwise, outside production: console.
  5. Otherwise, in production: throw EmailNotConfiguredError.

EMAIL_FROM is supplied by the LMS to provider drivers. See Email Delivery for configuration and verification steps.

Rendering, Locale, And Tenant Context

LMS actions pass the recipient's app-owned locale and the request tenant host to functions in src/lib/email/templates.ts. Those functions resolve next-intl strings, build translated subjects and template props (including a localized fallback-link label), and render the shared LinkEmail component to HTML and plaintext.

Tenant context is not inferred by the shared package. Actions resolve the tenant host and build tenant-scoped links before rendering. Invitation links use /invite/\[token\]; authentication links use their owning /learn/... routes.

Invitation sends now persist both invitations.course_slug and invitations.course_title when a course is selected. The invite email uses a course-aware copy variant that states the selected course by title. Resend operations reuse the stored course_title and fall back to course_slug when title data is unavailable (for example, legacy rows created before the column existed).

Invitation email locale currently follows the inviting administrator's session locale (session.user.locale), not a recipient preference or tenant default.

Current LMS sends pass tenantHost to the shared template. The shared template now supports locale-aware lang, centered CTA layout, visible fallback URLs, dark-mode meta tags, optional logo rendering, and accent-color contrast handling. Sender-name, reply-to, and per-tenant verified-domain branding are not wired end to end.

Categories And Preferences

The shared category type supports:

Category Preference behavior LMS status
transactional Always allowed Used by every implemented LMS send
course-activity Honors courseActivity when canSend is called No LMS sends yet
marketing Honors marketing when canSend is called No LMS sends yet
collaboration Honors collaboration when canSend is called CMS-owned category

LMS preferences are stored as a flat JSON object in users.preferences:

{
  "courseActivity": true,
  "marketing": true
}

Both values default to true. They are captured during sign-up and editable in account settings.

The shared canSend utility exists and has category-policy tests. No optional LMS notification send currently calls it because all implemented sends are transactional. Adding a course-activity or marketing event requires an explicit canSend check; persisted preferences alone do not enforce suppression.

Implemented Transactional Events

All implemented events are awaited inline by their owning server action and tagged transactional.

Event Owner
Sign-up verification src/auth/actions/sign-up.ts
Duplicate-sign-up notice src/auth/actions/sign-up.ts
Forgot password src/auth/actions/forgot-password.ts
Resend verification src/auth/actions/resend-verification.ts
Magic-link sign-in src/auth/actions/request-magic-link.ts
Email-change confirmation src/app/\[locale\]/learn/account/actions.ts
Admin resend verification src/app/\[locale\]/admin/users/actions.ts
Admin password reset src/app/\[locale\]/admin/users/actions.ts
Admin invitation src/app/\[locale\]/admin/users/actions.ts

Provider failure does not change enumeration-safe responses on account-discovery surfaces. Auth mechanics, token consumption, TTLs, and route behavior remain canonical in Authentication.

Send And Logging Contract

sendEmail returns either an accepted messageId or a provider-neutral failure result. The LMS uses its structured logger:

  • email/sent: driver, recipient domain, subject, tags, and message ID;
  • email/failed: driver, recipient domain, subject, reason, and sanitized provider detail.

Normal structured logs do not record a full recipient address. The local-only console driver intentionally logs the full recipient, subject, tags, and extracted links so developers can complete token flows without an external provider. Do not enable or expose the console driver in production.

Persistence State

The email_events table and unique dedupe index exist:

  • columns: id, tenant_id, user_id, event, subject_id, template, status, message_id, created_at;
  • unique index: (tenant_id, user_id, event, subject_id).

There are currently no query helpers or event writes. The table does not yet provide audit history, idempotency, delivery-state tracking, or suppression.

Current Limitations

  • Sends run inline; there is no after() dispatch or bounded retry.
  • email_events is not read or written, so duplicate suppression is not active.
  • There are no unsubscribe links or endpoints.
  • There are no provider webhooks or bounce/complaint suppression.
  • There are no course-activity, marketing, or collaboration notification sends in the LMS.
  • Invitation locale is based on the inviting admin's session locale, not a recipient locale.
  • Deliverability monitoring and fuller tenant branding remain future work.

See T-026 Email Platform Roadmap for the remaining implementation sequence.