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_eventsdatabase 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
sendEmailbehavior, 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:
NODE_ENV=test: console (ensures tests never deliver to external providers).- Otherwise,
RESEND_API_KEYpresent: Resend. - Otherwise,
SMTP_HOSTpresent: SMTP. - Otherwise, outside production: console.
- 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:
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_eventsis 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.