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.
