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.ts—EmailSender(default export): the transport wrapper andisConfiguredguard.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.htmlsrc/services/email/htmlTemplates/passwordReset/passwordResetEmail.htmlsrc/services/email/htmlTemplates/invitation/invitationTemplate.htmlscripts/copy-assets.mjs— copies thehtmlTemplates/tree intodist/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)whereEmailMessage = { to: string; subject: string; html: () => Promise<string> }.send(): Promise<...>— assertsemail,email.to,email.subject,email.htmlare present, awaitsemail.html()to build the body, creates anodemailertransport fromtransportConfig, and sends a mail withfrom,to,subject, the HTML body, and the headerX-SES-CONFIGURATION-SET: flatlogic-app. Returns thetransporter.sendMail(...)result.static get isConfigured(): boolean—trueonly when bothconfig.email.auth.passandconfig.email.auth.userare set.get transportConfig()— returnsconfig.email.get from()— returnsconfig.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 fromgetNotification('emails.emailAddressVerification.subject', getNotification('app.title'));html()readsaddressVerification/emailAddressVerification.htmland replaces{appTitle}withapp.title,{signupUrl}withlink,{to}withto.PasswordResetEmail(to: string, link: string)— subject fromgetNotification('emails.passwordReset.subject', getNotification('app.title'));html()readspasswordReset/passwordResetEmail.htmland replaces{appTitle}withapp.title,{resetUrl}withlink,{accountName}withto.InvitationEmail(to: string, { loginUrl, temporaryPassword })— subject fromgetNotification('emails.invitation.subject', getNotification('app.title'));html()readsinvitation/invitationTemplate.htmland 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 fromsrc/shared/constants/app.ts:from(EMAIL_FROM, defaultSchool Chain Manager <app@flatlogic.app>),host(EMAIL_HOST, defaultemail-smtp.us-east-1.amazonaws.com),port(EMAIL_PORT, default587),auth.user(EMAIL_USER, default empty string),auth.pass(EMAIL_PASS, no default), andtls.rejectUnauthorized: false. BecauseisConfiguredrequires bothauth.userandauth.pass, email sending is effectively disabled until both are provided. backend/.envis the checked-in development override file. It carries the non-secret SMTP defaults (EMAIL_FROM,EMAIL_HOST,EMAIL_PORT) plus emptyEMAIL_USER/EMAIL_PASSslots. 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__dirnamein each file) to resolve the template path relative to the running module, thenfs.readFile(path, 'utf8'). Substitution is plain globalString.replaceof the{placeholder}tokens. On read or render failure each class logs vialogger.errorand rethrows. - Because
tsconly emits.tsand these templates are read at runtime from a path derived fromimport.meta,scripts/copy-assets.mjscopiessrc/services/email/htmlTemplatesintodist/services/email/htmlTemplatesso the compiled production build can find them.
Used By
src/services/auth.ts(AuthService):signupand the related flow callsendEmailAddressVerificationEmail(email, host)only whenEmailSender.isConfigured.signintreats the user asemailVerifiedwhenEmailSender.isConfiguredis false (so unconfigured environments do not block login).sendEmailAddressVerificationEmail(email, host?)generates a verification token, builds a frontend/verify-email?token=...URL, and sends anEmailAddressVerificationEmail.sendPasswordResetEmail(email, type = 'register', host?)generates a password-reset token, builds a frontend/password-reset?token=...URL, and sends aPasswordResetEmail.sendInvitationEmail(email, temporaryPassword, host?)builds a frontend/loginURL and sends anInvitationEmailcontaining 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.tsexposesEmailSender.isConfiguredover HTTP (res.status(200).send(EmailSender.isConfigured)) and calls the twoAuthServicesend methods from its request handlers.src/services/users.ts(UsersService):createandbulkImportgenerate temporary passwords, store only their bcrypt hashes, mark invited internal accounts verified, and callAuthService.sendInvitationEmail(...)after commit. Bulk import sends invitations only for rows that the repository reports as created. WhenEmailSender.isConfiguredis false, single-user create responses include the generatedtemporaryPasswordonce so the creator can copy it from the UI and deliver it manually.
Tests
src/services/users.test.tscovers 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.
Related
backend-architecture.md(layering),permissions.md, and the auth slice (src/services/auth.ts,src/api/controllers/auth.controller.ts).