Email Template System — Architecture Decision Records¶
Generated: 2026-02-25 | Method: Architecture Decision Records with multi-persona debate Participants: Winston (Architect), Amelia (Dev), John (PM) Source Deep-Dive:
docs/deep-dive-email-template-system.md
ADR-1: Unify the Dual Email Sending Infrastructure¶
Status¶
Proposed
Context¶
Two parallel email dispatch paths exist:
- Path A (Legacy): helper/email.ts — uses MAIL_TYPE env var, supports nodemailer + resend only
- Path B (Factory): lib/email/EmailFactory.ts — uses EMAIL_SERVICE env var, supports nodemailer + resend + brevo
Different event handlers use different paths. Three duplicate nodemailer transports exist. Three duplicate IEmailSendPayload interfaces exist. Users must configure TWO env vars for one system.
Options Considered¶
Option A — Consolidate onto EmailFactory (Selected)¶
Migrate the 3 legacy-path handlers (sendLoginOtp, sendForgotPass, inviteOnlyLogin) to use EmailFactory.getEmailService().sendEmail(). Delete legacy files. Single env var EMAIL_SERVICE.
| Pros | Cons |
|---|---|
| Factory is the correct architectural pattern | Legacy helper supports attachments + multiple recipients; factory doesn't yet |
| Already supports Brevo (3 providers vs 2) | Requires extending IEmailSendPayload interface |
| Singleton prevents transport sprawl | Migrating 3 handlers requires testing all email types |
| Reduces 16 email files to 7 | |
| Completes a half-finished migration |
Option B — Consolidate onto helper/email.ts (Rejected)¶
Add Brevo to legacy helper. Delete factory files.
- Rejected because: moves backwards architecturally. Switch statement vs interface-based factory. Harder to test, harder to extend.
Option C — New unified service from scratch (Rejected)¶
Build a new EmailService combining the best of both.
- Rejected because: over-engineering a solved problem. Both paths work. Factory just needs extending. ~8 hours vs ~4 hours.
Decision¶
Consolidate onto EmailFactory with extended interface.
Implementation¶
EXTEND: lib/email/email.type.ts
- to: string → to: string | string[]
- add: attachments?: Array<{ filename: string; content: Buffer; contentType?: string }>
- add: from?: string
UPDATE: lib/email/NodeMailerService.ts
- Handle to as string | string[]
- Handle attachments
- Use payload.from ?? config.mail.from ?? default
- Remove new Promise(async ...) antipattern
- Remove transporter.verify() from every send
UPDATE: lib/email/ResendService.ts
- Handle to as string | string[]
- Handle attachments
- Use config.resend.mail_user for from
UPDATE: lib/email/BrevoService.ts
- Handle to as string | string[]
- Fix: re-throw errors (remove silent swallow)
- Fix: move setApiKey to constructor
- Fix: remove dead test email object
- Fix: add sender name from config
UPDATE: lib/email/EmailFactory.ts
- Move console.log inside if(!instance) block
MIGRATE: events/handlers/sendLoginOtp.handler.ts → getEmailService().sendEmail()
MIGRATE: events/handlers/sendForgotPass.handler.ts → getEmailService().sendEmail()
MIGRATE: events/handlers/inviteOnlyLoginSendEmail.ts → getEmailService().sendEmail()
DELETE: helper/email.ts
DELETE: helper/transporterMail.ts
DELETE: helper/email/resend.ts
DELETE: lib/email/email.ts (already dead code)
DELETE: lib/email/transporterMail.ts (already dead code)
DELETE: lib/email/template/signupEmailVerification.ts (already dead code)
UPDATE: .env.example — add EMAIL_SERVICE, BREVO_API_KEY; deprecate MAIL_TYPE
UPDATE: config/index.ts — remove MAIL_TYPE references after migration
Net result: -6 files, -1 env var, -2 duplicate interfaces. Single code path for all email sending.
Effort¶
~4 hours
Prerequisites¶
- Fix BrevoService error swallowing (5 min)
- Fix NodeMailerService
new Promise(async)antipattern (10 min) - Remove
verify()on every send (5 min)
Risks¶
- Email sending regression if migration has bugs
- Mitigated by: testing each of the 5 email types after migration (register, OTP, forgot-password, invite-user, invite-only-login)
Consequences¶
- Single
EMAIL_SERVICEenv var controls all email routing - All 3 providers have consistent error handling (all re-throw)
- Attachments and multiple recipients available to all callers
- From address configurable (not hardcoded)
ADR-2: Connect DB Templates to Email Sending¶
Status¶
Proposed
Context¶
The email_templates table stores templates designed in the visual builder with a type field (e.g., forgot_password, verify_email, booking_reminder). However, all email sending uses hardcoded TypeScript functions in src/template/. The two systems are completely disconnected. The type field strongly implies lookup-by-type was intended but never implemented.
Options Considered¶
Option A — DB-first with hardcoded fallback (Selected)¶
When sending email, query email_templates by type first. If active template found, interpolate variables and send its HTML. If not found, fall back to hardcoded TypeScript template.
| Pros | Cons |
|---|---|
| Zero breaking change — system works identically until admin creates DB template | Adds DB query per email send (mitigated by cache) |
| Admins can customize emails without code deploys | Requires variable interpolation system |
| Hardcoded fallback ensures reliability | Admin must know variable names (follow-up: "Insert Variable" UI) |
| Incremental adoption — one type at a time |
Option B — Full DB migration, no fallback (Rejected)¶
Remove hardcoded templates. Seed DB with 5 types. All sending through DB lookup.
- Rejected because: breaking change if seed hasn't run. Tighter coupling between app startup and database state.
Option C — Server-side template rendering from elements (Rejected)¶
Port html-generator.js to TypeScript on backend. Render from JSONB elements at send time instead of stored HTML.
- Rejected because: ~12 hours effort, couples email sending to builder element format, adds render latency per email.
Option D — Keep disconnected (Rejected)¶
Accept the builder is a standalone design tool.
- Rejected because: the
typefield, the save modal's purpose dropdown, and the entire module structure demonstrate this was always meant to be connected. Keeping it disconnected means we built a visual editor that does nothing.
Decision¶
DB-first with hardcoded fallback.
Implementation¶
New file: lib/email/templateResolver.ts¶
import { getDbRepository } from '../../shared/getDbRepository';
import { EmailTemplate } from '../../entity/EmailTemplate';
import { IsNull } from 'typeorm';
const templateCache = new Map<string, { html: string; cachedAt: number }>();
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
function interpolate(html: string, vars: Record<string, string>): string {
return html.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? '');
}
export async function resolveTemplate(
type: string,
variables: Record<string, string>,
fallbackHtml: string
): Promise<string> {
// Check cache
const cached = templateCache.get(type);
if (cached && Date.now() - cached.cachedAt < CACHE_TTL_MS) {
return interpolate(cached.html, variables);
}
// Query DB
const repo = getDbRepository(EmailTemplate);
const template = await repo.findOne({
where: { type, deletedAt: IsNull() }
});
if (template?.html) {
templateCache.set(type, { html: template.html, cachedAt: Date.now() });
return interpolate(template.html, variables);
}
// Fallback to hardcoded template
return fallbackHtml;
}
Event handler migration pattern (3-line change each):¶
// BEFORE (e.g., sendForgotPass.handler.ts):
const html = resetPassTemplate(resetPassLink);
sendEmail({ to, subject: 'Reset Password', html });
// AFTER:
const fallback = resetPassTemplate(resetPassLink);
const html = await resolveTemplate('forgot_password', { url: resetPassLink }, fallback);
getEmailService().sendEmail({ to, subject: 'Reset Password', html });
Template type → variable mapping:¶
| Template Type | Variables | Hardcoded Fallback |
|---|---|---|
verify_email |
{{name}}, {{url}} |
registerEmailTemplate(name, url) |
login_otp |
{{otp}} |
otpEmailTemplate(otp) |
forgot_password |
{{url}} |
resetPassTemplate(url) |
invite_user |
{{url}} |
inviteUserTemplate(url) |
invite_only_login |
{{password}} |
inviteOnlyLoginTemplate(password) |
Seed default templates:¶
Add to src/seed/ a templateSeed.ts that creates 5 EmailTemplate records using the hardcoded HTML (so the DB starts with the same content as the hardcoded templates). This lets admins see the default templates in the builder and customize from there.
Effort¶
~6 hours
Prerequisites¶
- ADR-1 (unified email infrastructure) should be completed first to avoid updating both code paths
- Seed
email-template.*permissions (currently unseeded — module is inaccessible to non-super_admin)
Follow-up Work¶
- Add "Insert Variable" button/panel to frontend builder — shows available
{{variables}}per template type - Add variable documentation to the save modal's type selector
- Consider adding a "Send Test Email" button to the builder
- Consider adding template versioning (keep history of changes)
Risks¶
- If
resolveTemplatefails (DB down), falls back to hardcoded — no disruption - Variable name typos in admin-edited templates will produce blank values (not errors)
- Mitigated by: test email preview showing interpolated variables before saving
Consequences¶
- Admins can customize transactional emails without code changes
- New email types can be added via DB without deploy (just insert a new EmailTemplate row and emit with the matching type)
- System degrades gracefully — hardcoded templates always available as fallback
- 5-minute cache means template changes take up to 5 minutes to take effect
ADR-3: Legacy v1 Email Template Builder¶
Status¶
Proposed
Context¶
email-templates-v1/ contains a 1,588-line monolithic builder (8 element types, no undo/redo, no HTML import, mock data list page) accessible at /dashboard/email-templates-v1/create. The v2 builder is a complete superset with 11 element types, undo/redo, 5 presets, HTML import/export, mobile responsive UI, and modular architecture. The v1 filename has a typo (email-temlate-form.jsx) and its list page links to nonexistent edit routes.
Decision¶
Delete v1 entirely.
Implementation¶
DELETE: Sass-boilerplate-frontend-v1/src/components/pages/email-templates-v1/email-temlate-form.jsx
DELETE: Sass-boilerplate-frontend-v1/src/components/pages/email-templates-v1/email-template-page.jsx
DELETE: Sass-boilerplate-frontend-v1/src/app/(dashboard-layout)/dashboard/email-templates-v1/create/page.js
Rationale¶
- v2 is a complete superset (11 vs 8 element types, undo/redo, presets, import/export, mobile)
- v1 list page uses hardcoded
MOCK_TEMPLATES, not real API data — it's a prototype - v1 links to nonexistent edit routes (
/dashboard/email-templates/edit/${id}) - v1 create route has no PermissionWrapper — unprotected
- Filename typo (
email-temlate-form.jsx) indicates incomplete/abandoned work - Git history preserves code if ever needed for reference
Effort¶
5 minutes
Risks¶
None. No user-visible feature depends on v1. v2 handles all email template functionality.
Implementation Phasing¶
Phase 1 — P0 Security (~45 min)
├── Whitelist orderBy values in email_template.service.ts
└── Add DOMPurify to email-templates-page.jsx template preview
Phase 2 — ADR-3: Delete v1 (~5 min)
└── Remove 3 files (2 components + 1 route)
Phase 3 — ADR-1: Unify Email Infrastructure (~4 hrs)
├── Fix provider issues (BrevoService errors, NodeMailer antipatterns)
├── Extend IEmailSendPayload (to[], attachments, from)
├── Migrate 3 legacy event handlers to factory
├── Delete 6 legacy/dead files
└── Update .env.example with EMAIL_SERVICE, BREVO_API_KEY
Phase 4 — ADR-2: Connect DB Templates (~6 hrs)
├── Create templateResolver.ts (lookup + interpolation + cache)
├── Update 5 event handlers (3-line change each)
├── Create templateSeed.ts (5 default templates)
├── Add email-template.* permissions to permission seed
└── Add permission check to getAllTemplates
Phase 5 — Follow-up (backlog)
├── "Insert Variable" UI panel in builder
├── Elements JSONB Zod validation schema
├── Sandboxed iframe preview (replace dangerouslySetInnerHTML)
├── "Send Test Email" button
├── Template versioning
└── Image upload integration with Cloudinary/S3
Total estimated effort: ~11 hours across Phases 1-4 Phase 1 is P0 — blocks 2 CRITICAL attack chains (SQL injection + stored XSS)
Cross-References¶
- Attack scenarios:
docs/extra-docs/email-template-attack-scenarios.md - Deep-dive:
docs/deep-dive-email-template-system.md - RBAC sprint plan (similar phasing):
docs/extra-docs/rbac-sprint-plan.md
Generated by BMAD Advanced Elicitation — Architecture Decision Records method | 2026-02-25