Skip to content

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

  1. Fix BrevoService error swallowing (5 min)
  2. Fix NodeMailerService new Promise(async) antipattern (10 min)
  3. 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_SERVICE env 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 type field, 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

  1. Add "Insert Variable" button/panel to frontend builder — shows available {{variables}} per template type
  2. Add variable documentation to the save modal's type selector
  3. Consider adding a "Send Test Email" button to the builder
  4. Consider adding template versioning (keep history of changes)

Risks

  • If resolveTemplate fails (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


Generated by BMAD Advanced Elicitation — Architecture Decision Records method | 2026-02-25