40227-vm/backend/docs/email.md

145 lines
8.0 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 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:
```bash
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.
## Related
- `backend-architecture.md` (layering), `permissions.md`, and the auth slice
(`src/services/auth.ts`, `src/api/controllers/auth.controller.ts`).