<!--
SUPPORT-INBOX-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.
-->

# SUPPORT-INBOX-PLAYBOOK

**Tier:** A (high leverage; depends on EMAIL-INFRASTRUCTURE + ADMIN-CONSOLE)
**Reference implementation:** BKE / Kompozy — `Active/BILT-KONTENT-ENGINE/`, Supabase project `<supabase-project-ref>`, live at `/admin/dashboard/support`.
**Invoke when:** the app has self-serve customers who will email for help and you want an in-app shared inbox (Help Scout / Front / Zendesk-lite) instead of forwarding to Gmail — i.e. once a marketing site + admin console exist. Week 3-4, after EMAIL-INFRASTRUCTURE (Postmark) and ADMIN-CONSOLE are live.

> **What this builds:** a complete inbound-email → ticket pipeline. A customer emails `support@yourapp.com`; Postmark posts the message to a webhook; the system threads it onto an existing ticket or opens a new one (`KOMP-1042` style numbers); operators see it in an admin inbox with unread badges, realtime toasts, full-text search, "similar resolved" answer suggestions, internal notes, assignment, and one-click status changes; replies go back out over Postmark threaded into the same email conversation; a customer reply to a resolved ticket auto-reopens it. No third-party helpdesk SaaS.

---

## 0. Architecture in one breath

The system is **one conversation spine reused by three parent types.** Support tickets are the primary one; the same `mailboxes` + `conversation_messages` + `conversation_events` + `conversation_attachments` tables also back **leads** (sales pipeline) and **mail_threads** (free-form personal team email). This playbook documents the **support-ticket** path end to end and notes where the spine is shared, so you can ship tickets alone and add leads/personal-mail later without re-architecting.

```
Customer email → support@yourapp.com
        │  (Postmark inbound parse webhook, HTTP Basic auth)
        ▼
POST /api/email/inbound   ── resolve mailbox by To-address
        │                    branch on mailbox.kind:
        │                      support|billing → ticket
        │                      sales|info      → lead
        │                      personal        → mail_thread
        ▼
  3-tier threading match (subject [KOMP-N] → In-Reply-To/References → fuzzy) 
        │   else create new ticket (global KOMP-N number)
        ▼
  conversation_messages (direction='in')  +  conversation_events('ticket_created')
        │   + attachments → private support-attachments bucket
        │   + bump last_activity_at / last_inbound_at  (→ marks UNREAD)
        │   + if reply landed on a resolved ticket → auto-reopen to 'pending'
        ▼
Admin console  /admin/dashboard/support
        │   Realtime (Supabase) → toast + nav unread badge
        ▼
Operator opens ticket → reads (clears unread, shared across team)
        │   replies (internal note OR customer-facing)
        ▼
POST /api/support/tickets/[id]/reply  → sendThreadedReply (Postmark outbound)
        │   threads via In-Reply-To + References + [KOMP-N] subject tag
        ▼
Customer receives reply in the same email thread. Their next reply re-threads.
```

**Hard dependencies already in the codebase (from sibling playbooks):**
- `is_super_admin(uid)` SQL function + JWT-based admin gating — from **ADMIN-CONSOLE-PLAYBOOK** (BKE migration `00126_admin_authz_via_jwt`).
- `requireAdmin()` / `isAdminResponse()` server helpers — ADMIN-CONSOLE.
- Postmark account + DKIM/SPF-verified sending domain, `sendPostmarkEmail` — **EMAIL-INFRASTRUCTURE-PLAYBOOK** (`src/lib/email/postmark.ts`).
- `profiles` table with `plan`, `account_status`, `email_opt_out`, `signature_html`, `display_name`, `full_name` — AUTH-ONBOARDING + ADMIN-CONSOLE.
- `createServerSupabase()` (RLS, SSR) + `createAdminSupabase()` (service-role) — AUTH-ONBOARDING.

If those aren't present yet, build them first — this playbook assumes them.

---

## 1. Database schema (the canonical DDL)

> **Note for the template maintainer:** in the BKE repo the *base* tables (`mailboxes`, `mailbox_access`, `tickets`, `leads`, `conversation_*`) were applied directly to Supabase and were never committed as a migration file — only the later additive migrations (`00131`, `00139`, `00140`, `00152`) are in `supabase/migrations/`. The DDL below was reconstructed by **live introspection of the production database** (`information_schema`, `pg_get_functiondef`, `pg_policies`, `pg_get_constraintdef`, generated-column expressions) so it is the authoritative, complete schema — ship it as ONE migration. Follow SUPABASE-MIGRATION-PLAYBOOK numbering + IF NOT EXISTS discipline.

### 1.1 Mailboxes + access (the identity layer)

A **mailbox** is a sending/receiving identity (`support@`, `billing@`, `sales@`, `hello@`, `moe@`). Login identity ≠ sending identity: an operator logged in as `you@gmail.com` sends *as* `support@yourapp.com`. Per-mailbox access is granted in `mailbox_access`; super-admins bypass it entirely.

```sql
create table if not exists public.mailboxes (
  id          uuid primary key default gen_random_uuid(),
  address     text not null unique,
  display_name text not null,
  kind        text not null
    check (kind = any (array['support','sales','billing','info','personal'])),
  default_assignment_strategy text not null default 'manual'
    check (default_assignment_strategy = any (array['manual','round_robin','load_balanced'])),
  archived_at timestamptz,
  created_at  timestamptz not null default now()
);

create table if not exists public.mailbox_access (
  mailbox_id uuid not null references public.mailboxes(id) on delete cascade,
  user_id    uuid not null references auth.users(id) on delete cascade,
  permission text not null check (permission = any (array['read','reply','admin'])),
  created_at timestamptz not null default now(),
  primary key (mailbox_id, user_id)
);
create index if not exists idx_mailbox_access_user on public.mailbox_access (user_id);
```

`default_assignment_strategy` exists for future round-robin auto-assignment; v1 uses `'manual'` (operators click "Assign to me"). It is not read by any code yet — keep the column, ignore the strategies.

### 1.2 Tickets

```sql
-- Global, monotonic ticket numbers shared across ALL support/billing mailboxes,
-- so a [KOMP-N] reply that lands on a different alias still threads correctly.
create sequence if not exists public.tickets_number_seq;

create table if not exists public.tickets (
  id               uuid primary key default gen_random_uuid(),
  mailbox_id       uuid not null references public.mailboxes(id),
  subject          text,
  status           text not null default 'open'
    check (status = any (array['open','pending','resolved','closed'])),
  priority         text not null default 'normal'
    check (priority = any (array['low','normal','high','urgent'])),
  assignee_user_id uuid references auth.users(id) on delete set null,
  customer_email   text not null,
  customer_name    text,
  category         text,
  tags             text[] not null default '{}'::text[],
  first_response_at timestamptz,
  resolved_at      timestamptz,
  last_activity_at timestamptz not null default now(),
  ticket_number    bigint not null default nextval('public.tickets_number_seq') unique,
  -- Unread tracking (BKE migration 00152). A ticket is UNREAD when the customer
  -- has sent inbound mail no operator has read since. read_at is SHARED across
  -- the team — one operator reading clears it for everyone.
  last_inbound_at  timestamptz,
  read_at          timestamptz,
  -- Full-text search vector — GENERATED, not trigger-maintained.
  search_tsv tsvector generated always as (
    to_tsvector('english',
      coalesce('KOMP-' || ticket_number::text, '') || ' ' ||
      coalesce(subject, '')        || ' ' ||
      coalesce(customer_email, '') || ' ' ||
      coalesce(customer_name, ''))
  ) stored
);
create index if not exists idx_tickets_status_mailbox   on public.tickets (mailbox_id, status);
create index if not exists idx_tickets_assignee         on public.tickets (assignee_user_id);
create index if not exists idx_tickets_customer_email   on public.tickets (customer_email);
create index if not exists idx_tickets_search_tsv       on public.tickets using gin (search_tsv);
```

**Status semantics:**
- `open` — new, no operator reply yet.
- `pending` — operator has replied; waiting on customer (auto-set on first outbound reply, and when a customer replies to a resolved ticket it reopens *to pending*).
- `resolved` — operator marked done; fires a resolution email; `resolved_at` set.
- `closed` — terminal; an inbound reply to a closed ticket is **rejected** (does not reopen — it would start a fresh ticket only via the fuzzy/new path, which excludes closed).

### 1.3 The conversation spine (shared by ticket / lead / mail_thread)

```sql
create table if not exists public.conversation_messages (
  id           uuid primary key default gen_random_uuid(),
  parent_type  text not null
    check (parent_type = any (array['ticket','lead','mail_thread'])),
  parent_id    uuid not null,
  direction    text not null check (direction = any (array['in','out'])),
  from_email   text,
  from_user_id uuid references auth.users(id) on delete set null,  -- set on outbound (the operator)
  to_emails    text[] not null default '{}'::text[],
  cc_emails    text[] not null default '{}'::text[],
  bcc_emails   text[] not null default '{}'::text[],
  subject      text,
  body_html    text,
  body_text    text,
  message_id   text unique,            -- RFC-5322 Message-ID (ours on outbound, sender's on inbound)
  in_reply_to  text,
  is_internal_note boolean not null default false,  -- true = private team note, never emailed
  postmark_message_id text,            -- Postmark's id; inbound-dedupe key
  created_at   timestamptz not null default now(),
  search_tsv tsvector generated always as (
    to_tsvector('english', coalesce(subject, '') || ' ' || coalesce(body_text, ''))
  ) stored
);
create index if not exists idx_messages_parent          on public.conversation_messages (parent_type, parent_id, created_at);
create index if not exists idx_conversation_messages_parent_type_id on public.conversation_messages (parent_type, parent_id);
create index if not exists idx_messages_in_reply_to     on public.conversation_messages (in_reply_to);
create index if not exists idx_conversation_messages_search_tsv on public.conversation_messages using gin (search_tsv);

create table if not exists public.conversation_events (
  id           uuid primary key default gen_random_uuid(),
  parent_type  text not null
    check (parent_type = any (array['ticket','lead','mail_thread'])),
  parent_id    uuid not null,
  event_type   text not null,   -- 'ticket_created','reply_sent','assigned','unassigned',
                                 -- 'status_changed','auto_reopened','customer_email_opt_out', …
  actor_user_id uuid references auth.users(id) on delete set null,  -- null = system
  payload      jsonb not null default '{}'::jsonb,
  created_at   timestamptz not null default now()
);
create index if not exists idx_events_parent on public.conversation_events (parent_type, parent_id, created_at);

create table if not exists public.conversation_attachments (
  id          uuid primary key default gen_random_uuid(),
  message_id  uuid not null references public.conversation_messages(id) on delete cascade,
  filename    text not null,
  content_type text,
  size_bytes  integer,
  storage_path text not null,    -- path inside the private support-attachments bucket
  created_at  timestamptz not null default now()
);
```

> **`message_id` UNIQUE is load-bearing** — it is the second idempotency gate (cross-delivery dedupe) in the inbound webhook. Do not drop it.

### 1.4 Leads (optional sibling — include only if you also want a sales pipeline)

```sql
create table if not exists public.leads (
  id            uuid primary key default gen_random_uuid(),
  mailbox_id    uuid not null references public.mailboxes(id),
  customer_email text not null,
  customer_name text,
  company       text,
  status        text not null default 'new',
  owner_user_id uuid references auth.users(id) on delete set null,
  source        text not null default 'enterprise_inbound',
  estimated_value numeric,
  expected_close_date date,
  first_response_at timestamptz,
  last_activity_at timestamptz not null default now(),
  created_at    timestamptz not null default now()
);
```
(`mail_threads` is the third sibling — see BKE migration `00131`. Skip both for a tickets-only build; the `parent_type` CHECKs already allow all three so you can add them later with zero schema churn.)

### 1.5 RLS policies

Enable RLS on every table, then:

```sql
alter table public.mailboxes               enable row level security;
alter table public.mailbox_access          enable row level security;
alter table public.tickets                 enable row level security;
alter table public.conversation_messages   enable row level security;
alter table public.conversation_events     enable row level security;
alter table public.conversation_attachments enable row level security;
-- (leads too, if included)

-- Mailboxes: a user sees mailboxes they have access to; super-admins see all.
create policy mailboxes_select on public.mailboxes for select
  using (is_super_admin(auth.uid()) or exists (
    select 1 from public.mailbox_access ma
    where ma.mailbox_id = mailboxes.id and ma.user_id = auth.uid()));
create policy mailboxes_super_admin_write on public.mailboxes for all
  using (is_super_admin(auth.uid())) with check (is_super_admin(auth.uid()));

-- Mailbox access rows: you see your own grants; only super-admins write them.
create policy mailbox_access_select on public.mailbox_access for select
  using (is_super_admin(auth.uid()) or user_id = auth.uid());
create policy mailbox_access_super_admin_write on public.mailbox_access for all
  using (is_super_admin(auth.uid())) with check (is_super_admin(auth.uid()));

-- Tickets: read needs 'read' on the mailbox; insert/update need 'reply'.
create policy tickets_select on public.tickets for select
  using (has_mailbox_access(mailbox_id, auth.uid(), 'read'));
create policy tickets_insert on public.tickets for insert
  with check (has_mailbox_access(mailbox_id, auth.uid(), 'reply'));
create policy tickets_update on public.tickets for update
  using (has_mailbox_access(mailbox_id, auth.uid(), 'reply'))
  with check (has_mailbox_access(mailbox_id, auth.uid(), 'reply'));
-- NOTE: there is intentionally NO ticket delete policy. Tickets are never deleted.

-- Messages + events: branch on parent_type, then check access on the parent's mailbox.
-- HIGH-RISK: a missing branch fails SILENTLY (zero rows, no error). Recreate verbatim.
create policy messages_select on public.conversation_messages for select using (
  (parent_type = 'ticket'      and exists (select 1 from public.tickets t       where t.id = parent_id and has_mailbox_access(t.mailbox_id, auth.uid(), 'read'))) or
  (parent_type = 'lead'        and exists (select 1 from public.leads l         where l.id = parent_id and has_mailbox_access(l.mailbox_id, auth.uid(), 'read'))) or
  (parent_type = 'mail_thread' and exists (select 1 from public.mail_threads mt where mt.id = parent_id and has_mailbox_access(mt.mailbox_id, auth.uid(), 'read'))));
create policy messages_insert on public.conversation_messages for insert with check (
  (parent_type = 'ticket'      and exists (select 1 from public.tickets t       where t.id = parent_id and has_mailbox_access(t.mailbox_id, auth.uid(), 'reply'))) or
  (parent_type = 'lead'        and exists (select 1 from public.leads l         where l.id = parent_id and has_mailbox_access(l.mailbox_id, auth.uid(), 'reply'))) or
  (parent_type = 'mail_thread' and exists (select 1 from public.mail_threads mt where mt.id = parent_id and has_mailbox_access(mt.mailbox_id, auth.uid(), 'reply'))));

create policy events_select on public.conversation_events for select using (
  (parent_type = 'ticket' and exists (select 1 from public.tickets t where t.id = parent_id and has_mailbox_access(t.mailbox_id, auth.uid(), 'read'))) or
  (parent_type = 'lead'   and exists (select 1 from public.leads l   where l.id = parent_id and has_mailbox_access(l.mailbox_id, auth.uid(), 'read'))));
create policy events_insert on public.conversation_events for insert with check (
  (parent_type = 'ticket' and exists (select 1 from public.tickets t where t.id = parent_id and has_mailbox_access(t.mailbox_id, auth.uid(), 'reply'))) or
  (parent_type = 'lead'   and exists (select 1 from public.leads l   where l.id = parent_id and has_mailbox_access(l.mailbox_id, auth.uid(), 'reply'))));

-- Attachments: visible if you can see the parent message (RLS on messages cascades the gate).
create policy attachments_select on public.conversation_attachments for select
  using (exists (select 1 from public.conversation_messages m where m.id = message_id));
```

> If you build **tickets only** (no leads / mail_threads), trim the `messages_*`/`events_*` policies to just the `'ticket'` branch and narrow the parent_type CHECK constraints to `array['ticket']`. Re-widen later when you add the siblings (BKE did exactly this across migrations `00131`).

### 1.6 The two SQL functions

```sql
-- Permission resolver used by every RLS policy. SECURITY DEFINER so it can read
-- mailbox_access regardless of the caller's own RLS. Super-admins bypass.
-- Permission hierarchy: admin ⊇ reply ⊇ read.
create or replace function public.has_mailbox_access(mid uuid, uid uuid, perm text)
returns boolean
language sql stable security definer
set search_path to 'public', 'auth'
as $$
  select public.is_super_admin(uid)
    or exists (
      select 1 from public.mailbox_access
      where mailbox_id = mid and user_id = uid
        and (
          permission = 'admin'
          or (perm = 'read'  and permission in ('read','reply'))
          or (perm = 'reply' and permission in ('reply','admin'))
        )
    )
$$;

-- "Similar resolved" answer suggestions. For the current ticket's question text,
-- find past resolved/closed tickets IN THE SAME MAILBOX whose FIRST inbound
-- message is the best FTS match, and return each one's question + the last
-- outbound reply (the answer that resolved it) so the operator can reuse it.
create or replace function public.suggest_similar_resolved_tickets(
  source_ticket_id uuid, source_mailbox_id uuid, query_text text, max_results integer default 3)
returns table(ticket_id uuid, ticket_number bigint, subject text, customer_email text,
              customer_name text, resolved_at timestamptz, question_body text,
              resolution_body text, score real)
language sql stable
set search_path to 'public'
as $$
  with q as (
    select plainto_tsquery('english', coalesce(nullif(query_text, ''), 'NEVER_MATCHES_TOKEN')) as tsq
  ),
  first_inbound as (
    select distinct on (cm.parent_id)
      cm.parent_id as ticket_id, cm.body_text as question_body,
      ts_rank(cm.search_tsv, (select tsq from q)) as score, cm.search_tsv
    from conversation_messages cm
    where cm.parent_type = 'ticket' and cm.direction = 'in' and cm.is_internal_note = false
    order by cm.parent_id, cm.created_at asc
  )
  select t.id, t.ticket_number, t.subject, t.customer_email, t.customer_name, t.resolved_at,
    fi.question_body,
    (select cm2.body_html from conversation_messages cm2
      where cm2.parent_type = 'ticket' and cm2.parent_id = t.id
        and cm2.direction = 'out' and cm2.is_internal_note = false
      order by cm2.created_at desc limit 1) as resolution_body,
    fi.score
  from tickets t
  join first_inbound fi on fi.ticket_id = t.id
  where t.id <> source_ticket_id and t.mailbox_id = source_mailbox_id
    and t.status in ('resolved','closed') and fi.search_tsv @@ (select tsq from q)
  order by fi.score desc, t.resolved_at desc nulls last
  limit greatest(max_results, 1);
$$;
```

### 1.7 Storage bucket

Create a **private** bucket `support-attachments` (no public access; reached only through the admin-gated signed-URL route in §4.6). Inbound attachment bytes are stored at `{parentType}/{parentId}/{messageId}/{safeFilename}`.

### 1.8 Seed the mailboxes

```sql
insert into public.mailboxes (address, display_name, kind) values
  ('support@yourapp.com', 'YourApp Support', 'support'),
  ('billing@yourapp.com', 'YourApp Billing', 'billing')
on conflict (address) do nothing;

-- Grant the founder admin on every mailbox (super-admins bypass anyway, so this
-- is the canonical ownership record, not the gate). Replace the uuid.
insert into public.mailbox_access (mailbox_id, user_id, permission)
select m.id, '<FOUNDER_AUTH_UID>'::uuid, 'admin' from public.mailboxes m
on conflict (mailbox_id, user_id) do nothing;
```

---

## 2. Email helper libraries (`src/lib/email/`)

Three files. Two of them already exist if EMAIL-INFRASTRUCTURE shipped — extend, don't duplicate.

### 2.1 `format.ts` — pure, client+server safe (NO next/headers, NO Supabase)

Ticket-number formatting/parsing lives here so `'use client'` components can import it without dragging `next/headers` into the bundle (importing `repo.ts` into a client component breaks the build).

```ts
export const TICKET_PREFIX = 'KOMP'                       // ← rename per app
export function formatTicketNumber(num: number): string { return `${TICKET_PREFIX}-${num}` }
export function parseTicketNumberFromSubject(subject?: string | null): number | null {
  if (!subject) return null
  const m = /\[\s*KOMP\s*-\s*(\d+)\s*\]/i.exec(subject)   // ← rename KOMP in the regex too
  if (!m) return null
  const n = parseInt(m[1], 10); return Number.isFinite(n) ? n : null
}
export function stripTicketPrefix(subject?: string | null): string {
  if (!subject) return ''
  return subject.replace(/^\s*\[\s*KOMP\s*-\s*\d+\s*\]\s*/i, '').trim()
}
```

### 2.2 `postmark.ts` — outbound send + threading (from EMAIL-INFRASTRUCTURE)

Key exports the ticketing system relies on:
- `newMessageId(localDomain='yourapp.com')` — RFC-5322 `<ts.rand@domain>` we control, so inbound can match replies via In-Reply-To even if Postmark rewrites headers.
- `sendPostmarkEmail(input)` — low-level. Routes through `https://api.postmarkapp.com/email`, picks `MessageStream` per call (`'outbound'` for transactional support replies). Sets `Message-ID`, `In-Reply-To`, `References` headers. Throws `PostmarkError` (5xx/429 retryable, 4xx permanent).
- `sendThreadedReply({mailboxAddress, mailboxDisplayName, toEmail, toName?, subject, bodyHtml, signatureHtml?, inReplyTo?, references?, messageId?})` — the support-reply convenience wrapper. Builds `From: "YourApp Support" <support@yourapp.com>`, sets `ReplyTo` to the mailbox, appends signature HTML, threads. Returns `{messageId, outboundMessageId, …}`.

### 2.3 `sanitize-html.ts` — XSS gate for rendering operator/inbound HTML

```ts
import DOMPurify from 'isomorphic-dompurify'
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
  if ((node as Element).tagName === 'A') {
    node.setAttribute('target', '_blank'); node.setAttribute('rel', 'noopener noreferrer')
  }
})
export function sanitizeAdminHtml(html: string): string {
  return DOMPurify.sanitize(html, {
    FORBID_TAGS: ['script','iframe','object','embed','form','input','style','link','meta','base'],
    FORBID_ATTR: ['onerror','onload','onclick','onmouseover','onfocus','onblur','onsubmit','formaction','srcdoc'],
    ADD_ATTR: ['target','rel'],
  })
}
```
**Rendering rule (used in the ticket UI):** outbound + internal-note HTML comes from our own composer → sanitize and render with `dangerouslySetInnerHTML`. **Inbound** HTML is from arbitrary senders → render **text only** (`body_text`, `white-space: pre-wrap`), never inbound `body_html`.

### 2.4 `repo.ts` — server-only query layer (RLS-respecting)

All reads use `createServerSupabase()` and ride the caller's RLS. Single source of truth for the column list (`TICKET_COLS`) so the query sites can't drift. Key exports:

- Types: `MailboxKind`, `TicketStatus`, `TicketPriority`, `Mailbox`, `TicketRow`, `ConversationMessage`, `ConversationEvent`, `ConversationAttachment`, `ResolutionSuggestion`.
- `isTicketUnread(t)` — **the unread predicate, used everywhere** (list page, badge, notifications). PostgREST can't compare two columns server-side, so unread is always computed in JS:
  ```ts
  export function isTicketUnread(t: Pick<TicketRow,'last_inbound_at'|'read_at'>): boolean {
    if (!t.last_inbound_at) return false
    if (!t.read_at) return true
    return new Date(t.read_at).getTime() < new Date(t.last_inbound_at).getTime()
  }
  ```
- `listMailboxesForUser()`, `getMailbox(id)`.
- `listTickets({mailboxId?, status?, assigneeUserId?, priority?, limit?})`, `getTicket(id)`, `getTicketByNumber(num)`.
- `searchTickets(q, limit)` — unioned FTS: direct `KOMP-N`/digit lookup first, then ticket-level `search_tsv`, then message-body `search_tsv` (pull parent ids), composed in that priority order.
- `countOpenTickets()`, `countUnassignedTickets()`, `countUnreadTickets()` (pulls the small open/pending set + filters with `isTicketUnread`).
- `listConversation('ticket', id)` — returns `{messages, events}` chronologically, batch-loads attachments grouped onto messages.
- `suggestResolutions(sourceTicketId, sourceMailboxId, queryText, max=3)` — wraps the `suggest_similar_resolved_tickets` RPC.
- `getProfileSignature(userId)`.

Re-export the pure formatters from `./format` so callers have one import:
```ts
export { TICKET_PREFIX, formatTicketNumber, parseTicketNumberFromSubject, stripTicketPrefix } from './format'
```

---

## 3. Inbound webhook — `POST /api/email/inbound` (`runtime='nodejs'`)

The most intricate piece. Postmark is configured (see §7) to POST every inbound parsed email to `https://user:pass@yourapp.com/api/email/inbound`. Full algorithm, in order:

1. **HTTP Basic auth** against `POSTMARK_INBOUND_USER` / `POSTMARK_INBOUND_PASS`. Reject 401 if absent/mismatch.
2. Parse JSON. Require `MessageID` (400 if missing).
3. **Resolve recipient** via `OriginalRecipient` (envelope-To, most accurate with CC/BCC) → first `ToFull[].Email` → parse `To` header. Lowercase.
4. **Idempotency gate 1:** if a `conversation_messages` row already has this `postmark_message_id`, return `{duplicate:true}`.
5. **Resolve mailbox** by address. Unknown or `archived_at` → return 200 `{ignored:'unknown_mailbox'}` (200 so Postmark stops retrying; just don't store).
6. **Branch parent type** on `mailbox.kind`: `personal`→`mail_thread`, `sales|info`→`lead`, else `ticket`.
7. **Idempotency gate 2 (cross-delivery):** compute `senderMessageId` = the RFC `Message-ID` header, fallback `<postmark-{id}@inbound>`. If any stored message already has this `message_id`, return `{duplicate:'message_id'}`. (Catches the *same* email delivered to two of our aliases as separate Postmark webhooks.)
8. **Three-tier threading match** (tickets shown; the policy helper rejects `closed`, flags `resolved`→reopen):
   - **Path 1 — subject `[KOMP-N]`** (`parseTicketNumberFromSubject`): exact `ticket_number` lookup (global, no mailbox scope). Bulletproof even when clients strip headers.
   - **Path 2 — header threading:** parse `In-Reply-To` + `References` → match prior `conversation_messages.message_id` with same `parent_type`.
   - **Path 3 — fuzzy:** same `customer_email` + normalized subject (strip `[KOMP-N]` then `Re:/Fwd:/Aw:/Sv:`) within 30 days, status NOT in `(resolved,closed)`. For tickets this matches **across all support/billing mailboxes** (cross-mailbox consolidation — one customer emailing two aliases lands on one ticket).
9. **Create new parent** if no match: insert ticket (`status='open'`, `priority='normal'`) → it auto-gets the next `KOMP-N`; insert `conversation_events('ticket_created', payload:{source:'inbound_email', mailbox_address})`.
10. **Insert the message row** (`direction='in'`, `from_email`, `to_emails=[recipient]`, `cc_emails`, `subject`, `body_html`, `body_text` = `StrippedTextReply || TextBody`, `message_id=senderMessageId`, `in_reply_to`, `postmark_message_id`).
11. **Attachments** → upload each to `support-attachments` at `{parentType}/{parentId}/{messageId}/{safeName}` (sanitize filename `[^\w.\-]+ → _`), insert `conversation_attachments` metadata.
12. **Bump activity:** set `last_activity_at = last_inbound_at = now()` (stamping `last_inbound_at` is what flips the ticket **UNREAD**). If the matched ticket was `resolved`, also set `status='pending'`, `resolved_at=null`, and insert `conversation_events('auto_reopened', payload:{reason:'customer_replied_to_resolved'})`.
13. Return 200.

> **Why 200 on almost everything:** Postmark retry-storms any non-2xx. Unknown mailbox, blocked sender, forward failure all return 200 with a reason field; only auth failure (401) and malformed payload (400) and genuine DB insert failure (500) are non-2xx.

The BKE file also has optional `FORWARD_MAP` (pure relay, no storage) and `PERSONAL_RELAY_MAP` (additive Gmail copy for personal mailboxes during rollout) — both default empty. Omit for a tickets-only build.

---

## 4. Operator-facing API + UI

### 4.1 `POST /api/support/tickets/[id]/reply` (`runtime='nodejs'`)

Body: `{ bodyHtml: string, isInternalNote?: boolean, ccEmails?: string[] }`.
1. `auth.getUser()` → 401 if none.
2. Load ticket + its mailbox (RLS already filters unreadable tickets).
3. **Permission gate:** `supabase.rpc('has_mailbox_access', {mid, uid, perm:'reply'})`. Required unless it's an internal note (`read`-tier can note but not reply outward → actually note still requires the row insert to pass `messages_insert` RLS which needs `reply`; the route allows the note path to skip the *explicit* reply check but RLS still governs).
4. **Internal note path:** insert message (`direction='out'`, `from_user_id`, `is_internal_note=true`), bump `last_activity_at` + `read_at=now()`, return. **No Postmark send.**
5. **Customer reply path:**
   - Pull profile (`display_name`, `signature_html`).
   - Build threading: all prior non-internal `message_id`s → `References`; last inbound's `message_id` → `In-Reply-To`.
   - Subject: strip any existing `[KOMP-N]`, ensure `Re:` prefix, then `[${formatTicketNumber(n)}] Re: …`.
   - `sendThreadedReply(...)` (Postmark) → on throw, 502 (do NOT persist a row; user can retry safely).
   - Persist outbound `conversation_messages` row with the `outboundMessageId`. If the row insert fails *after* the send succeeded → return **207** `{sent:true, persisted:false}` (mail went out; never tell the user to retry — that double-sends).
   - Bump `last_activity_at`, `read_at=now()` (replying = read), set `first_response_at` if first reply, flip `status open→pending`.
   - Insert `conversation_events('reply_sent', actor_user_id, payload:{postmark_message_id})`.

### 4.2 `POST /api/support/tickets/[id]/status` (`runtime='nodejs'`)

Body: `{ status: 'open'|'pending'|'resolved'|'closed' }`. Requires `has_mailbox_access(...,'reply')`.
- No-op if unchanged.
- **On a fresh transition INTO `resolved`** (not resolved↔closed toggles): unless an operator sent an outbound reply in the last **60 s** (that reply *is* the resolution comms — don't double-email), send a threaded resolution email (`buildResolutionHtml` — "we marked this resolved; reply to reopen") and persist the outbound row. Email failure is non-fatal: the status change still lands; surface `resolutionEmailError` so the UI toasts it.
- Apply update: set `status`; set `resolved_at=now()` for resolved/closed, else `null`.
- Insert `conversation_events('status_changed', actor_user_id, payload:{from,to,resolution_email_sent})`.

### 4.3 Inbox list — `src/app/admin/dashboard/support/page.tsx` (Server Component, `force-dynamic`)

- Tabs: **Open** (`open,pending`), **Unread** (open/pending narrowed in-page via `isTicketUnread`), **Resolved**, **Closed**, **All**. Search box (`TicketSearchInput`) hides tabs when active.
- Header chips: `{openCount} open · {unassignedCount} unassigned · {unreadCount} unread` (unread chip highlights accent when > 0).
- Each row links to `/admin/dashboard/support/{ticket_number}` (number-based canonical URLs). Row shows: unread dot + bold subject when unread, `KOMP-N` mono tag, subject, customer name/email, an "unassigned" pill when open+unassigned (row tinted accent), status chip, ET timestamp.

### 4.4 Ticket detail — `[id]/page.tsx` (Server) + `TicketWorkspace.tsx` (Client)

Server page: resolves `[id]` as UUID **or** bare number (`getTicketByNumber`), loads `listConversation`, profile signature, builds `suggestions` from the **first inbound message's `body_text`** via `suggestResolutions`, builds a `profilesMap` (uuid→display name) for message/event attribution, and resolves a **CustomerContext** (service-role lookup of the customer's `plan`/`account_status`/`email_opt_out` + active email-enrollment count by matching `customer_email` to `auth.users` — this is cross-user data RLS would block, so it uses `createAdminSupabase()`; best-effort).

`TicketWorkspace` (client):
- **Realtime:** one channel `ticket-{id}` subscribing to `conversation_messages` INSERT, `conversation_events` INSERT, and `tickets` UPDATE (filtered to this ticket) so multiple operators stay in sync live. A customer reply arriving while the ticket is open writes `read_at=now()` immediately (keeps it read on-screen).
- **Mark-read on open:** `useEffect` writes `tickets.read_at=now()` (shared across team).
- **Feed:** merged messages + events sorted by `created_at`. Events render as centered mono separators (`eventLabel()` maps `event_type` → human text, incl. `auto_reopened`, `assigned`, `status_changed`, opt-out, and forward-looking `auto_closed`). Messages render as cards: outbound/internal = sanitized HTML, inbound = text-only; internal notes tinted amber.
- **Controls bar:** status `<select>` (→ status route), "Assign to me"/"Unassign" (direct Supabase update + `assigned`/`unassigned` event).
- **Composer:** textarea, "Internal note" checkbox, signature preview, escapes text → `<p>…<br>…</p>` → reply route.
- **Aside:** Customer panel (plan/status chips, email opt-out toggle → `/api/admin/email/users/[id]/opt-out`) + "Similar resolved" suggestions with "Use this answer →" (drops the past resolution into the composer).

### 4.5 Nav badge + notifications — `_components/AdminNotifications.tsx` + admin `layout.tsx`

- Admin `layout.tsx` holds `inboxCounts` state, renders `<AdminNotifications currentUserId={…} onCountsChange={setInboxCounts} />`, and the Support nav item shows `badge: inboxCounts.unreadTickets || null`.
- `AdminNotifications` subscribes to a single `admin-notifications` realtime channel: tickets INSERT/UPDATE, leads INSERT/UPDATE, profiles INSERT (signup chime), conversation_messages INSERT. On any event it `refreshCounts()` (cheap head-count queries + JS unread filter) and pushes a toast where appropriate (new ticket; reply on *your* or *unassigned* ticket — suppressed when assigned to someone else). Uses refs to avoid stale closures, and a single pre-unlocked `<audio>` element for the chime (browsers block `play()` without a prior user gesture).

### 4.6 Attachment download — `GET /api/admin/attachments/[id]` (`runtime='nodejs'`, `force-dynamic`)

The only path to the private bucket. `requireAdmin()` → look up `conversation_attachments` row → `createSignedUrl(storage_path, 120, mode==='download' ? {download: filename} : {})` → 307 redirect to the signed URL. `?mode=view` inline, `?mode=download` forces save.

---

## 5. Env vars + external setup

```
POSTMARK_SERVER_TOKEN=…        # Postmark Server API token (outbound send) — shared with EMAIL-INFRA
POSTMARK_INBOUND_USER=…        # HTTP Basic user the inbound webhook checks
POSTMARK_INBOUND_PASS=…        # HTTP Basic pass
NEXT_PUBLIC_SUPABASE_URL=…
NEXT_PUBLIC_SUPABASE_ANON_KEY=…
SUPABASE_SERVICE_ROLE_KEY=…    # createAdminSupabase() in the webhook + attachment route + customer lookup
```

**Postmark dashboard:**
1. Verify the sending domain (SPF + DKIM + return-path) — done in EMAIL-INFRASTRUCTURE.
2. Add an **Inbound stream**; set its webhook URL to `https://POSTMARK_INBOUND_USER:POSTMARK_INBOUND_PASS@yourapp.com/api/email/inbound` (Postmark sends the creds as HTTP Basic).
3. Point `support@yourapp.com` (and `billing@`) MX or forwarding at the inbound stream's hash address (or set the domain's inbound to that stream).
4. Send a test email to `support@yourapp.com`; confirm a ticket appears + a realtime toast fires.

---

## 6. Adaptation checklist (per new app)

- [ ] Rename `TICKET_PREFIX` in `format.ts` (`KOMP` → e.g. `TRCK`) — **and the `KOMP` literal inside the two regexes** in the same file.
- [ ] Rename the tsvector generated expression on `tickets.search_tsv` (the `'KOMP-' || ticket_number` literal) to match the new prefix, or FTS-by-number drifts from the displayed tag.
- [ ] Replace `yourapp.com` everywhere (mailbox seeds, `newMessageId` default domain, resolution email copy, From display names).
- [ ] Replace `<FOUNDER_AUTH_UID>` in the mailbox_access seed.
- [ ] Decide tickets-only vs. tickets+leads+mail_threads; trim parent_type CHECKs + RLS branches accordingly.
- [ ] Confirm `is_super_admin`, `requireAdmin`, `profiles.signature_html`/`plan`/`account_status`/`email_opt_out`, `createServerSupabase`/`createAdminSupabase` exist (ADMIN-CONSOLE + AUTH-ONBOARDING prerequisites).
- [ ] Create the private `support-attachments` bucket.
- [ ] Wire the Support nav item + `AdminNotifications` into the admin layout.
- [ ] Configure the Postmark inbound stream + webhook (§5).

---

## 7. Build order (slices — pause after each per planning rules)

1. **Schema migration** (§1, one file) + seed mailboxes + private bucket. Verify RLS with a non-super-admin user.
2. **Email libs** (`format.ts`, `sanitize-html.ts`; `postmark.ts` from EMAIL-INFRA) + `repo.ts` read layer.
3. **Inbound webhook** (§3). Test with a real email → ticket row + message + (no UI yet) DB inspection.
4. **Inbox list + ticket detail UI** (§4.3–4.4) read-only.
5. **Reply + status routes** (§4.1–4.2) + composer + status controls + resolution email.
6. **Realtime + nav badge + notifications + attachments** (§4.5–4.6).
7. **Resolution suggestions** RPC + aside panel (§1.6, §4.4).

---

## 8. Hard-won rules (bake these in — they are non-obvious)

- **Unread is computed in JS, never SQL.** PostgREST can't do `read_at < last_inbound_at`. Always pull the small open/pending set and filter with `isTicketUnread`. One predicate, imported everywhere — do not re-implement inline (the BKE notifications component re-implements it once for the realtime path; keep those two byte-identical).
- **Read is shared across the team, and INBOUND-driven.** `read_at` advances on open/reply by *any* operator. Our own outbound replies stamp `read_at=now()` so they never re-mark a ticket unread.
- **`ticket_number` is a GLOBAL sequence + UNIQUE**, not per-mailbox. This is what lets a `[KOMP-N]` reply thread correctly no matter which alias it lands on.
- **`message_id` UNIQUE is the cross-delivery idempotency gate.** Same email CC'd to two aliases = two Postmark webhooks with distinct Postmark ids but one RFC Message-ID. Without the UNIQUE + the gate-2 check you double-create tickets.
- **Inbound HTML is never rendered.** Arbitrary-sender HTML is XSS. Render `body_text` only for inbound; sanitize + render HTML only for our own outbound/notes.
- **Return 200 to Postmark for anything you intend to drop** (unknown mailbox, blocked sender). Non-2xx triggers retry storms.
- **Resolution email has a 60 s "just replied" guard** so resolving right after a reply doesn't double-email the customer.
- **Reply route: if Postmark succeeds but the DB row insert fails, return 207, not an error.** Telling the user to retry double-sends.
- **The RLS message/event policies fail silently on a missing parent_type branch** (zero rows, no error). When you add leads/mail_threads, recreate the whole policy verbatim with the new arm — never patch one branch.
- **No ticket delete policy, ever.** Tickets/messages are append-only history. (Consistent with the workspace-wide "no destructive DB writes" rule.)
- **`auto_closed` is forward-looking only.** The UI `eventLabel` handles it but **no cron emits it yet** — there is no auto-close-after-N-days worker in the reference build. If a client wants it: a daily Trigger.dev task that sets `resolved` tickets older than N days to `closed` + inserts the event (see SCHEDULER-ENGINE / TRIGGER-DEV-WORKER playbooks). Auto-**reopen** (customer replies to resolved) IS live, in the inbound webhook.

---

**Source of truth:** read the BKE files rather than re-deriving — `src/app/api/email/inbound/route.ts`, `src/app/api/support/tickets/[id]/{reply,status}/route.ts`, `src/lib/email/{repo,format,postmark,sanitize-html}.ts`, `src/app/admin/dashboard/support/**`, `src/app/admin/dashboard/_components/AdminNotifications.tsx`, `src/app/api/admin/attachments/[id]/route.ts`, and the planning doc `ADMIN_MAILBOX_GAME_PLAN.md`. Schema reconstructed from live DB introspection (the base tables are not in `supabase/migrations/`).
