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.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/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 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, host: string)— subject fromgetNotification('emails.invitation.subject', getNotification('app.title'));html()readsinvitation/invitationTemplate.html, buildssignupUrl =${host}&invitation=true`` and replaces{appTitle}withapp.title,{signupUrl}with that URL,{to}withto.
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. - 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${host}/verify-email?token=..., and sends anEmailAddressVerificationEmail.sendPasswordResetEmail(email, type = 'register' | 'invitation', host?)generates a password-reset token, builds${host}/password-reset?token=..., and sends either anInvitationEmail(whentype === 'invitation') or aPasswordResetEmailotherwise.
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):createandbulkImportcallAuthService.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).