# Build your own Help Scout

> Source: https://buildyourown.software/like/helpscout
> Category: Shared inbox. Original vendor: Help Scout PBC. Last updated 2026-09-10.
> This file is written for AI coding agents. It contains the context, the build prompt, and the test plan for a self-built replacement of Help Scout.

## How to use this file

1. Read the whole file first.
2. Build the app described in "Build prompt". Treat its acceptance criteria as the definition of done.
3. When the build works, follow "Test prompt" to write and run automated tests.
4. Ask the user before changing the data model, the stack, or the non-goals.
5. The app must run the "one box" way described in "Default deployment" below: one process, SQLite, local disk, no hosted services unless the build prompt names one.

## What Help Scout does

Help Scout turns a support address like support@yourcompany.com into a shared inbox. Every inbound email becomes a conversation. Your team assigns it to someone, sets a status (active, pending, closed), adds tags, and replies from the shared address. Customers only ever see a normal email.

Around that inbox it sells a help center (Docs), a website widget (Beacon) with live chat, workflows that auto-tag and auto-assign, an AI assistant, and reports on volume and response time. It also has customer profiles that show every past conversation with a person.

The part small teams actually use is the inbox: threading, assignment, a few tags, saved replies, private notes, and a warning when two people open the same email. That's a small mail receiver, a few tables, and one tricky piece (email threading headers).

## What it costs

Typical spend: $1,500 per year. Help Scout Standard for a 5-person team: $25 × 5 users × 12 months billed monthly. About $1,260 on annual billing.

- Free: $0 up to 5 users (A contact is someone who got a reply from your team or was resolved by the AI assistant in that month.)
- Standard: $25 per user / month billed monthly (about $21 on annual billing)
- Plus: $45 per user / month billed monthly (about $38 on annual billing)
- Pro: $75 per user / month, minimum 10 users (Contact sales. Annual price for Pro is not shown on the page. Verify before you buy.)

Prices checked 2026-09-10 at https://www.helpscout.com/pricing/.

## Features and whether to build them

- [build] Shared inbox with threading: Inbound email to support@ becomes a conversation. Replies thread correctly in both directions. This is the product. Get Message-ID, In-Reply-To, and References right and the rest is CRUD.
- [build] Assignment and status: Assign a conversation to a person. Mark it active, pending, or closed. Two columns on the conversations table. Without them a shared inbox is just Gmail with more people in it.
- [build] Tags: Label conversations (billing, bug, refund) and filter by them. A join table. Tags are how you find out what customers keep asking about.
- [build] Saved replies with variables: Canned answers with placeholders like the customer's first name. Most support answers repeat. A template table and a string substitution function cover it.
- [build] Private notes: Team-only messages inside a conversation that the customer never sees. It's a message with a flag that says 'don't email this'. Cheap, and it replaces a Slack thread per ticket.
- [build] Collision detection: Warn when a teammate is already viewing or replying to the same conversation. Stops two people from answering the same customer. A heartbeat every 15 seconds and a banner.
- [build] Customer profiles: A record per email address with name, notes, and every past conversation. You need to know if this is the third time someone has emailed about the same thing.
- [build] Help center (Docs): A public knowledge base with articles and search. Markdown files in a table, rendered on a public route, with SQLite full text search. It cuts inbound volume.
- [build] Reports: Volume, first response time, resolution time, replies per agent. One daily aggregation query. Keep the report to the numbers you'll actually look at.
- [maybe] Workflows: Rules like 'if subject contains refund, tag billing and assign to Sam'. Two or three hardcoded rules in the inbound handler do the job. Build a rule editor only if the rules keep changing.
- [maybe] AI drafts and summaries: Draft a reply from the thread and past articles. Summarize a long conversation. One OpenRouter call with the thread and your help center articles as context. Worth adding once the inbox works.
- [skip] Beacon widget and live chat: A chat bubble on your site that creates conversations in real time. Chat means presence, typing indicators, and someone online to answer. A contact form that emails support@ covers the small-team case.
- [skip] SMS, WhatsApp, social channels, phone: Pull messages from other channels into the same inbox. Each channel is a separate integration with its own rules. Email first.

## Under the hood

### Data model

- Conversation: id, number, mailbox_id, subject, customer_id, assignee_id, status (active | pending | closed | spam), reply_token, last_customer_message_at, last_agent_message_at, first_response_at, closed_at. number is a short human-friendly sequence for the UI. reply_token goes in the Reply-To address as a threading fallback.
- Message: id, conversation_id, kind (inbound | outbound | note), author_user_id, from_email, to_emails, cc_emails, message_id, in_reply_to, references, text_body, html_body, quoted_text, sent_at. message_id is unique. Store the raw headers you receive and the ones you send, plus the path to the raw .eml on disk.
- Customer: id, mailbox_id, email, name, company, notes, custom (json), created_at. Email is unique per mailbox. Create on first inbound message.
- User (agent): id, email, name, role (admin | agent), signature
- Saved reply: id, mailbox_id, name, body_markdown, created_by. Variables are {{customer.first_name}}, {{agent.first_name}}, {{conversation.subject}}.
- Article: id, collection_id, slug, title, body_markdown, published, updated_at. Public at /help/<collection>/<slug>. Searched through an articles_fts FTS5 table kept in sync with triggers.

### Key flows

**Inbound email becomes a conversation**
1. The app's own SMTP receiver (smtp-server on port 25) accepts the message, saves the raw .eml to ./data/files, and mailparser parses it. On a laptop, pnpm mail:send-test or the paste-an-.eml import feeds the same function.
2. Reject duplicates by Message-ID. Drop auto-replies and bounces (Auto-Submitted, Precedence: bulk, mailer-daemon).
3. Look up In-Reply-To and References against stored message IDs. Then check for a reply_token in the To address. Then match subject plus sender within 30 days.
4. Append to the matched conversation and set it back to active if it was closed or pending. Otherwise create a customer (if new) and a new conversation.
5. Split quoted text from the new text so the thread view stays readable.

**Reply from the shared address**
1. Agent writes a reply, optionally starting from a saved reply with variables filled in.
2. Server generates a Message-ID, sets In-Reply-To to the customer's last message, and builds References from the chain.
3. Send through nodemailer and SMTP_URL from support@yourcompany.com with Reply-To support+<reply_token>@yourcompany.com and the agent's name as the display name. With no SMTP_URL the message is written to ./data/outbox.
4. Store the outbound message with its Message-ID so the customer's next reply threads back.
5. Set status to the one the agent picked (closed by default) and stamp first_response_at if empty.

**Collision warning**
1. Opening a conversation posts a presence heartbeat every 15 seconds with state viewing or replying. Presence lives in memory in the one app process.
2. The conversation view subscribes to an SSE stream and shows a banner when someone else has a heartbeat under 45 seconds old.
3. Typing in the reply box switches the state to replying, which turns the banner into a stronger warning.

**Daily report**
1. Bucket conversations by day in the mailbox timezone.
2. Count new conversations, replies sent, and conversations closed per day and per agent.
3. Compute median first response time (first_response_at minus created_at) and median resolution time.
4. Show the last 30 days as a table with a CSV export.

### Integrations

- In-process SMTP receiver (smtp-server + mailparser) (required): Receive mail sent to support@ on port 25 and parse it. An MX record points at your VPS; no inbound provider.
- SMTP relay (optional; outbox in dev) (optional): Send replies through nodemailer with SMTP_URL. Unset, mail goes to ./data/outbox so every flow works offline.
- DNS (A, MX, SPF, DKIM, DMARC) (required): Point inbound mail at your box and keep your replies out of spam.
- SQLite on local disk (required): Conversations, messages, customers, articles, and FTS5 full text search in ./data/app.db.
- Self-issued sessions (required): First-run admin from env or a browser prompt, invite links for teammates, signed httpOnly cookies.
- Local file store (required): Attachments and raw .eml files under ./data/files, served through signed URLs the app mints.
- Server-sent events (required): Presence and new-message updates from an in-memory bus in the app process.
- OpenRouter (optional): Draft replies and summarize long threads with any model.
- CSV export (required): Get conversations and customers out of Help Scout and out of your own app later.

## Where to build it

- Claude Code (https://claude.com/claude-code): Best fit. The threading logic needs tests against real email fixtures, and Claude Code can write the parser, run the fixtures, and fix the edge cases in one loop.
- Replit (https://replit.com): Fine for building and trying it. Replit won't expose port 25, so test inbound with pnpm mail:send-test and the .eml import there, then move the folder to a VPS for real mail.
- Lovable (https://lovable.dev): Skip it, or use it for the inbox UI only. Lovable is built around Supabase, and this app needs its own SMTP receiver, SQLite, and SSE in one Node process.
- ChatGPT / Codex (https://chatgpt.com): Use ChatGPT to decide which statuses and tags your team actually uses and trim the spec, then hand it to Codex.
- OpenRouter (https://openrouter.ai): Optional. Send the thread plus your help center articles to any model to draft a reply or summarize a 20-message conversation.

---

## Default deployment

Also at https://buildyourown.software/deploy.md. The build prompt below assumes this.

Every web app on buildyourown.software is written to run the same way. Learn it once and every tool's build prompt makes sense.

## The shape

- **One process.** Node 22 running the app. A second process only when the tool genuinely needs one (a job worker, a probe runner). Never a serverless function.
- **SQLite.** `better-sqlite3` with Drizzle, WAL mode, file at `./data/app.db`. Migrations checked into the repo and run on startup. Keep the schema portable so `DATABASE_URL=postgres://...` works later, but don't build for it now.
- **Local disk for files.** Uploads live under `./data/files/<table>/<id>/`. The app serves them through an authenticated route with short-lived signed URLs it mints itself. No bucket.
- **In-process scheduler.** `croner` inside the app process for reminders, retention, digests, and polling. A row in a `job_locks` table stops two instances from running the same job.
- **Sessions the app issues itself.** On first run the app creates an admin from `ADMIN_EMAIL` and `ADMIN_PASSWORD` (or prompts in the browser if unset). Teammates join through invite links. Sessions are signed, httpOnly cookies. Google sign-in is an optional add-on, never a requirement.
- **Realtime from the process.** Server-sent events fed by an in-memory event bus. No database change feeds, no websocket service.
- **Email through any SMTP server.** `nodemailer` with `SMTP_URL`. Without it, mail is written to `./data/outbox/*.eml` and printed to the log, so every flow works on a laptop with no account. When you go public, point `SMTP_URL` at the one relay you choose. Sending straight from a VPS lands in spam, so this is the one hosted service most tools keep.
- **UTC in the database,** local time in the UI, `date-fns-tz` for every conversion.

## Running it

- **Laptop:** `pnpm install && pnpm dev`. That's the whole install. No env vars are required; every setting has a default that works locally.
- **Public:** a VPS with 1 CPU and 1 GB RAM, one DNS record, and `docker compose up -d`. The compose file has two services: `app` and `caddy`. Caddy terminates TLS with a certificate it gets on its own. `./data` is a named volume.
- **Backups:** `pnpm backup` runs `sqlite3 .backup` into `./data/backups/<date>.db` and prunes to the last 14. A `backup` service in compose runs it nightly. Restoring is copying one file.
- **Updates:** `git pull && docker compose up -d --build`. Migrations run on boot.

## What this rules out

No Vercel, Supabase, Neon, Clerk, Auth0, R2, S3, Fly, Cloudflare Workers, Upstash, or any service that needs an account to run the app. If a tool can't avoid an outside service (a calendar OAuth app, a GitHub webhook, an SMTP relay, a second probe location), the build prompt says so in one line and makes it optional or last.

## Acceptance criteria every tool inherits

- A fresh clone runs with `pnpm install && pnpm dev` and no env vars, and the first browser visit creates the admin.
- `docker compose up -d` on a clean Ubuntu VPS with one A record serves the app over HTTPS with no other setup.
- Killing the process mid-job and restarting it doesn't double-run or lose the job.
- `pnpm backup` then deleting `./data/app.db` and restoring from the backup brings every record back.
- Every outbound email in dev shows up in `./data/outbox`.

---

## Build prompt

Also available on its own at https://buildyourown.software/like/helpscout/build-prompt.md

# Build a shared support inbox (replacing Help Scout)

You are building a shared email inbox for a support address like support@yourcompany.com, used by a team of 2 to 10 people. It replaces Help Scout for teams that only use the inbox, saved replies, notes, tags, customer history, a small help center, and a basic report. Build it end to end. Correct email threading matters more than anything else: if a customer's reply lands in the wrong conversation, nothing else counts.

## Stack

- Next.js (App Router) with TypeScript and Tailwind, running as one Node 22 process. No serverless functions.
- SQLite via `better-sqlite3` with Drizzle, WAL mode, file at `./data/app.db`. Migrations are checked in and run on startup. Keep the schema portable so `DATABASE_URL=postgres://...` works later, but don't build for it now.
- Inbound email: the app runs its own SMTP receiver in-process with the `smtp-server` package. It listens on 2525 inside the process; docker-compose maps host port 25 to it. An MX record for the support domain points at the VPS. `mailparser` turns what arrives into conversations.
- Outbound email: `nodemailer` with `SMTP_URL`, setting real `Message-ID`, `In-Reply-To`, and `References` headers. Inbound port 25 is normally open on a VPS but outbound 25 usually isn't, and mail sent straight from a VPS lands in spam anyway, so replies go out through an SMTP relay. Without `SMTP_URL`, mail is written to `./data/outbox/*.eml` and printed to the log.
- Files on local disk under `./data/files/`. The app serves them through an authenticated route with short-lived signed URLs it mints itself. No bucket.
- `croner` in-process for the report digest, queued delivery, and retention, with a `job_locks` table so two instances never run the same job.
- Sessions the app issues itself as signed httpOnly cookies. First run creates an admin from `ADMIN_EMAIL` and `ADMIN_PASSWORD`, or prompts in the browser if they're unset. Teammates join through invite links.
- Realtime: server-sent events fed by an in-memory event bus, for presence and new messages.
- `sanitize-html` for HTML bodies, `marked` for markdown, `html-to-text` for HTML-only mail, `zod` for every request body.
- `date-fns` and `date-fns-tz` for every date calculation. UTC in the database, the mailbox timezone in the UI. Never do timezone math by hand.
- Full profile: https://buildyourown.software/deploy.md

## Running it

- **Laptop:** `pnpm install && pnpm dev`. No env vars. The SMTP receiver listens on `localhost:2525`, outbound mail goes to `./data/outbox`, and the first browser visit creates the admin. No MX record points at your laptop, so inbound is tested two ways: `pnpm mail:send-test fixtures/inbound/<name>.eml` delivers a fixture to the local receiver on 2525 over real SMTP, and Settings has a "Paste an .eml" box that runs the same pipeline. Every flow works offline.
- **VPS:** one box with 1 CPU and 1 GB RAM, an A record for the app, an MX record for the support domain pointing at the same box, then `docker compose up -d`. Services: `app` (host port 25 mapped to the receiver, 3000 on the internal network), `caddy` (TLS on 443 with a certificate it gets on its own, `flush_interval -1` on the proxy so SSE streams), and `backup`. `./data` is a named volume. Set `SMTP_URL` for outbound; everything else has a default.
- **Backups:** `pnpm backup` runs `sqlite3 .backup` into `./data/backups/<date>.db` and prunes to the last 14. The `backup` service shares the image and volume and runs it nightly. Restoring is copying one file back to `./data/app.db` and restarting. Attachments and raw mail live under `./data/files`, which the volume already covers.
- **Updates:** `git pull && docker compose up -d --build`. Migrations run on boot.

## Environment variables

All optional. Every one has a default that works on a laptop.

- `ADMIN_EMAIL`, `ADMIN_PASSWORD`: create the first admin on boot. Unset means the browser asks on first visit.
- `SESSION_SECRET`: signs cookies and file URLs. Unset means the app generates one and stores it in `./data/secret`.
- `SMTP_URL`: outbound relay, e.g. `smtps://user:pass@smtp.example.com:465`. Unset means `./data/outbox`.
- `SMTP_INBOUND_PORT`: default `2525`.
- `APP_URL`: default `http://localhost:3000`. Used in invite links and the digest email.
- `INBOUND_IMPORT_SECRET`: lets an outside forwarder call the .eml import without a session. Unset means the import is session-only.
- `OPENROUTER_API_KEY`: turns on the AI buttons.

## Data model

Every table has `id` (text, uuid v4), `created_at`, `updated_at` (text, ISO 8601 UTC like `2026-09-10T19:42:00.000Z`). Arrays and objects are json stored as text. Booleans are integer 0 or 1.

- `mailboxes`: `name`, `address` (e.g. `support@yourcompany.com`, unique), `display_name` (e.g. `Acme Support`), `timezone` (IANA, e.g. `America/New_York`), `reply_domain` (e.g. `yourcompany.com`), `signature_markdown`.
  One mailbox is enough. Keep `mailbox_id` on everything so a second one can be added later.
- `users`: `email` (unique), `name`, `password_hash` (argon2), `role` (`admin` | `agent`), `signature_markdown` (nullable).
- `sessions`: `user_id`, `token_hash`, `expires_at`, `last_seen_at`. The cookie carries the raw token, signed with `SESSION_SECRET`.
- `invites`: `mailbox_id`, `email`, `role`, `token_hash`, `invited_by`, `expires_at`, `accepted_at` (nullable).
- `mailbox_members`: `mailbox_id`, `user_id`. Primary key on both.
- `customers`: `mailbox_id`, `email` (unique per mailbox, lowercased), `name` (nullable), `company` (nullable), `notes` (text), `custom` (json text), `conversation_count` (integer, maintained by the app).
- `conversations`: `mailbox_id`, `number` (integer, unique with `mailbox_id`), `subject`, `customer_id`, `assignee_id` (nullable FK users), `status` (`active` | `pending` | `closed` | `spam`), `reply_token` (16 random bytes, base32, unique), `last_customer_message_at`, `last_agent_message_at` (nullable), `first_response_at` (nullable), `closed_at` (nullable), `message_count` (integer).
- `messages`: `conversation_id`, `kind` (`inbound` | `outbound` | `note`), `author_user_id` (nullable, set for outbound and note), `from_email`, `from_name`, `to_emails` (json text), `cc_emails` (json text), `message_id` (text, unique, nullable for notes), `in_reply_to` (nullable), `references` (json text), `subject`, `text_body`, `html_body` (nullable), `quoted_text` (nullable), `raw_headers` (json text), `raw_path` (nullable, the stored `.eml`), `sent_at`, `delivery_status` (`queued` | `sent` | `failed`, outbound only), `delivery_error` (nullable), `relay_message_id` (nullable).
  Index on `message_id`. `messages_fts` is an FTS5 virtual table over `text_body` kept in sync with triggers.
- `attachments`: `message_id`, `filename`, `content_type`, `size_bytes`, `path` (relative to `./data/files`).
- `tags`: `mailbox_id`, `name` (unique per mailbox, lowercase), `color`.
- `conversation_tags`: `conversation_id`, `tag_id`. Primary key on both.
- `saved_replies`: `mailbox_id`, `name`, `body_markdown`, `created_by`, `use_count` (integer).
- `conversation_events`: `conversation_id`, `actor_user_id` (nullable), `type` (`status_changed` | `assigned` | `tagged` | `untagged` | `reopened_by_customer` | `merged`), `data` (json text).
  Rendered inline in the thread as small gray lines.
- `collections`: `mailbox_id`, `slug`, `name`, `position`.
- `articles`: `collection_id`, `slug` (unique per collection), `title`, `body_markdown`, `published` (integer), `view_count`. `articles_fts` is an FTS5 table over `title` and `body_markdown`, synced with triggers.
- `job_locks`: `name` (primary key), `locked_at` (nullable), `locked_by` (nullable), `run_key` (nullable), `last_done_key` (nullable).

What changes if you had a `timestamptz` and `jsonb` schema in mind: no `timestamptz` (ISO UTC strings compare correctly because the format is fixed), no `jsonb` (json text, `json_extract()` when you filter on it), no `tsvector` (FTS5), no `text[]` (json text), no sequences (`number` is `max(number) + 1` per mailbox inside the insert transaction; `better-sqlite3` is synchronous and single-writer so it can't race). Presence has no table; it lives in memory (see Collision detection).

Seed: one mailbox at `support@acme.test`, two users (one admin), tags `billing`, `bug`, `question`, `refund`, three saved replies, one collection with three articles, and 30 conversations across the last 14 days with a mix of statuses, assignees, and reply counts. Every fixture in `fixtures/inbound/` is addressed to `support@acme.test`.

## Screens

Left sidebar: Inbox, Customers, Help Center, Reports, Settings. Top bar has a search box that searches conversation subject, message body, and customer email.

### 1. First run and sign in

- With no users in the database: if `ADMIN_EMAIL` and `ADMIN_PASSWORD` are set, the admin is created on boot and `/login` is shown. Otherwise the first visit redirects to `/setup`, which asks for name, email, password, and the mailbox (name, address, timezone), creates the admin, and signs you in. `/setup` returns 404 once any user exists.
- `/login` is email and password. Sessions last 30 days and refresh on use.
- Admins create invite links in Settings. `/invite/[token]` (7-day expiry, one use) asks for name and password and lands the new agent in the inbox.

### 2. Inbox (`/inbox`)

- Folders on the left: Unassigned, Mine, All Active, Pending, Closed, Spam, plus one entry per tag with a count.
- Each row shows customer name, subject, a one-line preview of the latest message, assignee avatar, tags, and the relative time of the last message.
- Default sort for Active is oldest `last_customer_message_at` first, so the customer who has waited longest is at the top. Closed sorts newest first.
- Bulk select with checkboxes for assign, tag, and close.
- Keyboard: j/k to move, enter to open, e to close, a to assign to me, s to mark pending.
- Paginate at 50. Server components for the initial render. The list subscribes to `GET /api/events` and refreshes a row when its conversation changes.

### 3. Conversation (`/inbox/[number]`)

- Header: subject, `#number`, status dropdown, assignee dropdown, tag picker.
- Thread: messages oldest first. Inbound on the left, outbound on the right, notes with a yellow background and a lock icon. Events as gray one-liners between messages.
- Each message shows sender, time in the mailbox timezone, and a "Show quoted text" toggle when `quoted_text` is set.
- Attachments listed under the message with filename and size, downloadable through a signed URL from `GET /api/files/:attachment_id`.
- Right column: the customer card (name, email, company, notes, editable inline) and a "Previous conversations" list with status and date.
- Presence banner above the composer (see Collision detection). New messages append to the thread live over SSE.

### 4. Reply composer

- Lives at the bottom of the conversation. Tabs for Reply and Note.
- Toolbar: Insert saved reply (searchable dropdown), attach file, and a send button with a split menu: "Send and close" (default), "Send and keep active", "Send and mark pending".
- Cmd+Enter sends. Esc clears focus.
- Draft auto-saves to localStorage every 2 seconds keyed by conversation id and restores on reload.
- Optional "Draft reply" and "Summarize" buttons when `OPENROUTER_API_KEY` is set.

### 5. Customers (`/customers`, `/customers/[id]`)

- Table with search by name, email, or company. Columns: name, email, company, conversation count, last contact.
- Detail page: editable profile on the left, every conversation for that customer on the right, newest first, with status and assignee.
- Admins can merge two customers (moves conversations, keeps the older record, writes a `merged` event on each moved conversation).

### 6. Help Center admin (`/help-center`)

- Collections list with drag-to-reorder. Articles per collection.
- Article editor: title, slug (auto from title, editable), collection, markdown body with a live preview, published toggle.
- "View public page" link. Unpublished articles show a preview link that only works when signed in.

### 7. Public help center (`/help`, `/help/[collection]`, `/help/[collection]/[slug]`)

- No auth. Home lists collections with article counts.
- Search box hits `GET /api/help/search?q=` and shows the top 10 results with a highlighted snippet.
- Article page renders sanitized markdown with a table of contents from headings, a "Last updated" date, and a "Still stuck? Email us" mailto link to the mailbox address.
- Only `published = 1` articles are reachable. Unpublished slugs return 404.

### 8. Reports (`/reports`)

- Date range picker, default last 30 days.
- A table with one row per day and a totals row. Below it, a per-agent table for the range.
- CSV export of both tables.

### 9. Settings (`/settings`)

- Mailbox: name, display name, timezone (searchable IANA list), signature.
- Members, pending invites, and a "Create invite link" button. Admins can change roles and remove members.
- Tags: rename, recolor, merge into another tag.
- Saved replies: list, create, edit, delete, with a variables cheat sheet.
- Inbound: whether the receiver is listening and on which port, the exact MX record to add, the last 10 accepted and rejected deliveries, and a "Paste an .eml" box that runs the message through the same pipeline and shows `matched_by` and a link to the conversation.
- Outbound: whether `SMTP_URL` is set, or the path to `./data/outbox` with the last 10 files.
- Export: conversations, messages, and customers as CSV.

## API endpoints

- `POST /api/auth/setup`, `POST /api/auth/login`, `POST /api/auth/logout`
- `POST /api/invites`, `POST /api/invites/:token/accept`
- `POST /api/inbound/import` (raw `.eml` body; session or `INBOUND_IMPORT_SECRET` header)
- `GET /api/conversations?folder=&tag=&q=&page=`
- `GET /api/conversations/:id`
- `PATCH /api/conversations/:id` (status, assignee, tags)
- `POST /api/conversations/:id/reply`
- `POST /api/conversations/:id/note`
- `POST /api/messages/:id/retry`
- `POST /api/conversations/:id/presence`, `DELETE` same path
- `GET /api/conversations/:id/events` (SSE: presence, message, event)
- `GET /api/events` (SSE: conversation_updated for the whole mailbox)
- `GET /api/files/:attachment_id?exp=&sig=`
- `GET /api/search?q=`
- `GET /api/customers/:id`, `PATCH /api/customers/:id`, `POST /api/customers/:id/merge`
- `GET /api/help/search?q=` (public)
- `GET /api/reports/daily?from=&to=`
- `POST /api/ai/draft`, `POST /api/ai/summarize` (optional)

Every authenticated route checks that the user is a member of the mailbox that owns the record. Return 404 for records in other mailboxes and 401 with no session.

## Behaviors

### Inbound email (`lib/inbound/receiver.ts` and `lib/inbound/ingest.ts`)

The receiver, the `.eml` import, and `pnpm mail:send-test` all end in one function: `ingest(raw: Buffer, source: "smtp" | "import")`. Build the receiver with `smtp-server`: `disabledCommands: ["AUTH"]`, no relaying, `size` capped at 25 MB, plain SMTP (offer STARTTLS later with Caddy's certificate if you want). Start it once when the app boots, guarded by a global so Next's dev reloads don't bind the port twice.

1. `onRcptTo`: accept only addresses whose domain matches a mailbox's `reply_domain` or `address` domain. Reject everything else with `550 5.1.1 no such mailbox`. `onData`: buffer the stream, write it to `./data/files/raw/<yyyy-mm>/<sha256>.eml` before anything else, then call `ingest`. Answer `250` when ingest returns, `451 4.3.0 try again later` when it throws, so the sender retries and nothing is lost.
2. Parse with `mailparser`'s `simpleParser` into: `message_id`, `in_reply_to`, `references[]`, `from` (email, name), `to[]`, `cc[]`, `subject`, `text`, `html`, `date`, `headers`, `attachments[]`. A message with no `Message-ID` gets one made from the sha256 of the raw bytes.
3. If `message_id` already exists in `messages`, return `{ matched_by: "duplicate" }` and do nothing. Senders retry, and customers forward.
4. Drop (log it, return `{ matched_by: "dropped" }`) when any of these is true:
   - `Auto-Submitted` header is present and not `no`
   - `Precedence` is `bulk`, `junk`, or `auto_reply`
   - `X-Autoreply` or `X-Autorespond` is present
   - the from address starts with `mailer-daemon@`, `postmaster@`, `noreply@`, or `no-reply@`
   - the from address equals the mailbox address (your own sent mail looping back)
5. Find the conversation, in this order:
   - Any message whose `message_id` equals `in_reply_to` or any entry in `references`.
   - A `reply_token` in any `to` or `cc` address of the form `support+<token>@<reply_domain>`.
   - A conversation from the same customer whose normalized subject matches and whose `last_customer_message_at` or `last_agent_message_at` is within the last 30 days. Normalize by repeatedly stripping leading `Re:`, `RE:`, `Fwd:`, `FW:`, `AW:`, `SV:`, then trim and lowercase.
   - Otherwise none.
6. If none: find or create the customer by lowercased email. Split the display name into `name`; leave it null if absent. Create the conversation with `status = active`, `subject` (or `(no subject)`), a fresh `reply_token`, and the next `number` for the mailbox.
7. Insert the inbound message. Split the body with a quote detector on `text` that cuts at the first line matching any of:
   - `^On .+ wrote:$` (allow the line to wrap once)
   - `^-----Original Message-----`
   - `^From: .+` followed within 3 lines by `^Sent: ` or `^Date: `
   - a run of 3 or more lines starting with `> `
   - in `html`, a `<div class="gmail_quote">` or `<blockquote type="cite">` boundary
   Everything after the cut goes in `quoted_text`. If the detector leaves `text_body` empty, keep the whole thing in `text_body` and null `quoted_text`.
8. Write attachments to `./data/files/attachments/<message_id>/<filename>` (sanitize the filename, suffix `-2` on collision) and insert `attachments` rows. Skip any single file over 10 MB with a log line; keep the message.
9. Update the conversation: `last_customer_message_at = sent_at`, `message_count += 1`. If the status was `closed` or `pending`, set it to `active` and write a `reopened_by_customer` event. Increment the customer's `conversation_count` only on a new conversation.
10. Publish `conversation_updated` and `message` on the bus. Return `{ conversation_id, message_id, matched_by: "headers" | "token" | "subject" | "new" }`.

Run steps 3 through 9 in one `better-sqlite3` transaction so a crash mid-way can't leave a conversation without a message.

`POST /api/inbound/import` takes the raw `.eml` as the request body, runs `ingest(raw, "import")`, and returns the same object. `scripts/send-test-mail.ts` (wired as `pnpm mail:send-test <file.eml> [--port 2525]`) reads the envelope from the file's `From` and `To` headers and sends it with nodemailer to `127.0.0.1` with `secure: false, ignoreTLS: true` and the file as `raw`. The README shows both.

### Outbound reply (`POST /api/conversations/:id/reply`)

1. Body: `{ text_markdown, status_after: "closed" | "active" | "pending", attachments?: [] }`. Validate with zod. Notes go to `POST /api/conversations/:id/note` and are never emailed.
2. Render markdown to HTML and to plain text. Append the agent's signature if set, else the mailbox signature.
3. Generate `message_id = <conv-<conversation_id>-<uuid>@<reply_domain>>`. Set `in_reply_to` to the `message_id` of the most recent inbound message. Set `references` to that message's `references` plus its `message_id`, capped at the last 20 entries.
4. Build the nodemailer message with:
   - `from: "<agent name> (<mailbox display_name>) <mailbox address>"`
   - `to: customer email`
   - `cc: the cc list from the last inbound message minus the mailbox address`
   - `replyTo: support+<reply_token>@<reply_domain>`
   - `subject: "Re: <conversation subject>"` (don't double the `Re:`)
   - `messageId`, `inReplyTo`, and `references` set explicitly. Never let nodemailer generate `Message-ID`.
5. Insert the outbound message with `delivery_status = queued` inside the transaction, then send through `lib/mail.ts`. With `SMTP_URL` that's `nodemailer.createTransport(SMTP_URL)`. Without it, the transport writes the full message to `./data/outbox/<iso-time>-<message_id>.eml` and logs one line with the path. Either way, success sets `sent` and stores `relay_message_id` (the relay's response id, or the outbox path). On failure set `failed`, store the error, and show a red "Not sent, retry" banner on the message. `POST /api/messages/:id/retry` reuses the same `message_id`.
6. Update the conversation: `last_agent_message_at`, `message_count`, `status = status_after`, `closed_at` when closing (null it when reopening), `first_response_at` if null. Write a `status_changed` event when the status changed. Bump `use_count` on any saved reply that was inserted. Publish on the bus.
7. If the conversation has no inbound message yet (an agent started it from a customer page), send without `In-Reply-To` and still set `Reply-To` with the token.
8. A `deliver-queued` job every minute picks up outbound rows still `queued` after 2 minutes (the process died mid-send) and sends them. The same `message_id` goes out, so a rare duplicate threads into the same place.

### Files (`GET /api/files/:attachment_id?exp=&sig=`)

- The thread renders each attachment link as `/api/files/<id>?exp=<unix seconds, now + 1 hour>&sig=<hmac-sha256(SESSION_SECRET, id + ":" + exp)>`.
- The route serves the file when the signature checks out and `exp` is in the future, or when the request carries a valid session for the owning mailbox. Set `Content-Disposition: attachment` and the stored `content_type`. Never serve a path the client supplies; look the path up by id.

### Saved replies

- Variables: `{{customer.first_name}}`, `{{customer.name}}`, `{{customer.email}}`, `{{agent.first_name}}`, `{{agent.name}}`, `{{conversation.subject}}`, `{{conversation.number}}`, `{{mailbox.name}}`.
- `first_name` is the first whitespace-separated token of `name`.
- Fallbacks when a value is missing: `{{customer.first_name}}` becomes `there`, `{{customer.name}}` becomes the email local part, everything else becomes an empty string.
- Unknown variables stay as literal text so the agent notices.
- Put this in `lib/templates.ts` as a pure function and unit test it.

### Status, assignment, tags (`PATCH /api/conversations/:id`)

- Accepts `{ status?, assignee_id?, add_tags?: string[], remove_tags?: string[] }`. Every change writes a `conversation_events` row and publishes `conversation_updated`.
- Closing sets `closed_at`. Reopening (any status to `active`) nulls it.
- `spam` hides the conversation from every folder except Spam and increments `spam_count` inside `customers.custom`. Future emails from that customer still create conversations; don't auto-drop them.
- Assigning to yourself from the inbox list is one keystroke (`a`).
- Tag names are created on first use, lowercased and trimmed. Merging tags in Settings moves every `conversation_tags` row and deletes the old tag.

### Collision detection

- `lib/bus.ts` is an in-memory `EventEmitter` plus a presence map: `Map<conversation_id, Map<user_id, { state, seen_at }>>`. One process, so memory is the source of truth. A restart clears it and clients re-announce within 15 seconds.
- The conversation page opens `GET /api/conversations/:id/events`, an SSE stream. On connect the server sends the current presence list, then pushes `presence` whenever the map changes for that conversation, `message` when a message is inserted, and `event` for status, assignee, and tag changes. Send an SSE comment every 25 seconds as a keepalive. Remove the subscriber when the response closes.
- The page calls `POST /api/conversations/:id/presence` with `{ state: "viewing" | "replying" }` on open, every 15 seconds while open, on focus of the composer (`replying`), and on blur back to `viewing`. It sends `DELETE` on unmount and on `pagehide` through `navigator.sendBeacon`.
- Every presence write stamps `seen_at` and publishes. A 30-second in-process sweep drops entries older than 45 seconds and publishes when something was dropped.
- The banner above the composer shows every other user in the list: "Sam is viewing this conversation" in gray, or "Sam is replying to this conversation" in amber with the composer border turned amber. Never show the current user.
- Sending a reply while someone else is `replying` still works, behind a confirm dialog: "Sam is also replying. Send anyway?"

### Scheduled jobs (`lib/jobs.ts`)

- `croner` runs inside the app process, started once on boot behind the same global guard as the receiver. Jobs: `report-digest` at 08:00 in the mailbox timezone (rebuild the cron when the timezone changes), `deliver-queued` every minute, `presence-sweep` every 30 seconds (memory only, no lock), `retention` daily at 03:00 UTC deleting raw `.eml` and outbox files older than 90 days.
- Every job except the sweep claims a `job_locks` row first with one statement: `UPDATE job_locks SET locked_at = ?, locked_by = ?, run_key = ? WHERE name = ? AND (locked_at IS NULL OR locked_at < ?) AND (last_done_key IS NULL OR last_done_key < ?)`, where the cutoff is 10 minutes ago, `locked_by` is hostname plus pid, and `run_key` is the day (`2026-09-10`) or the minute for `deliver-queued`. Zero changed rows means skip. When the job finishes, set `last_done_key = run_key` and null the lock in one statement.
- On boot, run a catch-up pass: for each job whose scheduled time for today has already passed and whose `last_done_key` is behind, claim and run it once. That's what makes a kill mid-job safe: the stale lock expires, the restart claims it, and the job runs once because `last_done_key` was never set.
- The digest doesn't send SMTP directly. It inserts an outbound row per admin with `delivery_status = queued` in the same transaction that sets `last_done_key`, and `deliver-queued` sends them. A crash between "sent" and "marked sent" can produce one duplicate email; a crash anywhere else produces nothing missing.

### Reports (`GET /api/reports/daily?from=YYYY-MM-DD&to=YYYY-MM-DD`)

- Day boundaries are in the mailbox timezone. Pull the rows for the range plus a day on each side, convert every timestamp with `date-fns-tz`, and bucket in JavaScript. Never bucket on the UTC date string.
- Per day: `new_conversations` (created that day), `replies_sent` (outbound messages with `delivery_status = sent` that day), `closed` (conversations with `closed_at` that day), `median_first_response_minutes` (over conversations created that day with `first_response_at` set), `median_resolution_minutes` (over conversations closed that day, `closed_at` minus `created_at`). Medians are computed in code; SQLite has no percentile function.
- Show medians as `1h 12m` in the UI and as integer minutes in CSV. Show a dash when there are no samples.
- Per agent for the range: `replies_sent`, `closed`, `median_first_response_minutes` over conversations where their reply was the first response.
- Cap the range at 366 days. Default to the last 30 days.
- The `report-digest` job emails yesterday's row to every admin. Skip it when there was no activity.

### Search

- `GET /api/search?q=` searches conversations by subject with `LIKE` (case-insensitive for ASCII in SQLite), messages through `messages_fts MATCH`, and customers by email or name.
- Return the top 20 conversations with the matching snippet from `snippet()`.
- Search inside the inbox respects the current folder.

### Help center

- `GET /api/help/search?q=` runs `articles_fts MATCH` joined to `articles` where `published = 1`, ranked with `bm25()`, with `snippet()` for the highlight. Quote the user's terms so FTS5 syntax characters can't break the query. Limit 10.
- Markdown renders through `marked` and then `sanitize-html` with a strict allowlist: no scripts, no iframes, no inline event handlers, links get `rel="noopener"`.
- Headings get ids for the table of contents.
- Article pages are cached by Next and revalidated on publish with `revalidatePath`.
- Increment `view_count` in the background; never block the page on it.

### Timezones and dates

- Store every timestamp as an ISO 8601 UTC string with milliseconds and a `Z` suffix. Write one helper, `nowIso()`, and use it everywhere so the format never drifts.
- Render in the mailbox timezone in the app and the report. Lists show relative times ("14m ago"); hover and the thread show absolute times ("Sep 10, 2026, 3:42 PM EDT").
- The `sent_at` for an inbound message is the email's `Date` header when it parses and is within 24 hours of now. Otherwise use the receipt time. A spoofed or broken `Date` header must not push a conversation to the top or bottom of the inbox.
- Report day buckets use the mailbox timezone at query time. If an admin changes the timezone, historical days re-bucket. That's expected; say so in the UI.

### Edge cases to handle

- A customer emails from two addresses about the same thing. Don't auto-merge. The customer merge tool in the UI handles it.
- A customer CCs a colleague. Replies go to the customer and CC the colleague. If the colleague replies, they join the same conversation by headers and get a customer record of their own.
- An agent replies to a conversation that was already closed by a teammate 10 seconds ago. The reply still sends; the status follows `status_after`.
- The same person emails support@ and billing@ (a future second mailbox). Customers are per mailbox, so they'd be two records. Fine for now.
- An inbound HTML-only email with no `text` part: derive `text_body` from the HTML with `html-to-text` before running the quote detector.
- Subject longer than 998 characters or containing newlines: truncate to 255 and strip control characters before storing.
- A sender opens an SMTP connection and never sends `DATA`: `smtp-server`'s socket timeout (60 seconds) closes it. Cap concurrent connections at 20.

## Integrations

- **DNS.** README lists the exact records: an A record for the app, an MX record for `reply_domain` pointing at the VPS hostname (priority 10), SPF including your relay, the relay's DKIM record, and DMARC (`p=none` to start). Explain that replies from support@ need DKIM on `reply_domain` or Gmail shows "via" and may junk them.
- **Outbound SMTP relay (`SMTP_URL`).** Needed because outbound port 25 is blocked on most VPS hosts and a bare VPS IP has no sending reputation. Any relay with SMTP credentials works. On a laptop, leave it unset and read `./data/outbox`.
- **Inbound port 25.** Nothing to sign up for: the MX record and the receiver in this app are the whole setup. If your host blocks inbound port 25, Cloudflare Email Routing can forward each message to a webhook that posts the raw `.eml` to `POST /api/inbound/import` with `INBOUND_IMPORT_SECRET`.
- **OpenRouter (optional, behind `OPENROUTER_API_KEY`).** "Draft reply" sends the last 10 messages plus the 3 best-matching published articles (reuse the help center search) and inserts the result into the composer for editing. "Summarize" writes a 3-sentence summary as a private note. Never send anything to the customer automatically.

## Non-goals

Do not build: live chat or a website widget, AI answers sent without a human, phone or SMS, WhatsApp or social channels, multiple brands, SSO, SLAs, a workflow rule editor, customer satisfaction ratings, a mobile app. No hosted services beyond an SMTP relay for outbound mail, DNS records, and OpenRouter (optional). If you're tempted, add a TODO comment instead. Keep `mailbox_id` on every table so a second inbox is a later feature and not a rewrite.

## Acceptance criteria

1. An admin creates an invite link; the invitee opens it, sets a name and password, and sees the same inbox. Every `/api/*` route except the public help center and `/api/auth/*` returns 401 without a session cookie.
2. Delivering `fixtures/inbound/new-email.eml` from `jane@example.org` through `pnpm mail:send-test` creates a customer, a conversation with `status = active` and a `reply_token`, one inbound message with `raw_path` set, and returns `matched_by: "new"`. Pasting the same file into the `.eml` import gives the same result. Delivering it a second time (same `Message-ID`) creates nothing.
3. A reply from the app produces one message with `From` set to the mailbox address, `Reply-To` containing the `reply_token`, `In-Reply-To` equal to the customer's last `Message-ID`, and `References` containing every prior id in order. The outbound row is stored with its own `Message-ID` and `delivery_status = sent`.
4. An inbound email whose `In-Reply-To` is the outbound `Message-ID` from criterion 3 lands in the same conversation with `matched_by = headers`, and a closed conversation flips back to `active` with a `reopened_by_customer` event.
5. An inbound email with no threading headers but addressed to `support+<token>@` lands in the right conversation with `matched_by = token`. One with no headers and no token but a matching normalized subject from the same customer within 30 days matches with `matched_by = subject`. The same subject from a different customer creates a new conversation.
6. An email with `Auto-Submitted: auto-replied` or from `mailer-daemon@` is accepted with `250` and creates no rows. `RCPT TO` for a domain no mailbox owns is refused with `550`.
7. A Gmail-style reply with `On Tue, Sep 8, 2026 at 3:41 PM Acme Support wrote:` followed by quoted lines stores only the new text in `text_body` and the rest in `quoted_text`.
8. Inserting a saved reply fills `{{customer.first_name}}` from the customer's name, falls back to `there` when the name is null, and leaves `{{unknown.var}}` untouched.
9. A note is stored with `kind = note`, appears in the thread, and no email is sent (`./data/outbox` stays empty and the transport mock is not called).
10. Two agents open the same conversation; within 20 seconds each sees the other's viewing banner over SSE, and focusing the composer changes the other agent's banner to "replying".
11. A customer's page lists every conversation for that email, and the inbox search finds a conversation by a word in a message body.
12. A published article is reachable and searchable at `/help`; unpublishing it makes the URL 404 and removes it from search.
13. The daily report for a seeded day matches hand-computed counts and medians, and a conversation created at 11:30 PM in `America/Los_Angeles` is bucketed on that calendar day even though it is the next day in UTC.
14. A fresh clone runs with `pnpm install && pnpm dev` and no env vars, and the first browser visit creates the admin.
15. `docker compose up -d` on a clean Ubuntu VPS with one A record serves the app over HTTPS with no other setup.
16. Killing the process mid-job and restarting it doesn't double-run or lose the job.
17. `pnpm backup` then deleting `./data/app.db` and restoring from the backup brings every record back.
18. Every outbound email in dev shows up in `./data/outbox`.

## Deliverables

- The app, migrations, and a seed script that creates the demo mailbox described above.
- `docker-compose.yml` with `app`, `caddy`, and `backup`, a `Dockerfile`, and a `Caddyfile` that reads the domain from `DOMAIN` and proxies to `app:3000` with `flush_interval -1`.
- `scripts/backup.sh` (wired as `pnpm backup`) and `scripts/send-test-mail.ts` (wired as `pnpm mail:send-test`).
- `fixtures/inbound/` with raw `.eml` files covering: a new email, a reply with `In-Reply-To`, a reply with only `References`, a reply with a `reply_token` and no headers, a subject-only match, an auto-reply, a bounce, a duplicate `Message-ID`, a Gmail quoted reply, an Outlook quoted reply, an HTML-only email, and an email with two attachments.
- `lib/threading.ts` (conversation matching), `lib/quotes.ts` (quoted text splitter), `lib/templates.ts` (saved reply variables), and `lib/jobs.ts` (lock claim and release) as pure, unit-tested modules.
- README covering the laptop setup, the VPS setup with the exact A, MX, SPF, DKIM, and DMARC records and the port 25 mapping, choosing and configuring an SMTP relay, the optional OpenRouter key, how to deliver a fixture with `pnpm mail:send-test` and the `.eml` import, and backup and restore.

Build in this order: schema and migrations, then `lib/threading.ts` and `lib/quotes.ts` with their tests against the fixtures, then the receiver and `ingest`, then the outbound reply and the outbox transport, then the inbox and conversation screens, then everything else. Deliver a fixture with `pnpm mail:send-test` before you build the inbox screen, and send a real email to the VPS before you build the reports.

---

## Manual test checklist

- [ ] Send an email from a personal Gmail to support@ and confirm a conversation appears within a minute with the right subject and sender.
- [ ] Reply from the app and confirm the customer sees it from support@yourcompany.com with the agent's name, and that it lands in the same Gmail thread.
- [ ] Close the conversation, reply to that email from Gmail, and confirm it lands in the same conversation and the status flips back to active.
- [ ] Forward the original email to a second personal address and reply from there. Confirm it still threads (Reply-To token) even though the sender changed.
- [ ] Deliver the same .eml twice with pnpm mail:send-test (same Message-ID) and confirm only one message exists.
- [ ] Set up a vacation auto-reply on a test account, email it from the app, and confirm the auto-reply does not create a new conversation.
- [ ] Add a private note and confirm nothing was emailed and the note is visually distinct in the thread.
- [ ] Insert a saved reply with {{customer.first_name}} and confirm the name is filled in, and that a customer with no name gets a sensible fallback.
- [ ] Open the same conversation in two browsers as two agents and confirm both see the collision banner within 20 seconds.
- [ ] Open a customer's profile and confirm every past conversation is listed with status and date.
- [ ] Publish a help article, search for a word from its body on the public help center, and confirm it's found. Unpublish it and confirm it's gone.
- [ ] Check the daily report on a day with known activity and confirm counts and response times match what you can see in the inbox.
- [ ] Run `docker compose up -d` on a fresh VPS and open the domain over HTTPS.

## Test prompt

Also available on its own at https://buildyourown.software/like/helpscout/test-prompt.md

Write automated tests for the shared inbox app in this repo. Treat the acceptance criteria below as the spec. Use Vitest for the pure modules and the API routes, and Playwright for the browser flows. Every test file gets its own on-disk SQLite database in a temp directory (`./data` pointed at `mkdtemp`), migrated in `beforeAll` and deleted in `afterAll`, so files run in parallel without touching each other. Mock only the outbound SMTP transport (the nodemailer `sendMail` call when `SMTP_URL` is set) and OpenRouter at the module boundary. The SMTP receiver, the outbox, the scheduler, and the file store are real and run in the test process. Use the raw `.eml` files in `fixtures/inbound/` as inputs; if a fixture is missing, create a realistic one from a real email's headers.

## Acceptance criteria to cover

1. An admin creates an invite link; the invitee sets a name and password and sees the same inbox. Every `/api/*` route except the public help center and `/api/auth/*` returns 401 without a session cookie.
2. Delivering `new-email.eml` through the receiver on a random free port creates one customer, one active conversation with a `reply_token`, and one inbound message with `raw_path` set. Response has `matched_by: "new"`. `POST /api/inbound/import` with the same file gives the same result. Delivering it twice creates exactly one message.
3. A reply sends one message whose `From` is the mailbox address, `Reply-To` contains the `reply_token`, `In-Reply-To` equals the last inbound `Message-ID`, and `References` lists every prior id in order. The stored outbound message has its own `Message-ID` and `delivery_status = sent`.
4. An inbound email whose `In-Reply-To` is that outbound `Message-ID` joins the same conversation with `matched_by: "headers"`. If the conversation was closed, it becomes active and a `reopened_by_customer` event exists.
5. Threading fallbacks:
   - a headerless email to `support+<token>@` matches by token
   - a headerless email with subject `Re: Re: Order 1234` from the same customer within 30 days matches a conversation titled `Order 1234` by subject
   - the same subject from a different customer creates a new conversation
   - the same subject from the same customer 31 days later creates a new conversation
6. Emails with `Auto-Submitted: auto-replied`, `Precedence: bulk`, or from `mailer-daemon@` get `250` and create no rows. `RCPT TO` for `someone@other.example` is refused with `550`.
7. The Gmail and Outlook quoted-reply fixtures split into `text_body` (new text only) and `quoted_text`. A message with no quote boundary keeps everything in `text_body` and `quoted_text` is null. The HTML-only fixture produces a non-empty `text_body`.
8. Saved reply variables: `{{customer.first_name}}` fills from the name, falls back to `there` when the name is null, `{{customer.name}}` falls back to the email local part, and `{{unknown.var}}` is left as literal text.
9. A note is stored with `kind = note`, renders in the thread, the outbox directory stays empty, and the transport mock is never called.
10. Two agents on the same conversation each see the other's "viewing" banner within 20 seconds through the SSE stream; focusing the composer switches the other agent's banner to "replying"; a presence entry older than 45 seconds is not shown.
11. The customer page lists every conversation for that email; inbox search finds a conversation by a word that appears only in a message body.
12. A published article is served at `/help/<collection>/<slug>` and appears in `GET /api/help/search`; after unpublishing, the page returns 404 and the search result disappears.
13. The daily report for a seeded day matches hand-computed counts and medians, and a conversation created at 2026-09-09 23:30 `America/Los_Angeles` is counted on 2026-09-09.
14. A fresh clone runs with `pnpm install && pnpm dev` and no env vars, and the first browser visit creates the admin.
15. `docker compose up -d` on a clean Ubuntu VPS with one A record serves the app over HTTPS with no other setup.
16. Killing the process mid-job and restarting it doesn't double-run or lose the job.
17. `pnpm backup` then deleting `./data/app.db` and restoring from the backup brings every record back.
18. Every outbound email in dev shows up in `./data/outbox`.
19. A failed outbound send stores `delivery_status = failed` with the error, and `POST /api/messages/:id/retry` reuses the same `Message-ID` and sends exactly once. A row left `queued` for more than 2 minutes is sent by the `deliver-queued` job.
20. A request to `/api/conversations/:id` for a conversation in another mailbox returns 404, and `POST /api/inbound/import` with no session and a wrong `INBOUND_IMPORT_SECRET` returns 401 and creates nothing.

## Test layout

- `tests/unit/threading.test.ts`: criteria 4 and 5 as table-driven cases over `{ headers, to, subject, from, existing, now, expectMatchedBy }`.
- `tests/unit/quotes.test.ts`: criterion 7 against every fixture in `fixtures/inbound/`.
- `tests/unit/templates.test.ts`: criterion 8.
- `tests/unit/reports.test.ts`: the timezone bucketing and median helpers for criterion 13, including a DST boundary day.
- `tests/unit/jobs.test.ts`: criterion 16 against a real temp database. Claim a lock, try to claim it again from a second "instance" and assert zero rows changed; set `locked_at` 11 minutes in the past and assert the second claim wins; mark done and assert a claim for the same `run_key` is refused while the next key is accepted. Then simulate a kill: claim, throw before release, construct a fresh jobs module against the same file, run the catch-up pass, and assert the job body ran exactly once in total.
- `tests/integration/`: API routes and the receiver against a temp SQLite file, the outbox pointed at a temp directory, and the transport mock capturing every call when `SMTP_URL` is set. Cover criteria 2, 3, 4, 5, 6, 9, 11, 12, 13, 17, 18, 19, 20. For criterion 18, leave `SMTP_URL` unset, send a reply, and assert exactly one `.eml` in the outbox whose headers include the `Message-ID` and `In-Reply-To` from the stored row. For criterion 17, seed, run the backup script, delete the database file, copy the backup back, reopen, and compare every table row for row. Deliver fixtures to the receiver with the same nodemailer call `pnpm mail:send-test` uses.
- `tests/e2e/`: Playwright for criteria 1 and 14 (fresh temp database, first visit lands on `/setup`), criterion 10 with two browser contexts, and the composer flow for criterion 3 (send and close, then confirm the conversation is in the Closed folder and the draft in localStorage is cleared).
- Criterion 15 is a manual check. Add `scripts/smoke-vps.sh` that curls `https://$DOMAIN/help` and expects 200, and note it in the README.

## Fixtures

- If `fixtures/inbound/` is incomplete, add raw `.eml` files for: new email, reply with `In-Reply-To`, reply with only `References`, reply with a `reply_token` and no headers, subject-only match, auto-reply, bounce, duplicate `Message-ID`, Gmail quoted reply, Outlook quoted reply, HTML-only email, and an email with two attachments.
- Address every fixture to `support@acme.test` so the receiver accepts it against the seeded mailbox.

## Rules

- Name every test after its criterion: `test("AC5: headerless reply with reply_token matches by token")`.
- Freeze time with `vi.useFakeTimers()` or an injected `now`. Test the 30-day subject window, the 45-second presence cutoff, and the 10-minute lock expiry on both sides of the boundary.
- Never send real email. If `SMTP_URL` is set in the test environment, fail fast with a clear message.
- Reset the database between integration tests. Seed one mailbox and two users in a `beforeEach`.
- Add `pnpm test` and a GitHub Actions workflow that runs it with no services needed: no database container, no browser UI, nothing beyond `pnpm install`.
- Run the suite. Fix the app where the app is wrong and the test where the test is wrong. Report per-criterion pass/fail and what you changed.
