<!--
Email Infrastructure 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 Infrastructure Playbook

**Portable spec for shipping end-to-end email infrastructure in a new SaaS app: Postmark setup, DNS auth, drip campaign engine, transactional vs marketing routing, opt-out + unsubscribe handling, webhook event ingestion, and inbound mail processing.**

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

> **This is the engine/plumbing half.** For the operator-facing admin console that manages it — the 6 "Automations" pages (campaigns list with Active/Paused/Draft tabs, campaign editor, Templates, Layout, Enrollments, Analytics), the 14 admin API routes, the condition DSL, the lifecycle/expire/lead-nurture workers, and the seed campaigns — run [a-tier/EMAIL-AUTOMATION-CONSOLE-PLAYBOOK.md](../a-tier/EMAIL-AUTOMATION-CONSOLE-PLAYBOOK.md) **after** this one. Together they rebuild the entire email automation system, same design, end to end.

---

## 0. Use this spec

**Inputs:**
- A Postmark account with a verified sending domain (`yourapp.com`)
- DNS access to that domain (Cloudflare / Route 53 / Namecheap)
- Supabase project (auth + service role key) from the Auth + Onboarding playbook
- Trigger.dev project (for the drip cron + lifecycle workers)
- App URL (e.g. `https://yourapp.com`) — used in unsubscribe links

**Outputs:**
- Postmark `outbound` (transactional) + `broadcast` (marketing) message streams
- SPF / DKIM / DMARC DNS records — strict (`-all`, `p=quarantine`)
- 4 core tables: `email_templates`, `email_campaigns`, `email_enrollments`, `email_sends`
- Singleton `email_layouts` row holding the brand HTML/text wrapper
- `profiles.email_opt_out` flag + HMAC-signed unsubscribe URL
- `/email/unsubscribe` page (one-click GET + POST per RFC 8058)
- `/api/email/postmark-events` webhook (Open/Click/Bounce/SpamComplaint/SubscriptionChange)
- `/api/email/inbound` webhook (parses inbound replies, routes by alias)
- Trigger.dev workers: `email-drip-tick` (cron `*/15 * * * *`), `email-lifecycle-tick`
- DB trigger to auto-enroll new profiles in `trigger_event='trial_signup'` campaigns

---

## 1. Architecture

```
src/
├── lib/email/
│   ├── postmark.ts                # sendPostmarkEmail / sendBroadcastEmail / sendThreadedReply
│   ├── render-template.ts         # wrapAndRender({{merge_tags}} + layout {{content}} substitution)
│   ├── repo.ts                    # CRUD on templates / campaigns / enrollments / sends
│   ├── enroll.ts                  # enrollUserInTriggerEvent() — server-side enrollment hook
│   ├── conditions.ts              # evaluateCondition('has_published' | 'plan_paid' | ...)
│   ├── format.ts                  # MJML-lite block helpers for template authoring
│   ├── sanitize-html.ts           # DOMPurify wrapper for admin-authored body HTML
│   └── unsubscribe-token.ts       # HMAC sign / verify of <userId>.<sig> tokens
│
├── app/api/email/
│   ├── postmark-events/route.ts   # HTTP Basic auth, Open/Click/Bounce/SpamComplaint/SubscriptionChange
│   └── inbound/route.ts           # HTTP Basic auth, parses + persists inbound mail
│
├── app/email/unsubscribe/
│   └── page.tsx                   # GET + POST (one-click); verifies HMAC token, flips opt_out
│
├── trigger/
│   ├── email-drip-tick.ts         # cron */15 * * * * — fires due steps
│   └── email-lifecycle-tick.ts    # cron */15 * * * * — emits inactive_7d / trial_ending_3d events
│
└── supabase/migrations/
    ├── 00065_email_campaigns.sql              # 4 core tables + indexes
    ├── 00066_email_layout.sql                 # singleton layout wrapper
    ├── 00067_email_opt_out.sql                # profiles.email_opt_out
    ├── 00068_enroll_trial_signup_on_profile_create.sql
    ├── 00069_extend_email_trigger_events.sql  # widen CHECK constraint
    └── 00070_email_campaigns_transactional_flag.sql
```

---

## 2. Postmark account setup

1. **Verify your sending domain.** Postmark → Sender Signatures → Add Domain → `yourapp.com`. Postmark gives you DKIM + Return-Path values; record them for §3.
2. **Two message streams** under the server:
   - `outbound` — transactional (default). Auth emails, billing notices, password resets, support replies.
   - `broadcast` — marketing (drip campaigns, newsletters). **Stream separation is load-bearing**: Postmark scores sender reputation per stream, so a spam complaint on a newsletter doesn't poison your password-reset deliverability.
3. **Server API token** (single token covers both streams; pick stream per-call). Store as `POSTMARK_SERVER_TOKEN` — **server-only env var**. Never ship to client bundle.
4. **From addresses:** verified signatures for every `From` you'll use. `noreply@yourapp.com` for system, `hello@yourapp.com` for marketing, `support@yourapp.com` for support replies. Display names ride in the `From` header (`"Yourapp <hello@yourapp.com>"`).
5. **Webhooks** (set after building the routes in §8–9):
   - Open / Click / Bounce / Spam Complaint / Subscription Change → `https://yourapp.com/api/email/postmark-events`
   - Inbound (if using inbound) → `https://yourapp.com/api/email/inbound`
   - **HTTP Basic auth on both.** Postmark supports `https://user:pass@host/...`. Set `POSTMARK_WEBHOOK_USER` + `POSTMARK_WEBHOOK_PASS` and validate in the route.

Reference: `src/lib/email/postmark.ts` — single-file client with `sendPostmarkEmail`, `sendBroadcastEmail`, `sendThreadedReply`, `isRetryablePostmarkError`.

---

## 3. DNS auth (SPF + DKIM + DMARC)

Add at the apex (`yourapp.com`):

```
; SPF — authorize Postmark; -all forces hard fail for everything else
yourapp.com.        TXT  "v=spf1 a mx include:spf.mtasv.net ~all"

; DKIM — Postmark gives you the host + key after domain verification
20251201._domainkey.yourapp.com.   TXT  "k=rsa; p=MIGfMA0GCSqGSIb3...IDAQAB"

; Return-Path / bounce alignment (Postmark-issued)
pm-bounces.yourapp.com.   CNAME  pm.mtasv.net.

; DMARC — start permissive, tighten over 4 weeks
_dmarc.yourapp.com.   TXT  "v=DMARC1; p=none; rua=mailto:dmarc@yourapp.com; ruf=mailto:dmarc@yourapp.com; fo=1; aspf=s; adkim=s"
```

**SPF: `~all` first, then `-all`.** `~all` (softfail) for the first 1-2 weeks while you verify there are no legitimate senders missing from the record (Stripe receipts? GitHub notifications from a no-reply alias? marketing automation?). Once DMARC aggregate reports show 100% authenticated, tighten to `-all`.

**DMARC tightening schedule (load-bearing):**
- **Week 0–2: `p=none`** — collect reports only. Read `rua` aggregates daily. You're looking for any IP sending mail on your behalf that isn't Postmark.
- **Week 2–4: `p=quarantine`** — failures go to spam. Mailbox providers start trusting your domain.
- **Week 4+: `p=reject`** — only attempt after two consecutive weeks of 100% aligned authentication in DMARC reports.

The BKE recipe tightened from `p=none` → `p=quarantine` on **2026-05-20** after 18 days of clean reports. **Skipping this gradient is the single most common way to torch your own deliverability** — a stray legacy sender (Mailchimp from 2 years ago, a CI cron mailer) gets reject-bounced and you don't notice for a week.

Set `aspf=s` and `adkim=s` (strict alignment) — `relaxed` lets subdomains masquerade.

---

## 4. Schema

Reference: `supabase/migrations/00065_email_campaigns.sql`.

### email_templates
Subject + html + plain-text body for a single email. Merge tags use `{{first_name}}` placeholders rendered server-side at send time.

```sql
create table email_templates (
    id uuid primary key default gen_random_uuid(),
    slug text not null,
    name text not null,
    subject text not null,
    html_body text not null,
    text_body text not null,
    from_email text not null,
    from_name text not null,
    archived_at timestamptz,                  -- soft delete only
    updated_by uuid references auth.users(id) on delete set null,
    created_at timestamptz default now(),
    updated_at timestamptz default now()
);
-- Slug unique only among active templates; archived rows keep their slug for history.
create unique index ux_email_templates_active_slug
    on email_templates(slug) where archived_at is null;
```

### email_campaigns
A campaign = trigger event + ordered steps (JSONB). `is_transactional` flips routing (stream, opt-out gate, footer).

```sql
create table email_campaigns (
    id uuid primary key default gen_random_uuid(),
    name text not null,
    trigger_event text not null check (trigger_event in (
        'trial_signup','paid_signup','inactive_7d','trial_ending_3d',
        'subscription_canceled','payment_failed','manual'
    )),
    status text not null default 'draft' check (status in ('draft','active','paused','archived')),
    steps jsonb not null default '[]'::jsonb,
    -- shape: [{ delay_hours: 0, template_slug: 'trial-welcome', skip_if: 'has_published', exit_goal: 'plan_paid' }]
    exit_goal text,
    is_transactional boolean not null default false,
    description text,
    updated_by uuid references auth.users(id) on delete set null,
    created_at timestamptz default now(),
    updated_at timestamptz default now()
);
create index idx_email_campaigns_trigger_active
    on email_campaigns(trigger_event) where status = 'active';
```

### email_enrollments
One row per (user, campaign). `current_step` indexes into `campaigns.steps`. `next_send_at` is the wall-clock the drip-tick worker fires at.

```sql
create table email_enrollments (
    id uuid primary key default gen_random_uuid(),
    user_id uuid not null references auth.users(id) on delete cascade,
    campaign_id uuid not null references email_campaigns(id) on delete restrict,
    status text not null default 'active' check (status in ('active','completed','exited','paused')),
    current_step integer not null default 0 check (current_step >= 0),
    next_send_at timestamptz,
    exit_reason text,
    enrolled_at timestamptz default now(),
    completed_at timestamptz,
    updated_at timestamptz default now()
);
-- Partial unique: one ACTIVE enrollment per (user, campaign). Inactive history kept.
create unique index ux_email_enrollments_active_user_campaign
    on email_enrollments(user_id, campaign_id) where status = 'active';
-- Hot path for the drip-tick worker.
create index idx_email_enrollments_due
    on email_enrollments(next_send_at) where status = 'active';
```

### email_sends
Append-only history. `dedupe_key = '<enrollment_id>:<step_index>'` UNIQUE guarantees a retried tick never double-sends.

```sql
create table email_sends (
    id uuid primary key default gen_random_uuid(),
    enrollment_id uuid not null references email_enrollments(id) on delete restrict,
    user_id uuid not null references auth.users(id) on delete cascade,
    campaign_id uuid not null references email_campaigns(id) on delete restrict,
    template_slug text not null,
    step_index integer not null check (step_index >= 0),
    dedupe_key text not null,
    postmark_message_id text,
    to_email text not null,
    subject text not null,
    sent_at timestamptz default now(),
    opened_at timestamptz,
    clicked_at timestamptz,
    bounced_at timestamptz,
    unsubscribed_at timestamptz,
    spam_complained_at timestamptz,
    error_code integer,
    error_message text
);
create unique index ux_email_sends_dedupe on email_sends(dedupe_key);
create unique index ux_email_sends_postmark_mid
    on email_sends(postmark_message_id) where postmark_message_id is not null;
```

**State machines are status-flag based — no row in this system is ever DELETEd.** Campaigns archive, enrollments transition status (`active → completed | exited | paused`), sends are append-only. Same destructive-write hard rule as the rest of the app: deletes only originate from explicit user actions.

**Storage discipline: don't store full message bodies in `email_sends`.** Store `template_slug` + `subject` + `postmark_message_id` only. The body is regenerable from template + merge context (you can choose to also persist a `render_context_json` blob keyed to the send if you need exact-byte audit replay, but the rendered HTML itself is throwaway). Storage costs explode if you keep every body — at 100k sends/mo with a 30KB HTML body that's 3GB/mo of cold rows.

### email_layouts (singleton)
One row holds the brand HTML/text wrapper. `{{content}}` is substituted with the template body at send time.

```sql
create table email_layouts (
    id integer primary key default 1,
    html_wrapper text not null,
    text_wrapper text not null,
    updated_by uuid references auth.users(id) on delete set null,
    updated_at timestamptz default now(),
    constraint email_layouts_singleton check (id = 1)
);
```

### profiles.email_opt_out
```sql
alter table profiles add column email_opt_out boolean not null default false;
```

---

## 5. Layout wrapper + marketing-only sentinels

The wrapper holds header/footer/styling. Templates focus on body content. `wrapAndRender(template, layout, mergeCtx, { transactional })` substitutes `{{content}}` then merge tags.

**Marketing-only sentinels** — wrap the unsubscribe footer block in HTML comments:

```html
<!-- ... brand header, body slot ... -->
<div class="body">{{content}}</div>

<!--@marketing-only-start-->
<div class="footer">
  You're receiving this because you signed up at yourapp.com.
  <a href="{{unsubscribe_url}}">Unsubscribe</a> at any time.
</div>
<!--@marketing-only-end-->
```

And in the text wrapper:
```
{{content}}

@marketing-only-start
Unsubscribe: {{unsubscribe_url}}
@marketing-only-end
```

`wrapAndRender(..., { transactional: true })` strips everything between `@marketing-only-start` and `@marketing-only-end`. Transactional sends (password reset, billing receipt) ship without an unsubscribe footer — which is CAN-SPAM compliant because they're not commercial speech.

Reference: `supabase/migrations/00066_email_layout.sql`, `supabase/migrations/00070_email_campaigns_transactional_flag.sql`, `src/lib/email/render-template.ts`.

---

## 6. Drip-tick worker

Reference: `src/trigger/email-drip-tick.ts`.

```ts
export const emailDripTick = schedules.task({
  id: 'email-drip-tick',
  cron: '*/15 * * * *',
  maxDuration: 300,
  run: async () => {
    // 1. Fetch ≤200 active enrollments with next_send_at <= now, ordered.
    // 2. Bulk-fetch the campaigns they reference (one query).
    // 3. Fetch the layout singleton once.
    // 4. For each enrollment:
    //    a. If steps exhausted → status=completed.
    //    b. If profile.email_opt_out && !campaign.is_transactional → status=exited (reason=unsubscribed).
    //    c. Evaluate campaign.exit_goal against ConditionContext; if hit → status=exited.
    //    d. Walk steps from current_step, skipping any whose skip_if matches.
    //       Step exit_goal → status=exited. Bounded at MAX_SKIP_HOPS=20.
    //    e. INSERT email_sends with dedupe_key=`${enrollmentId}:${stepIdx}` FIRST.
    //       UNIQUE on dedupe_key is the double-send guard.
    //    f. Send via Postmark — broadcast stream + List-Unsubscribe for marketing,
    //       outbound stream for transactional.
    //    g. On soft Postmark fail (5xx/429): DELETE the sends row (free the dedupe),
    //       push next_send_at +15min. Enrollment stays on the same step.
    //    h. On hard fail (4xx): keep the sends row with error fields, advance step.
    //    i. On success: UPDATE sends row with postmark_message_id, advance step.
  },
})
```

**Optimistic-lock advance:** the step-advance UPDATE has `.eq('current_step', enrollment.current_step)` so a double-fire (instant trigger + cron back-to-back) can't jump current_step by 2 — second writer matches 0 rows and silently no-ops.

**Dedupe key is the load-bearing guarantee.** Insert-first-then-send means a retried Trigger.dev attempt can never hand the same step to Postmark twice — the second INSERT trips the UNIQUE and the worker treats it as "already handled, advance."

---

## 7. Trigger events + enrollment

Seven trigger events drive enrollment:

| Event | Fires when | Typical campaign | Transactional? |
|---|---|---|---|
| `trial_signup` | New profile row created | Welcome / onboarding nudges | no |
| `paid_signup` | Stripe subscription activated | "Welcome to Pro" + setup tips | no |
| `inactive_7d` | No login + no generation in 7 days | Re-engagement nudge | no |
| `trial_ending_3d` | Trial ends in 3 days | Conversion push | no |
| `subscription_canceled` | Stripe cancellation event | Winback + offboarding | no |
| `payment_failed` | Stripe `invoice.payment_failed` | Dunning (update card!) | **yes** |
| `manual` | Admin-triggered from console | Announcements / bespoke blasts | varies |

**Two enrollment surfaces:**
1. **DB trigger on `profiles` INSERT** auto-enrolls every new profile in active `trigger_event='trial_signup'` campaigns. Idempotent via the partial unique index — re-runs are no-ops. See `supabase/migrations/00068_enroll_trial_signup_on_profile_create.sql`.
2. **Server-side `enrollUserInTriggerEvent(userId, event)`** from app code — Stripe webhook handlers, billing routes, admin actions all call this. Lives in `src/lib/email/enroll.ts`.

**Why the DB trigger is the canonical signup path:** Supabase email confirmation may be disabled (BKE has it OFF). If you rely on `/auth/callback` to fire enrollment, email/password signups skip the callback entirely. The DB trigger runs on every profile insert regardless of auth path. The app-side hook stays as a belt-and-suspenders backup for OAuth flows.

**Lifecycle events (`inactive_7d`, `trial_ending_3d`, etc.)** are fired by `src/trigger/email-lifecycle-tick.ts` — same cron cadence, scans `profiles` + Stripe subs and calls `enrollUserInTriggerEvent`. Migrations `00069_extend_email_trigger_events.sql` widen the CHECK constraint as you add events.

---

## 8. Transactional vs marketing routing

`email_campaigns.is_transactional` is the routing switch:

| Concern | Marketing (`is_transactional=false`) | Transactional (`is_transactional=true`) |
|---|---|---|
| Postmark stream | `broadcast` | `outbound` |
| `email_opt_out` gate | **enforced** → enrollment exits | **bypassed** → always sends |
| Layout footer | Unsubscribe block rendered | Stripped via marketing-only sentinels |
| `List-Unsubscribe` header | RFC 8058 one-click added | Omitted |
| Postmark `Tag` | campaign id | campaign id |
| Use case | Drips, newsletters, nurture | Billing, dunning, security, password reset |

**Transactional MUST bypass the opt-out gate.** Silencing a "your card failed" email harms both you (uncollected revenue) and them (service interruption). CAN-SPAM exempts transactional content from the unsubscribe requirement, and stripping the footer prevents recipients from accidentally unsubscribing themselves from billing notices.

**Stream separation is also reputation isolation.** A spam complaint on a newsletter doesn't move the needle on `outbound`'s deliverability score — meaning password resets keep landing in the inbox even after a bad marketing send.

---

## 9. Unsubscribe handling (HMAC + one-click)

Reference: `src/lib/email/unsubscribe-token.ts`.

```ts
// Token = <userId>.<base64url(HMAC-SHA256(userId, EMAIL_UNSUBSCRIBE_SECRET)[:16])>
// 16 bytes of sig = 128 bits of security, half the URL length of full SHA256.

export function buildUnsubscribeUrl(appUrl: string, userId: string): string {
  return `${appUrl.replace(/\/+$/, '')}/email/unsubscribe?u=${encodeURIComponent(buildUnsubscribeToken(userId))}`
}

export function verifyUnsubscribeToken(token: string): string | null {
  const dot = token.indexOf('.')
  const userId = token.slice(0, dot)
  const providedSig = token.slice(dot + 1)
  if (!timingSafeEqual(fromBase64Url(providedSig), fromBase64Url(sign(userId)))) return null
  return userId
}
```

**Why HMAC over a session-cookie unsub flow:** every drip recipient must be able to opt out without logging in. HMAC tokens are signed once at send time and verified server-side without state. **Tokens don't expire** — opting out should still work on a 6-month-old email forwarded from a coworker.

**`/email/unsubscribe` page (GET + POST):**
- **GET** with `?u=<token>` → verify, show confirmation page with a "Confirm unsubscribe" button.
- **POST** (same URL, RFC 8058 one-click) — Gmail / Apple Mail's native "Unsubscribe" button hits this. Body: `List-Unsubscribe=One-Click`. Flips `profiles.email_opt_out=true`, sets `email_enrollments.status='exited'` for that user's active marketing rows, returns 200.

Add `List-Unsubscribe` + `List-Unsubscribe-Post: List-Unsubscribe=One-Click` headers on every marketing send (Postmark detects these and suppresses its own auto-injected footer link so the user sees one path, not two).

---

## 10. Postmark events webhook

Reference: `src/app/api/email/postmark-events/route.ts`.

```
POST /api/email/postmark-events
Authorization: Basic <base64(POSTMARK_WEBHOOK_USER:POSTMARK_WEBHOOK_PASS)>
Body: { RecordType: 'Open' | 'Click' | 'Bounce' | 'SpamComplaint' | 'SubscriptionChange', MessageID: '...', ... }
```

**Handlers (by RecordType):**

| Event | Action |
|---|---|
| `Open` | `email_sends.opened_at = coalesce(opened_at, now())` for the matching `postmark_message_id` |
| `Click` | `email_sends.clicked_at = coalesce(clicked_at, now())` |
| `Bounce` | `email_sends.bounced_at = now()`, error fields populated. If `Type='HardBounce'` → flip `profiles.email_opt_out=true` (the address is dead) and exit all active marketing enrollments |
| `SpamComplaint` | `email_sends.spam_complained_at = now()` + `profiles.email_opt_out=true` + exit active enrollments. **Treat as a hard signal — never email again until manual review.** |
| `SubscriptionChange` | When `SuppressSending=true`, the user opted out via Postmark's own unsubscribe link. Flip `email_opt_out=true` + exit enrollments. |

**Always verify the webhook is from Postmark.** HTTP Basic is the floor. Better: also verify an HMAC of the raw body against a shared secret in a custom header (Postmark doesn't sign bodies natively, but you can pre-share a token in the webhook URL path as a defense-in-depth move).

**Idempotency:** Postmark retries on non-2xx. All handlers use `coalesce(existing_at, now())` so a duplicate Open event doesn't shift the timestamp later.

---

## 11. Inbound mail webhook

Reference: `src/app/api/email/inbound/route.ts`.

Postmark inbound parses the MIME and POSTs a JSON payload. Set up an inbound stream + point your MX (or a subdomain like `reply.yourapp.com`) at Postmark's inbound server.

```
POST /api/email/inbound
Authorization: Basic <...>
Body: {
  From, FromName, To, ToFull[], Subject, MessageID, Date, MailboxHash,
  TextBody, HtmlBody, StrippedTextReply,
  Headers[], Attachments[{ Name, Content, ContentType, ContentID, ContentLength }],
}
```

**Routing pattern:**
- Reply-to address is `support+<alias-token>@yourapp.com`. Postmark's `MailboxHash` extracts `<alias-token>` — match against your `conversation_threads` table to bind the inbound to an existing ticket.
- If the alias is unknown, fall back to threading by `In-Reply-To` header → `email_sends.postmark_message_id`.
- Store full content (text + HTML + stripped reply) in your support/inbox table. Decode base64 `Attachments[].Content` into Supabase Storage immediately — Postmark drops inbound payloads after 45 days.

**Reject early:**
- 401 on bad Basic auth.
- 415 on missing JSON.
- 422 if the address can't be routed (no alias match, no `In-Reply-To` match) — better than silently dropping; lets Postmark surface failures in their dashboard.

---

## 12. Opt-out gating (drip worker side)

The drip-tick worker checks `profiles.email_opt_out` before EVERY send (see `src/trigger/email-drip-tick.ts` line ~160):

```ts
if (profile?.email_opt_out && !campaign.is_transactional) {
  await supabase.from('email_enrollments').update({
    status: 'exited',
    exit_reason: 'unsubscribed',
    next_send_at: null,
    completed_at: now(),
  }).eq('user_id', enrollment.user_id).eq('status', 'active')
  return { outcome: 'skipped', detail: 'user opted out' }
}
```

**One unsubscribe click → every active marketing enrollment exits, immediately.** The next drip-tick walks the user's other active enrollments and flips them all to `exited` on first sight. No further marketing email reaches them until they explicitly re-opt-in (separate flow — typically a Settings → Email Preferences toggle that hits `email_opt_out=false` + emits a re-enrollment).

---

## 13. Sender resolution + merge context

```ts
// From profiles.full_name → auth.users.user_metadata.full_name → prettified email local part.
function pickFirstName(profile, authUser, email) {
  return (profile?.full_name?.split(' ')[0])
    || (authUser?.user_metadata?.full_name?.split(' ')[0])
    || prettify(email.split('@')[0].split(/[._-]/)[0])
}
```

**Always have a fallback for first name.** "Hi ," is the worst possible email opener — Gmail's spam classifier specifically scores empty merge tags. The email-local-part fallback isn't elegant but it's never empty.

Merge context shape (rendered server-side at send time):
```ts
{ first_name, full_name, email, unsubscribe_url, /* + campaign-specific */ }
```

---

## 14. Common pitfalls

**Don't centralize `POSTMARK_SERVER_TOKEN` in client code.** Server-only env var. Send goes through API routes / Trigger workers. If a token leaks via a client bundle, anyone can send mail from your domain → instant DMARC complaint storm.

**Don't tighten DMARC to `p=reject` before two weeks of clean reports.** A stray legacy sender will get rejected by mailbox providers worldwide and you won't notice for days. Use `rua=mailto:dmarc@yourapp.com` and read the daily aggregates. Tools: [dmarcian.com](https://dmarcian.com/) or [postmarkapp.com/dmarc](https://postmarkapp.com/dmarc) for parsing.

**ALWAYS verify Postmark webhook authenticity.** HTTP Basic is the minimum. Without it, anyone who knows the route can flip your users' bounce / opt-out / complaint state. Add a request-path secret too — `https://user:pass@host/api/email/postmark-events?k=<shared-secret>` and require both.

**Transactional emails MUST skip the opt-out gate.** CAN-SPAM exempts transactional content; regulators in some jurisdictions REQUIRE you to send security / billing notices regardless of marketing opt-out. Hard-code `if (campaign.is_transactional) skipOptOut = true`.

**Never send marketing email to unconfirmed addresses.** If you have email confirmation enabled, gate marketing enrollment on `auth.users.email_confirmed_at IS NOT NULL`. Sending to unconfirmed addresses tanks deliverability because the addresses include typos, spamtraps, and one-shot relays.

**Unsubscribe link in EVERY marketing email + `List-Unsubscribe` header.** Both. The body link is the visible escape hatch; the header enables Gmail / Apple Mail's native one-click button (which has a much higher success rate and significantly lowers spam-complaint rates).

**Don't store full message bodies in `email_sends`.** Store `template_slug` + `subject` + `postmark_message_id` (+ optional `render_context_json` if you need audit replay). The body is regenerable. At any real volume, body storage eclipses every other table.

**Test DMARC reports before tightening.** Read at least 7 days of `rua` aggregates at `p=none` before moving to `p=quarantine`. Read another 7 days at `p=quarantine` before `p=reject`. Skipping this is the #1 way to ship a deliverability outage.

**Don't DELETE rows in this system.** Same hard rule as the rest of the app — destructive writes have wiped user data before. Campaigns archive (`status='archived'`), enrollments exit (`status='exited'`), sends are append-only. The only legitimate DELETE is in the soft-fail Postmark retry path where the dedupe row is freed for the next tick.

---

## 15. Kickoff prompt

> Execute the Email Infrastructure Playbook at `<workspace>/templates/s-tier/EMAIL-INFRASTRUCTURE-PLAYBOOK.md` for this new app. Mirror the BKE implementation at `<reference-app>/src/lib/email/` + `src/app/api/email/` + `src/trigger/email-*.ts`. When in doubt about a pattern, READ the BKE file rather than re-deriving.
>
> Inputs:
> - `POSTMARK_SERVER_TOKEN`, `POSTMARK_WEBHOOK_USER`, `POSTMARK_WEBHOOK_PASS`, `EMAIL_UNSUBSCRIBE_SECRET`: [provided in .env]
> - Sending domain: [yourapp.com]
> - From addresses: [noreply@, hello@, support@]
> - Trigger events to enable at launch: [list — typically trial_signup + payment_failed]
> - Marketing opt-out semantics: [default — honor on all non-transactional]
>
> Pause after migrations 00065–00070 + the drip-tick worker ship for review. Then ship webhooks + unsubscribe page + lifecycle worker as a second slice.

---

**Reference files in BKE:**
- `src/lib/email/postmark.ts` — Postmark HTTP client
- `src/lib/email/render-template.ts` — `wrapAndRender()` + merge-tag substitution + marketing-only sentinel stripping
- `src/lib/email/repo.ts` — CRUD over the 4 tables
- `src/lib/email/enroll.ts` — server-side enrollment hook
- `src/lib/email/conditions.ts` — skip_if / exit_goal evaluator
- `src/lib/email/unsubscribe-token.ts` — HMAC token sign / verify
- `src/app/api/email/postmark-events/route.ts` — events webhook
- `src/app/api/email/inbound/route.ts` — inbound mail webhook
- `src/trigger/email-drip-tick.ts` — cron `*/15 * * * *`, drip step fan-out
- `src/trigger/email-lifecycle-tick.ts` — lifecycle event emission
- `supabase/migrations/00065_email_campaigns.sql`
- `supabase/migrations/00066_email_layout.sql`
- `supabase/migrations/00067_email_opt_out.sql`
- `supabase/migrations/00068_enroll_trial_signup_on_profile_create.sql`
- `supabase/migrations/00069_extend_email_trigger_events.sql`
- `supabase/migrations/00070_email_campaigns_transactional_flag.sql`
