# 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.
