Email Template System — Pre-mortem Analysis¶
Generated: 2026-02-25 | Method: Pre-mortem Analysis (imagine failure, work backwards) Source Deep-Dive:
docs/deep-dive-email-template-system.mdSetting: 3 months post-deployment with paying customers. Email template system shipped as-is without fixes.
Executive Summary¶
7 realistic failure scenarios analyzed. 2 are existential business threats preventable with 45 minutes of work. 1 is guaranteed to occur on the first non-super_admin user. The dual email infrastructure makes every config change a potential partial outage that's invisible due to silent error swallowing.
| Incident | Likelihood | Impact | Fix Time | Priority |
|---|---|---|---|---|
| 1. SQL Injection Breach | HIGH | CATASTROPHIC | 15 min | P0 |
| 6. XSS → Admin Takeover | HIGH | CATASTROPHIC | 30 min | P0 |
| 2. Silent Email Failure | HIGH | HIGH | 4 hrs | P1 |
| 3. Permission Lockout | CERTAIN | MEDIUM | 10 min | P1 |
| 5. Config Nightmare | HIGH | MEDIUM | 30 min | P1 |
| 4. Wasted Sprint | MEDIUM | MEDIUM | 6 hrs | P2 |
| 7. Runtime Crash | HIGH | LOW | 5 min | P2 |
Incident 1: "The Silent Breach" — SQL Injection Data Exfiltration¶
What happened: A security researcher (or attacker) found the orderBy injection in GET /email-templates. Using automated tooling (sqlmap), they extracted the entire users table — emails, hashed passwords, OTP secrets, role assignments — in under 2 hours. The breach was discovered 3 weeks later when user credentials appeared on a dark web paste.
Root cause: email_template.service.ts:59 — template.${query.orderBy} without whitelist.
Why it wasn't caught: - No Zod validation on query parameters for the list endpoint - No security audit or penetration test before deployment - No WAF or query anomaly detection in place - The endpoint requires only basic authentication — no privilege needed
Business impact: - GDPR breach notification required (72-hour deadline) - All user passwords must be force-reset - Legal liability for any account takeover via leaked credentials - Customer trust destroyed - Potential regulatory fine
Prevention: Whitelist orderBy values. 15 minutes of work prevents a company-ending event.
Detection gap: Even WITH the fix, there's no logging of unusual query parameters. Add request logging for orderBy values that don't match the whitelist — catches probing attempts.
Incident 2: "The Ghost Outage" — Silent Email Failure¶
What happened: The ops team rotated the SMTP password as part of routine credential hygiene. They updated the .env file and restarted. Registration emails (using EmailFactory → NodeMailerService) started failing. But sendLoginOtp and sendForgotPass (using helper/email.ts) kept working because they use a different transport with different config timing.
Then, 2 weeks later, the team switched the Resend API key. Now the helper/email.ts path breaks too. But BrevoService was also configured — and it silently swallows errors, so the factory path appears to work but emails never arrive.
Timeline of discovery: - Day 1: SMTP password rotated. Registration emails fail. BrevoService fallback appears to work (no errors in logs). - Day 1-14: New users register but never receive verification emails. They contact support. Support manually verifies accounts. Nobody connects it to the password rotation. - Day 14: Resend key rotated. OTP and password reset emails break. Users can't log in with 2FA. Password reset is dead. - Day 14: Full email outage. Finally escalated. - Day 15: Root cause found — 3 different code paths, 2 different config vars, 1 error-swallowing service.
Root causes:
1. Dual infrastructure (MAIL_TYPE vs EMAIL_SERVICE) — one fix doesn't fix both
2. BrevoService swallows errors — team thinks emails are sending when they're not
3. No email delivery monitoring or alerting
4. No health check endpoint for email sending
Business impact: - 14 days of users unable to verify accounts - Unknown number of users churned during "silent failure" period - Support costs for manual verification - Loss of 2FA and password reset for all users during full outage
Prevention:
1. ADR-1 (unify email infrastructure) — single code path, single config var
2. Fix BrevoService error swallowing — re-throw so callers know it failed
3. Add email health check: GET /api/v1/health/email that sends test email on startup
4. Add delivery monitoring: log success/failure for every email send with recipient and type
Incident 3: "The Permission Trap" — Content Team Lockout¶
What happened: The product team hired content editors and gave them a "Content Manager" role with blog and page permissions. The editors were told they could also manage email templates. They navigate to the email templates page — blank. No data, no actions visible.
Investigation reveals:
1. email-template.* permissions don't exist in the database (never seeded)
2. Only super_admin can access email templates (hardcoded bypass)
3. The CTO decides to "just add the permissions" — but there's no admin UI to create individual permissions by string name. The permission management UI only shows existing permissions.
4. A developer manually INSERTs permission rows. But they need to know the exact strings: email-template.view, email-template.read, email-template.create, email-template.update, email-template.delete, email-template.restore. They guess email-template.trash (which the frontend checks but backend doesn't).
5. Permissions created and assigned. But getAllTemplates (GET /all) still has no permission check — content editors see all templates even before role update.
Root cause: Permissions never seeded. Module was only tested as super_admin.
Likelihood: CERTAIN — this happens the moment the first non-super_admin user tries the feature.
Business impact: - Content team blocked for days - Developer time wasted on manual DB operations - Exposed the broader pattern: how many other modules have unseeded permissions?
Prevention: Add email-template.* permissions to src/seed/data/permission.ts. Run seed as part of deployment. Test with non-super_admin user.
Incident 4: "The Wasted Sprint" — Templates That Don't Send¶
What happened: A new developer spent 2 weeks designing 12 beautiful email templates in the visual builder: welcome emails, payment receipts, subscription renewals, account alerts. They saved all templates with appropriate type values. They demo'd to the team — everyone loved the designs.
Then the developer tried to connect them to the actual email sending. They searched for where templates are loaded by type. Nothing. They traced the registration email flow: auth.service.ts → registerEmailTemplate() → hardcoded HTML. They traced the OTP flow: sendLoginOtp.handler.ts → otpEmailTemplate() → hardcoded function.
The templates in the database are never read by any email sending code.
The developer raised the issue. The team had two options: 1. Build the template resolver (ADR-2, ~6 hours) 2. Accept the 2 weeks were wasted and continue with hardcoded templates
They chose option 2 because they were in a feature freeze. The 12 templates sit in the database unused.
Root cause: The type field on EmailTemplate entity implies lookup-by-type, but resolveTemplate() was never built. The entity comment even lists forgot_password, verify_email, booking_reminder as example types — the intent was clear, the implementation was never done.
Business impact: - 2 weeks of developer time wasted (~$5,000-10,000) - Team morale hit - Templates need to be rebuilt or maintained when resolver is eventually implemented
Prevention: ADR-2 (connect DB templates to sending). Or at minimum, document prominently: "Templates are design-only. They are NOT connected to email sending."
Incident 5: "The Config Nightmare" — Deployment Debugging¶
What happened: A DevOps engineer deploying to a new environment reads .env.example and configures all listed variables. Email sending is configured with Brevo (best deliverability for the region).
They set MAIL_TYPE=brevo. But MAIL_TYPE only supports nodemailer and resend — the switch falls through to undefined. They don't set EMAIL_SERVICE because it's not in .env.example. So EmailFactory throws: "Unknown email service type: undefined".
3 hours of debugging to understand:
- Two config vars control two different code paths
- MAIL_TYPE only supports 2 providers
- EMAIL_SERVICE supports 3 providers
- Neither is fully documented
- BREVO_API_KEY isn't in .env.example either
Root cause: Dual infrastructure with inconsistent config. Missing env vars from .env.example.
Business impact: - 3+ hours of DevOps time per deployment - Risk of partial email failure in every new environment - On-call incidents when email breaks at 2 AM
Prevention:
1. ADR-1 (unify to single EMAIL_SERVICE var)
2. Add all email vars to .env.example with clear comments
3. Add startup validation: if EMAIL_SERVICE is set, verify the corresponding API key exists
Incident 6: "The XSS Escalation" — Admin Account Compromise¶
What happened: A disgruntled former employee who still had a basic user account (never deprovisioned) used the orderBy SQL injection to insert a malicious template. When the CTO previewed email templates in the admin dashboard, the XSS payload fired. The script called PATCH /users/profile with { roleId: 1 } — the self-role-change vulnerability documented in the RBAC deep-dive. The former employee's account became super_admin.
They then: - Exported the entire user database via the admin API - Changed the Stripe webhook URL to their own endpoint - Deleted the audit logs - Created 3 backdoor admin accounts with innocuous names
Discovery took 5 days. By then, 47 subscription payments had been redirected.
Root causes (chain of 4 vulnerabilities):
1. SQL injection (C1) — allowed DB write
2. No HTML sanitization (H10) — allowed XSS execution
3. Self-role-change in /users/profile — allowed privilege escalation
4. No user deprovisioning process — former employee retained access
Business impact: - Financial loss from redirected payments - Complete security breach - Legal liability - Likely end of the business
Prevention: P0 security fixes (orderBy whitelist + DOMPurify) block the entire chain. Defense-in-depth requires fixing the self-role-change vulnerability too (RBAC sprint plan).
Incident 7: "The Runtime Crash" — Page Size Bug¶
What happened: A support agent trying to find a specific email template changed the "items per page" dropdown from 10 to 25. The page crashed with a white screen.
The function is referenced at email-templates-page.jsx:668 in the dropdown's onChange handler but never defined in the component.
Root cause: Missing function definition. Likely deleted during a refactor but the reference wasn't removed.
Business impact: Low — page reload fixes it. But embarrassing in production.
Prevention: Define the function or remove the reference. Any basic test catches this.
Key Takeaways¶
The 45-Minute Rule¶
Incidents 1 and 6 are existential threats preventable with 45 minutes of work:
- 15 min: Whitelist orderBy values → blocks SQL injection
- 30 min: Add DOMPurify to preview → blocks stored XSS
Every day shipped without these fixes is a day the entire database is one GET request away from exfiltration.
The Silent Failure Problem¶
Incident 2 is the most insidious. Silent email failure means users silently churn. The dual infrastructure means every config change is a game of "which emails broke this time?" BrevoService's error swallowing makes failures invisible.
The Certainty Problem¶
Incident 3 isn't a risk — it's a certainty. The first non-super_admin user who tries email templates hits a wall. 10 minutes of seeding prevents days of confusion.
The Intent-vs-Reality Gap¶
Incident 4 reveals the deepest architectural issue: the entire email template system was designed with sending integration in mind (the type field, the purpose dropdown, the entity comments), but the integration was never built. The visual builder is a beautiful dead end.
Cross-References¶
- Attack scenarios (chains 1, 2, 6):
docs/extra-docs/email-template-attack-scenarios.md - ADRs (fixes for incidents 2, 4, 5):
docs/extra-docs/email-template-adrs.md - RBAC self-role-change (incident 6):
docs/deep-dive-user-management-rbac.md - Deep-dive (all 52 issues):
docs/deep-dive-email-template-system.md
Generated by BMAD Advanced Elicitation — Pre-mortem Analysis method | 2026-02-25