Skip to content

Email Template System — 5 Whys Root Cause Analysis

Generated: 2026-02-25 | Method: 5 Whys Deep Dive (why chain → root cause → solution) Source Deep-Dive: docs/deep-dive-email-template-system.md Chains Analyzed: 5 symptom-to-root-cause chains covering disconnected templates, dual infrastructure, unseeded permissions, phantom endpoint, and duplicate transports


Executive Summary

All 5 architectural problems in the email template system trace back to 3 systemic root causes: zero test coverage, super_admin-only development, and no cross-cutting integration tracking. These root causes reinforce each other in a cycle that produces additive (rather than replacement) development, invisible permission gaps, and disconnected feature silos.


Chain 1: Why Are DB Templates Disconnected From Email Sending?

Symptom: The email_templates table stores templates that are never read by any email sending code.

Depth Question Answer
Why 1 Why are DB templates never used for sending? No code queries the EmailTemplate entity when sending. Event handlers call hardcoded TypeScript functions in src/template/ directly.
Why 2 Why do event handlers use hardcoded templates? Hardcoded templates were built first (auth system). The EmailTemplate CRUD module was built later as a separate feature.
Why 3 Why wasn't the CRUD module connected when built? The visual builder alone was ~28 components, ~7,400 LOC. The developer shipped the builder without the integration layer. The type field shows the intent was there, but resolveTemplate() was never written.
Why 4 Why was the connecting function never written? The builder "works" standalone — saves, loads, previews, exports HTML. Within its own scope, the feature was complete. Integration was implicitly deferred.
Why 5 Why wasn't the integration tracked? No TODO, no ticket, no code comment marking this as debt. The type field with example values (forgot_password, verify_email) is the only breadcrumb. The feature was considered "done."

Root Cause: Feature built in isolation without a cross-cutting integration plan. Builder and sending pipeline developed as separate workstreams with no connecting tissue. No tracking of the gap — the debt became invisible.

Pattern: "Works in isolation, dead in context" — each piece functions within its boundary, but the pieces were never wired together.

Fix: ADR-2 (templateResolver.ts, ~6h). Process fix: any feature touching email sending should trace the full flow from trigger to inbox.


Chain 2: Why Do Two Parallel Email Infrastructures Exist?

Symptom: helper/email.ts (MAIL_TYPE) and lib/email/EmailFactory.ts (EMAIL_SERVICE) do the same thing through different code paths.

Depth Question Answer
Why 1 Why do both exist? Built at different times. Helper was original. Factory was a later refactoring toward cleaner architecture.
Why 2 Why wasn't the helper replaced? Factory was an additive change, not a replacement. Only 2 of 5 handlers migrated. Other 3 still use the helper.
Why 3 Why were only 2 handlers migrated? Developer who built the factory only updated handlers they were working on (registerEmail, inviteUserCreate). Other 3 were in different files.
Why 4 Why was the migration never completed? Both paths "work." No visible failure. No tracking, no TODO, no ticket for remaining migration.
Why 5 Why prefer adding new code over completing migration? Zero test coverage. Refactoring without tests means you can't verify the 5 email types still work. Adding alongside is safer.

Root Cause: Zero test coverage makes refactoring risky → incentivizes additive development → parallel systems never consolidated.

Pattern: "Half-migration" — new infrastructure built but old never removed because it functions and there are no tests to validate the switch.

Fix: ADR-1 (unify onto factory, ~4h). Deeper fix: test coverage for email sending.


Chain 3: Why Are Email-Template Permissions Not Seeded?

Symptom: Zero email-template.* permissions in database. Non-super_admin users permanently locked out.

Depth Question Answer
Why 1 Why aren't permissions in the seed? permission.ts was written for original modules and never updated when EmailTemplate was added.
Why 2 Why wasn't it updated? Developer tested with super_admin, which bypasses all permission checks via hardcoded role === 'super_admin' short-circuit. Never experienced "no permission."
Why 3 Why does super_admin bypass mask this? checkPermissionAndThrow returns early for super_admin without querying the permission table. Happy path works perfectly, hiding missing rows.
Why 4 Why isn't there a gate that catches this? index.md step 5 says "Add permissions via seed or API" but it's guidance, not enforcement. No CI check, no startup validation.
Why 5 Why no automated permission validation? RBAC designed for runtime flexibility (permissions created via API). No compile-time or startup-time check that strings referenced in checkPermissionAndThrow() exist in the database.

Root Cause: Super_admin bypass masks missing permissions during development. Only happy path tested. No automated validation.

Pattern: "God-mode development" — testing as highest-privilege user makes permission issues invisible. Same pattern found in RBAC deep-dive (#76).

Fix: Seed permissions (10 min). Structural fix: startup validator scanning checkPermissionAndThrow('X') calls and verifying X exists in permission table.


Chain 4: Why Does the Frontend Call a Non-existent Endpoint?

Symptom: useBulkPermanentDeleteEmailTemplates calls DELETE /bulk-permanent-delete → always 404.

Depth Question Answer
Why 1 Why does the hook call a non-existent route? Frontend API hooks written speculatively — developer anticipated needing bulk permanent delete. Backend route was never implemented.
Why 2 Why wasn't the backend route implemented? Single-item permanent delete was sufficient for MVP. Bulk was deferred.
Why 3 Why wasn't the unused hook removed? No integration testing verifying frontend hooks match backend routes. Hook only triggered from trash tab bulk actions — rare path.
Why 4 Why hasn't anyone noticed? Trash tab bulk-permanent-delete is a rare user action. Failure likely shows a toast notification — easy to dismiss.
Why 5 Why no integration testing? Same root cause: zero test coverage. No API contract tests anywhere. Frontend and backend developed independently.

Root Cause: Speculative frontend development without backend contract verification, enabled by zero test coverage.

Pattern: "Optimistic interface" — frontend assumes backend will implement an endpoint, codes against it preemptively. When backend doesn't deliver, dead call persists.

Fix: Implement route (~30 min) or remove hook. Structural fix: API contract testing.


Chain 5: Why Do 3 Duplicate Nodemailer Transports Exist?

Symptom: Three createTransport() calls with same credentials but different TLS configs.

Depth Question Answer
Why 1 Why 3 transports? Each subsystem created its own: helper/transporterMail.ts (legacy), lib/email/transporterMail.ts (library, dead), NodeMailerService.ts (factory).
Why 2 Why didn't the library reuse the helper's? Library (lib/email/) designed as clean-room implementation with IEmailService interface. Didn't import from helper/.
Why 3 Why didn't the factory reuse either? NodeMailerService is a class owning its transport lifecycle (create in constructor, use in sendEmail()). Importing module-level transport would break encapsulation.
Why 4 Why was lib/email/transporterMail.ts never deleted? No dead code detection in CI. No one tracks which files are imported and which are orphaned. File sits harmlessly.
Why 5 Why different TLS configs? Each written at different time with different security assumptions. No security review standardized the configuration.

Root Cause: Clean-room reimplementation pattern — each new subsystem builds its own infrastructure from scratch. No dead code detection, no security config standard.

Pattern: "NIH (Not Invented Here) layering" — each layer reinvents what the previous built, creating duplication that accumulates.

Fix: ADR-1 consolidates to single NodeMailerService. Delete 2 orphaned transports.


Meta-Analysis: 3 Systemic Root Causes

All 5 chains converge on three root causes:

Root Cause #1: Zero Test Coverage

Chains affected: 2, 4, 5

How it manifests: - Can't verify refactors → safer to add new code than replace old → half-migrations (Chain 2) - No contract testing → frontend/backend drift undetected (Chain 4) - No dead code detection → orphaned files accumulate (Chain 5)

Impact: Every architectural improvement becomes a high-risk manual effort. The codebase grows additively, never subtracting.

Root Cause #2: Super_admin-Only Development

Chains affected: 3

How it manifests: - Permission bypass masks missing seed data → modules ship "working" but broken for real users - Same pattern across multiple modules (RBAC deep-dive #76, email templates, likely others)

Impact: Every new module potentially ships with invisible permission gaps that only surface when non-super_admin users try to use it.

Root Cause #3: No Cross-Cutting Integration Tracking

Chains affected: 1, 2, 4

How it manifests: - Features ship in isolation → disconnected systems (Chain 1: builder vs sending) - Migrations start but never complete → parallel systems (Chain 2: helper vs factory) - Speculative code has no verification loop → dead hooks (Chain 4: phantom endpoint)

Impact: Features work within their boundaries but fail to connect. Technical debt is invisible because there's no tracking system to surface it.

The Reinforcing Cycle

    ┌──────────────┐
    │   No Tests   │◄──── Testing feels daunting
    └──────┬───────┘      (more untested code)
           │                      ▲
           ▼                      │
    ┌──────────────┐       ┌──────────────┐
    │   Can't      │──────►│   Additive   │
    │   Refactor   │       │   Development│
    │   Safely     │       │   (new code  │
    └──────────────┘       │   alongside  │
                           │   old code)  │
                           └──────┬───────┘
                           ┌──────────────┐
                           │   Parallel   │
                           │   Systems    │
                           │   Accumulate │
                           └──────────────┘

Breaking the cycle requires attacking Root Cause #1 first. Even basic test coverage (integration tests for 5 email types, contract tests for frontend API hooks) would have prevented Chains 2, 4, and 5. Chain 1 (disconnected templates) requires process change. Chain 3 requires a development practice change (test as non-super_admin).


Fix Effort Prevents
Add integration tests for email sending (5 email types) 4 hrs Future half-migrations, silent regressions
Add API contract test script (hit each frontend-called endpoint, verify non-404) 2 hrs Frontend/backend drift, phantom endpoints
Add startup permission validator (scan code for permission strings, verify they exist in DB) 3 hrs Missing permissions across ALL modules
Adopt "test as non-super_admin" development practice 0 hrs Permission gaps, RBAC blind spots
Add dead code detection to CI (ts-prune or similar) 1 hr Orphaned files, unused imports accumulating

Total: ~10 hours of structural investment that prevents the systemic issues from recurring in every future module.


Cross-References


Generated by BMAD Advanced Elicitation — 5 Whys Deep Dive method | 2026-02-25