<!--
Email Automation Console Playbook

Part of the free Claude Code template library from BILT AI — https://biltai.io/claude-templates

These are the actual playbooks used to build and ship production SaaS apps with
Claude Code. They're written to be handed to an agent as-is: point Claude at one
and it has the full pattern, the schema, the gotchas, and the "don't do this"
list. Paths, project refs, and org names have been replaced with placeholders
(<your-app>, <supabase-project-ref>, <your-github-org>) — search for "<" and
fill in your own before running.

Free to use, modify, and ship. No attribution required.
-->

# Email Automation Console Playbook

**Portable spec for rebuilding the FULL admin-side email automation console from BKE / Kompozy in any new app — the 6 management pages (Automations list, campaign editor, Templates, Layout, Enrollments, Analytics), the 14 admin API routes that back them, the exact mono/card design system, the campaign engine + condition DSL, the supporting Trigger.dev workers, and the seed campaigns. One kickoff rebuilds the whole thing, same design, end to end.**

Built from the BKE / Kompozy implementation. Reference repo: `reference app`.

> **This is the management half.** Its sibling — [s-tier/EMAIL-INFRASTRUCTURE-PLAYBOOK.md](../s-tier/EMAIL-INFRASTRUCTURE-PLAYBOOK.md) — builds the plumbing (Postmark account, SPF/DKIM/DMARC, the 4 core tables, the drip-tick worker, the unsubscribe + webhook routes). **Run EMAIL-INFRASTRUCTURE first, then this.** This playbook owns everything an operator clicks: the console UI, the admin CRUD API, the condition DSL, the lifecycle/expire/gift/lead workers, and the seed campaigns. Where the two overlap (schema, drip worker), this playbook references the infra playbook rather than re-specifying. The §16 kickoff orchestrates both so "use this template" = the entire automation system.

---

## 0. Use this spec

**Inputs:**
- EMAIL-INFRASTRUCTURE-PLAYBOOK already shipped (Postmark streams, the 4 core tables `email_templates` / `email_campaigns` / `email_enrollments` / `email_sends`, the `email_layouts` singleton, `profiles.email_opt_out`, the HMAC unsubscribe, the `email-drip-tick` worker).
- The admin console shell from [s-tier/ADMIN-CONSOLE-PLAYBOOK.md](../s-tier/ADMIN-CONSOLE-PLAYBOOK.md) — `requireAdmin()` guard, the `TopNav` chrome, the `--brand-*` CSS tokens in `globals.css`, the `createServerSupabase` / `createAdminSupabase` clients.
- Trigger.dev project (for the lifecycle / expire / gift / lead-nurture workers).
- A `profiles` table with `role`, `plan`, `account_status`, `email_opt_out`, `full_name` (from AUTH-ONBOARDING + STRIPE-BILLING playbooks).

**Outputs (what an operator can do when this ships):**
- A nav item **"Automations"** → `/admin/dashboard/email` with 4 secondary links (Analytics · Enrollments · Layout · Templates).
- **Campaigns list** with Active / Paused / Draft / All status tabs, per-campaign rollup counters (active enrollments, sent, opens, clicks), inline create, system-campaign locking.
- **Campaign editor** — name/description/trigger/status, ordered email steps with per-step delay + skip-if + exit-goal, cumulative "fires Day N" hints, transactional toggle, and a manual **Audience & Fire** panel (all / paid / free / pick users).
- **Templates** — 3-pane (list / editor / live iframe preview) CRUD with slug/subject/from + HTML body, duplicate, archive, merge-tag preview.
- **Layout** — split-pane brand-wrapper editor (HTML + plain text) with live preview, `{{content}}` enforcement.
- **Enrollments** — lookup a user by email or load a cohort (all/paid/free/pending), inspect their enrollments + recent sends, pause/resume/exit/skip, opt-out toggle, multi-select bulk enroll + bulk one-off send.
- **Analytics** — windowed (7/14/30/60/90d) overview stat cards, daily SVG bar chart, per-campaign + per-template tables.
- The condition DSL (`converted_to_paid`, `has_published`, `onboarding_complete`, `trial_expired`, `unsubscribed`), the lifecycle/expire workers that auto-enroll on behavioral events, and seed campaigns (lead-magnet nurture, welcome offer).

**File budget:** 6 pages (~2,900 lines), 14 API routes (~1,560 lines), 4 extra workers, 1 lib file (`conditions.ts`) + extensions to `enroll.ts`. The infra playbook already shipped `postmark.ts`, `render-template.ts`, `unsubscribe-token.ts`, the 4 tables, and `email-drip-tick.ts`.

---

## 1. What this builds (full surface map)

```
src/
├── app/admin/dashboard/email/
│   ├── page.tsx                    # Campaigns list — Active/Paused/Draft/All tabs, rollup counts, inline create
│   ├── [id]/page.tsx               # Campaign editor — steps, timing hints, transactional, manual Audience & Fire
│   ├── templates/page.tsx          # 3-pane template CRUD (list / editor / live preview)
│   ├── layout/page.tsx             # Brand-wrapper editor (HTML + text) w/ split preview
│   ├── enrollments/page.tsx        # User lookup + cohort ops + per-user enrollments/sends
│   └── analytics/page.tsx          # Windowed overview + daily chart + per-campaign/template tables
│
├── app/api/admin/email/
│   ├── campaigns/route.ts          # GET list (+rollup counts) · POST create
│   ├── campaigns/[id]/route.ts     # GET one · PATCH update (system-lock + transactional guard)
│   ├── campaigns/[id]/enroll/route.ts   # POST manual/backfill enroll by audience
│   ├── templates/route.ts          # GET list (active) · POST create
│   ├── templates/[slug]/route.ts   # GET one · PATCH update/archive (auto-derive text_body)
│   ├── templates/[slug]/duplicate/route.ts  # POST duplicate
│   ├── layout/route.ts             # GET singleton · PATCH (require {{content}})
│   ├── enrollments/route.ts        # GET lookup by email → user + enrollments + sends
│   ├── enrollments/[id]/route.ts   # PATCH pause|resume|exit|skip (state machine, no DELETE)
│   ├── analytics/route.ts          # GET ?days= → overview/daily/byCampaign/byTemplate
│   ├── send-one/route.ts           # POST one-off template send (the only admin route that sends)
│   ├── users/route.ts              # GET audience picker (?filter=all|paid|free|pending)
│   ├── users/[id]/route.ts         # PATCH email_opt_out flip
│   └── users/[id]/opt-out/route.ts # POST full opt-out (exit all enrollments + audit)
│
├── lib/email/
│   ├── conditions.ts               # evaluateCondition() — skip_if / exit_goal DSL (THIS playbook)
│   ├── enroll.ts                   # + enrollUserInCampaignsByEvent / exitActiveEnrollmentsByEvent
│   └── lead-nurture.ts             # instant lead-magnet delivery email builder
│
├── trigger/
│   ├── email-lifecycle-tick.ts     # daily — inactive_7d / trial_ending_3d cohort enroll
│   ├── trial-expire-tick.ts        # daily — flip trial cliff + win-back enroll
│   ├── trial-day2-gift.ts          # daily — return-loop gift + announce email (app-specific, optional)
│   └── lead-nurture-tick.ts        # 30-min — anonymous magnet_leads funnel
│
└── supabase/migrations/
    ├── <n>_extend_trigger_events.sql   # widen CHECK to the full event list
    ├── <n>_enroll_on_profile_create.sql # lead_signup + welcome_offer DB triggers
    ├── <n>_leads_capture.sql            # magnet_leads + magnet_lead_sends (if lead funnel)
    └── <n>_seed_campaigns.sql           # lead-magnet nurture + welcome-offer seed rows
```

**Every page is a client component (`'use client'`)** that fetches the `/api/admin/email/*` REST endpoints. There is **no shared email-layout component** — each page repeats a small set of inline `React.CSSProperties` style objects + a toast helper (§3). The only shared chrome is the admin `TopNav`.

---

## 2. Design system — the replication core (copy verbatim)

> The whole console is built with **inline style objects + `--brand-*` CSS custom properties**, NOT Tailwind utility classes. This is what makes it portable: drop the same tokens in `globals.css` (ADMIN-CONSOLE / APP-INFRASTRUCTURE establish them) and the console looks identical in any app. The signature look = **mono font, hollow pill chips, bg-1 cards with a 1px border, orange accent**.

### 2.1 CSS tokens (must exist in `globals.css`)

| Token | Light | Dark | Role |
|---|---|---|---|
| `--brand-bg-0` | `#FAFAF7` | `#0A0A0C` | page bg / inset surfaces |
| `--brand-bg-1` | `#FFFFFF` | `#111114` | **card bg** |
| `--brand-bg-2` | `#F4F3EF` | `#17171B` | hover bg |
| `--brand-border` | `#E4E3DD` | `#26262E` | **all borders** |
| `--brand-text-0` | `#131317` | `#F5F5F7` | primary text |
| `--brand-text-1` | `#4A4A52` | `#B4B4BE` | secondary text |
| `--brand-text-2` | `#8B8B93` | `#6E6E78` | muted / labels / **draft badge** |
| `--brand-accent` | `#D97E0E` | `#E8941C` | **orange — paused badge, selected outline, CTA** |
| `--brand-success` | `#3EA35A` | `#4ADE80` | **active badge** |
| `--brand-danger` | `#D94A15` | `#FF5A1F` | **exited/error/bounce** |
| `--font-mono` | JetBrains Mono | — | nearly all console text |
| `--font-display` | Space Grotesk | — | headings, nav links |

> Re-theme by swapping `--brand-accent` (and the font vars) in the new app's `globals.css`. Everything below inherits. Keep `--brand-success` green / `--brand-danger` red / `--brand-text-2` grey — the status semantics depend on them.

### 2.2 Shared inline style objects (paste at module scope in each page)

```ts
const cardStyle: React.CSSProperties = {
  background: 'var(--brand-bg-1)', border: '1px solid var(--brand-border)',
  borderRadius: 8, padding: '1.25rem',                 // analytics uses '1rem'
}
const tableStyle: React.CSSProperties = {
  width: '100%', borderCollapse: 'collapse',
  fontFamily: 'var(--font-mono)', fontSize: '0.78rem',
}
const thStyle: React.CSSProperties = {
  textAlign: 'left', padding: '0.5rem 0.75rem', color: 'var(--brand-text-2)',
  fontSize: '0.65rem', textTransform: 'uppercase', letterSpacing: '0.08em',
  fontWeight: 600, borderBottom: '1px solid var(--brand-border)',
}
const tdStyle: React.CSSProperties = {
  padding: '0.75rem', color: 'var(--brand-text-0)', verticalAlign: 'top',
}
const inputStyle: React.CSSProperties = {
  padding: '0.5rem', background: 'transparent', border: '1px solid var(--brand-border)',
  color: 'var(--brand-text-0)', fontFamily: 'var(--font-mono)', fontSize: '0.8rem',
  width: '100%', marginTop: 4,
}
const labelStyle: React.CSSProperties = {
  display: 'flex', flexDirection: 'column', color: 'var(--brand-text-2)',
  fontSize: '0.7rem', textTransform: 'uppercase', letterSpacing: '0.06em', fontWeight: 600,
}
const chipStyle: React.CSSProperties = {
  padding: '3px 8px', fontSize: '0.65rem', background: 'transparent',
  border: '1px solid var(--brand-border)', borderRadius: 4, fontFamily: 'var(--font-mono)',
  textTransform: 'uppercase', letterSpacing: '0.04em',
}
const btnSecondary: React.CSSProperties = {
  padding: '0.4rem 0.8rem', background: 'transparent', border: '1px solid var(--brand-border)',
  color: 'var(--brand-text-0)', cursor: 'pointer', fontFamily: 'var(--font-mono)',
  fontSize: '0.72rem', textDecoration: 'none', textTransform: 'uppercase',
  letterSpacing: '0.04em', borderRadius: 4,
}
const iconBtn: React.CSSProperties = {
  padding: '0.4rem 0.6rem', background: 'transparent', border: '1px solid var(--brand-border)',
  color: 'var(--brand-text-0)', cursor: 'pointer', fontFamily: 'var(--font-mono)',
  fontSize: '0.85rem', borderRadius: 4,
}
const toastStyle: React.CSSProperties = {
  position: 'fixed', top: 16, right: 16, padding: '0.75rem 1rem', background: 'var(--brand-bg-1)',
  border: '1px solid var(--brand-border)', fontFamily: 'var(--font-mono)', fontSize: '0.75rem',
  textTransform: 'uppercase', letterSpacing: '0.04em', zIndex: 999, borderRadius: 4,
}
const sectionHeading: React.CSSProperties = {           // analytics + enrollments
  fontSize: '0.7rem', margin: '0 0 8px 0', fontFamily: 'var(--font-mono)',
  textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--brand-text-2)', fontWeight: 600,
}
```

Primary button is the global `.btn.btn-solid` class (orange fill, pill radius, `color:#0b0b0f`) — established in `globals.css` by ADMIN-CONSOLE. Pages shrink it via inline overrides but keep the class.

### 2.3 Toast (every page, identical)

```ts
const [toastMsg, setToastMsg] = useState<string | null>(null)
function toast(msg: string) { setToastMsg(msg); window.setTimeout(() => setToastMsg(null), 3000) }
// at end of render: {toastMsg && <div style={toastStyle}>{toastMsg}</div>}
```

### 2.4 The three signature surface treatments (do not improvise — these ARE the look)

1. **Hollow pill chip** — transparent fill, `1px solid` border + same-color text, `borderRadius:4`, `3px 8px`, mono, uppercase. Colored variants set **both** `borderColor` and `color` to one token (accent / danger / success); the fill stays transparent.
2. **Selected = inverted solid OR orange outline.** Status-filter tabs invert (`background:'var(--brand-text-0)'`, `color:'var(--brand-bg-0)'`). Audience/filter/window cards use `border:'2px solid var(--brand-accent)'` over `--brand-bg-0`. Loaded list rows get `borderLeft:'3px solid var(--brand-accent)'`.
3. **Card** = always `--brand-bg-1` + `1px --brand-border` + `borderRadius:8`. Page container = `<div style={{ padding:'2rem', maxWidth:<N>, margin:'0 auto' }}>` (N = 1200 list / 1100 editor / 1400 analytics / 1300 enrollments+layout; Templates is full-height flex — §7).

### 2.5 Nav wiring

In the admin `TopNav` items array (from ADMIN-CONSOLE), the email entry is:
```ts
{ href: '/admin/dashboard/email', label: 'Automations' }
```
`TopNav` longest-prefix matching keeps "Automations" highlighted across all `/email/*` subroutes. The subpages are **not** separate nav items — they're reached via the 4 secondary links on the list page + "← Campaigns" back-links on each subpage.

---

## 3. Admin auth guard (every `/api/admin/email/*` route)

Reference: `src/lib/admin-auth.ts`.

```ts
export async function requireAdmin(): Promise<AdminContext | NextResponse> {
  const ssr = await createServerSupabase()                       // user-session RLS client
  const { data: { user } } = await ssr.auth.getUser()
  if (!user) return NextResponse.json({ error: 'not authenticated' }, { status: 401 })
  const { data: profile } = await ssr.from('profiles').select('role').eq('id', user.id).maybeSingle()
  if (!profile || profile.role !== 'admin')
    return NextResponse.json({ error: 'admin role required' }, { status: 403 })
  return { userId: user.id, adminClient: createAdminSupabase() } // service-role ONLY after role passes
}
```

Every handler opens with:
```ts
const ctx = await requireAdmin()
if (isAdminResponse(ctx)) return ctx        // short-circuit 401/403
const { userId, adminClient } = ctx          // userId → updated_by audit
```

The role check runs on the **user's** RLS client; the **service-role** client is only handed back after `role === 'admin'`. `userId` stamps `updated_by` on writes. All routes `export const runtime = 'nodejs'` and validate bodies with `zod`. Errors funnel through a shared `internalError(err, context)` → `{ error, correlationId }` 500 (opaque in prod).

---

## 4. Page — Campaigns list (`email/page.tsx`)

Reference: `src/app/admin/dashboard/email/page.tsx`. Container `maxWidth:1200`.

**Data:**
```ts
type Campaign = {
  id: string; name: string; trigger_event: string; status: string
  description: string | null; updated_at: string
  counts: { enrolled_active: number; enrolled_total: number; sent: number; opened: number; clicked: number }
}
```
`GET /api/admin/email/campaigns` on mount; `POST` to create as draft.

**The status model (verbatim — this is the active/paused/draft system):**
```ts
const STATUS_COLORS: Record<string, string> = {
  draft:    'var(--brand-text-2)',   // grey
  active:   'var(--brand-success)',  // green
  paused:   'var(--brand-accent)',   // orange
  archived: 'var(--brand-text-2)',   // grey
}
```
Status filter tabs (`'active' | 'paused' | 'draft' | 'all'`, default `'active'`):
```ts
const visibleCampaigns = campaigns.filter(c => statusFilter === 'all' || c.status === statusFilter)
const statusCounts = {
  all: campaigns.length,
  active: campaigns.filter(c => c.status === 'active').length,
  paused: campaigns.filter(c => c.status === 'paused').length,
  draft:  campaigns.filter(c => c.status === 'draft').length,
}
```
Each tab is a `btnSecondary` button labeled `Active (3)` etc.; the **selected** tab overrides to the inverted solid (`background:'var(--brand-text-0)', color:'var(--brand-bg-0)', borderColor:'var(--brand-text-0)'`).

**Trigger events** (the `<select>` for create + the trigger-chip label map):
```ts
const TRIGGER_EVENTS = [
  { value: 'lead_signup',          label: 'Lead Signup (no checkout)' },
  { value: 'abandoned_checkout',   label: 'Abandoned Checkout' },
  { value: 'lead_magnet',          label: 'Lead Magnet (cold opt-in)' },
  { value: 'trial_signup',         label: 'Trial Signup (legacy)' },
  { value: 'paid_signup',          label: 'Paid Signup' },
  { value: 'inactive_7d',          label: 'Inactive 7 days' },
  { value: 'trial_ending_3d',      label: 'Trial Ending 3 days' },
  { value: 'trial_expired',        label: 'Trial Expired' },
  { value: 'subscription_canceled',label: 'Subscription Canceled' },
  { value: 'payment_failed',       label: 'Payment Failed' },
  { value: 'manual',               label: 'Manual' },
]
```

**Layout:** header (`<h2>` "Email Automations" + mono "N of M campaigns") with right-side links → **Analytics · Enrollments · Layout · Templates** (`btnSecondary` `<Link>`s) + a `.btn.btn-solid` toggling the inline create form (Name input · Trigger `<select>` · Create). Then the status tabs, then a `cardStyle` table:

| Name | Trigger | Status | Active | Sent | Opens | Clicks | (edit) |

- **Status badge cell:** `<span style={{ ...chipStyle, borderColor: STATUS_COLORS[c.status], color: STATUS_COLORS[c.status] }}>{c.status}</span>`.
- **Trigger cell:** `chipStyle` chip with the TRIGGER_EVENTS label.
- **System lock:** `const isSystem = c.name.startsWith('_')`. System rows render `opacity:0.55`, name as a non-link grey span (`cursor:not-allowed`, `title="System campaign — locked"`), and a disabled "Locked" pseudo-button instead of Edit. Non-system rows: name → `<Link href={`/admin/dashboard/email/${c.id}`}>`, edit cell → `btnSecondary` "Edit".
- Numeric cells right-aligned from `counts`.

---

## 5. Page — Campaign editor (`email/[id]/page.tsx`)

Reference: [src/app/admin/dashboard/email/[id]/page.tsx](file:///<reference-app>/src/app/admin/dashboard/email/%5Bid%5D/page.tsx). Container `maxWidth:1100`.

**Data:**
```ts
type Step = { delay_hours: number; template_slug: string; skip_if?: string | null; exit_goal?: string | null }
type Campaign = {
  id: string; name: string; trigger_event: string
  status: 'draft'|'active'|'paused'|'archived'
  steps: Step[]; exit_goal: string|null; description: string|null
  is_transactional: boolean; updated_at: string
}
const STATUSES = ['draft','active','paused','archived'] as const
const CONDITION_HINTS = ['converted_to_paid','has_published','trial_expired','unsubscribed']  // datalist
```
Loads `GET campaigns/[id]` + `GET templates` in parallel. Saves via `PATCH campaigns/[id]` with the full body. `dirty` tracks unsaved edits; Save is disabled unless dirty.

**Step timing is RELATIVE to the previous email** (not enrollment). The editor renders a cumulative "fires" hint per step:
```ts
function formatFiresHint(cumHours: number, trigger: string): string {
  const anchor = trigger === 'lead_magnet' ? 'opt-in' : 'signup'
  if (cumHours === 0) return `fires on ${anchor} (Day 0)`
  const days = cumHours / 24
  if (Number.isInteger(days)) return `fires Day ${days}`
  const d = Math.floor(cumHours / 24); const h = cumHours % 24
  return d === 0 ? `fires +${h}h after ${anchor}` : `fires Day ${d} +${h}h`
}
// cumulativeHours: running sum of max(0, floor(delay_hours)) across steps
```

**Sections (top → bottom):**
1. "← Campaigns" back-link.
2. Borderless title `<input>` (name) + borderless sub `<input>` (description). Right: **Save Changes** (`.btn.btn-solid`, disabled `!dirty||saving`) + **Archive** (`btnSecondary` recolored danger → `confirm()` → PATCH `status:'archived'` → route back).
3. 3-col grid: Trigger `<select>` (TRIGGER_EVENTS) · Status `<select>` (STATUSES) · Exit Goal text input (`list="condition-hints"`).
4. **Transactional toggle card** — checkbox + "Transactional campaign"; when on shows an `ENABLED` accent chip + warning copy ("routes via transactional stream, bypasses opt-out, strips unsub footer + List-Unsubscribe — legally-exempt notices ONLY").
5. **Steps section** — `<h3>` "Steps (N)" + a row per step: grid `40px 110px 1fr 1fr 1fr auto`:
   - step # · **Delay (hours)** number input + grey `formatFiresHint` line · **Template** `<select>` (`{name} ({slug})`) · **Skip If** input · **Exit Goal** input · `↑`/`↓`/`×` `iconBtn`s (move up/down/remove).
   - `lead_magnet` trigger shows a non-editable "Instant delivery email · AUTO · DAY 0" row at top (code-defined, §13).
   - footer `+ Add Step` (`.btn.btn-solid`). `addStep` appends `{ delay_hours:0, template_slug: templates[0]?.slug ?? '' }`. `handleSave` sanitizes: `delay_hours = max(0, floor(Number||0))`, trims slug, drops empty `skip_if`/`exit_goal`.
6. **Audience & Fire — only when `trigger_event === 'manual'`:** 4 selectable audience cards (All confirmed / Paid `plan≠free` / Trial-free `plan=free` / Pick users). `select` lazy-loads `GET users`. The user picker (search + select-visible + scroll box `maxHeight:320`, plan + `OPTED OUT` chips) toggles a `Set<string>`. Fire footer: `Fire Campaign →` (`.btn.btn-solid`) disabled when `firing || dirty || status !== 'active'`; `handleFire` guards then `POST campaigns/[id]/enroll { audience, userIds? }` and shows `lastFireResult` (enrolled / already-active / matched / worker-fired).
7. Conditions reference footer card documenting the DSL + evaluation order (campaign `exit_goal` → step `exit_goal` → step `skip_if`).

---

## 6. Page — Templates (`email/templates/page.tsx`)

Reference: `src/app/admin/dashboard/email/templates/page.tsx`. **Full-height 3-pane layout** (the only page that breaks the centered-container pattern):

```
root: display:flex; flexDirection:column; height:calc(100vh - 64px); padding:'0.85rem 1.25rem'; gap:'0.75rem'; overflow:hidden
grid: 200px 1fr 1.1fr  (list / editor / live preview), fills remaining height
```

**Data:**
```ts
type Template = { id; slug; name; subject; html_body; text_body; from_email; from_name; updated_at: string }
type Layout = { html_wrapper: string; text_wrapper: string }
```
Loads `GET templates` + `GET layout` in parallel.

**Panes:**
1. **List** — scrollable; each item = name + mono slug; selected gets `borderLeft:'3px solid --brand-accent'` + `--brand-bg-0`.
2. **Editor** — Slug (editable only when `adding`, sanitized `[a-z0-9-]`, disabled+`opacity:0.6` when editing) + Name; From Name + From Email; Subject (note "supports {{first_name}}"); Body (HTML) `<textarea flex:1 resize:none mono>`. Footer: **Archive** (danger) + **Duplicate** (when not adding) + Save (`.btn.btn-solid`, label `Create`/`Save Changes`/`Saved`).
3. **Live preview** — FROM line + merge-rendered SUBJECT + `<iframe srcDoc={wrapPreview(html_body, layout, SAMPLE_CTX)}>` (`flex:1`, bg `#FAFAF7`).

**Preview helpers (mirror server `render-template.ts`):**
```ts
function renderMergeTags(input: string, ctx: Record<string,string>) {
  return input.replace(/\{\{\s*([a-z0-9_]+)\s*\}\}/gi, (_m, k) => ctx[k.toLowerCase()] ?? '')
}
function wrapPreview(body: string, layout: Layout|null, ctx) {
  const wrapped = layout ? layout.html_wrapper.replace('{{content}}', body) : body
  return renderMergeTags(wrapped, ctx)
}
const SAMPLE_CTX = { first_name:'Alex', full_name:'Alex Rivera', email:'preview@yourapp.com', trial_days_left:'5' }
```
**API:** `POST templates` (create), `PATCH templates/[slug]` (edit OR `{archive:true}`), `POST templates/[slug]/duplicate {newSlug}`. `startNew()` seeds a sample body (orange eyebrow / display headline / `{{first_name}}` body / orange CTA table) + `from_email`/`from_name` defaults.

---

## 7. Page — Layout (`email/layout/page.tsx`)

Reference: `src/app/admin/dashboard/email/layout/page.tsx`. Container `maxWidth:1300`.

```ts
type Layout = { html_wrapper: string; text_wrapper: string; updated_at: string }
```
`GET layout` / `PATCH layout`. **Before save, BOTH wrappers must contain `{{content}}`** (else toast error — it's where the template body injects). 2-col grid: left = HTML Wrapper `<textarea rows=26>` + Plain Text Wrapper `<textarea rows=10>`; right = HTML preview in an `<iframe srcDoc={wrapPreview(html_wrapper, SAMPLE_CONTENT_HTML)}>` + plain-text preview in a `<pre>`. `SAMPLE_CONTENT_HTML` is a hardcoded sample body so the wrapper renders with realistic content.

---

## 8. Page — Enrollments (`email/enrollments/page.tsx`)

Reference: `src/app/admin/dashboard/email/enrollments/page.tsx`. Container `maxWidth:1300`. The most stateful page.

**Data:**
```ts
type EnrolledUser = { id; email; full_name:string|null; plan:string|null; email_opt_out:boolean; created_at:string }
type EnrollmentRow = {
  id; campaign_id; status:'active'|'completed'|'exited'|'paused'
  current_step:number; next_send_at:string|null; exit_reason:string|null
  enrolled_at:string; completed_at:string|null
  campaign: { id; name; trigger_event; status; steps:unknown[] } | null
}
type SendRow = { id; template_slug; step_index:number; subject; sent_at; opened_at; clicked_at; bounced_at; unsubscribed_at; error_message:string|null }
```
**Enrollment status color (verbatim):**
```ts
function statusColor(s: string): string {
  if (s === 'active') return 'var(--brand-success)'   // green
  if (s === 'paused') return 'var(--brand-accent)'    // orange
  if (s === 'exited') return 'var(--brand-danger)'    // red
  return 'var(--brand-text-2)'                          // completed/other = grey
}
```

**Surfaces:**
- **Search form** — email input → `GET enrollments?email=`.
- **4 filter cards** (All confirmed / Paid / Trial-free / Pending) → `GET users?filter=`. Active card = `2px solid --brand-accent`.
- **Cohort list** — in-list search, counter (`total · shown · selected`), select-visible, multi-select `Set`. **Bulk enroll panel** (campaign `<select>` → `POST campaigns/[id]/enroll {audience:'select', userIds}`). Each row: checkbox + name/email + `plan` chip + `PENDING` chip (`!email_confirmed`) + `OPTED OUT` chip.
- **User detail card** — name/email/chips + actions: **Send template…** (panel → `POST send-one` per target, **concurrency 4** worker pool), **Enroll in campaign…**, **Re-subscribe / Opt out** (`PATCH users/[id] {email_opt_out}`). When a cohort is selected, actions target the checked set (`getActionTargetIds()`).
- **Enrollments list** — campaign name (→ editor) + `trigger · step X/Y`; status chip via `statusColor` + `exit_reason`; timing (`Next:`/`Ended:`/`Enrolled:`); action `iconBtn`s by status (active → `⏭` skip / `⏸` pause / `×` exit; paused → `▶` resume / `×` exit) → `PATCH enrollments/[id] {action}`.
- **Recent Sends table** — subject + `template_slug · step N` + sent_at + status chips (`error` danger / `bounced` danger / `clicked` success / `opened` / `unsub` accent).

---

## 9. Page — Analytics (`email/analytics/page.tsx`)

Reference: `src/app/admin/dashboard/email/analytics/page.tsx`. Container `maxWidth:1400`.

```ts
type AnalyticsResponse = {
  windowDays: number; since: string
  overview: { sent; opened; clicked; bounced; unsubscribed; spam; errors: number }
  daily: { date: string; sent; opened; clicked: number }[]
  byCampaign: { campaign_id; name; trigger_event; status; is_transactional; sent; opened; clicked; bounced; unsubscribed; errors }[]
  byTemplate: { template_slug: string; sent; opened; clicked: number }[]
}
const pct = (n: number, d: number) => d === 0 ? '—' : `${(n/d*100).toFixed(1)}%`
```
`GET analytics?days=` (default 30). Window buttons `[7,14,30,60,90]` → selected = `border:'2px solid --brand-accent'` over `--brand-bg-0`.

- **5 stat cards** (`StatCard`: `cardStyle` + uppercase mono label + `1.75rem` value + optional sub): Sent (sub `{errors} send errors`, danger if >0) · Open rate · Click rate · Bounce rate (danger sub) · Unsub rate.
- **Daily chart** — hand-rolled inline SVG (`viewBox 0 0 100 100`, `preserveAspectRatio none`): grey full-height bar = sent, orange overlay (opacity 0.7) = opens share, green overlay = clicks share, `<title>` tooltip per bar; x-labels every `floor(points/8)`. Legend: Sent grey / Opened orange / Clicked green.
- **Per-campaign table** — Campaign (→ editor) · Trigger chip · Sent · Open · Click · Bounce · Unsub · Errors (rates via `pct`; bounce/errors cells turn danger when >0; `TRANSACTIONAL` chip on flagged rows).
- **Per-template table** — slug · Sent · Open rate · Click rate.

---

## 10. The 14 admin API routes (reference)

All `runtime='nodejs'`, `requireAdmin`, `zod`-validated. Reference dir: `src/app/api/admin/email/`.

| Route | Methods | Notes |
|---|---|---|
| `campaigns` | GET, POST | GET rolls up counts from parallel `email_enrollments` + `email_sends` queries (head-count overrides for `lead_magnet` from `magnet_leads`/`magnet_lead_sends`). POST validates `is_transactional` only on `payment_failed` (`TRANSACTIONAL_ALLOWED_TRIGGERS`). |
| `campaigns/[id]` | GET, PATCH | PATCH **403s if name starts `_`** (system lock); re-checks effective-transactional guard (patch-value-else-existing for both flag + trigger). |
| `campaigns/[id]/enroll` | POST | Audience `all` (auth.listUsers, confirmed only, ≥1000 = truncation note) / `paid` / `free` (profiles) / `select` (trusted userIds). Skips already-active. INSERT in chunks of 500. `next_send_at = now + floor(steps[0].delay_hours)`. Best-effort `triggerAndRecord('email-drip-tick')`. 400 if archived or `steps.length===0`. |
| `templates` | GET, POST | GET active only (`archived_at is null`). POST slug regex `^[a-z0-9][a-z0-9-]{0,63}$`; derives `text_body` via `htmlToPlainText` if omitted; 23505 → 409. |
| `templates/[slug]` | GET, PATCH | PATCH `{archive:true}` sets `archived_at`; else updates + auto-derives `text_body`. |
| `templates/[slug]/duplicate` | POST | Copies an active template under `{newSlug}`; default name `"<src> (Copy)"`; 23505 → 409. |
| `layout` | GET, PATCH | Singleton `id=1`. Each provided wrapper **must contain `{{content}}`** (400 otherwise). |
| `enrollments` | GET | `?email=` → match via `auth.listUsers` (no get-by-email API), then profiles + enrollments(+campaign) + last 50 sends. |
| `enrollments/[id]` | PATCH | State machine `{action: pause|resume|exit|skip}`. pause: active→paused (next_send_at null). resume: paused→active (next_send_at now). exit: →exited (+exit_reason, completed_at). skip: active→ current_step+1, next_send_at now. **No DELETE.** Invalid transitions → 400. |
| `analytics` | GET | `?days=` 1–90. Reads `email_sends` window + campaign labels; all rollups computed in JS. `sent` = rows with `postmark_message_id`; `errors` = rows with `error_message` and no message id. |
| `send-one` | POST | The **only** admin route that sends. `{userId, templateSlug, force?}`. Blocks opted-out unless `force`; 30-min same-template 409 guard; find-or-creates `_Ad-hoc Sends (system)` campaign; inserts synthetic completed enrollment + `email_sends` (`dedupe_key='<enrollmentId>:0'`); `wrapAndRender` + `sendBroadcastEmail` with `unsubscribe_url`. |
| `users` | GET | `?filter=all|paid|free|pending` from `auth.listUsers({perPage:1000})` + profiles join. `truncated` flag at ≥1000. |
| `users/[id]` | PATCH | `{email_opt_out}` flip on profiles only (does NOT exit enrollments). |
| `users/[id]/opt-out` | POST | Full opt-out: flip flag + **exit ALL active enrollments** (`exit_reason:'support_opt_out'`) + stamp latest send `unsubscribed_at` + optional `conversation_events` audit row (`ticketId`). |

---

## 11. The engine layer

### 11.1 Drip worker (`email-drip-tick`)
Owned by EMAIL-INFRASTRUCTURE. This console relies on its contract: cron `*/15 * * * *`, ≤200 due active enrollments/tick, insert-`email_sends`-row-before-send (`dedupe_key` UNIQUE), optimistic-lock advance (`.eq('current_step', old)`), retryable (5xx/429 → delete send row + back off 15min, stay on step) vs hard (4xx → keep row + advance), opt-out gate (`email_opt_out && !is_transactional` → exit this enrollment), per-user 2-emails/UTC-day marketing cap. See [s-tier/EMAIL-INFRASTRUCTURE-PLAYBOOK.md §6](../s-tier/EMAIL-INFRASTRUCTURE-PLAYBOOK.md).

### 11.2 Condition DSL (`src/lib/email/conditions.ts`) — owned here
Reference: `src/lib/email/conditions.ts`. Conditions are **plain strings** stored in `email_campaigns.exit_goal` and `steps[].skip_if` / `steps[].exit_goal`. Evaluated by the drip worker. **Fail-soft**: unknown/errored → `false` (a typo can't silently nuke a campaign).

```ts
export const KNOWN_CONDITIONS = ['converted_to_paid','has_published','onboarding_complete','trial_expired','unsubscribed']

type ConditionContext = {
  userId: string; enrolledAt: string
  profile: { plan; account_status; email_opt_out; onboarding_step_completed? } | null
  supabase: SupabaseClient; isTransactional?: boolean
}

export async function evaluateCondition(key: string, ctx: ConditionContext): Promise<boolean> {
  const k = key.trim().toLowerCase(); if (!k) return false
  try {
    switch (k) {
      case 'converted_to_paid':  return !!ctx.profile?.plan && ctx.profile.plan !== 'free'
      case 'has_published': {    // any scheduled_posts published AFTER enrollment
        const { data } = await ctx.supabase.from('scheduled_posts').select('id')
          .eq('user_id', ctx.userId).eq('status','published').gt('published_at', ctx.enrolledAt).limit(1)
        return (data?.length ?? 0) > 0
      }
      case 'onboarding_complete': return (ctx.profile?.onboarding_step_completed ?? 0) >= 3
      case 'trial_expired':       return ctx.profile?.account_status === 'trial_expired'
      case 'unsubscribed':        return ctx.isTransactional ? false : ctx.profile?.email_opt_out === true
      default: console.warn('[email-conditions] unknown key', k); return false
    }
  } catch { return false }
}
```
**To add a condition:** extend the switch + `KNOWN_CONDITIONS`, keep fail-soft. `has_published` is the app-specific "activated" signal — swap `scheduled_posts`/`published_at` for whatever your app's activation event is.

### 11.3 Trigger-event model + enroll helpers (`src/lib/email/enroll.ts`)
Reference: `src/lib/email/enroll.ts`. Both helpers are best-effort (return counts, never throw — enrollment is a retention nicety, never blocks a signup/webhook).

```ts
type CampaignTriggerEvent =
  | 'trial_signup' | 'paid_signup' | 'inactive_7d' | 'trial_ending_3d' | 'trial_expired'
  | 'manual' | 'subscription_canceled' | 'payment_failed' | 'lead_signup'
  | 'abandoned_checkout' | 'lead_magnet' | 'welcome_offer'

enrollUserInCampaignsByEvent(admin, userId, event, opts?: { repeatable?: boolean; cooldownDays?: number })
  → { enrolled, skipped, errored }
exitActiveEnrollmentsByEvent(admin, userId, fromEvent, reason)
  → { exited, errored }
```
De-dupe: `repeatable=false` (default) skips if ANY prior enrollment in that campaign exists; `repeatable=true` only blocks an **active** one — unless `cooldownDays` is set (then also blocks if most-recent `enrolled_at` within the window — load-bearing for daily sweeps so a still-dormant user isn't re-enrolled every day). 23505 on the active-partial unique index counts as success.

Where events fire: `lead_signup`/`paid_signup`/`welcome_offer` from signup + Stripe webhooks (one-shot); `inactive_7d`/`trial_ending_3d` from `email-lifecycle-tick` (repeatable, cooldown 14d); `trial_expired` from `trial-expire-tick`; `payment_failed`/`subscription_canceled` dunning (transactional); `lead_magnet` rides `magnet_leads` (not `email_enrollments`); `manual` from admin fire / `_Ad-hoc Sends (system)`.

### 11.4 Supporting workers
| Worker | Cron (UTC) | Role |
|---|---|---|
| `email-lifecycle-tick` | `0 13 * * *` | `plan='free'` only. Inactive cohort (`inactive_7d`, opt-out pre-filtered, dormancy via no recent activity) + trial-ending cohort (`trial_ending_3d`, credits expiring ≤3d still on free plan). Both `{repeatable:true, cooldownDays:14}`. Uses `Date.parse` not string compare. |
| `trial-expire-tick` | `0 12 * * *` | 1h BEFORE lifecycle. Flip expired free-trial users `account_status='trial_expired'` (`.eq('account_status','active')` = idempotent one-shot) then enroll into `trial_expired` win-back. Upsert-only. |
| `trial-day2-gift` | `30 14 * * *` | App-specific return-loop (gift a free generation 24–48h post-first-use + announce email). **Optional** — only if your app has a per-output gift to give. |
| `lead-nurture-tick` | `*/30 * * * *` | Anonymous `magnet_leads` funnel (recipient = `magnet_leads.email`, no auth user). Claims `(lead_id, step_index)` in `magnet_lead_sends` before send; exits on `user_id`/`converted_at`. Mirrors the drip worker's at-most-once + retry pattern. |

All workers use the **service-role admin client** (`NEXT_PUBLIC_SUPABASE_URL` + `SUPABASE_SERVICE_ROLE_KEY`), not the RLS client. Redeploy Trigger.dev after editing any worker.

---

## 12. Schema additions beyond EMAIL-INFRASTRUCTURE

EMAIL-INFRASTRUCTURE ships the 4 core tables + `email_layouts` + `profiles.email_opt_out`. This playbook adds:

**1. Widen `email_campaigns.trigger_event` CHECK** to the full final list (build it directly — don't replay incremental ALTERs):
```sql
check (trigger_event in (
  'trial_signup','paid_signup','inactive_7d','trial_ending_3d','trial_expired','manual',
  'subscription_canceled','payment_failed','lead_signup','abandoned_checkout','lead_magnet','welcome_offer'
))
```

**2. Profile-insert enrollment triggers** (`security definer`, `search_path=public`) — fire on `after insert on profiles`, enroll into active campaigns matching the event, idempotent via the active-partial unique index (catch `unique_violation`):
```sql
create or replace function public.enroll_user_in_lead_signup_campaigns()
returns trigger language plpgsql security definer set search_path = public as $$
declare rec record; v_delay int;
begin
  for rec in select id, steps from public.email_campaigns
             where trigger_event = 'lead_signup' and status = 'active' loop
    v_delay := greatest(0, coalesce((rec.steps->0->>'delay_hours')::int, 0));
    begin
      insert into public.email_enrollments (user_id, campaign_id, current_step, next_send_at)
      values (new.id, rec.id, 0, now() + (v_delay || ' hours')::interval);
    exception when unique_violation then null; end;
  end loop;
  return new;
end; $$;
create trigger trg_profiles_enroll_lead_signup
  after insert on public.profiles for each row
  execute function public.enroll_user_in_lead_signup_campaigns();
```
Clone the same function for `welcome_offer` (`enroll_user_in_welcome_offer_campaigns` + `trg_profiles_enroll_welcome_offer`). **Why a DB trigger, not app code:** if email confirmation is OFF, email/password signups skip `/auth/callback` — the DB trigger fires on every profile insert regardless of auth path. App-side `enrollUserInCampaignsByEvent` stays as the OAuth/webhook belt-and-suspenders.

**3. Lead-magnet tables** (only if building the cold-traffic funnel):
```sql
create table public.magnet_leads (
  id uuid primary key default gen_random_uuid(),
  email text not null, name text, magnet_slug text not null,
  qualifier text, source text, utm_medium text, utm_campaign text, referrer text,
  landing_path text, visitor_id text, session_id text,
  nurture_step integer not null default 0 check (nurture_step >= 0),
  nurture_status text not null default 'active' check (nurture_status in ('active','completed','exited')),
  next_email_at timestamptz,
  user_id uuid references auth.users(id) on delete set null,   -- lead→paid join
  converted_at timestamptz,
  created_at timestamptz not null default now(), updated_at timestamptz not null default now()
);
create unique index ux_magnet_leads_email on public.magnet_leads (email);
create index ix_magnet_leads_nurture_due on public.magnet_leads (next_email_at) where nurture_status = 'active';

create table public.magnet_lead_sends (
  id uuid primary key default gen_random_uuid(),
  lead_id uuid not null references public.magnet_leads(id) on delete cascade,
  step_index integer not null, sent_at timestamptz not null default now(),
  unique (lead_id, step_index)
);
```
Both **service-role-only RLS** (every `email_*` and `magnet_*` table follows the same single policy):
```sql
alter table public.<t> enable row level security;
create policy "<t> service role" on public.<t> for all
  using (auth.role() = 'service_role') with check (auth.role() = 'service_role');
```

**Gotchas:** table is `email_layouts` (plural, singleton `id=1`). `magnet_leads` has no `updated_at` trigger (app sets it). The layout wrapper row + its `<!--@marketing-only-start/end-->` markers were seeded via Management-API `apply_migration` (not checked-in SQL) — **export the live wrapper HTML** from the source app, don't reconstruct it.

---

## 13. Seed campaigns

Seed idempotently (`where not exists` on the campaign's `trigger_event`; dollar-quote JSONB steps). Two reference sequences:

**Lead-magnet nurture** (`trigger_event='lead_magnet'`, active, no exit_goal) — 6 templates `lead-nurture-1..6`, steps `[24,24,24,24,24,48]` hours. Driven by `lead-nurture-tick` against `magnet_leads`. The **instant delivery** email is code-defined in `src/lib/email/lead-nurture.ts` (`buildDeliveryEmail`) — sent transactionally the moment a lead opts in; only the follow-ups live in the admin-editable campaign row.

**Welcome offer** (`trigger_event='welcome_offer'`, active, **`exit_goal='converted_to_paid'`**) — 3 templates `welcome-offer-1..3`, steps `[0,24,21]` hours (immediately → day-1 → "expires tonight" at ~T-3h). Merge tag `{{offer_expires_at}}` supplied by the drip worker's merge context (`offer_started_at + window`, formatted ET).

Templates seed with `from_email`/`from_name` matching the app (`hello@yourapp.com` / `"<Founder> at <App>"`) and merge tags `{{first_name}}`, `{{magnet_name}}`, `{{pricing_url}}`, `{{offer_expires_at}}` + the layout's `{{unsubscribe_url}}`.

---

## 14. Invariants to preserve (do not regress)

1. **No DELETEs.** Campaigns archive (`status='archived'`), enrollments transition status, sends are append-only. The only legit DELETE is the soft-fail Postmark retry freeing a dedupe row.
2. **System-campaign lock.** Names starting `_` are PATCH-locked (403). `_Ad-hoc Sends (system)` backs `send-one` + day-2 gift.
3. **Transactional allowlist.** `is_transactional=true` only on `payment_failed` (or your dunning events) — enforced on create AND patch (effective-state). Transactional bypasses opt-out + strips footer + omits List-Unsubscribe in 3 coordinated places (worker opt-out gate, `evaluateCondition('unsubscribed')`, `wrapAndRender({transactional})`).
4. **`{{content}}` required** in both layout wrappers (validated on PATCH).
5. **Insert-send-row-before-send** with `dedupe_key` UNIQUE = at-most-once. 23505 → advance, don't resend.
6. **Optimistic-lock advance** (`.eq('current_step', old)`) against concurrent double-fires.
7. **Fail-soft conditions** — unknown/errored → `false`.
8. **PostgREST int columns:** `Math.floor`/`max(0,...)` on `delay_hours` before any `.eq/.gte/.lte`.
9. **Opt-out parity** — `users/[id]/opt-out`, the public unsubscribe page, and the Postmark SubscriptionChange/Bounce/Spam handlers all perform the same flag-flip + exit-active-enrollments mutation.
10. **`send-one` is the only admin send path.** Everything else writes campaign/enrollment state; the drip worker does the bulk sending.

---

## 15. Build sequence (slices — pause after each for review)

1. **Schema** — widen trigger_event CHECK, add enrollment triggers, (optional) magnet tables, seed campaigns. Apply via Management API, track applied status (SUPABASE-MIGRATION-PLAYBOOK).
2. **API layer** — the 14 routes + `requireAdmin`. Ship `campaigns` + `campaigns/[id]` + `templates*` + `layout` first (the CRUD the editor needs), then `enrollments*` + `users*` + `send-one` + `analytics`.
3. **conditions.ts + enroll.ts extensions** — DSL + the two enroll helpers.
4. **Pages 1-2** (list + editor) — the core management loop. Review the design against the BKE screenshots before continuing.
5. **Pages 3-4** (templates + layout) — content authoring.
6. **Pages 5-6** (enrollments + analytics) — ops + reporting.
7. **Workers** — lifecycle + expire (+ lead-nurture if magnet tables shipped). Redeploy Trigger.dev. Wire the lifecycle cohort queries to your app's real activity signal.
8. **Seed + smoke** — create a draft campaign in the UI, add a step, flip to active, fire a manual enroll to yourself, confirm a send lands + analytics counts it.

---

## 16. Kickoff prompt

> Execute the Email Automation Console Playbook at `<workspace>/Templates/a-tier/EMAIL-AUTOMATION-CONSOLE-PLAYBOOK.md` for this app. **Prerequisite:** run the Email Infrastructure Playbook first (`Templates/s-tier/EMAIL-INFRASTRUCTURE-PLAYBOOK.md`) so Postmark, the 4 core tables, the layout singleton, opt-out, unsubscribe, and `email-drip-tick` exist. The admin console shell (`requireAdmin`, `TopNav`, `--brand-*` tokens) from `Templates/s-tier/ADMIN-CONSOLE-PLAYBOOK.md` must also exist.
>
> Mirror the BKE implementation: `src/app/admin/dashboard/email/*` (6 pages), `src/app/api/admin/email/*` (14 routes), `src/lib/email/conditions.ts` + `enroll.ts`, `src/trigger/{email-lifecycle-tick,trial-expire-tick,lead-nurture-tick}.ts`. When in doubt about a pattern, READ the BKE file rather than re-deriving — the design system (§2), STATUS_COLORS (§4), statusColor (§8), and the route contracts (§10) must be byte-faithful so the console looks and behaves identically.
>
> Inputs:
> - App name + founder display name (for template from-lines): [App / "<Founder> at <App>"]
> - Sending domain + from addresses: [hello@, support@]
> - Build the cold-traffic lead-magnet funnel? [yes → magnet tables + lead-nurture worker | no → skip §12.3 + lead-nurture]
> - Build the day-2 gift worker? [only if the app has a per-output gift to give]
> - App's "activation" signal for `has_published` condition: [e.g. first published post / first generation / first project]
> - Trigger events to seed campaigns for at launch: [typically lead_signup + welcome_offer + trial_expired]
>
> Follow the §15 slice order. Pause after the schema slice and again after pages 1-2 for review. Redeploy Trigger.dev after the workers slice. Re-theme by confirming `--brand-accent` + font vars in the new app's globals.css — do not hardcode Kompozy orange.

---

## 17. Reference files in BKE

**Pages:** `email/page.tsx` · [email/[id]/page.tsx](file:///<reference-app>/src/app/admin/dashboard/email/%5Bid%5D/page.tsx) · `templates/page.tsx` · `layout/page.tsx` · `enrollments/page.tsx` · `analytics/page.tsx`

**API:** `api/admin/email/` (all 14 routes) · `lib/admin-auth.ts`

**Engine:** `lib/email/conditions.ts` · `lib/email/enroll.ts` · `lib/email/render-template.ts` · `lib/email/lead-nurture.ts`

**Workers:** `email-drip-tick.ts` · `email-lifecycle-tick.ts` · `trial-expire-tick.ts` · `trial-day2-gift.ts` · `lead-nurture-tick.ts`

**Migrations:** 00065 (core tables) · 00066 (layout) · 00068/00150 (enrollment triggers) · 00069/00089/00150/00166/00169 (trigger_event widening) · 00070 (transactional flag) · 00164 (magnet_leads) · 00166 (lead nurture seed) · 00167 (magnet_lead_sends) · 00169 (welcome offer seed) — under `supabase/migrations/`.

**Sibling playbook:** [s-tier/EMAIL-INFRASTRUCTURE-PLAYBOOK.md](../s-tier/EMAIL-INFRASTRUCTURE-PLAYBOOK.md) (the engine/plumbing half).
