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

106 lines
5.8 KiB
Markdown

# 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.ts``EmailSender` (default export): the transport wrapper
and `isConfigured` guard.
- `src/services/email/list/addressVerification.ts``EmailAddressVerificationEmail`.
- `src/services/email/list/passwordReset.ts``PasswordResetEmail`.
- `src/services/email/list/invitation.ts``InvitationEmail`.
- `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(): boolean``true` 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.
## Related
- `backend-architecture.md` (layering), `permissions.md`, and the auth slice
(`src/services/auth.ts`, `src/api/controllers/auth.controller.ts`).