# Email Template Editor (`/settings/email-templates`)

**Date:** 2026-05-31  
**Status:** Draft  
**Spec:** 66  
**Tier:** Business+  
**Depends on:** `system-communications-notifications`, `custom-smtp-email-whitelabel`, `settings-module`, `foundation-auth-rbac`  
**Referenced by:** `system-communications-notifications`

---

## Overview

Tenant-customizable email templates for system-generated customer-facing emails (invoice sent, proposal sent, contract signing request, etc.). Spec 5 (system-communications-notifications) and spec 51 (custom-smtp-email-whitelabel) own the email delivery infrastructure. This spec adds: a `tenant_email_templates` table, a template editor UI, and the integration point where `TenantEmailAdapter` checks for overrides before using system defaults.

Internal staff notifications (in-app) are not customizable.

---

## Scope: Which Emails Are Customizable

| Template key | Event | Default subject |
|---|---|---|
| `invoice_sent` | Invoice emailed to customer | "Invoice #{number} from {tenantName}" |
| `invoice_reminder` | Payment reminder (manual or recurring) | "Reminder: Invoice #{number} is due" |
| `invoice_paid_receipt` | Receipt email after payment | "Receipt for Invoice #{number}" |
| `proposal_sent` | Proposal sent to recipient | "{tenantName} sent you a proposal" |
| `contract_signing_request` | Signing request to signatory | "{tenantName} sent you a contract to sign" |
| `portal_invitation` | Customer portal invite | "You've been invited to {tenantName}'s portal" |
| `lead_form_thank_you` | Auto-reply after form submission | "Thank you for your enquiry" |

Non-customizable (system integrity): verification emails, password reset, staff invitations, subscription emails.

---

## Data Model

```sql
CREATE TABLE tenant_email_templates (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id       UUID NOT NULL REFERENCES tenants(id),
  template_key    TEXT NOT NULL,                -- e.g. 'invoice_sent'
  subject         TEXT NOT NULL,                -- with {{variable}} tokens
  body_html       TEXT NOT NULL,                -- HTML with {{variable}} tokens
  body_text       TEXT NOT NULL,                -- plain text fallback
  is_active       BOOLEAN DEFAULT true,
  updated_at      TIMESTAMPTZ DEFAULT now(),
  UNIQUE (tenant_id, template_key)
);
```

If no row exists for a `(tenant_id, template_key)` pair, the system default template is used. `TenantEmailAdapter` in spec 51 is already the delivery path — it checks this table before using the default.

---

## Page: `/settings/email-templates`

```
┌──────────────────────────────────────────────────────────────┐
│  Settings / Email Templates                                  │
│                                                              │
│  Customize emails sent to your customers.                    │
│                                                              │
│  ┌──────────────────────────────────────────────────────┐   │
│  │  Template               Status       Last edited      │   │
│  │  ──────────────────────────────────────────────────  │   │
│  │  Invoice sent           ✏ Custom     2026-05-20       │   │
│  │  Payment reminder       ◎ Default                    │   │
│  │  Receipt email          ◎ Default                    │   │
│  │  Proposal sent          ✏ Custom     2026-05-15       │   │
│  │  Contract signing req.  ◎ Default                    │   │
│  │  Portal invitation      ◎ Default                    │   │
│  │  Lead form thank you    ◎ Default                    │   │
│  └──────────────────────────────────────────────────────┘   │
└──────────────────────────────────────────────────────────────┘
```

Row click → opens template editor.

---

## Template Editor

```
┌──────────────────────────────────────────────────────────────┐
│  Edit: Invoice Sent                     [Preview] [Save]     │
│                                                              │
│  Subject:                                                    │
│  [Invoice #{{invoiceNumber}} from {{tenantName}}_________]  │
│                                                              │
│  Available variables:                                        │
│  {{tenantName}}  {{customerName}}  {{invoiceNumber}}        │
│  {{invoiceTotal}}  {{currency}}  {{dueDate}}  {{payLink}}   │
│                                                              │
│  Body (HTML):                                                │
│  ┌──────────────────────────────────────────────────────┐   │
│  │  <p>Dear {{customerName}},</p>                       │   │
│  │                                                      │   │
│  │  <p>Please find attached Invoice #{{invoiceNumber}}  │   │
│  │  for {{invoiceTotal}} {{currency}}.</p>              │   │
│  │                                                      │   │
│  │  <p><a href="{{payLink}}">Pay now</a></p>            │   │
│  └──────────────────────────────────────────────────────┘   │
│                                                              │
│  [Reset to default]                                          │
└──────────────────────────────────────────────────────────────┘
```

Subject: single-line text input.  
Body HTML: raw HTML textarea (not WYSIWYG — Business+ users are expected to know HTML; WYSIWYG adds significant complexity for marginal benefit).  
Plain text: auto-generated from HTML by stripping tags (not manually editable — reduces duplicate editing burden).

### Preview

"Preview" button fetches a rendered preview: `POST /api/email-templates/{key}/preview` with the current draft subject + body. Returns rendered HTML with variables substituted with realistic sample data. Shown in a modal (`<iframe>` sandboxed).

### Reset to default

"Reset to default" removes the custom row: `DELETE /api/email-templates/{key}`. Next delivery uses system default. Confirm dialog before deleting.

---

## Variable Interpolation

Template variables use `{{variable}}` Mustache-style syntax. Interpolation at send time:

```ts
function renderTemplate(template: string, vars: Record<string, string>): string {
  return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? '')
}
```

Unknown variables render as empty string (not as `{{variable}}`). Variable names are documented per template in the UI.

#### Variable Value Escaping at Send Time

Save-time sanitization strips `<script>`, `<iframe>`, `on*` attrs from the template HTML. Variable *values* are substituted at send time — after save-time sanitization has already run — so they must be independently HTML-escaped at the point of substitution.

```ts
function interpolateTemplate(html: string, vars: Record<string, string>): string {
  return html.replace(/\{\{(\w+)\}\}/g, (_, key) => {
    const val = vars[key] ?? ''
    return val
      .replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;')
      .replace(/'/g, '&#x27;')
  })
}
```

Raw HTML slots (where the tenant intentionally embeds HTML — e.g., a custom footer block) use `{{{rawSlot}}}` triple-brace syntax and are processed through DOMPurify with an explicit allowlist before insertion. Never use triple-brace for user-data variables (customer names, invoice amounts, etc.).

---

## API Endpoints

```
GET  /api/email-templates
     → list all templates with custom/default status
       returns: { templates: [{ key, subject, isCustom, updatedAt }] }

GET  /api/email-templates/:key
     → full template (custom if exists, else system default)
       returns: { key, subject, bodyHtml, variables: string[], isCustom }

PUT  /api/email-templates/:key
     → create or update custom template
       body: { subject, bodyHtml }
       Validates: subject non-empty; bodyHtml non-empty; no script tags (XSS)

DELETE /api/email-templates/:key
     → reset to default (removes custom row)

POST /api/email-templates/:key/preview
     → render template with sample data
       body: { subject, bodyHtml } (current draft — not saved)
       returns: { renderedSubject, renderedHtml }
```

All require `settings:write`. Tenant-scoped.

---

## Security

`bodyHtml` sanitization on save: strip `<script>`, `<iframe>`, `on*` event attributes, `javascript:` hrefs. Template is HTML for email clients — not rendered in browser context — but sanitize as defense-in-depth.

---

## Foundation Deltas

**New table:** `tenant_email_templates`

---

## Architecture Decisions

| Decision | Choice | Reason |
|----------|--------|--------|
| HTML textarea | Not WYSIWYG | WYSIWYG (Tiptap/Quill) adds ~100KB JS + significant complexity; Business+ tenants who customize templates are technical enough for HTML |
| Plain text auto-generated | Not separately editable | 95% of email clients use HTML; maintaining two manually-edited bodies doubles the editing surface for minimal gain |
| `{{variable}}` Mustache-style | Not React/JSX | Simple string replace at runtime; no sandboxed eval; no template DSL to secure |
| Custom row optional | System default fallback | Tenants who don't customize get working emails out of the box; no forced migration needed |
