Email Template System - Deep Dive Documentation¶
Generated: 2026-02-25 Scope: Full-stack — Backend EmailTemplate module + shared email services + entity + frontend visual builder + legacy v1 Files Analyzed: 44 Lines of Code: ~10,600+ Workflow Mode: Exhaustive Deep-Dive Known Issues Found: 60 (6 Critical, 10 High, 23 Medium, 21 Low) — Expert Panel reviewed: 5 recalibrated, 9 added, 1 removed Security Analysis: Red Team Attack Scenarios — 5 attack chains, 2 CRITICAL (SQL injection → DB compromise, Stored XSS → privilege escalation) Architecture Decisions: ADRs — 3 ADRs: unify email infra (4h), connect DB templates to sending (6h), delete v1 (5min) Pre-mortem Analysis: Failure Scenarios — 7 incidents: 2 catastrophic (SQL injection breach, XSS takeover), 1 certain (permission lockout), 1 insidious (silent email failure) Root Cause Analysis: 5 Whys — 5 chains → 3 systemic causes: zero test coverage, super_admin-only dev, no integration tracking
Overview¶
The Email Template System is a full-stack feature consisting of a backend CRUD module for managing email templates in the database, a visual drag-and-drop email builder on the frontend (v2), a legacy monolithic builder (v1), and an email sending infrastructure with three providers (Nodemailer, Resend, Brevo) using a factory pattern.
Critical Architectural Finding: The database-stored templates managed by the visual builder are completely disconnected from the actual email sending pipeline. All emails are sent using hardcoded TypeScript template functions in src/template/. The EmailTemplate CRUD module is a design/management tool with zero integration into email dispatch.
Purpose: Enable admins to design, manage, and store email templates via a visual builder; provide email sending infrastructure for transactional emails (verification, OTP, password reset, invitations).
Key Responsibilities: - Template CRUD with soft-delete, bulk operations, restore, trash management - Visual email builder with 11 element types, drag-and-drop, undo/redo, HTML import/export - 5 template presets (Purchase, Verification, Order, Invitation, Password Reset) - Email dispatch via 3 providers (Nodemailer, Resend, Brevo) with factory pattern - Transactional email templates for auth flows (hardcoded, separate from DB templates)
Integration Points: Auth system (email verification, OTP, password reset), Invite system, RBAC permissions, Config/environment variables, Database (PostgreSQL + TypeORM)
Architecture Overview¶
┌─────────────────────────────────────────────────┐
│ EMAIL TEMPLATE SYSTEM │
│ │
│ ┌──────────────┐ ┌────────────────────┐ │
│ │ DESIGN PATH │ │ SENDING PATH │ │
│ │ (Connected) │ │ (Disconnected) │ │
│ │ │ │ │ │
│ │ Frontend │ │ Event Handlers │ │
│ │ Builder UI │ │ ├ registerEmail │ │
│ │ │ │ │ ├ sendLoginOtp │ │
│ │ ▼ │ │ ├ sendForgotPass │ │
│ │ API CRUD │ │ ├ inviteUserCreate│ │
│ │ 10 endpoints│ │ └ inviteOnlyLogin │ │
│ │ │ │ │ │ │ │
│ │ ▼ │ │ ▼ │ │
│ │ email_ │ │ src/template/ │ │
│ │ templates │ │ (5 hardcoded │ │
│ │ (DB table) │ │ TS functions) │ │
│ │ │ │ │ │ │
│ │ ╳ DEAD END │ │ ▼ │ │
│ │ Never read │ │ EmailFactory OR │ │
│ │ for sending │ │ helper/email.ts │ │
│ └──────────────┘ │ │ │ │
│ │ ▼ │ │
│ │ SMTP / Resend / │ │
│ │ Brevo API │ │
│ └────────────────────┘ │
└─────────────────────────────────────────────────┘
Dual Email Sending Infrastructure¶
The system has two parallel, incompatible email sending architectures:
| Architecture | Config Key | Env Var | Providers | Used By |
|---|---|---|---|---|
Path A: helper/email.ts (Legacy) |
config.mail.type |
MAIL_TYPE |
Nodemailer, Resend | sendLoginOtp, sendForgotPass, inviteOnlyLogin |
Path B: lib/email/EmailFactory.ts (Newer) |
config.mail.service |
EMAIL_SERVICE |
Nodemailer, Resend, Brevo | registerEmail, inviteUserCreate |
Both must be configured independently via different environment variables.
Complete File Inventory¶
Backend — EmailTemplate Module (4 files, ~460 LOC)¶
| # | File | LOC | Purpose |
|---|---|---|---|
| 1 | saas-boilerplate/src/app/modules/v1/EmailTemplate/email_template.routes.ts |
~35 | 10 route definitions with auth + contextMiddleware |
| 2 | saas-boilerplate/src/app/modules/v1/EmailTemplate/email_template.controller.ts |
~110 | 10 controller actions mapping routes to service |
| 3 | saas-boilerplate/src/app/modules/v1/EmailTemplate/email_template.service.ts |
~260 | Business logic: CRUD, search, pagination, soft-delete, bulk ops |
| 4 | saas-boilerplate/src/app/modules/v1/EmailTemplate/email_template.validation.ts |
~55 | Zod schemas: createTemplate, updateTemplate, deleteByIds, bulkRestore |
Backend — Email Services (9 files, ~285 LOC)¶
| # | File | LOC | Purpose |
|---|---|---|---|
| 5 | saas-boilerplate/src/lib/email/IEmailService.ts |
6 | Interface: single sendEmail(payload) method |
| 6 | saas-boilerplate/src/lib/email/email.type.ts |
5 | Type: IEmailSendPayload { to, subject, html } |
| 7 | saas-boilerplate/src/lib/email/EmailFactory.ts |
32 | Singleton factory: selects provider via EMAIL_SERVICE env |
| 8 | saas-boilerplate/src/lib/email/NodeMailerService.ts |
55 | SMTP email via nodemailer |
| 9 | saas-boilerplate/src/lib/email/ResendService.ts |
25 | Resend API email |
| 10 | saas-boilerplate/src/lib/email/BrevoService.ts |
47 | Brevo API email |
| 11 | saas-boilerplate/src/lib/email/email.ts |
33 | Orphaned standalone sender (DEAD CODE) |
| 12 | saas-boilerplate/src/lib/email/transporterMail.ts |
19 | Orphaned nodemailer transport (DEAD CODE) |
| 13 | saas-boilerplate/src/lib/email/template/signupEmailVerification.ts |
13 | Orphaned template (DEAD CODE, duplicate of registerEmailTemplate) |
Backend — Legacy Email Helpers (3 files, ~97 LOC)¶
| # | File | LOC | Purpose |
|---|---|---|---|
| 14 | saas-boilerplate/src/helper/email.ts |
29 | ACTIVE legacy dispatcher: routes to nodemailer or resend via MAIL_TYPE |
| 15 | saas-boilerplate/src/helper/transporterMail.ts |
14 | Nodemailer transport used by helper/email.ts |
| 16 | saas-boilerplate/src/helper/email/resend.ts |
54 | Resend with attachment support, used by helper/email.ts |
Backend — Hardcoded Templates (5 files, ~65 LOC)¶
| # | File | LOC | Purpose |
|---|---|---|---|
| 17 | saas-boilerplate/src/template/registerEmailTemplate.ts |
~13 | Signup verification email (used by auth.service) |
| 18 | saas-boilerplate/src/template/otpEmailTemplate.ts |
~13 | Login OTP email |
| 19 | saas-boilerplate/src/template/resetPassTemplate.ts |
~13 | Password reset email |
| 20 | saas-boilerplate/src/template/inviteUserEmailTemplate.ts |
~13 | User invitation email |
| 21 | saas-boilerplate/src/template/inviteOnlyTemplate.ts |
~13 | Invite-only login credentials email |
Backend — Entity (1 file, ~35 LOC)¶
| # | File | LOC | Purpose |
|---|---|---|---|
| 22 | saas-boilerplate/src/entity/EmailTemplate.ts |
35 | TypeORM entity: 7 columns + timestamps + soft-delete |
Frontend — API Layer (1 file, 238 LOC)¶
| # | File | LOC | Purpose |
|---|---|---|---|
| 23 | Sass-boilerplate-frontend-v1/src/api/email-templates/index.js |
238 | 10 React Query hooks (CRUD + bulk + restore) |
Frontend — v2 Components (20 files, ~6,372 LOC)¶
| # | File | LOC | Purpose |
|---|---|---|---|
| 24 | email-templates-page.jsx |
821 | List page: DataTable, search, tabs (Active/Draft/Trash), CRUD actions |
| 25 | email-template-edit-page.jsx |
43 | Edit wrapper: fetches template by ID, renders form |
| 26 | email-template-form.jsx |
549 | Core builder: element list, DnD, undo/redo, preview, save/export |
| 27 | email-preview.jsx |
482 | Static HTML preview with iframe rendering |
| 28 | interactive-email-preview.jsx |
1025 | Click-to-select preview with element highlighting |
| 29 | element-editor.jsx |
95 | Editor panel: delegates to content + style sub-editors |
| 30 | element-editor-content.jsx |
733 | Content editing per element type (text, URLs, images, items) |
| 31 | element-editor-style.jsx |
772 | Style editing (colors, sizes, padding, alignment, fonts) |
| 32 | element-toolbox.jsx |
75 | Sidebar palette of 11 draggable element types |
| 33 | toolbar.jsx |
129 | Top bar: undo/redo, device preview, HTML import/export |
| 34 | save-template-modal.jsx |
248 | Save dialog: title, type (11 purpose options), subject fields |
| 35 | html-export-modal.jsx |
79 | Copy/download generated HTML |
| 36 | html-import-modal.jsx |
225 | Paste/upload HTML for import to builder |
| 37 | sortable-element.jsx |
161 | DnD element wrapper (@dnd-kit) with action buttons |
| 38 | template-selector-modal.jsx |
121 | Choose from 5 preset templates |
| 39 | global-styles-panel.jsx |
248 | Container/body/font/link global styles |
| 40 | form-fields.jsx |
125 | Reusable form field components (text, select, color, range, checkbox) |
| 41 | mobile-bottom-nav.jsx |
48 | Mobile: bottom navigation tabs |
| 42 | mobile-properties-drawer.jsx |
154 | Mobile: swipe-up properties panel |
| 43 | mobile-toolbox-drawer.jsx |
140 | Mobile: swipe-up toolbox drawer |
Frontend — Utilities (6 files, ~2,178 LOC)¶
| # | File | LOC | Purpose |
|---|---|---|---|
| 44 | utils/constants.js |
295 | Element types, defaults, social networks, purpose options, history actions |
| 45 | utils/html-generator.js |
175 | Elements + globalStyles → full HTML5 email document |
| 46 | utils/html-parser.js |
845 | HTML string → elements array + globalStyles object |
| 47 | utils/index.js |
21 | Barrel re-exports (has circular import issue) |
| 48 | utils/template-presets.js |
804 | 5 preset templates with pre-configured elements |
| 49 | utils/utils.js |
38 | generateId() + historyReducer for undo/redo |
Frontend — Legacy v1 (2 files, ~1,783 LOC)¶
| # | File | LOC | Purpose |
|---|---|---|---|
| 50 | email-templates-v1/email-temlate-form.jsx (typo) |
~1,588 | Monolithic builder: 8 element types, no undo, no import |
| 51 | email-templates-v1/email-template-page.jsx |
195 | List page with mock data, links to nonexistent edit route |
Frontend — Page Routes (4 files)¶
| # | Route | File | Permission |
|---|---|---|---|
| 52 | /dashboard/email-templates |
email-templates/page.js |
email-template.view |
| 53 | /dashboard/email-templates/create |
email-templates/create/page.js |
NONE (missing!) |
| 54 | /dashboard/email-templates/[id] |
email-templates/[id]/page.js |
email-template.create (wrong — should be .update) |
| 55 | /dashboard/email-templates-v1/create |
email-templates-v1/create/page.js |
NONE |
Database Schema¶
email_templates Table¶
CREATE TABLE email_templates (
id SERIAL PRIMARY KEY,
title VARCHAR(255) DEFAULT NULL,
type VARCHAR(255) NOT NULL UNIQUE,
subject VARCHAR(255) DEFAULT NULL,
elements JSONB NOT NULL,
html TEXT NOT NULL,
"createdAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deletedAt" TIMESTAMP DEFAULT NULL
);
| Column | DB Type | Nullable | Default | Unique | Notes |
|---|---|---|---|---|---|
id |
SERIAL | NOT NULL | auto-inc | PK | @PrimaryGeneratedColumn() |
title |
VARCHAR(255) | YES | null |
No | Template display name |
type |
VARCHAR(255) | NOT NULL | none | YES | e.g. forgot_password, verify_email, booking_reminder |
subject |
VARCHAR(255) | YES | null |
No | Email subject line |
elements |
JSONB | NOT NULL | none | No | Builder element array (see Data Model) |
html |
TEXT | NOT NULL | none | No | Generated HTML output |
createdAt |
TIMESTAMP | NOT NULL | NOW() |
No | Auto-managed |
updatedAt |
TIMESTAMP | NOT NULL | NOW() |
No | Auto-managed |
deletedAt |
TIMESTAMP | YES | null |
No | Soft-delete (@DeleteDateColumn) |
Relationships: None. Completely standalone entity — no foreign keys to or from any other table.
Migrations: None. Project uses synchronize: true in development. No migration files exist for any entity.
Seed Data: None. No email template records or permissions are seeded.
Indexes: Only PK (id) and UNIQUE (type). No index on deletedAt despite frequent soft-delete queries.
API Endpoints¶
Mounted at /api/v1/email-templates with auth() + contextMiddleware on all routes.
| # | Method | Path | Permission | Validation | Description |
|---|---|---|---|---|---|
| 1 | GET | /all |
NONE | None | All templates (no pagination, ignores trash) |
| 2 | GET | / |
email-template.view |
None | Paginated list with search, filters, ordering |
| 3 | GET | /:id |
email-template.read |
None | Single template by ID |
| 4 | POST | / |
email-template.create |
createTemplate |
Create (auto-restores trashed duplicate by type) |
| 5 | PATCH | /:id |
email-template.update |
updateTemplate |
Update template fields |
| 6 | PATCH | /:id/restore |
email-template.restore |
None | Restore from trash (checks type conflicts) |
| 7 | PATCH | /bulk-delete |
email-template.delete |
deleteTemplateByIds |
Soft-delete multiple |
| 8 | PATCH | /bulk-restore |
email-template.restore |
bulkEmailTemplateRestore |
Restore multiple (NO type conflict check!) |
| 9 | DELETE | /:id |
email-template.delete |
None | Soft-delete single |
| 10 | DELETE | /:id/permanent |
email-template.delete |
None | Permanent delete |
Note: All permission checks are done inside service methods via checkPermissionAndThrow(), not via route-level hasPermission middleware. This is inconsistent with how other modules handle permissions.
Frontend API Hooks (10 hooks + 1 phantom)¶
| Hook | Backend Route | Status |
|---|---|---|
useEmailTemplates |
GET / |
OK |
useEmailTemplateById |
GET /:id |
OK |
useCreateEmailTemplate |
POST / |
OK |
useUpdateEmailTemplate |
PATCH /:id |
OK |
useDeleteEmailTemplate |
DELETE /:id |
OK |
useBulkDeleteEmailTemplates |
PATCH /bulk-delete |
OK |
usePermanentDeleteEmailTemplate |
DELETE /:id/permanent |
OK |
useRestoreEmailTemplate |
PATCH /:id/restore |
OK |
useBulkRestoreEmailTemplates |
PATCH /bulk-restore |
OK |
useBulkPermanentDeleteEmailTemplates |
DELETE /bulk-permanent-delete |
404 — Route does not exist |
Email Template Data Model (Frontend Builder)¶
GlobalStyles Object¶
{
bodyBackground: "#f4f4f4", // Body/page background
containerBackground: "#ffffff", // Email container background
containerWidth: "600", // Container max-width (px)
containerPadding: "20", // Outer padding (px)
contentPadding: "30", // Inner content padding (px)
borderRadius: "8", // Container border-radius (px)
fontFamily: "Arial, sans-serif", // Base font
linkColor: "#007bff", // Default link color
baseFontSize: "14", // Base font size (px)
lineHeight: "1.6", // Base line-height
}
Element Types (11 types)¶
| Type | Key Properties | Notes |
|---|---|---|
heading |
content, level(h1-h6), color, fontSize, fontWeight, textAlign, backgroundColor, padding, margin | |
text |
content, color, fontSize, fontWeight, lineHeight, textAlign, backgroundColor, padding | |
image |
src (URL), alt, width(%), height, alignment, backgroundColor, padding, borderRadius | No upload — manual URL only |
link |
content, href, color, fontSize, textAlign, padding, textDecoration | |
button |
content, href, backgroundColor, color, fontSize, fontWeight, textAlign, padding, paddingHorizontal, borderRadius, width | |
spacer |
height, backgroundColor | |
divider |
color, height, width(%), margin | |
list |
items[], listType(ordered/unordered), color, fontSize, lineHeight, padding | |
social |
socialLinks[{name,url}], iconStyle(circle/rounded/square), iconSize, alignment, color, padding, backgroundColor | 7 networks with inline SVG |
columns |
columns(2-3), gap, padding, backgroundColor, textColor, col½/3Content | |
table |
rows, columns, cells[][], orientation, borders, header config, alternate row colors | Supports text + image cells |
All elements also have: id (string), type (string), isHidden (boolean, optional).
Template Presets (5)¶
| Preset | Elements | Theme Color | Key Features |
|---|---|---|---|
| Purchase Successful | 7 | Blue (#2563eb) | 2-col table with totals, CTA button |
| Email Verification | 10 | Green (#10b981) | Verification link, expiry notice |
| Order Processing | 10 | Amber (#f59e0b) | 4-col table with images, 3-col summary |
| Event Invitation | 16 | Purple (#8b5cf6) | Vertical table, social links |
| Password Reset | 18 | Red (#ef4444) | Security tips list, warning banner |
Email Sending Infrastructure¶
Provider Comparison¶
| Feature | NodeMailerService | ResendService | BrevoService | helper/email.ts |
|---|---|---|---|---|
| Path | Factory (B) | Factory (B) | Factory (B) | Legacy (A) |
| Error handling | Rejects (propagates) | No try/catch (bubbles) | Swallows (console.error only) | Propagates in prod, swallows in dev |
| From address | Hardcoded "Sass Template" <[email protected]> |
config.mail.user |
config.mail.user (no name) |
Same hardcoded "Sass Template" |
| Attachments | No | No | No | Yes (via helper/email/resend.ts) |
| Multiple recipients | No | No | No | Yes (via helper/email/resend.ts) |
| Verify before send | Yes (every call!) | No | No | No |
| TLS validation | Disabled (rejectUnauthorized: false) |
N/A (API) | N/A (API) | N/A |
Nodemailer Transport Instances (Triple!)¶
| # | Location | secure | TLS | Used By |
|---|---|---|---|---|
| 1 | lib/email/NodeMailerService.ts |
Yes (port === '465') |
rejectUnauthorized: false |
Factory path (newer) |
| 2 | lib/email/transporterMail.ts |
Yes (port === '465') |
rejectUnauthorized: false |
DEAD CODE (orphaned) |
| 3 | helper/transporterMail.ts |
No | No | Legacy path (active) |
IEmailSendPayload Declarations (Triple!)¶
| # | Location | Status |
|---|---|---|
| 1 | lib/email/email.type.ts |
Canonical |
| 2 | lib/email/email.ts |
Local duplicate |
| 3 | helper/email.ts |
Local duplicate |
Environment Variables¶
| Env Var | Purpose | In .env.example |
Used By |
|---|---|---|---|
MAIL_HOST |
SMTP host | Yes | NodeMailerService, both transporters |
MAIL_PORT |
SMTP port | Yes | NodeMailerService, both transporters |
MAIL_USER |
SMTP user / from | Yes | All services |
MAIL_PASS |
SMTP password | Yes | NodeMailerService, both transporters |
MAIL_TYPE |
Legacy dispatcher selector | Yes | helper/email.ts ("nodemailer" or "resend") |
RESEND_API_KEY |
Resend API key | Yes | ResendService, helper/email/resend.ts |
EMAIL_SERVICE |
Factory selector | NO | EmailFactory.ts ("nodemailer", "resend", "brevo") |
BREVO_API_KEY |
Brevo API key | NO | BrevoService |
MAIL_FROM |
From address | NO | UNUSED (configured but never read) |
Frontend Component Architecture¶
Component Hierarchy¶
email-templates/page.js
└── EmailTemplatesPage
├── DataTable (list view with tabs: Active/Draft/Trash)
├── ConfirmationModal (delete/restore dialogs)
└── EmailPreview (preview modal with dangerouslySetInnerHTML)
email-templates/create/page.js
└── EmailTemplateForm (builder)
├── Toolbar (undo/redo, device preview, import/export)
├── ElementToolbox (11 draggable element types)
├── SortableElement[] (@dnd-kit sortable list)
│ └── Action buttons (move up/down, duplicate, hide, delete)
├── ElementEditor
│ ├── ElementEditorContent (type-specific content fields)
│ └── ElementEditorStyle (type-specific style fields)
├── GlobalStylesPanel
├── InteractiveEmailPreview (click-to-select preview)
├── SaveTemplateModal (title, type, subject)
├── HtmlExportModal (copy/download HTML)
├── HtmlImportModal (paste/upload HTML)
├── TemplateSelectorModal (5 presets)
├── MobileBottomNav (responsive)
├── MobileToolboxDrawer (responsive)
└── MobilePropertiesDrawer (responsive)
email-templates/[id]/page.js
└── PermissionWrapper (email-template.create — WRONG, should be .update)
└── EmailTemplateEditPage
└── EmailTemplateForm (same builder, prefilled with existing data)
State Management¶
The EmailTemplateForm component manages ALL state locally via useState and useReducer:
| State | Type | Purpose |
|---|---|---|
elements |
Element[] |
Current element list (managed via historyReducer for undo/redo) |
selectedElementId |
string | null |
Currently selected element for editing |
globalStyles |
GlobalStyles |
Container/body styling |
activeTab |
string |
Active panel: "elements", "editor", "global", "preview" |
devicePreview |
string |
Preview mode: "desktop", "tablet", "mobile" |
showSaveModal |
boolean |
Save dialog visibility |
showExportModal |
boolean |
Export dialog visibility |
showImportModal |
boolean |
Import dialog visibility |
showPresetModal |
boolean |
Preset selector visibility |
No Redux, no Context — fully self-contained component state.
User Flow: Creating an Email Template¶
- Navigate to
/dashboard/email-templates(requiresemail-template.viewpermission in sidebar) - Click "Add New" button (requires
email-template.createpermission check) - Redirected to
/dashboard/email-templates/create(NO server-side permission check) - Choose a preset template from selector OR start blank
- Add elements from toolbox (drag-and-drop via @dnd-kit)
- Click elements to select → edit content and styles in side panel
- Preview in desktop/tablet/mobile modes
- Optionally import HTML from external source
- Click "Save" → fill title, type (purpose), subject in modal
- Template saved to
email_templatestable viaPOST /api/v1/email-templates - HTML is generated client-side via
generateFullHtml()and sent with the save request - Template sits in database — never used for actual email sending
User Flow: Editing an Email Template¶
- From list page, click edit icon on a template
- Navigated to
/dashboard/email-templates/[id] useEmailTemplateByIdfetches template dataelementsarray loaded into builder state- Edit as needed, save via
PATCH /api/v1/email-templates/:id
HTML Generation Pipeline¶
- Filters hidden elements (isHidden)
- Maps each element to inline-styled HTML via type-specific generators
- Wraps in table-based email-safe layout (outer table → container table → content)
- Full DOCTYPE, head (charset, viewport), body with background
HTML Import Pipeline¶
- Parses via browserDOMParser API
- Extracts global styles from body/container/content DOM structure
- Recursively walks DOM nodes, routing by tag name to type-specific parsers
- Distinguishes layout tables (presentation) from data tables (borders/th)
- Fallback aggressive search if no elements found
Generator/Parser Asymmetry¶
| Element Type | Generator | Parser | Round-trip |
|---|---|---|---|
| heading | Yes | Yes | OK |
| text | Yes | Yes | OK |
| image | Yes | Yes | OK |
| link | Yes | Yes | OK |
| button | Yes | Yes | OK |
| spacer | Yes | Yes | OK |
| divider | Yes | Yes | OK |
| list | Yes | Yes | OK |
| social | Yes | No | LOSSY — parsed as generic links |
| columns | Yes | No | LOSSY — parsed as layout or flat elements |
| table | Yes | Yes | OK |
| blockquote | No | Yes | LOSSY — imported but renders empty |
| code | No | Yes | LOSSY — imported but renders empty |
Dependency Graph¶
Backend Dependencies¶
email_template.routes.ts
├── email_template.controller.ts
│ └── email_template.service.ts
│ ├── EmailTemplate (entity)
│ ├── checkPermissionAndThrow (helper)
│ ├── getDbRepository (shared)
│ ├── parsePaginationQuery (helper)
│ └── requestContext (shared)
└── email_template.validation.ts
└── validateRequest (middleware)
EmailFactory.ts (NOT connected to EmailTemplate module)
├── NodeMailerService.ts
├── ResendService.ts
└── BrevoService.ts
helper/email.ts (NOT connected to EmailTemplate module)
├── helper/transporterMail.ts
└── helper/email/resend.ts
Frontend Dependencies¶
email-templates/page.js
└── email-templates-page.jsx
├── api/email-templates/index.js (10 hooks)
├── components/ui/* (DataTable, Dialog, etc.)
└── hooks/useCheckPermission.js
email-templates/create/page.js & [id]/page.js
└── email-template-form.jsx
├── utils/constants.js (ELEMENT_TYPES, DEFAULT_*)
├── utils/utils.js (generateId, historyReducer)
├── utils/html-generator.js (generateFullHtml)
├── utils/html-parser.js (parseHtmlToElements)
├── utils/template-presets.js (TEMPLATE_PRESETS)
├── sortable-element.jsx (@dnd-kit/sortable)
├── element-editor.jsx
│ ├── element-editor-content.jsx
│ └── element-editor-style.jsx
├── element-toolbox.jsx
├── toolbar.jsx
├── global-styles-panel.jsx
├── interactive-email-preview.jsx
├── save-template-modal.jsx
├── html-export-modal.jsx
├── html-import-modal.jsx
├── template-selector-modal.jsx
├── form-fields.jsx
├── mobile-bottom-nav.jsx
├── mobile-toolbox-drawer.jsx
└── mobile-properties-drawer.jsx
Entry Points (Not imported by others in scope)¶
email-templates/page.js— list pageemail-templates/create/page.js— create pageemail-templates/[id]/page.js— edit pageemail-templates-v1/create/page.js— legacy v1 createemail_template.routes.ts— backend route entry
Leaf Nodes (Don't import others in scope)¶
email.type.ts— type definitions onlyIEmailService.ts— interface onlyutils/constants.js— constants onlyutils/utils.js— utilities onlyform-fields.jsx— reusable form fields
Circular Dependencies¶
utils/index.jsline 19:export * from "../utils"resolves to itself (circular barrel re-export). Should beexport * from "./utils".
Testing Analysis¶
Test Coverage: 0% — No test files exist anywhere in the project.
Testing Gaps: - No unit tests for service CRUD operations - No unit tests for HTML generator (element → HTML) - No unit tests for HTML parser (HTML → elements) — this is the most complex file at 845 LOC - No integration tests for API endpoints - No component tests for the builder UI - No E2E tests for template creation workflow - No validation testing for edge cases (empty elements, malformed HTML, XSS payloads)
Suggested Tests:
1. html-generator.test.js — Test each element type generates correct HTML
2. html-parser.test.js — Test round-trip fidelity for each element type
3. email_template.service.test.ts — Test CRUD, soft-delete, restore, bulk operations
4. email_template.routes.test.ts — Test auth, permissions, validation
5. EmailFactory.test.ts — Test provider selection, singleton behavior
6. email-template-form.test.jsx — Test builder interactions, undo/redo, DnD
Security Analysis — Red Team Attack Chains¶
Full analysis:
docs/extra-docs/email-template-attack-scenarios.md
Attacker profile: Authenticated user with zero permissions (freshly registered).
| Chain | Attack | Feasibility | Impact | Overall |
|---|---|---|---|---|
| 1 | SQL Injection via orderBy → blind DB extraction of users, credentials, secrets |
HIGH (trivial) | CRITICAL | CRITICAL |
| 2 | Stored XSS (via Chain 1 DB write + dangerouslySetInnerHTML) → admin session hijack → self-role-change to super_admin |
HIGH (chained) | CRITICAL | CRITICAL |
| 3 | MITM email interception (rejectUnauthorized: false) → capture verification/reset/OTP tokens → account takeover |
MEDIUM | HIGH | HIGH |
| 4 | Permission bypass (getAllTemplates no check) → info disclosure of all template HTML/elements |
HIGH (trivial) | LOW | MEDIUM |
| 5 | Bulk restore type collision → UNIQUE constraint 500 error | LOW | LOW | LOW |
Critical path: Chain 1 → Chain 2 creates a realistic "new signup → super_admin in <5 minutes" scenario by combining orderBy SQL injection, dangerouslySetInnerHTML stored XSS, and the known PATCH /users/profile self-role-change vulnerability.
P0 fixes (block both CRITICAL chains in ~45 min):
1. Whitelist orderBy values (15 min)
2. Add DOMPurify to template preview (30 min)
Known Issues¶
Expert Panel Review (2026-02-25): Winston (Architect), Amelia (Dev), Quinn (QA) peer-reviewed all issues. 5 severity recalibrations, 9 new issues added, 1 removed (L9 was a valid re-export, not circular). Original 52 → 60.
Critical (6)¶
| # | Issue | Location | Description |
|---|---|---|---|
| C1 | SQL injection via orderBy |
email_template.service.ts:59 |
User-supplied query.orderBy interpolated directly into template.${query.orderBy} QueryBuilder without validation or whitelist. Exploitation: blind conditional injection in ORDER BY position (e.g., CASE WHEN (SELECT...) THEN id ELSE title END). Confirmed exploitable by panel. |
| C2 | getAllTemplates has NO permission check |
email_template.service.ts:18-27 |
GET /email-templates/all skips checkPermissionAndThrow(). Any authenticated user can read all templates including HTML and JSONB elements, bypassing RBAC. |
| C3 | email-template permissions NOT seeded | src/seed/data/permission.ts |
Zero email-template.* permissions exist in seed data. Non-super_admin users are permanently locked out of all email template operations since permissions cannot be granted (they don't exist in the Permission table). |
| C4 | EmailTemplate entity NEVER used for sending | All event handlers | DB-stored templates are managed via CRUD but never queried for email dispatch. All 5 event handlers use hardcoded TypeScript template functions from src/template/. The type field (e.g., forgot_password, verify_email) implies lookup-by-type intent, but this was never implemented. |
| C5 | Frontend calls non-existent endpoint | api/email-templates/index.js:209 |
useBulkPermanentDeleteEmailTemplates calls DELETE /email-templates/bulk-permanent-delete which does not exist in backend routes. Always returns 404. |
| C6 | Dual email infrastructure with different config | helper/email.ts vs lib/email/EmailFactory.ts |
Two parallel sending architectures use different env vars (MAIL_TYPE vs EMAIL_SERVICE). Setting one does NOT affect the other. Both must be configured since different event handlers use different paths. |
High (10)¶
| # | Issue | Location | Description |
|---|---|---|---|
| H1 | BrevoService silently swallows errors | BrevoService.ts:38-40 |
catch block only calls console.error without re-throwing. Caller gets resolved Promise even when sending fails. Inconsistent with other providers. |
| H3 | elements field has no Zod validation |
email_template.validation.ts |
JSONB elements field absent from all Zod schemas. Arbitrary JSON stored without schema validation. |
| H4 | bulkEmailTemplateRestore skips type conflict check |
email_template.service.ts |
Single restore checks for active templates with same type before restoring. Bulk restore blindly calls repo.restore(ids) — can violate UNIQUE constraint on type. |
| H5 | Edit page checks wrong permission | email-templates/[id]/page.js:9 |
PermissionWrapper checks email-template.create instead of email-template.update. User with create-only permission can access edit page. |
| H6 | Create page has no PermissionWrapper | email-templates/create/page.js |
Any authenticated user can navigate directly to /dashboard/email-templates/create and use the full builder UI. Backend rejects on save, but entire UI is exposed. |
| H7 | EMAIL_SERVICE missing from .env.example |
.env.example |
Factory path requires EMAIL_SERVICE env var but it's undocumented. Without it, getEmailService() throws "Unknown email service type: undefined". |
| H9 | handleItemsPerPageChange undefined |
email-templates-page.jsx:668 |
Function referenced in items-per-page dropdown onChange handler but never defined. Causes runtime error when changing page size. |
| H10 | dangerouslySetInnerHTML without sanitization (list preview) |
email-templates-page.jsx:805 |
Template HTML rendered in preview modal without DOMPurify or any sanitization. If malicious HTML is stored (via SQL injection C1 or direct API), it executes in admin browser (stored XSS). |
| H16 | Race condition on duplicate type (unhandled 500) | email_template.service.ts |
Create flow does check-then-insert without transaction. Concurrent requests can both pass check and violate UNIQUE constraint — returns raw PostgreSQL error to user. (Promoted from M16 by panel — user-facing 500 in realistic multi-user scenario.) |
| H17 | No pagination on getAllTemplates (DoS vector) |
email_template.service.ts:18-27 |
repo.find() with no limit. Combined with C2 (no permission check), any authenticated user can trigger unbounded memory allocation. 10K templates = massive JSON response, potential OOM. (NEW — found by panel.) |
Medium (23)¶
| # | Issue | Location | Description |
|---|---|---|---|
| M1 | tls: { rejectUnauthorized: false } |
NodeMailerService.ts:33, lib/email/transporterMail.ts |
Disables TLS certificate validation, allowing MITM attacks in production. |
| M2 | Hardcoded "Sass Template" from address | NodeMailerService.ts:40, helper/email.ts:15 |
"Sass" instead of "SaaS" in email sender name. Hardcoded instead of configurable. |
| M3 | NodeMailerService new Promise(async ...) |
NodeMailerService.ts:37 |
Explicit promise construction antipattern. Should be async method directly. |
| M4 | transporter.verify() on every send |
NodeMailerService.ts:39 |
Establishes SMTP connection to verify, then sends. Doubles latency per email. |
| M5 | ResendService from reads wrong config |
ResendService.ts:21 |
Uses config.mail.user (SMTP username) instead of config.resend.mail_user. |
| M6 | 'use server' directive in Express backend |
lib/email/email.ts:1 |
Next.js Server Actions directive is meaningless in Express.js. Copy-paste artifact. |
| M7 | signupEmailVerification.ts is duplicate dead code | lib/email/template/signupEmailVerification.ts |
Near-identical to src/template/registerEmailTemplate.ts but with fewer params (no name). Never imported. |
| M8 | BrevoService setApiKey() on every send |
BrevoService.ts:22 |
API key set inside sendEmail() instead of constructor. Redundant per-call operation. |
| M9 | MAIL_FROM configured but never used |
config/index.ts |
config.mail.from exists in config but no email service reads it. All hardcode their from addresses. |
| M10 | title and subject typed string but default null |
EmailTemplate.ts |
Entity declares title: string with default: null. TypeScript won't warn about null values. Should be string | null. |
| M11 | Frontend filter tabs have no backend support | email-templates-page.jsx |
"Active" and "Draft" tabs exist in UI but entity has no status field — only deletedAt for trash. |
| M12 | No image upload integration | element-editor-content.jsx |
Builder requires manual URL entry for images. No integration with existing Cloudinary/S3 upload infrastructure. |
| M13 | api.md incorrectly documents GET /all permission | docs/architecture/api.md:1340 |
Documents email-template.view permission for GET /all, but code has no permission check. |
| M14 | Frontend references phantom permission | email-templates-page.jsx:450 |
email-template.trash permission checked in UI but never defined or checked in backend. |
| M15 | HTML parser/generator asymmetry | html-generator.js, html-parser.js |
Social and columns elements can be generated but not parsed (lossy import). Blockquote and code can be parsed but not generated (render empty on re-export). |
| M17 | BREVO_API_KEY missing from .env.example |
.env.example |
Brevo provider requires this key but it's not listed. Only affects Brevo users. (Demoted from H8 by panel — conditional on provider choice.) |
| M18 | Triple nodemailer transporter instances | NodeMailerService.ts, lib/email/transporterMail.ts, helper/transporterMail.ts |
Three transport instances with same credentials but different TLS/security configs. Confusing but functionally harmless. (Demoted from H11 by panel — no user impact.) |
| M19 | No size limit on html field |
EmailTemplate.ts, email_template.validation.ts |
html column is TEXT (no length constraint). Zod schema doesn't validate length. A user could save a 50MB HTML document. (NEW — found by panel.) |
| M20 | Duplicate type returns raw 500 error |
email_template.service.ts (create) |
Creating a template with an existing active type doesn't check for active duplicates before INSERT. UNIQUE constraint violation produces raw PostgreSQL error, not 409 Conflict. (NEW — found by panel.) |
| M21 | No autosave in builder | email-template-form.jsx |
All state in component memory (useState/useReducer). Browser crash = all work lost. Undo/redo history is also in-memory only. (NEW — found by panel.) |
| M22 | interactiveEmailPreview renders HTML in same origin |
interactive-email-preview.jsx |
Complete template HTML rendered inside a div in the builder page. Script tags and event handlers execute in builder context. Same XSS risk as H10 but different location. (NEW — found by panel.) |
| M23 | No audit trail for template changes | email_template.service.ts |
No versioning, changelog, or updatedBy column. If template is maliciously modified, no forensic trail. Only updatedAt timestamp exists. (NEW — found by panel.) |
| M24 | No email delivery monitoring | All email services | No logging of send success/failure per recipient and type. BrevoService swallows errors silently. No health check endpoint. Silent failures invisible until user complaints. (NEW — found by panel, related to pre-mortem incident 2.) |
Low (21)¶
| # | Issue | Location | Description |
|---|---|---|---|
| L1 | console.log on every getEmailService() call |
EmailFactory.ts:5 |
Log fires every call, not just first instantiation. Spams logs in busy app. |
| L2 | No singleton reset for testing | EmailFactory.ts |
Module-scoped instance has no reset. Makes unit testing difficult. |
| L3 | Unused imports in BrevoService | BrevoService.ts |
CreateContact and ContactsApi imported but never used. |
| L4 | Inconsistent variable naming | NodeMailerService.ts |
this.transPorter with capital P. |
| L5 | Redundant html after spread |
ResendService.ts:21 |
...payload, html: payload.html — spread already includes html. |
| L6 | lib/email/email.ts is dead code |
lib/email/email.ts |
Standalone sender not imported anywhere. Orphaned alongside transporterMail.ts. |
| L7 | signupEmailVerification.ts is dead code |
lib/email/template/signupEmailVerification.ts |
Never imported. Hardcoded ${"there"} greeting (was meant to be parameterized). |
| L8 | Duplicate generateId implementations |
utils/utils.js vs utils/html-parser.js |
Different ID formats: el_{ts}_{random5} vs {random9}. Inconsistent but functional. |
| L10 | containerBorderRadius in presets never rendered |
template-presets.js |
Several preset elements include this property but html-generator.js ignores it. |
| L11 | v1 links to nonexistent edit route | email-template-page.jsx:163 (v1) |
Links to /dashboard/email-templates/edit/${id} which doesn't exist (v2 uses /${id}). |
| L12 | Legacy v1 still accessible | email-templates-v1/create/page.js |
Older monolithic builder accessible at /dashboard/email-templates-v1/create. |
| L13 | Commented-out slug column | EmailTemplate.ts:12-13 |
Dead code: // @Column({ unique: true }) slug: string; |
| L14 | No index on deletedAt |
EmailTemplate.ts |
Frequent soft-delete queries filter by deletedAt but no index exists for performance. |
| L15 | findByIds deprecated |
email_template.service.ts:250 |
TypeORM 0.3+ deprecates repo.findByIds(). Use repo.findBy({ id: In(ids) }). |
| L16 | api.md describes elements as "BlockNote blocks" | docs/architecture/api.md:1363 |
Elements are custom DnD builder format, not BlockNote. |
| L17 | fs.readFileSync blocks event loop |
helper/email/resend.ts |
Synchronous file read for attachment encoding blocks the Node.js event loop. |
| L18 | No XSS sanitization on template URLs | All hardcoded templates | verifyUrl, resetPassLink, otp interpolated into HTML without sanitization. |
| L19 | min(0) allows ID=0 |
email_template.validation.ts |
Zod validation for template IDs uses .min(0) but PostgreSQL serial starts at 1. |
| L20 | Dead test code in BrevoService | BrevoService.ts:25-33 |
Unused const email object with hardcoded test values. Never sent. Zero runtime impact. (Demoted from H2 by panel.) |
| L21 | No keyboard accessibility for DnD | sortable-element.jsx, email-template-form.jsx |
@dnd-kit supports keyboard sensors but only PointerSensor is configured. Keyboard/screen reader users cannot reorder elements. (NEW — found by panel.) |
| L22 | HTML export may leak internal URLs | html-export-modal.jsx |
Exported HTML includes any internal/staging URLs in link/button/image elements. Could leak when shared externally. (NEW — found by panel.) |
| L23 | Edit page has no loading/error states | email-template-edit-page.jsx |
Calls useEmailTemplateById(id) but doesn't handle loading or error. Missing template (deleted/wrong ID) shows undefined data with no feedback. (NEW — found by panel.) |
Severity Summary¶
| Severity | Count | Panel Changes |
|---|---|---|
| Critical | 6 | Unchanged (C1 attack vector clarified) |
| High | 10 | -H2(→L20), -H8(→M17), -H11(→M18), +M16(→H16), +NEW H17 |
| Medium | 23 | +H8(M17), +H11(M18), -M16(→H16), +NEW M19-M24 |
| Low | 21 | +H2(L20), -L9(removed — valid re-export), +NEW L21-L23 |
| Total | 60 | +9 new, -1 removed, 5 recalibrated |
Related Code & Reuse Opportunities¶
Similar Patterns Elsewhere¶
| Pattern | Email Template Module | Also Used In | Notes |
|---|---|---|---|
| Soft-delete + restore + bulk ops | EmailTemplate service | Blog, GenericPage, Product | Same @DeleteDateColumn + manual restore pattern |
checkPermissionAndThrow in service |
All methods except getAllTemplates |
User, Role, Blog, etc. | Standard RBAC pattern |
| Paginated list with search + order | templateList |
Most CRUD services | Common query builder pattern |
| React Query hooks (CRUD) | api/email-templates/ |
All 16 API hook domains | Same pattern: query + mutations + invalidation |
| DataTable with tabs | email-templates-page.jsx |
Users, Blogs, Products pages | Same AdvanceTable + tab filter pattern |
Reusable Utilities Available (Not Used by Email Templates)¶
| Utility | Path | Could Be Used For |
|---|---|---|
uploadImage / handleFileUpload |
src/helper/uploadImage.ts |
Adding image upload to builder (M12) |
CloudinaryStorage / AwsStorage |
src/shared/storage/ |
Image hosting for email template images |
sanitizeImageName |
src/shared/sanitizeImageName.ts |
Image URL sanitization |
DOMPurify |
Could add to frontend | Sanitizing preview HTML (H10) |
Modification Guidance¶
Detailed ADRs with implementation plans, code samples, and phasing:
docs/extra-docs/email-template-adrs.md
ADR-1: Unify Email Infrastructure (~4 hrs)¶
Consolidate all sending onto lib/email/EmailFactory.ts. Extend IEmailSendPayload with to: string | string[], optional attachments, optional from. Migrate 3 legacy handlers (sendLoginOtp, sendForgotPass, inviteOnlyLogin) to getEmailService().sendEmail(). Delete 6 legacy/dead files. Single EMAIL_SERVICE env var replaces both EMAIL_SERVICE and MAIL_TYPE.
ADR-2: Connect DB Templates to Sending (~6 hrs)¶
New lib/email/templateResolver.ts: query email_templates by type with 5-min TTL cache, interpolate {{variables}} via Mustache-like replacement, fall back to hardcoded TypeScript template if no DB record found. Update 5 event handlers (3-line change each). Seed 5 default templates matching current hardcoded HTML. Seed email-template.* permissions.
ADR-3: Delete Legacy v1 (~5 min)¶
Delete email-templates-v1/ directory (2 components + 1 route page). v2 is a complete superset. Git history preserves code.
To Add a New Element Type¶
- Add type constant to
utils/constants.js→ELEMENT_TYPES - Add default properties to
DEFAULT_ELEMENTS - Add content editor section in
element-editor-content.jsx - Add style editor section in
element-editor-style.jsx - Add HTML generation case in
html-generator.js - Add HTML parser case in
html-parser.js - Add icon and label in
element-toolbox.jsx
Implementation Phasing¶
Phase 1 — P0 Security (~45 min): Whitelist orderBy + DOMPurify preview
Phase 2 — ADR-3 (~5 min): Delete v1 builder
Phase 3 — ADR-1 (~4 hrs): Unify email infrastructure
Phase 4 — ADR-2 (~6 hrs): Connect DB templates to sending
Phase 5 — Follow-up (backlog): Insert Variable UI, elements validation,
sandboxed iframe, image upload integration
Testing Checklist for Changes¶
- Verify all 10 API endpoints return correct responses
- Test permission checks: each endpoint denies unauthorized users
- Test
getAllTemplates— confirm if permission bypass is intentional - Test create with duplicate
type— verify proper error handling - Test bulk restore — verify type conflict handling
- Test HTML generation for each element type
- Test HTML parser round-trip for each element type
- Test undo/redo with multiple operations
- Test drag-and-drop reordering
- Test HTML import with various email HTML formats
- Test preset template loading
- Test save/update with all field combinations
- Test trash/restore/permanent-delete flows
- Test mobile responsive builder UI
- Test
orderByparameter with SQL injection payloads - Test bulk-permanent-delete resolves to valid endpoint
Contributor Checklist¶
Risks & Gotchas:
- The orderBy SQL injection (C1) is the highest-priority security fix
- getAllTemplates permission bypass (C2) exposes all template data
- The dual email infrastructure means changes to email sending must be made in TWO places
- Frontend and backend have different permission expectations (e.g., email-template.trash only exists in frontend)
- The elements JSONB has no schema — any shape is accepted and stored
- HTML preview uses dangerouslySetInnerHTML — XSS risk if templates contain malicious content
Pre-change Verification Steps:
1. Check which email sending path your change affects (factory vs helper)
2. Verify permission strings match between frontend and backend
3. Test with non-super_admin user (permissions won't exist unless manually created)
4. Check both .env vars: MAIL_TYPE AND EMAIL_SERVICE
Suggested Tests Before PR:
1. Run the app, attempt template CRUD as non-super_admin — verify behavior
2. Test GET /email-templates/all without permission — should it be allowed?
3. Test orderBy=1;DROP TABLE-- on GET /email-templates
4. Test bulk operations with conflicting types
5. Create template, export HTML, import HTML — verify round-trip fidelity
Generated by document-project workflow (deep-dive mode)
Base Documentation: docs/index.md
Scan Date: 2026-02-25
Analysis Mode: Exhaustive