Notification System
Source: equa-server/modules/notifications/
Last Updated: 2026-02-28
Verified Against: Spec 021 verification-results.md (13/13 tasks PASS)
Overview
The notification system handles transactional email delivery for the Equa platform. It implements a Notifier interface that accepts a user ID and a notification object, renders HTML from Handlebars templates, and sends via a configurable transport layer. The module is backend-only with no dedicated API endpoints — it is consumed as a library by auth, admin, referral, billing, and organization modules.
Architecture
Core Interface
The Notifier type from common:
type Notifier = (user: Uuid | undefined, notification: Notification) => Promise<void>
The factory function newNodeMailerNotifier (email-notifier.ts:64-90) creates a Notifier that:
- Resolves recipient email via
getContactInfo(user) or falls back to notification.args.to
- Looks up the template by
notification.template name
- Renders HTML via the compiled Handlebars template
- Sends via
transporter.sendMail({ from, to, bcc, subject, html })
- Calls optional
onFinished callback
Throws if to is undefined or template is not found.
Email Transports
Transport is selected via the EMAIL_TRANSPORTER environment variable in services.ts:96-113:
EMAIL_TRANSPORTER Value | Transport | Factory Function | Use Case |
|---|
| (unset/empty) | AWS SES | newNodemailerSesTransporter | Production email sending |
smtp | SMTP | newNodemailerSmtpTransporter | Staging or alternative provider |
dev | Buffer | newNodemailerBufferTransporter | Local dev (saves .eml + .txt to temp/) |
none | No-Op | emptyNotifier | Disables all email sending |
Transport Composition
combineNodeMailerMethods (email-notifier.ts:49-62) sends via multiple transports simultaneously using Promise.all. Returns the last transport’s response. Used in test utilities to combine dev + test methods.
Template System
Loading (templates.ts:27-35)
newEmailTemplateMap loads templates synchronously at startup:
- Reads all
.handlebars files from src/partials/ and registers as Handlebars partials
- Reads all
.handlebars files from src/templates/ and compiles with { noEscape: true }
- Returns a map of
{ templateName: compiledFunction }
noEscape: true means all Handlebars expressions render raw HTML without escaping. This is intentional for HTML email content but requires that template variables are sanitized upstream if they contain user-provided content.
Email Templates (10)
| Template | Partial | Consumer Module | Call Site |
|---|
magicLink | main | auth | magic-link.ts:79 |
emailVerification | main | auth | email-verification.ts:43 |
passwordReset | main | auth | temp-password.ts:82 |
passwordChanged | main | auth | update-user.ts:19 |
userInvitation | mainNew | referral | referral.ts:428 |
sendCompanyInfo | main | referral | referral.ts:521 |
organizationInvitation | mainNew | organizations, admin | organizations/writing.ts:517, admin/writing.ts:42 |
organizationInvitationConvertible | mainNew | (no active consumer) | Defined but unused |
contactSupport | main | billing | billing/writing.ts:237 |
adminOrganizationCreated | mainOrg | organizations | organizations/writing.ts:408 |
Layout Partials (3)
| Partial | Structure | Templates Using It |
|---|
main | Original Equa-branded layout: green (#33BB40/#33AA40) theme, centered 538px table, Equa logo from Azure blob, confidentiality footer | magicLink, emailVerification, passwordReset, passwordChanged, sendCompanyInfo, contactSupport |
mainNew | Updated layout: Nunito Sans font, dark header (#304651), 600px width, responsive mobile styles, dark/light mode support | userInvitation, organizationInvitation, organizationInvitationConvertible |
mainOrg | Organization-branded minimal layout: 100% width, Nunito Sans font, responsive | adminOrganizationCreated |
AWS SES Configuration
newSesClient (aws.ts:6-13) creates an AWS SES client with API version 2010-12-01 using aws-sdk v2.
newAwsConnectionConfig (aws.ts:15-21) builds credentials from environment:
| Variable | Required | Default | Source |
|---|
AWS_ACCESS_KEY_ID | Yes | — | aws.ts:17 |
AWS_SECRET_ACCESS_KEY | Yes | — | aws.ts:18 |
AWS_SES_REGION | No | us-east-1 | services.ts:110 |
The SES region is passed as a function parameter (not read from env directly by the notifications module). The wiring in services.ts reads AWS_SES_REGION and passes it through. These are separate credentials from S3 file storage (AWS_S3_ACCESS_KEY_ID / AWS_S3_SECRET_ACCESS_KEY).
Complete Environment Variables
| Variable | Required | Default | Source |
|---|
EMAIL_TRANSPORTER | No | (SES) | services.ts:96-113 |
AWS_ACCESS_KEY_ID | If SES | — | aws.ts:17 |
AWS_SECRET_ACCESS_KEY | If SES | — | aws.ts:18 |
AWS_SES_REGION | No | us-east-1 | services.ts:110 |
SMTP_HOST | If SMTP | mail.equastart.io | services.ts:88 |
SMTP_PORT | If SMTP | 465 | services.ts:89 |
SMTP_SECURE | If SMTP | true | services.ts:90 |
SMTP_USER | If SMTP | '' | services.ts:91 |
SMTP_PASS | If SMTP | '' | services.ts:92 |
FROM_EMAIL_NAME | No | Equa | services.ts:140 |
FROM_EMAIL_ADDRESS | No | equabot@equastart.io | services.ts:141 |
GLOBAL_BCC | No | — | services.ts:116 (comma-separated) |
Type Definitions
Core Types (types.ts)
| Type | Definition |
|---|
EmailAddress | { name: string, address: string } |
UserEmailGetter | (id: string) => Promise<EmailAddress | undefined> |
EmailTemplateArguments | { from: EmailAddress } |
EmailTemplateMap | { [key: string]: (args: any) => string } |
AwsConnectionConfig | { accessKeyId: string, secretAccessKey: string, region: string } |
Transport Types (email-notifier.ts)
| Type | Definition |
|---|
OnEmailFinished | (response: any, user: Uuid | undefined, notification: Notification) => void |
NodeMailerMethod | { transporter: any, onFinished?: OnEmailFinished } |
SmtpConfig | { host: string, port: number, secure?: boolean, user?: string, pass?: string } |
User Email Resolution
UserEmailGetter is implemented as getUserContactInfo in persistence/src/common/reading.ts:528-542:
- SQL:
SELECT "fullName", users."email" FROM "users" LEFT JOIN profiles ON profiles."id" = $1 WHERE "users"."id" = $1
- Returns
{ name: fullName, address: email } or undefined if not found
- Wired in
services.ts:137 and passed to newNodeMailerNotifier
Legacy: Mandrill
The mandrill/ subdirectory contains a Mandrill (Mailchimp Transactional) implementation that is fully commented out. The index.ts re-exports types only. This is dead code from a previous email provider; current production uses Nodemailer + SES exclusively.
Known Issues
| Issue | Severity | Details |
|---|
Missing magicLink in authNotificationTemplates | High | magic-link.ts:79 references authNotificationTemplates.magicLink which is undefined, causing runtime errors. Fix: add magicLink: 'magicLink' to auth/src/types.ts. |
aws-sdk v2 deprecation | Medium | aws.ts:1 imports aws-sdk/clients/ses (v2, maintenance mode). Should migrate to @aws-sdk/client-ses v3. |
| No email queue/retry | Medium | sendMail() is called synchronously with no retry or dead letter queue. Relies on SES reliability. |
noEscape: true in templates | Medium | HTML is not escaped in template rendering. Low risk since variables come from server code, not user input. |
| Mandrill dead code | Low | Commented-out implementation still exported from module index. |