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

**Spec:** docs/specs/2026-05-31-email-template-editor.md  ·  **Slug:** email-template-editor  ·  **Wave:** 11
**Depends on:** custom-smtp-email-whitelabel, foundation-auth-rbac, settings-module, system-communications-notifications

## Goal
Lets Business+ tenants customize the HTML/subject of seven customer-facing system emails (invoice sent, payment reminder, receipt, proposal sent, contract signing request, portal invitation, lead-form thank-you). It adds one table (`tenant_email_templates`), a CRUD + preview API surface, and a settings editor page. At send time the email delivery path resolves a tenant override before falling back to the packaged system default, interpolating `{{variable}}` tokens with HTML-escaped values. Tenants who never customize keep working defaults — the custom row is optional.

## Architecture
- **Storage**: new `tenant_email_templates` table (unique per `(tenant_id, template_key)`). Absence of a row = use the packaged default. Plain text body is auto-derived from HTML (stripped tags), not stored as a separately edited field, but is persisted (`body_text`) so the send path never re-strips.
- **Defaults registry**: a code-level `EMAIL_TEMPLATE_DEFAULTS` map in `@zync/notifications` holds the seven default subjects/HTML and each key's declared variable list. This is the source of truth for GET fallback, preview sample data, and the variable palette shown in the UI.
- **Send-time integration**: `@zync/notifications` already exposes `sendEmail(opts: SendEmailOptions)` and the per-tenant adapter resolver `resolveEmailAdapter` (from `custom-smtp-email-whitelabel`, file `packages/notifications/src/email/resolve-adapter.ts`). This plan inserts a template-resolution step (`resolveEmailTemplate`) **before** adapter selection: it loads the tenant override or the default, runs `interpolateTemplate` (HTML-escaped `{{var}}`) + `interpolateRawSlots` (DOMPurify allowlist for `{{{rawSlot}}}`), and hands the rendered subject/html/text to the resolved `EmailAdapter`. Internal/system-integrity emails (verification, password reset, staff invites, subscription) are never routed through overrides.
- **API**: Hono routes under `/api/email-templates` in `apps/zync-api`, all gated by `requirePermission('settings:write')` and tenant-scoped via `tenantQuery`. Save-time sanitization strips `<script>`/`<iframe>`/`on*`/`javascript:` from `body_html`.
- **UI**: `/settings/email-templates` renders inside the settings shell owned by `settings-module` (the sidebar entry already exists in the settings nav manifest, Business+). List view → editor view with subject input, raw-HTML textarea, variable palette, preview modal (sandboxed `<iframe>`), and reset-to-default confirm.
- **Tier gate**: Business+ only — UI uses `useTierGate`/`requireTier`; API enforces `requireTier('business')`.

Upstream names consumed verbatim: `tenants(id)`, `users(id)`, `tenantQuery`, `requirePermission`, `requireTier`, `authMiddleware`, `useTierGate`, `Card`, `DataTable`, `Dialog`, `Button`, `Input`, `Textarea`, `Badge`, `Form`, `FormField`, `toast`, `EmailAdapter`, `SendEmailOptions`, `sendEmail`, `resolveEmailAdapter`, `useDirection`, `LocaleProvider`.

## Tech Stack
- **`packages/db`** (`@zync/db`): Drizzle table `tenant_email_templates` + SQL migration.
- **`packages/types`** (`@zync/types`): shared template DTO/types + the seven `TemplateKey` literals.
- **`packages/notifications`** (`@zync/notifications`): `EMAIL_TEMPLATE_DEFAULTS`, `renderTemplate`/`interpolateTemplate`/`interpolateRawSlots`, `sanitizeTemplateHtml`, `htmlToPlainText`, `resolveEmailTemplate`, `SAMPLE_TEMPLATE_VARS`; wiring into `sendEmail`.
- **`apps/zync-api`** (Hono Worker): `/api/email-templates` router; uses Hyperdrive Postgres binding `DB`, Drizzle.
- **`apps/zync-app`** (Vite+React): settings page, editor, preview modal, React Query hooks.
- Libraries: `drizzle-orm`, `zod`, `isomorphic-dompurify` (allowlist for raw slots), `@tanstack/react-query`, `react-i18next`.
- Bindings: `DB` (Neon Postgres via Hyperdrive). No new Cloudflare binding required.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 11.a | 1 (schema + migration), 2 (types) | `packages/db`, `packages/types` | 1 & 2 parallel |
| 11.b | 3 (defaults registry + sample vars), 4 (render/sanitize/escape utils) | `packages/notifications` | 3 & 4 parallel, after 2 |
| 11.c | 5 (template repo/service), 6 (send-time resolver wiring) | `packages/notifications` | 5 after 1+3+4; 6 after 5 |
| 11.d | 7 (zod schemas), 8 (API routes) | `apps/zync-api` | 8 after 5+7 |
| 11.e | 9 (React Query hooks), 10 (list page), 11 (editor + preview modal) | `apps/zync-app` | 10 & 11 after 9 |
| 11.f | 12 (settings nav wiring), 13 (tests) | `apps/zync-app`, test files | 13 last |

## Tasks

### Task 1: `tenant_email_templates` table + migration
**Blocks:** 5  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/src/schema/tenant-email-templates.ts`
- Modify: `packages/db/src/schema/index.ts` (export new table)
- Create: `packages/db/migrations/<timestamp>_tenant_email_templates.sql`
**Steps:**
- [ ] Define the Drizzle pgTable mirroring the canonical DDL below (UUID PK, UUID FK to `tenants`, partial-not text columns, boolean `is_active`, TIMESTAMPTZ `updated_at`).
- [ ] Add a composite unique index on `(tenant_id, template_key)`.
- [ ] Add a CHECK constraint pinning `template_key` to the seven allowed keys.
- [ ] Export the table from `packages/db/src/schema/index.ts`.
- [ ] Author the raw SQL migration; ensure `gen_random_uuid()` is available (pgcrypto) — already guaranteed by foundation.
**Schema / Interfaces:**
```sql
CREATE TABLE tenant_email_templates (
  id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id     UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  template_key  TEXT NOT NULL CHECK (template_key IN (
                  'invoice_sent',
                  'invoice_reminder',
                  'invoice_paid_receipt',
                  'proposal_sent',
                  'contract_signing_request',
                  'portal_invitation',
                  'lead_form_thank_you'
                )),
  subject       TEXT NOT NULL,            -- with {{variable}} tokens
  body_html     TEXT NOT NULL,            -- sanitized HTML with {{variable}} / {{{rawSlot}}} tokens
  body_text     TEXT NOT NULL,            -- plain-text fallback, auto-derived from body_html on save
  is_active     BOOLEAN NOT NULL DEFAULT true,
  updated_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (tenant_id, template_key)
);
CREATE INDEX tenant_email_templates_tenant_idx ON tenant_email_templates (tenant_id);
```
```ts
// packages/db/src/schema/tenant-email-templates.ts (Drizzle)
export const tenantEmailTemplates = pgTable('tenant_email_templates', {
  id: uuid('id').primaryKey().defaultRandom(),
  tenantId: uuid('tenant_id').notNull().references(() => tenants.id, { onDelete: 'cascade' }),
  templateKey: text('template_key').notNull(),
  subject: text('subject').notNull(),
  bodyHtml: text('body_html').notNull(),
  bodyText: text('body_text').notNull(),
  isActive: boolean('is_active').notNull().default(true),
  updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, (t) => ({
  uniqTenantKey: uniqueIndex('tenant_email_templates_tenant_key_uniq').on(t.tenantId, t.templateKey),
  tenantIdx: index('tenant_email_templates_tenant_idx').on(t.tenantId),
}))
```
**Acceptance:**
- [ ] Migration applies cleanly on Neon; table exists with the unique constraint and CHECK.
- [ ] `pnpm --filter @zync/db build` passes; table re-exported from schema index.

### Task 2: Shared types & template-key literals
**Blocks:** 3, 5, 7, 9  ·  **Blocked by:** —
**Files:**
- Create: `packages/types/src/email-templates.ts`
- Modify: `packages/types/src/index.ts` (re-export)
**Steps:**
- [ ] Declare `EMAIL_TEMPLATE_KEYS` (readonly tuple of the seven keys) and `EmailTemplateKey` union derived from it.
- [ ] Declare the API DTO shapes used by both `apps/zync-api` and `apps/zync-app` (list item, detail, save body, preview body/response).
- [ ] Re-export from the package barrel.
**Schema / Interfaces:**
```ts
export const EMAIL_TEMPLATE_KEYS = [
  'invoice_sent', 'invoice_reminder', 'invoice_paid_receipt',
  'proposal_sent', 'contract_signing_request', 'portal_invitation', 'lead_form_thank_you',
] as const
export type EmailTemplateKey = typeof EMAIL_TEMPLATE_KEYS[number]

export interface EmailTemplateListItem {
  key: EmailTemplateKey
  label: string            // human label, e.g. "Invoice sent"
  subject: string          // current effective subject (custom or default)
  isCustom: boolean
  updatedAt: string | null // ISO; null when default
}
export interface EmailTemplateDetail {
  key: EmailTemplateKey
  subject: string
  bodyHtml: string
  variables: string[]      // declared variable names for this key (no braces)
  isCustom: boolean
}
export interface SaveEmailTemplateBody { subject: string; bodyHtml: string }
export interface PreviewEmailTemplateBody { subject: string; bodyHtml: string }
export interface PreviewEmailTemplateResponse { renderedSubject: string; renderedHtml: string }
```
**Acceptance:**
- [ ] `pnpm --filter @zync/types build` passes; types importable as `@zync/types`.

### Task 3: Default templates registry + sample variables
**Blocks:** 5, 8, 11  ·  **Blocked by:** 2
**Files:**
- Create: `packages/notifications/src/email/template-defaults.ts`
- Modify: `packages/notifications/src/index.ts` (export registry + sample vars)
**Steps:**
- [ ] Define `EMAIL_TEMPLATE_DEFAULTS`: for each of the seven keys, the default `subject`, default `bodyHtml`, default `bodyText`, declared `variables: string[]`, and human `label`.
- [ ] Use the spec's exact default subjects (table in spec §Scope). Bodies are minimal, valid HTML wrapping the documented variables; Hebrew RTL handled at send time via the adapter's `dir="rtl"` body wrapper (see system-communications-notifications), not baked per-template.
- [ ] Define `SAMPLE_TEMPLATE_VARS`: realistic sample values per variable for preview rendering (e.g. `tenantName: 'Acme Ltd'`, `invoiceNumber: 'INV-0042'`, `invoiceTotal: '1,200.00'`, `currency: '₪'`, `dueDate: '2026-06-30'`, `payLink: 'https://pay.zync.is/abc'`, `customerName: 'Dana Levi'`, `proposalLink`, `signLink`, `portalLink`, etc.).
- [ ] Export `EMAIL_TEMPLATE_DEFAULTS`, `SAMPLE_TEMPLATE_VARS`, and a helper `getTemplateVariables(key)`.
**Schema / Interfaces:**
```ts
export interface EmailTemplateDefault {
  label: string
  subject: string
  bodyHtml: string
  bodyText: string
  variables: string[]   // variable names without braces
}
export const EMAIL_TEMPLATE_DEFAULTS: Record<EmailTemplateKey, EmailTemplateDefault>
// Default subjects (verbatim from spec):
//  invoice_sent             -> "Invoice #{{invoiceNumber}} from {{tenantName}}"
//  invoice_reminder         -> "Reminder: Invoice #{{invoiceNumber}} is due"
//  invoice_paid_receipt     -> "Receipt for Invoice #{{invoiceNumber}}"
//  proposal_sent            -> "{{tenantName}} sent you a proposal"
//  contract_signing_request -> "{{tenantName}} sent you a contract to sign"
//  portal_invitation        -> "You've been invited to {{tenantName}}'s portal"
//  lead_form_thank_you      -> "Thank you for your enquiry"
// invoice_sent variables: tenantName, customerName, invoiceNumber, invoiceTotal, currency, dueDate, payLink
export const SAMPLE_TEMPLATE_VARS: Record<string, string>
export function getTemplateVariables(key: EmailTemplateKey): string[]
```
**Acceptance:**
- [ ] All seven keys present; subjects match the spec table verbatim.
- [ ] Every variable referenced in a default body/subject has a `SAMPLE_TEMPLATE_VARS` entry.

### Task 4: Render / sanitize / escape utilities
**Blocks:** 5, 6, 8  ·  **Blocked by:** —
**Files:**
- Create: `packages/notifications/src/email/template-render.ts`
- Modify: `packages/notifications/src/index.ts` (export utils)
**Steps:**
- [ ] Implement `interpolateTemplate(html, vars)`: replace `{{var}}` with the HTML-escaped value (`& < > " '`), unknown → empty string. (Exact algorithm in spec §Variable Value Escaping.)
- [ ] Implement `interpolateRawSlots(html, vars)`: replace `{{{rawSlot}}}` triple-brace tokens by running the value through DOMPurify with an explicit allowlist (`a, p, br, strong, em, ul, ol, li, span, div, img, table, tr, td, th, tbody, thead` + `href, src, style, alt, width, height` attrs; `href` schemes limited to `http/https/mailto`). Run triple-brace pass first, then single-brace escape pass over the remaining tokens.
- [ ] Implement `renderTemplate(subjectOrHtml, vars)` convenience that runs raw-slot then escaped-interpolation; subjects use escaped pass only.
- [ ] Implement `sanitizeTemplateHtml(html)`: strip `<script>`, `<iframe>`, all `on*` event attributes, and `javascript:` hrefs (DOMPurify configured to forbid those tags/attrs while preserving `{{token}}` text). Used on save.
- [ ] Implement `htmlToPlainText(html)`: strip tags, collapse whitespace, decode entities — used to derive `body_text` on save.
**Schema / Interfaces:**
```ts
export function interpolateTemplate(html: string, vars: Record<string, string>): string
export function interpolateRawSlots(html: string, vars: Record<string, string>): string
export function renderTemplate(input: string, vars: Record<string, string>): string
export function sanitizeTemplateHtml(html: string): string
export function htmlToPlainText(html: string): string
// interpolateTemplate body (spec-exact):
//   html.replace(/\{\{(\w+)\}\}/g, (_, k) => (vars[k] ?? '')
//     .replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
//     .replace(/"/g,'&quot;').replace(/'/g,'&#x27;'))
```
**Acceptance:**
- [ ] `interpolateTemplate` escapes `<script>` inside a variable value; unknown var → `''`.
- [ ] `sanitizeTemplateHtml('<script>x</script><p onclick="y">hi</p>')` drops the script tag and the `onclick` attr, keeps `<p>hi</p>`.
- [ ] Triple-brace slot with a disallowed tag is stripped to its allowed subset.

### Task 5: Template repository / service
**Blocks:** 6, 8  ·  **Blocked by:** 1, 3, 4
**Files:**
- Create: `packages/notifications/src/email/template-service.ts`
- Modify: `packages/notifications/src/index.ts` (export service fns)
**Steps:**
- [ ] `listTemplates(db, tenantId)`: return all seven keys merged with any custom rows → `EmailTemplateListItem[]` (label/subject/isCustom/updatedAt). No custom row → default subject, `isCustom:false`, `updatedAt:null`.
- [ ] `getTemplate(db, tenantId, key)`: return `EmailTemplateDetail` — custom row if present else default; always include `variables` from `getTemplateVariables(key)`.
- [ ] `upsertTemplate(db, tenantId, key, { subject, bodyHtml })`: sanitize `bodyHtml` via `sanitizeTemplateHtml`, derive `bodyText` via `htmlToPlainText`, then INSERT … ON CONFLICT (tenant_id, template_key) DO UPDATE SET subject, body_html, body_text, updated_at=now(). All reads/writes go through `tenantQuery` (tenant-scoped); never raw drizzle from routes.
- [ ] `deleteTemplate(db, tenantId, key)`: DELETE the custom row (reset to default). Idempotent.
- [ ] `getEffectiveTemplate(db, tenantId, key)`: internal accessor returning `{ subject, bodyHtml, bodyText }` (custom row if `is_active` else default) — consumed by the send-time resolver (Task 6).
**Schema / Interfaces:**
```ts
export async function listTemplates(db: Db, tenantId: string): Promise<EmailTemplateListItem[]>
export async function getTemplate(db: Db, tenantId: string, key: EmailTemplateKey): Promise<EmailTemplateDetail>
export async function upsertTemplate(db: Db, tenantId: string, key: EmailTemplateKey, body: SaveEmailTemplateBody): Promise<void>
export async function deleteTemplate(db: Db, tenantId: string, key: EmailTemplateKey): Promise<void>
export async function getEffectiveTemplate(db: Db, tenantId: string, key: EmailTemplateKey):
  Promise<{ subject: string; bodyHtml: string; bodyText: string; isActive: boolean }>
```
**Acceptance:**
- [ ] `listTemplates` always returns exactly seven items regardless of how many custom rows exist.
- [ ] `upsertTemplate` persists sanitized HTML (script tag gone) and a non-empty `body_text`.
- [ ] Cross-tenant read isolation holds: tenant A cannot read/modify tenant B's row.

### Task 6: Send-time template resolution wired into `sendEmail`
**Blocks:** —  ·  **Blocked by:** 5
**Files:**
- Create: `packages/notifications/src/email/resolve-template.ts`
- Modify: `packages/notifications/src/email/send-email.ts` (the `sendEmail` implementation)
- Modify: `packages/notifications/src/index.ts` (export `resolveEmailTemplate`)
**Steps:**
- [ ] Implement `resolveEmailTemplate(db, tenantId, templateKey, vars)`: map the `SendEmailOptions.templateKey` (which may carry a locale suffix like `invoice_sent_he`) to the base `EmailTemplateKey`; if the base key is one of the seven customizable keys, call `getEffectiveTemplate`, then `renderTemplate` the subject (escaped pass) and the body (`interpolateRawSlots` then `interpolateTemplate`), producing `{ subject, html, text }`. If the key is not customizable (verification/reset/staff-invite/subscription) return `null` so the existing packaged-MJML path is used unchanged.
- [ ] In `sendEmail`, after computing `vars`/`locale` and before/at adapter dispatch, call `resolveEmailTemplate`; when it returns rendered content, pass that to the adapter resolved by `resolveEmailAdapter(tenantId, …)`. When it returns `null`, fall through to the current default template selection.
- [ ] Ensure the customizable→non-customizable split is an explicit allowlist (the seven keys) — never route system-integrity emails through tenant overrides.
- [ ] Set `dir="rtl" lang="he"` body wrapper for `he-IL` locale exactly as the comms spec mandates; the tenant body is inserted inside that wrapper.
**Schema / Interfaces:**
```ts
export async function resolveEmailTemplate(
  db: Db, tenantId: string, templateKey: string, vars: Record<string, string>,
): Promise<{ subject: string; html: string; text: string } | null>
// returns null for non-customizable keys -> caller uses packaged default MJML
```
**Acceptance:**
- [ ] When a tenant has a custom `invoice_sent` row, `sendEmail({ templateKey: 'invoice_sent', … })` dispatches the rendered custom subject/body.
- [ ] When no custom row exists, the packaged default content is sent (no regression).
- [ ] `templateKey: 'email_verification'` (or any non-listed key) is never routed through overrides (`resolveEmailTemplate` returns null).
- [ ] Variable values are HTML-escaped in the final email; `{{{rawSlot}}}` content is DOMPurify-filtered.

### Task 7: Zod validation schemas
**Blocks:** 8  ·  **Blocked by:** 2
**Files:**
- Create: `apps/zync-api/src/routes/email-templates/schemas.ts`
**Steps:**
- [ ] `saveTemplateSchema`: `subject` non-empty (trim, max 300), `bodyHtml` non-empty (max ~50KB). Reject obvious `<script` substrings as an early guard (defense-in-depth; sanitizer is authoritative).
- [ ] `previewTemplateSchema`: same fields, used for the unsaved draft preview.
- [ ] `templateKeyParam`: `z.enum(EMAIL_TEMPLATE_KEYS)` for the `:key` path param — rejects unknown keys with 404/422.
**Schema / Interfaces:**
```ts
export const saveTemplateSchema = z.object({
  subject: z.string().trim().min(1).max(300),
  bodyHtml: z.string().min(1).max(50_000),
})
export const previewTemplateSchema = saveTemplateSchema
export const templateKeyParam = z.enum(EMAIL_TEMPLATE_KEYS)
```
**Acceptance:**
- [ ] Empty subject or empty bodyHtml → 422; unknown `:key` → 404.

### Task 8: API routes `/api/email-templates`
**Blocks:** 9  ·  **Blocked by:** 5, 7
**Files:**
- Create: `apps/zync-api/src/routes/email-templates/index.ts`
- Modify: `apps/zync-api/src/index.ts` (mount router at `/api/email-templates`)
**Steps:**
- [ ] Mount a Hono router; apply `authMiddleware`, `requireTier('business')`, and `requirePermission('settings:write')` to all routes; resolve tenant from session and use `tenantQuery`.
- [ ] `GET /api/email-templates` → `listTemplates` → `{ templates: EmailTemplateListItem[] }`.
- [ ] `GET /api/email-templates/:key` → validate key → `getTemplate` → `EmailTemplateDetail`.
- [ ] `PUT /api/email-templates/:key` → validate key + `saveTemplateSchema` → `upsertTemplate` → 200 with updated `EmailTemplateDetail`. Emit tenant audit entry `email_template.updated` (params: `{ key }`) in the same transaction (`require-audit-in-transaction`).
- [ ] `DELETE /api/email-templates/:key` → `deleteTemplate` → 204. Emit audit `email_template.reset`.
- [ ] `POST /api/email-templates/:key/preview` → validate key + `previewTemplateSchema` → sanitize the draft `bodyHtml`, then render with `SAMPLE_TEMPLATE_VARS` (escaped interpolation + raw-slot pass) → `{ renderedSubject, renderedHtml }`. Does NOT persist.
- [ ] All responses use existing `ApiError`/JSON conventions; CSP unaffected (no inline scripts emitted by API).
**Schema / Interfaces:**
```
GET    /api/email-templates           -> { templates: EmailTemplateListItem[] }
GET    /api/email-templates/:key      -> EmailTemplateDetail
PUT    /api/email-templates/:key      body SaveEmailTemplateBody -> EmailTemplateDetail
DELETE /api/email-templates/:key      -> 204
POST   /api/email-templates/:key/preview body PreviewEmailTemplateBody -> PreviewEmailTemplateResponse
// All require settings:write + Business tier; tenant-scoped.
```
**Acceptance:**
- [ ] A MEMBER (no `settings:write`) gets 403; a Free/Starter tenant gets 403 (tier gate).
- [ ] PUT then GET returns `isCustom:true` with sanitized HTML; DELETE then GET returns `isCustom:false` (default).
- [ ] Preview returns rendered HTML with sample data substituted and is not persisted.

### Task 9: React Query hooks
**Blocks:** 10, 11  ·  **Blocked by:** 8
**Files:**
- Create: `apps/zync-app/src/features/settings/email-templates/api.ts`
- Create: `apps/zync-app/src/features/settings/email-templates/hooks.ts`
**Steps:**
- [ ] Typed fetch wrappers for the five endpoints (using `@zync/types` DTOs).
- [ ] `useEmailTemplateList()` (query), `useEmailTemplate(key)` (query), `useSaveEmailTemplate(key)` (mutation, invalidates list + detail, success `toast`), `useResetEmailTemplate(key)` (mutation, invalidates), `usePreviewEmailTemplate(key)` (mutation returning rendered HTML; not cached).
- [ ] Surface errors via `toast`.
**Schema / Interfaces:**
```ts
export function useEmailTemplateList(): UseQueryResult<EmailTemplateListItem[]>
export function useEmailTemplate(key: EmailTemplateKey): UseQueryResult<EmailTemplateDetail>
export function useSaveEmailTemplate(key: EmailTemplateKey): UseMutationResult<EmailTemplateDetail, Error, SaveEmailTemplateBody>
export function useResetEmailTemplate(key: EmailTemplateKey): UseMutationResult<void, Error, void>
export function usePreviewEmailTemplate(key: EmailTemplateKey): UseMutationResult<PreviewEmailTemplateResponse, Error, PreviewEmailTemplateBody>
```
**Acceptance:**
- [ ] Saving a template invalidates and refetches both list and detail queries.

### Task 10: List page `/settings/email-templates`
**Blocks:** —  ·  **Blocked by:** 9
**Files:**
- Create: `apps/zync-app/src/features/settings/email-templates/EmailTemplatesPage.tsx`
- Create: `apps/zync-app/src/features/settings/email-templates/index.ts`
**Steps:**
- [ ] Render inside the settings shell (page heading "Email Templates", subheading "Customize emails sent to your customers.").
- [ ] Table (`DataTable`/`Table`) columns: Template (label), Status (`Badge`: "Custom" with edit glyph / "Default"), Last edited (formatted date or em-dash). Driven by `useEmailTemplateList`.
- [ ] Row click navigates to the editor route (`/settings/email-templates/:key`).
- [ ] Respect `useDirection` for RTL; status/date columns mirror correctly under `dir="rtl"`.
- [ ] Gate the whole page behind Business+ (`useTierGate`); show upgrade prompt for lower tiers.
- [ ] a11y: table has caption/`aria-label`; status badge has accessible text (not color-only — icon + label).
**Acceptance:**
- [ ] Seven rows render; default rows show "Default" with no date; custom rows show date.
- [ ] Keyboard: rows are focusable and Enter opens the editor.
- [ ] Page renders correctly under `dir="rtl"`.

### Task 11: Template editor + preview modal
**Blocks:** —  ·  **Blocked by:** 9
**Files:**
- Create: `apps/zync-app/src/features/settings/email-templates/TemplateEditor.tsx`
- Create: `apps/zync-app/src/features/settings/email-templates/PreviewModal.tsx`
- Create: `apps/zync-app/src/features/settings/email-templates/ResetConfirmDialog.tsx`
**Steps:**
- [ ] Editor loads via `useEmailTemplate(key)`; header shows "Edit: {label}" with [Preview] and [Save] buttons.
- [ ] Subject: single-line `Input`. Body: raw-HTML `Textarea` (monospace, not WYSIWYG, per spec architecture decision). No separate plain-text editor (auto-derived server-side).
- [ ] Variable palette: render the `variables` list as clickable chips that insert `{{name}}` at the textarea cursor; label "Available variables".
- [ ] [Save] → `useSaveEmailTemplate`; disabled while subject/body empty; success toast.
- [ ] [Preview] → `usePreviewEmailTemplate` with current unsaved draft → open `PreviewModal`: a sandboxed `<iframe sandbox="allow-same-origin">` (no `allow-scripts`) whose `srcdoc` is the returned `renderedHtml`; show `renderedSubject` above it. This isolates email HTML from the app and satisfies CSP/defense-in-depth.
- [ ] [Reset to default]: only shown when `isCustom`; opens `ResetConfirmDialog` (`Dialog`) → on confirm calls `useResetEmailTemplate`, toast, navigate back to list.
- [ ] a11y: `Dialog` traps focus, has `role="dialog"`/`aria-modal`, Escape closes; preview iframe has a descriptive `title`. Honor `prefers-reduced-motion` for modal transitions. RTL-aware layout via `useDirection`.
**Acceptance:**
- [ ] Clicking a variable chip inserts the token at the cursor.
- [ ] Preview opens a sandboxed iframe (no script execution) showing sample-data-rendered HTML; modal is not persisted.
- [ ] Reset shows a confirm dialog and, on confirm, reverts the template to default and returns to the list.
- [ ] Save persists; reopening the editor shows the saved custom content.

### Task 12: Settings navigation wiring
**Blocks:** —  ·  **Blocked by:** 10
**Files:**
- Modify: `apps/zync-app/src/features/settings/nav.ts` (settings sidebar manifest owned by `settings-module`)
- Modify: `apps/zync-app/src/router.tsx` (or settings route table) — add routes `/settings/email-templates` and `/settings/email-templates/:key`
**Steps:**
- [ ] Add/confirm the sidebar entry `Email Templates → /settings/email-templates`, marked Business+ (entry already enumerated in the settings nav manifest from `settings-module`; ensure it points to this feature's page component).
- [ ] Register the list route and the `:key` editor route inside the settings shell layout.
- [ ] Lazy-load the feature bundle so non-Business tenants don't pay the JS cost.
**Acceptance:**
- [ ] Navigating to `/settings/email-templates` renders the list inside the settings shell; the sidebar item is highlighted.
- [ ] The route is hidden/blocked for non-Business+ tenants.

### Task 13: Tests
**Blocks:** —  ·  **Blocked by:** 6, 8, 11
**Files:**
- Create: `packages/notifications/src/email/__tests__/template-render.test.ts`
- Create: `packages/notifications/src/email/__tests__/template-service.test.ts`
- Create: `apps/zync-api/src/routes/email-templates/__tests__/routes.test.ts`
**Steps:**
- [ ] Unit: `interpolateTemplate` HTML-escapes values and emits `''` for unknown vars; `sanitizeTemplateHtml` removes `<script>/<iframe>/on*`/`javascript:`; `htmlToPlainText` strips tags; raw-slot DOMPurify allowlist enforced.
- [ ] Service: `listTemplates` returns seven items; `upsertTemplate` sanitizes + derives text; `deleteTemplate` resets; cross-tenant isolation.
- [ ] Send-time: `resolveEmailTemplate` returns custom content when a row exists, `null` for non-customizable keys, default content otherwise.
- [ ] API: permission/tier gating (403 for MEMBER and for Free/Starter), validation (422 empty fields, 404 unknown key), preview is non-persisting, PUT→GET→DELETE round-trip.
**Acceptance:**
- [ ] All listed tests pass under the repo test runner; no skipped assertions.
