40227-vm/backend/docs/email.md

8.0 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 or message-specific options 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, { loginUrl, temporaryPassword }) — subject from getNotification('emails.invitation.subject', getNotification('app.title')); html() reads invitation/invitationTemplate.html and replaces {appTitle}, {loginUrl}, {email}, and {temporaryPassword}. Values are HTML-escaped before substitution.

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.
  • backend/.env is the checked-in development override file. It carries the non-secret SMTP defaults (EMAIL_FROM, EMAIL_HOST, EMAIL_PORT) plus empty EMAIL_USER/EMAIL_PASS slots. Fill those two credentials only in the local/VM environment that should send mail. Do not commit production SMTP credentials.
  • 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 a frontend /verify-email?token=... URL, and sends an EmailAddressVerificationEmail.
    • sendPasswordResetEmail(email, type = 'register', host?) generates a password-reset token, builds a frontend /password-reset?token=... URL, and sends a PasswordResetEmail.
    • sendInvitationEmail(email, temporaryPassword, host?) builds a frontend /login URL and sends an InvitationEmail containing the login URL, account email, and plaintext temporary password. The plaintext value is generated by the users service and is not stored.
  • 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 generate temporary passwords, store only their bcrypt hashes, mark invited internal accounts verified, and call AuthService.sendInvitationEmail(...) after commit. Bulk import sends invitations only for rows that the repository reports as created. When EmailSender.isConfigured is false, single-user create responses include the generated temporaryPassword once so the creator can copy it from the UI and deliver it manually.

Tests

  • src/services/users.test.ts covers temporary-password hashing, invitation delivery, and the duplicate/ignored-row guard for bulk import.

Configuration

Set these variables in backend/.env for local development or in the VM process environment for deployment:

EMAIL_FROM="School Chain Manager <app@flatlogic.app>"
EMAIL_HOST=email-smtp.us-east-1.amazonaws.com
EMAIL_PORT=587
EMAIL_USER=<smtp-user>
EMAIL_PASS=<smtp-password>

EMAIL_USER and EMAIL_PASS are the activation switch: if either is empty, EmailSender.isConfigured returns false, signup verification emails are skipped, and local sign-in treats users as verified so development is not blocked. When both are present, the platform sends verification, password-reset, and invitation emails through nodemailer.

While email is disabled, POST /api/users and POST /api/users/owner-with-organization return the generated temporary password once in the API response. The frontend displays it in the create form with copy instructions. This fallback is only for the internal manual handoff workflow; the password is not persisted as plaintext on the backend.

For VM deployments, keep SMTP credentials in the VM secret environment (for example ~/executor/.env or the process manager environment) and restart the backend process after changing them. The checked-in backend/.env should stay limited to non-secret defaults and local placeholders.

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