40227-vm/backend/docs/email.md
2026-06-10 18:27:19 +02:00

5.8 KiB

Email Backend

Purpose

The transactional email subsystem sends the three account lifecycle messages the backend produces: email-address verification, password reset, and invitation. It owns the SMTP transport (EmailSender) and the per-message classes that render an HTML body from a template file. It is infrastructure, not an HTTP slice: nothing here defines routes; the auth and users services call into it.

Files

  • src/services/email/index.tsEmailSender (default export): the transport wrapper and isConfigured guard.
  • src/services/email/list/addressVerification.tsEmailAddressVerificationEmail.
  • src/services/email/list/passwordReset.tsPasswordResetEmail.
  • src/services/email/list/invitation.tsInvitationEmail.
  • src/services/email/htmlTemplates/addressVerification/emailAddressVerification.html
  • src/services/email/htmlTemplates/passwordReset/passwordResetEmail.html
  • src/services/email/htmlTemplates/invitation/invitationTemplate.html
  • scripts/copy-assets.mjs — copies the htmlTemplates/ tree into dist/ for the compiled build.

Shared used: @/shared/config (the email config block), @/shared/logger, @/shared/notifications/helpers (getNotification), Node fs/path, nodemailer.

Public Interface

EmailSender (src/services/email/index.ts):

  • new EmailSender(email: EmailMessage) where EmailMessage = { to: string; subject: string; html: () => Promise<string> }.
  • send(): Promise<...> — asserts email, email.to, email.subject, email.html are present, awaits email.html() to build the body, creates a nodemailer transport from transportConfig, and sends a mail with from, to, subject, the HTML body, and the header X-SES-CONFIGURATION-SET: flatlogic-app. Returns the transporter.sendMail(...) result.
  • static get isConfigured(): booleantrue only when both config.email.auth.pass and config.email.auth.user are set.
  • get transportConfig() — returns config.email.
  • get from() — returns config.email.from.

Each message class takes its recipient plus a link/host in the constructor, exposes a subject getter, and an async html(): Promise<string> that loads its template and substitutes placeholders. They are passed to new EmailSender(...).send():

  • EmailAddressVerificationEmail(to: string, link: string) — subject from getNotification('emails.emailAddressVerification.subject', getNotification('app.title')); html() reads addressVerification/emailAddressVerification.html and replaces {appTitle} with app.title, {signupUrl} with link, {to} with to.
  • PasswordResetEmail(to: string, link: string) — subject from getNotification('emails.passwordReset.subject', getNotification('app.title')); html() reads passwordReset/passwordResetEmail.html and replaces {appTitle} with app.title, {resetUrl} with link, {accountName} with to.
  • InvitationEmail(to: string, host: string) — subject from getNotification('emails.invitation.subject', getNotification('app.title')); html() reads invitation/invitationTemplate.html, builds signupUrl = ${host}&invitation=true`` and replaces {appTitle} with app.title, {signupUrl} with that URL, {to} with to.

Behavior / Notes

  • SMTP configuration is read from config.email (src/shared/config/index.ts), built from environment variables with constant defaults from src/shared/constants/app.ts: from (EMAIL_FROM, default School Chain Manager <app@flatlogic.app>), host (EMAIL_HOST, default email-smtp.us-east-1.amazonaws.com), port (EMAIL_PORT, default 587), auth.user (EMAIL_USER, default empty string), auth.pass (EMAIL_PASS, no default), and tls.rejectUnauthorized: false. Because isConfigured requires both auth.user and auth.pass, email sending is effectively disabled until both are provided.
  • Template loading uses import.meta.dirname (aliased to __dirname in each file) to resolve the template path relative to the running module, then fs.readFile(path, 'utf8'). Substitution is plain global String.replace of the {placeholder} tokens. On read or render failure each class logs via logger.error and rethrows.
  • Because tsc only emits .ts and these templates are read at runtime from a path derived from import.meta, scripts/copy-assets.mjs copies src/services/email/htmlTemplates into dist/services/email/htmlTemplates so the compiled production build can find them.

Used By

  • src/services/auth.ts (AuthService):
    • signup and the related flow call sendEmailAddressVerificationEmail(email, host) only when EmailSender.isConfigured.
    • signin treats the user as emailVerified when EmailSender.isConfigured is false (so unconfigured environments do not block login).
    • sendEmailAddressVerificationEmail(email, host?) generates a verification token, builds ${host}/verify-email?token=..., and sends an EmailAddressVerificationEmail.
    • sendPasswordResetEmail(email, type = 'register' | 'invitation', host?) generates a password-reset token, builds ${host}/password-reset?token=..., and sends either an InvitationEmail (when type === 'invitation') or a PasswordResetEmail otherwise.
  • src/api/controllers/auth.controller.ts exposes EmailSender.isConfigured over HTTP (res.status(200).send(EmailSender.isConfigured)) and calls the two AuthService send methods from its request handlers.
  • src/services/users.ts (UsersService): create and bulkImport call AuthService.sendPasswordResetEmail(email, 'invitation', host) to send invitations to newly created/imported users.

Tests

None yet.

  • backend-architecture.md (layering), permissions.md, and the auth slice (src/services/auth.ts, src/api/controllers/auth.controller.ts).