fuzzyworld

fuzzyworld-tools / commit

Issues: bug reports by email become issue pages; run records and replay links; fw deploy extra files

55e5fc3e0cf723d165e2917c45285c6cf499e463 · Pavel Safronov · 2026-11-03 22:25:36 -0800 · parents a890b8a627b9

Issues: bug reports by email become issue pages; run records and replay links; fw deploy extra files

- server/issues.py: ingest the bugs mailbox (IMAP, or Maildir fallback), one static page per thread under
  <repo>/issues/<n>/, modes public|reviewed|private (fw issues <repo> <mode>), approve/hide/close/reopen,
  per-message approval, reporter addresses/names/Device strings never published, hostile-mail handling
- run record extraction to record.json (validated, key allowlist, no addresses), CORS rule in htaccess,
  replay link from the app's package.json "fuzzyworld.replay"
- lib/deploy.mjs: "extra" globs protected from --delete (rsync filter), "replay" template validation
- comments.py: IMAPSource takes optional config/stem/limit (defaults unchanged)
- hourly issues-ingest.sh job, README and PLAN.md updated, credentials filenames in .gitignore

FW-Tokens-In: 432 109932 19538385
FW-Tokens-Out: 4520844 13703
FW-Tokens-Cached:
FW-Session: 38928110-1cf6-4bd6-90d8-678798481a94
FW-Rtk-Saved:
FW-Rtk-Cmds:

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
 .gitignore                                   |   6 +
 PLAN.md                                      |  56 +-
 README.md                                    |  38 +-
 bin/fw                                       |   3 +-
 lib/deploy.mjs                               |  59 +-
 server/comments.py                           |  29 +-
 server/fw                                    |  21 +-
 server/gen.py                                |   4 +-
 server/install.sh                            |   2 +-
 server/issues.py                             | 896 +++++++++++++++++++++++++++
 server/jobs/crontab                          |   1 +
 server/jobs/issues-ingest.sh                 |   9 +
 server/site/htaccess                         |   5 +
 server/site/style.css                        |   2 +-
 tests/deploy.test.mjs                        |  44 ++
 tests/fixtures/issues/explodorama-report.eml | 147 +++++
 tests/fixtures/issues/q101-record.json       |   1 +
 tests/test_issues.py                         | 873 ++++++++++++++++++++++++++
 18 files changed, 2151 insertions(+), 45 deletions(-)

diff --git a/.gitignore b/.gitignore
index ae7c391..50b24da 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,9 @@ __pycache__/
 *.pyc
 .DS_Store
 .playwright-mcp/
+
+# credentials: never commit
+imap.json
+*-imap.json
+.env
+.env.*
diff --git a/PLAN.md b/PLAN.md
index 0d6c2f8..a08602e 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -62,7 +62,7 @@ The order goes from cheap to expensive, and each step reuses what the one before
    - heatmap: a commit-activity SVG per repo and site-wide
 4. **Wiki**: render a repo's `docs/` (or `wiki/`) folder with a sidebar. Import the GitLab wikis in
    `~/code/gitlab-export/*/wikis.json`. Static rendering only; editing comes with pull requests.
-5. **Issues**: see [Issues](#issues). The first feature that reads email; builds the mailbox plumbing.
+5. **Issues** (built 2026-11-03): see [Issues](#issues). The first feature that reads email; builds the mailbox plumbing.
 5b. **Blog comments** by email (see [Comments](#comments)): right after Issues, because it reuses the mailbox plumbing.
 6. **Pull requests**: see [Pull requests](#pull-requests). Last, because it reuses the issues mailbox plumbing
    and needs the CI runner (approved CI runs on contributed code).
@@ -308,19 +308,20 @@ because it runs on a machine in my home (part of Pull requests).
 
 ## Issues
 
-- Reports arrive at `bugs-k7q4+<repo>@fuzzyworld.net` (already live).
+- Reports arrive at `bugs-k7q4+<repo>@fuzzyworld.net` (only the address was live before 2026-11-03; the ingest and
+  the pages were built that day, see the task list under Report replays).
 - A cron job reads the mailbox (IMAP, Python standard library) and renders each thread as static pages.
 - Reporters' email addresses are never shown.
 - **Configurable per repo**, the same way as visibility: `git config fuzzyworld.issues public|reviewed|private`,
   set with `fw issues <repo> <mode>`:
-  - `public`: every report is published as it arrives
+  - `public`: a report whose sender passed email authentication is published as it arrives (an unauthenticated one waits for approval)
   - `reviewed`: a report is published only after I mark it public
-  - `private`: nothing is published; reports only reach my mailbox
+  - `private`: nothing is published; reports are still stored in `~/fw/issues` (and listed by `fw issues list`)
   - anything other than exactly `public` or `reviewed` counts as `private`, so nothing is published by accident.
     A private repo's issues are always private, whatever this says.
 - Import the GitLab issues and notes in `~/code/gitlab-export/*/issues.json`.
 
-### Report replays (Q104; planned 2026-11-03, nothing built here yet)
+### Report replays (Q104; planned 2026-11-03, built 2026-11-03 in this repo)
 
 Idea from the Explodorama bug mails: a report can carry a **machine-readable payload** (for the game: the run
 record, a few KB of JSON: seed, rules version and actions), and the issue page can then show *what the reporter
@@ -334,11 +335,13 @@ saw* as a looping animation. The game side (the replay page, its limits, how old
 - **Store it beside the issue.** For a repo whose issue mode allows publishing, the cron writes the payload as
   a static file next to the issue page (`…/issues/<n>/record.json`), under the same visibility rule as the
   report itself: `public` as it arrives, `reviewed` only after I mark it public, `private` never. The payload
-  is validated first (size ≤ 64 KB, parses as JSON, expected keys); anything else stays only in my mailbox.
+  is validated first (size ≤ 64 KB, parses as finite JSON, an integer `seed` and `simVersion`, a list `actions`) and only the keys
+  `simVersion, seed, startLevel, startCharge, options, actions` are kept; a record with an address-like string is refused.
+  Anything else stays only in my mailbox.
   Reporter emails are still never shown; the device string stays out of the page by default.
 - **Link a viewer, don't build one.** An app announces its viewer in `package.json`, for example
   `"fuzzyworld": { "replay": "replay.html?report={record}" }`; with it, an issue page that has a payload shows
-  "Watch what happened", a link (or an iframe on the same origin) to that page of the app, with `{record}` set
+  "Watch what happened", a plain link to that page of the app, with `{record}` set
   to the stored file. Without the key, nothing changes. This keeps the core free of any game knowledge (🔌
   plugin-shaped: an app opts in).
 - **CSP.** The viewer is the app's own script on the app's own origin, so the site-wide `script-src 'self'`
@@ -351,18 +354,51 @@ saw* as a looping animation. The game side (the replay page, its limits, how old
   served locally with the dev page's `&rules=` mode, which can use any commit from git), and the buttons that
   mark a report public, close it, or turn it into a regression fixture in the app's repo (`test/fixtures/`).
   The panel is a thin local web page over `fw` commands; it holds no state of its own.
+  **Decided 2026-11-03: local-only, never hosted on DreamHost.** Every panel action is privileged (publish,
+  merge, deploy), and a login form on shared hosting would make the most valuable target on the site; the
+  local panel's security is that it is unreachable, and its authority is the ssh key already held. It also
+  keeps "DreamHost only serves files". Cost: no triage from a phone (reports still arrive as email, and the
+  replay link works anywhere because it is a static page). Possible later add, not planned: one narrow
+  endpoint behind a per-report signed link in the report mail ("mark this report public"), with no login
+  and no general admin surface.
+
+**This repo's task list (2026-11-03; Pavel: "add to the plan, start implementing").** Items 1-4 were built
+on 2026-11-03 (`server/issues.py`, `lib/deploy.mjs`); the first line of each says so. Not yet committed or
+deployed is deliberately not recorded here. In dependency order:
+
+1. **Issues** (built 2026-11-03; Next 5): cron ingest of the bugs mailbox over IMAP (reuse the `comments.py` /
+   `comments-ingest.sh` plumbing), one static page per thread under `…/<repo>/issues/<n>/`,
+   `fw issues <repo> public|reviewed|private` (anything else = private), reporter addresses never shown, and
+   a "mark public" action for `reviewed` mode.
+2. **Record extraction** (built 2026-11-03): decode the quoted-printable mail, find the `Run record: {…}` line, validate
+   (≤ 64 KB, valid JSON, expected keys), write `record.json` beside the issue under the issue's visibility
+   rule; device string stays out of the page. Test with a real report (Explodorama's
+   `test/fixtures/q101-bomb-pass.json` and its `.eml`).
+3. **App opt-in** (built 2026-11-03): a `replay` key in the `"fuzzyworld"` block of `package.json`
+   (`"replay": "replay.html?report={record}"`); the issue-page generator reads it and shows "Watch what
+   happened". No key, no change.
+4. **`fw deploy` carries extra files, kept forever** (built 2026-11-03): a per-app glob list
+   (`"extra": ["sim/*.js"]`) published with the deploy; extra files are protected from `--delete` through an rsync
+   filter (uploaded and updated, never removed), so a later deploy never deletes old rules versions.
+
+No CSP change (the viewer is the app's own script) and no server code on DreamHost. The admin panel (Q102,
+local-only) comes after all four.
 
 **Order:** (1) the game's production replay page and limits (its PLAN §20.2 steps 1-2), by hand, with a stored
 record; (2) the cron extracting and publishing `record.json` and the `replay` key; (3) per-rules-version sim
 files in `fw deploy`; (4) the panel. Each step is useful alone.
 
-**Open:** whether the viewer opens in the same page (iframe) or its own; a `reviewed` report's payload being
-published at the same moment as its text; how long old `sim/` files are kept; compact records in the game's
-`mailto:` link (game PLAN §20.2 step 4) before or after step 2.
+**Decided 2026-11-03 (Pavel):** the viewer opens as its own page (a plain link, no iframe); a `reviewed`
+report's record is published at the same moment as its text; old `sim/` files are kept forever; compact records
+in the game's `mailto:` link (game PLAN §20.2 step 4) come after the cron (step 2); the published page defaults
+to the plain recorded run, with comparison as an option (game PLAN §20.2 step 5). Nothing open here.
 
 ## Pull requests
 
 - Contributions arrive at `contrib-68uq+<repo>@fuzzyworld.net` as a branch URL or patches (already live).
+  **Forward-only today** (2026-11-03): like `bugs-k7q4`, it must be recreated as a fully hosted mailbox before a
+  cron job can read it over IMAP, and so that the receiving server's `Authentication-Results` header is trusted
+  (a forwarded copy carries the destination's header instead). Give it its own account, never the comments one.
 - A cron job fetches each one into `refs/contrib/<n>` in the bare repo, never onto `main`.
 - It renders a review page: the diff, CI results (after I approve the run), and the discussion from email replies.
 - **Transcripts.** A submitter can attach the AI conversation behind the change, so the reviewer sees exactly
diff --git a/README.md b/README.md
index eb53309..54bbb26 100644
--- a/README.md
+++ b/README.md
@@ -53,7 +53,9 @@ Add to the app repo's `package.json`:
   "checks": ["typecheck", "test"],   // scripts that must pass (default: whichever of typecheck/lint/test exist)
   "build": "make public",            // default: `<pm> run build`
   "out": "dist",                     // default: dist, else public, else build
-  "keep": ["my-game.apk"]            // files on the server a deploy must not delete or replace (e.g. a download built elsewhere)
+  "keep": ["my-game.apk"],           // files on the server a deploy must not delete or replace (e.g. a download built elsewhere)
+  "extra": ["sim/*.js"],             // build-output files every later deploy keeps: uploaded and updated, never deleted (e.g. old rules versions)
+  "replay": "replay.html?report={record}"   // a page of this app that plays a bug report's run record; issue pages link to it (see Issues)
 },
 "devDependencies": { "fuzzyworld-tools": "git+https://code.fuzzyworld.net/fuzzyworld-tools.git#semver:^1.0.0" }
 ```
@@ -71,7 +73,7 @@ Then `pnpm run deploy` / `npm run deploy`. **`fw deploy`**:
 2. installs with the lockfile's package manager (`pnpm install --frozen-lockfile`, `npm ci`, `yarn install --frozen-lockfile`)
 3. runs `prebuild`, then the `checks`, then the build. Any failure stops the deploy.
 4. checks the output: every same-site URL in `index.html`, the web manifest's `id`/`scope`/`start_url`, and every `serviceWorker.register(...)` script and scope must stay inside `/<app>/`. That keeps apps on the shared origin from capturing each other, and makes each one install separately on Android. Any problem stops the deploy.
-5. `rsync --delete` to `apps.fuzzyworld.net/<app>/` (except `keep` paths), writes `.fuzzyworld.json` (commit, time; not web-readable), and refreshes the apps index and the repo's code page
+5. `rsync --delete` to `apps.fuzzyworld.net/<app>/` (except `keep` paths; `extra` files are protected from `--delete` by an rsync filter, so they are uploaded and updated but never removed), writes `.fuzzyworld.json` (commit, time, and the `replay` template; not web-readable), and refreshes the apps index and the repo's code page
 6. prints the URL
 
 `--dry-run` does everything except upload.
@@ -90,6 +92,8 @@ On the laptop, `bin/fw` runs `deploy` locally and forwards repo commands to Drea
 | `fw repoint [DIR] [--https] [--dry-run]` | for each repo in DIR whose `origin` is on gitlab.com: rename it `gitlab`, add `origin` → `dh:git/<repo>.git` (or read-only `https://code.fuzzyworld.net/<repo>.git` with `--https`, public repos only), fetch, and retarget branch tracking. Changes only remotes; checks that uncommitted work, HEAD and stashes are unchanged |
 | `fw usage [--all\|--now]` | Claude Code token use per session from `~/.claude/projects` (runs locally): messages, current context size, cache-read, output (cache-read and output include subagents). `--now` prints one line for the newest session, to decide when to put it down |
 | `fw comments list\|show\|approve\|reject\|remove\|untrust\|publish\|export\|ingest\|imap-check` | moderate blog comments received by email (see [Blog](#blog)); runs on the server |
+| `fw issues <repo> public\|reviewed\|private` | set who sees the repo's bug-report issue pages (`git config fuzzyworld.issues`; default private) and republish them |
+| `fw issues list\|show\|approve\|hide\|close\|reopen\|publish\|publish-all\|ingest\|imap-check` | moderate bug-report issues received by email (see [Issues](#issues)); runs on the server |
 | `fw list` | every repo, its visibility and last commit |
 | `fw new <name> [--public] [--description TEXT]` | create `~/git/<name>.git` on DreamHost; **private unless `--public`** |
 | `fw public <name>` | publish the source on code.fuzzyworld.net right away |
@@ -112,7 +116,7 @@ Each bare repo stores its own setting: `git config fuzzyworld.visibility public|
 **Anything other than exactly `public` is private**, including a missing value, so a repo is never published by accident.
 A private repo has no pages, no clone URL and no index entry. Pushing to it publishes nothing.
 
-Other settings in the repo config: `fuzzyworld.description`, `fuzzyworld.ci` (`off`: pushes queue no CI jobs), plus `fuzzyworld.gitlab-id` / `fuzzyworld.gitlab-path` for repos migrated from GitLab.
+Other settings in the repo config: `fuzzyworld.issues` (`public`, `reviewed` or anything else = private: who sees bug-report issue pages, see [Issues](#issues); a private repo never shows issues), `fuzzyworld.description`, `fuzzyworld.ci` (`off`: pushes queue no CI jobs), plus `fuzzyworld.gitlab-id` / `fuzzyworld.gitlab-path` for repos migrated from GitLab.
 
 ## Layout on DreamHost
 
@@ -130,6 +134,7 @@ Other settings in the repo config: `fuzzyworld.description`, `fuzzyworld.ci` (`o
 | `~/fw/pylib/` | Python-Markdown 3.7 (README rendering) and Pygments 2.21.0 (syntax highlighting), `pip install --target`; `install.sh` adds both (the landing page needs Markdown) |
 | `~/fw/queue/pending/`, `running/` | CI jobs, one JSON file each (see [CI](#ci)); `running/<job>.beat` is the runner's heartbeat, `declined/` the runners that gave a job back |
 | `~/fw/runners/<runner>` | one file per runner: its tags, and (by its time) when it last checked in |
+| `~/fw/issues/` | bug-report issues: stored reports, `secret`, `imap.json`, locks (see [Issues](#issues)); outside every web root |
 | `~/fw/ci/<name>/<sha>/` | CI results uploaded by runners: `pipeline.json` and one log per job |
 | `~/apps.fuzzyworld.net/_shots/<app>/` | screenshots of each app, uploaded by runners (`shots.json` lists them) |
 | `~/apps.fuzzyworld.net/vscode/` | VS Code in the browser (`vscode-web/install.sh`; not listed on the apps index) |
@@ -148,6 +153,7 @@ Every repo uses the shared hook via `core.hooksPath=~/fw/tools/server/hooks`. `p
 | `/<name>/blame/<path>.html` | the commit that last changed each line (text files ≤ 256 KB) |
 | `/<name>/raw/<path>` | raw copies of images only (png, jpg, gif, webp, ico, avif, bmp, svg; ≤ 2 MB) |
 | `/<name>/log/` | the branch's commits (up to 2000) |
+| `/<name>/issues/`, `/<name>/issues/<n>/` | bug-report issues and one issue's page (only when the repo's `fuzzyworld.issues` mode and the issue's state allow it; see [Issues](#issues)) |
 | `/<name>/commit/<sha>.html` | one commit with its diff (truncated at 300 KB); a page for every commit on every branch, up to 2000 per repo |
 | `/<name>/refs/` | branches and tags |
 | `/<name>/usage/` | Claude Code token usage per commit and per session, read from `Usage:` trailers in commit messages (only for repos that have them; see below) |
@@ -199,6 +205,8 @@ Jobs live in `server/jobs/`. `install.sh` writes `server/jobs/crontab` into the
 |---|---|---|---|
 | `north-star-cleanup.sh` | Tuesdays 06:00 UTC (was the GitLab schedule) | updates `~/fw/jobs/north-star` to `main`, then runs `scripts/clean-old-firebase.sh --days=30` as a preview and then `--apply` | `~/fw/logs/cron-north-star-cleanup.log` |
 
+| `issues-ingest.sh` | hourly | `issues.py ingest`: turns new mail in the bugs mailbox into issues and republishes the touched repos (quiet unless something happened) | `~/fw/logs/cron-issues-ingest.log` |
+
 Run one by hand: `ssh dh '~/fw/tools/server/jobs/north-star-cleanup.sh --now --preview-only'` (or without `--preview-only` for the real thing).
 
 Secrets are in `~/.config/<project>.env` on DreamHost (mode 600, never in a repo or the web root): `north-star.env` holds `FIREBASE_TOKEN`.
@@ -291,6 +299,30 @@ pictures go to `apps.fuzzyworld.net/_shots/<app>/` and appear on the apps index
 
 Without runner screenshots, the images in a public repo's `screenshots/` folder are shown instead.
 
+## Issues
+
+Bug reports arrive by email at `bugs-k7q4+<repo>@fuzzyworld.net` (the address is on every repo's summary page; the repo name is part of it, and a repo that does not exist under `~/git` sends the mail to the bad folder). `server/issues.py` (`fw issues ...`, Python standard library only) turns each report into an issue and, when allowed, a static page. It reuses the comments plumbing in `comments.py` (IMAP or Maildir source, `Authentication-Results` check, locks, atomic JSON files).
+
+**Modes.** `fw issues <repo> public|reviewed|private` sets `git config fuzzyworld.issues` in the bare repo and republishes. Anything other than exactly `public` or `reviewed` is `private`, and a repo that is itself private (`fuzzyworld.visibility`) never shows issues, whatever its mode.
+
+| Mode | An issue's page appears |
+|---|---|
+| `private` | never; reports are stored and listed by `fw issues list` only |
+| `reviewed` | after `fw issues approve <repo> <n>` |
+| `public` | as soon as its first message passes email authentication (unauthenticated mail is stored with `authenticated: false` and waits for `approve`) |
+
+In `public` and `reviewed` mode each sender may send 5 messages per 24 hours (the rest goes to the bad folder).
+
+`fw issues hide <repo> <n>` takes an issue off the site in any mode and keeps it off until `approve`. `close` / `reopen` change the state shown on the page. A reply (its `In-Reply-To` or `References` matches an earlier message of an issue of the same repo) is added to that issue. `approve` marks the messages the issue has at that moment; a message that arrives later is shown only in `public` mode and only if its sender passed authentication, or after another `approve`. An unauthenticated reply is never shown automatically. A mail that repeats a stored Message-ID or a stored byte-for-byte copy is ignored.
+
+**Pages.** `/<repo>/issues/` lists the visible issues, `/<repo>/issues/<n>/` shows one. Every publish rebuilds the whole `issues/` folder, so hiding removes the page; when the folder appears or disappears the summary page is rebuilt so its "Issues" link follows. The reporter is always shown as "Reporter". **Privacy rules:** no address, name, Message-ID or sender hash is written to a page; every address-shaped string in the title and text is replaced by `[address hidden]`; lines starting `Device:` or `Run record:` are left out, and so are the lines that wrap a long `Device:` value (following lines are dropped while they are indented or look like a user agent: `token/version`, parentheses or semicolons; a blank line, a template label such as `Seed ...`, or plain prose ends the block, so the reporter's own words are kept); format=flowed text is re-joined first (an empty line always ends a paragraph) and the text is NFKC-normalized before any filtering (so fullwidth `Device:` or `@` count); the text is cut at the start of a quoted reply so quoted names never publish: an attribution line in any language (a colon line with an address, or an `On`/`Am`/`Le`/`El`/`Op`... line with a date that says wrote/schrieb/a écrit/schreef/escribió; any such line directly followed by a quoted `>` line), a wrapped attribution (up to three lines ending in a colon, directly followed by a quoted line, or ending in a separate `wrote:` line), a `-----Original Message-----` line, or a block of at least three `Label: value` lines that is a mail header (`From:`/`Von:`/`De :`..., with an address or three header labels; an Outlook `____` line before it is dropped); a colon line followed only by quoted lines at the end of the text is cut too, but never when it would leave nothing, and prose such as `I pressed <Enter> and wrote:` stays; `delsp=yes` is honoured when re-joining flowed text; all text is HTML-escaped inside `<pre class="issue-text">` and no page has a script.
+
+**Run records.** If the text part holds a line `Run record: {json}` (the Explodorama bug-report mail does), it is read from the full decoded part (the text shown is cut to 4000 characters, the record is not) and checked: at most 64 KB, valid JSON, nesting at most 32 deep, an object with integer `seed` and `simVersion` and a list `actions`, finite numbers only. Only the keys `simVersion`, `seed`, `startLevel`, `startCharge`, `options` and `actions` are kept (all others are dropped), and a record with an address-like string anywhere in it is refused. A run record split over several lines by a mail client is not recognised. The first line that passes wins, so the mail's later "too long for an email link" footer is ignored; if the decoded text has none, the raw mail is decoded as quoted-printable and searched again. The record is stored re-serialized, never as mail text, and only the first record of an issue is kept (a record in an unauthenticated reply is ignored; every refusal is logged in `rejected.log`). A record is as public as the message that carried it: one in a reply is published only once that reply is shown (after `approve` in `reviewed` mode; at once in `public` mode for an authenticated sender). It is published as `/<repo>/issues/<n>/record.json` at the same moment as the issue page (`approve` publishes both in `reviewed` mode; `hide` removes both). `server/site/htaccess` lets `https://apps.fuzzyworld.net` read it (CORS) and sends `no-cache`.
+
+**Replay link.** If a deployed app of the repo (`apps.fuzzyworld.net/<app>/.fuzzyworld.json` with `repo` equal to the repo and an `index.html`) has a `replay` template (the `"fuzzyworld"` block of its `package.json`, e.g. `"replay": "replay.html?report={record}"`), a visible issue with a record shows "Watch what happened": a plain link to `https://apps.fuzzyworld.net/<app>/<replay>` with `{record}` replaced by the URL-encoded address of `record.json`. The app named like the repo is preferred, so a `-next` copy is not chosen. It is a link, not an iframe (the site's CSP blocks framing). No `replay` key, no link.
+
+**State** is in `~/fw/issues/` (override with `FW_ISSUES_DIR`), outside every web root: `repos/<repo>/<n>/issue.json` and `record.json`, `secret` (salt for sender hashes; mode 600), `msgids.json` (threading), `keys.json` (a mail is never stored twice), `rejected.log`, the locks. Mail source: `~/fw/issues/imap.json` (mode 600, the same format as the comments one; finished mail goes to `issues-done` / `issues-bad`) or, without it, the Maildir `~/Maildir/.bugs` (`.bugs.done`, `.bugs.bad`). **The bugs mailbox needs its own account or its own `folder`** in `imap.json`: ingest and `imap-check` refuse to run when it has the same host, user and folder as the comments `imap.json` (the two jobs would move each other's mail to their bad folders). Ingest also refuses to run until `authserv_id` is set in `server/comments/sites.json`. The hourly job is `issues-ingest.sh`.
+
 ## Blog
 
 A generic static-blog generator: a folder of Markdown files and one JSON config become a site (`server/blog.py`).
diff --git a/bin/fw b/bin/fw
index 1e24f8a..2b64227 100755
--- a/bin/fw
+++ b/bin/fw
@@ -14,6 +14,7 @@
 #   fw blog watch install|uninstall <config> [--print]  the launchd agent that runs it every 60 s (ONE machine only)
 #   fw describe <name> TEXT | publish <name> | publish-all | ci <name> [branch] | ci-list | ci-watch
 #   fw comments list|show|approve|reject|remove|untrust|publish|export|ingest|imap-check (blog comments)
+#   fw issues <repo> public|reviewed|private | list|show|approve|hide|close|reopen|publish|publish-all|ingest|imap-check (bug-report issues)
 #                                                     (run on DreamHost over ssh)
 set -euo pipefail
 HOST=${FW_HOST:-dh}
@@ -86,7 +87,7 @@ case "${1:-help}" in
     shift; exec python3 "$HERE/lib/usage.py" "$@" ;;
   repoint)
     shift; exec bash "$HERE/lib/repoint.sh" "$@" ;;
-  list|new|public|private|describe|publish|publish-all|ci|ci-list|ci-watch|comments|help|-h|--help)
+  list|new|public|private|describe|publish|publish-all|ci|ci-list|ci-watch|comments|issues|help|-h|--help)
     # printf %q keeps arguments (e.g. a description with spaces) intact through ssh.
     exec ssh "$HOST" "~/bin/fw $(printf '%q ' "$@")" ;;
   *)
diff --git a/lib/deploy.mjs b/lib/deploy.mjs
index f9e455f..c0e43e5 100644
--- a/lib/deploy.mjs
+++ b/lib/deploy.mjs
@@ -17,11 +17,14 @@
 //     "build": "make public",      // build command (default: `<pm> run build`)
 //     "out": "dist",               // output directory (default: dist, else public, else build)
 //     "keep": ["app.apk"]          // files already on the server that a deploy must not delete or replace
+//     "extra": ["sim/*.js"],       // build-output files that every later deploy must keep (uploaded, updated, never deleted)
+//     "replay": "replay.html?report={record}"   // viewer for bug-report records: an issue page links here (page of this app, relative)
 //   }
 import { spawnSync, execFileSync } from 'node:child_process';
 import fs from 'node:fs';
 import os from 'node:os';
 import path from 'node:path';
+import { fileURLToPath } from 'node:url';
 
 const HOST = process.env.FW_HOST || 'dh';
 const APPS_URL = 'https://apps.fuzzyworld.net';
@@ -65,7 +68,31 @@ function parseArgs(argv) {
   return o;
 }
 
-function readConfig(dir) {
+// A relative path or glob inside the app folder: no leading slash, no "..", no empty segments.
+function checkPath(k, key, file) {
+  if (typeof k !== 'string' || !k || k.startsWith('/') || k.split('/').some((p) => p === '..' || p === '')) {
+    die(`invalid fuzzyworld.${key} entry ${JSON.stringify(k)} in ${file}`);
+  }
+}
+
+// The replay template becomes a link on issue pages, so it must stay a page of this app.
+export function checkReplay(t, file = 'package.json') {
+  const head = typeof t === 'string' ? t.split('?')[0] : '';
+  if (typeof t !== 'string' || t.length > 200 || !t.includes('{record}') || t.startsWith('/') || t.includes('//') ||
+      t.includes('..') || t.includes('\\') || head.includes(':') || head.includes('%') || /[\s\x00-\x1f\x7f]/.test(head)) {
+    die(`invalid fuzzyworld.replay ${JSON.stringify(t)} in ${file} (a relative page of the app, containing {record})`);
+  }
+}
+
+// `keep` paths are excluded: rsync neither uploads nor deletes them. `extra` paths get a "protect" filter:
+// they are uploaded and updated as usual, but --delete never removes them (old rules versions stay).
+export function rsyncArgs(cfg, out, dest) {
+  const keep = (cfg.keep || []).flatMap((k) => ['--exclude', `/${k}`]);
+  const extra = (cfg.extra || []).flatMap((g) => ['--filter', `P /${g}`]);
+  return ['-rlptz', '--delete', ...keep, ...extra, `${out}/`, dest];
+}
+
+export function readConfig(dir) {
   const file = path.join(dir, 'package.json');
   if (!fs.existsSync(file)) die(`no package.json in ${dir}`);
   const pkg = JSON.parse(fs.readFileSync(file, 'utf8'));
@@ -73,11 +100,12 @@ function readConfig(dir) {
   if (!cfg) die(`no "fuzzyworld" section in ${file}`);
   if (!cfg.app || !NAME_RE.test(cfg.app)) die(`invalid or missing fuzzyworld.app in ${file}`);
   if (cfg.repo && !NAME_RE.test(cfg.repo)) die(`invalid fuzzyworld.repo in ${file}`);
-  for (const k of cfg.keep || []) {
-    if (typeof k !== 'string' || !k || k.startsWith('/') || k.split('/').some((p) => p === '..' || p === '')) {
-      die(`invalid fuzzyworld.keep entry ${JSON.stringify(k)} in ${file}`);
-    }
+  for (const k of cfg.keep || []) checkPath(k, 'keep', file);
+  for (const g of cfg.extra || []) {
+    checkPath(g, 'extra', file);
+    if ((cfg.keep || []).includes(g)) die(`fuzzyworld.extra entry ${JSON.stringify(g)} is also in keep (keep files are never uploaded)`);
   }
+  if (cfg.replay !== undefined) checkReplay(cfg.replay, file);
   return { pkg, cfg };
 }
 
@@ -225,19 +253,18 @@ export function deploy(argv) {
     console.log('  ✓ all URLs, manifest and service worker stay inside', `/${app}/`);
 
     fs.writeFileSync(path.join(out, '.fuzzyworld.json'), JSON.stringify({
-      app, repo, title: (cfg.title || cfg.app) + (o.as ? ` (${o.as.startsWith(cfg.app + '-') ? o.as.slice(cfg.app.length + 1) : o.as})` : ''), description: cfg.description || '',
+      app, repo, title: (cfg.title || cfg.app) + (o.as ? ` (${o.as.startsWith(cfg.app + '-') ? o.as.slice(cfg.app.length + 1) : o.as})` : ''), description: cfg.description || '', replay: cfg.replay || '',
       commit, ref: o.here ? '(working copy)' : o.ref, deployed: new Date().toISOString(),
     }, null, 2));
 
     if (o.dryRun) {
       console.log(`\n(dry run) would upload ${out}/ to ${HOST}:apps.fuzzyworld.net/${app}/`);
+      if (cfg.extra?.length) console.log(`(dry run) kept across deploys: ${cfg.extra.join(', ')}`);
       return;
     }
     step(`uploading to apps.fuzzyworld.net/${app}/`);
     run('ssh', [HOST, `mkdir -p apps.fuzzyworld.net/${app}`]);
-    // `keep` paths are excluded, so rsync --delete neither removes nor overwrites them.
-    const keep = (cfg.keep || []).flatMap((k) => ['--exclude', `/${k}`]);
-    run('rsync', ['-rlptz', '--delete', ...keep, `${out}/`, `${HOST}:apps.fuzzyworld.net/${app}/`]);
+    run('rsync', rsyncArgs(cfg, out, `${HOST}:apps.fuzzyworld.net/${app}/`));
     run('ssh', [HOST, `~/bin/fw app-deployed ${app} ${repo}`]);
     console.log(`\n✅ ${APPS_URL}/${app}/  (commit ${commit.slice(0, 12)})`);
   } finally {
@@ -245,10 +272,12 @@ export function deploy(argv) {
   }
 }
 
-try {
-  deploy(process.argv.slice(2));
-} catch (e) {
-  if (!(e instanceof DeployError)) throw e;
-  console.error(`\nfw deploy: ${e.message}`);
-  process.exit(1);
+if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
+  try {
+    deploy(process.argv.slice(2));
+  } catch (e) {
+    if (!(e instanceof DeployError)) throw e;
+    console.error(`\nfw deploy: ${e.message}`);
+    process.exit(1);
+  }
 }
diff --git a/server/comments.py b/server/comments.py
index 0946b1a..e60b335 100644
--- a/server/comments.py
+++ b/server/comments.py
@@ -659,8 +659,8 @@ def imap_config_path():
     return os.path.expanduser(os.environ.get("FW_COMMENTS_IMAP") or os.path.join(state_dir(), "imap.json"))
 
 
-def load_imap_config():
-    path = imap_config_path()
+def load_imap_config(path=None):
+    path = path or imap_config_path()
     mode = os.stat(path).st_mode
     if mode & 0o077:
         raise CommentError(f"{path} must not be readable by group or others (chmod 600 it)")
@@ -689,16 +689,21 @@ IMAP_NAME_RE = re.compile(r"^[A-Za-z0-9._/-]+$")
 
 
 class IMAPSource:
-    """The comments mailbox on an IMAP server. Finished mail is copied to comments-done / comments-bad."""
-
-    def __init__(self):
+    """The comments mailbox on an IMAP server. Finished mail is copied to comments-done / comments-bad.
+    issues.py reuses this class for the bugs mailbox: its own config file (cfg_path), folder stem, size limit and logger."""
+
+    def __init__(self, cfg_path=None, stem="comments", max_size=IMAP_MAX_SIZE, log=None):
+        self.cfg_path = cfg_path
+        self.stem = stem
+        self.max_size = max_size
+        self.log = log or log_rejection
         self.m = None
         self.cfg = None
         self.validity = ""
         self.caps = set()
 
     def available(self):
-        return os.path.exists(imap_config_path())
+        return os.path.exists(self.cfg_path or imap_config_path())
 
     def _redact(self, text):
         pw = self.cfg["password"] if self.cfg else ""
@@ -720,7 +725,7 @@ class IMAPSource:
         return data
 
     def _open(self, readonly=False):
-        self.cfg = load_imap_config()
+        self.cfg = load_imap_config(self.cfg_path)
         try:
             self.m = imaplib.IMAP4_SSL(self.cfg["host"], 993, ssl_context=ssl.create_default_context(), timeout=IMAP_TIMEOUT)
             self.m.login(self.cfg["user"], self.cfg["password"])
@@ -787,7 +792,7 @@ class IMAPSource:
             self.prefix = ""
 
     def _folder(self, outcome):
-        return self.cfg[outcome + "_folder"] or f"{self.prefix}comments-{outcome}"
+        return self.cfg[outcome + "_folder"] or f"{self.prefix}{self.stem}-{outcome}"
 
     def close(self):
         if self.m is not None:
@@ -818,14 +823,14 @@ class IMAPSource:
         sizes = self._sizes(uids)
         keys = []
         for uid in uids:
-            if sizes.get(uid, 0) > IMAP_MAX_SIZE:   # never downloaded
+            if sizes.get(uid, 0) > self.max_size:   # never downloaded
                 try:
                     self._move(uid, "bad")
                 except (CommentError, OSError, imaplib.IMAP4.error) as e:   # leave it, keep going
-                    log_rejection(new_id(), f"cannot move oversize message {uid}: {e}")
+                    self.log(new_id(), f"cannot move oversize message {uid}: {e}")
                     continue
-                log_rejection(new_id(), f"message over {IMAP_MAX_SIZE // 1024} KB")
-                print(f"bad: message over {IMAP_MAX_SIZE // 1024} KB")
+                self.log(new_id(), f"message over {self.max_size // 1024} KB")
+                print(f"bad: message over {self.max_size // 1024} KB")
             else:
                 keys.append(f"{self.validity}:{uid}")
         return keys
diff --git a/server/fw b/server/fw
index 4a5cdc7..7f0332e 100755
--- a/server/fw
+++ b/server/fw
@@ -20,6 +20,8 @@
 #   fw ci-pages <name>              rebuild a repo's CI pages and the dashboard (runner-api runs this)
 #   fw ci-dashboard                 rebuild code.fuzzyworld.net/ci/
 #   fw comments <cmd>               moderate blog comments (list, show, approve, reject, ... ingest, imap-check)
+#   fw issues <repo> public|reviewed|private   who sees a repo's bug-report issue pages (default private)
+#   fw issues <cmd>                 moderate issues (list, show, approve, hide, close, reopen, publish, publish-all, ingest, imap-check)
 #
 # Visibility is `git config fuzzyworld.visibility` inside each repo. Anything other
 # than exactly "public" is treated as private.
@@ -82,6 +84,8 @@ publish_one() {
   fi
   git -C "$pub" symbolic-ref HEAD "$(git -C "$src" symbolic-ref HEAD)"
   git -C "$pub" update-server-info
+  # Issue pages (bug reports) live in the repo's folder; rebuilt under the lock we already hold.
+  python3 "$TOOLS/server/issues.py" publish "$name" --locked || echo "fw: issues publish failed for $name" >&2
   "${GEN[@]}" repo "$name"
 }
 
@@ -215,6 +219,20 @@ cmd_ci_list() {
   done
 }
 
+cmd_issues() {
+  case "${1:-}" in
+    ingest|imap-check|list|show|approve|hide|close|reopen|publish|publish-all|help|-h|--help|"")
+      python3 "$TOOLS/server/issues.py" "$@" ;;
+    *)
+      # fw issues <repo> <mode>
+      [ $# -eq 2 ] && [[ "$2" =~ ^(public|reviewed|private)$ ]] || die "usage: fw issues <repo> public|reviewed|private (or fw issues help)"
+      need_repo "$1"
+      git -C "$(repo_dir "$1")" config fuzzyworld.issues "$2"
+      python3 "$TOOLS/server/issues.py" publish "$1"
+      echo "$1: issues $2" ;;
+  esac
+}
+
 cmd_ci_watch() {
   local last="" now
   while true; do
@@ -247,6 +265,7 @@ case "${1:-}" in
   ci-pages) need_repo "${2:-}"; with_lock "$2" "${GEN[@]}" ci "$2" ;;
   ci-dashboard) "${GEN[@]}" ci-dashboard ;;
   comments) shift; python3 "$TOOLS/server/comments.py" "$@" ;;
-  ""|-h|--help|help) sed -n '2,24p' "$0" | sed 's/^# \{0,1\}//' ;;
+  issues) shift; cmd_issues "$@" ;;
+  ""|-h|--help|help) sed -n '2,27p' "$0" | sed 's/^# \{0,1\}//' ;;
   *) die "unknown command: $1 (try: fw help)" ;;
 esac
diff --git a/server/gen.py b/server/gen.py
index e6e3789..ce6d106 100755
--- a/server/gen.py
+++ b/server/gen.py
@@ -1229,7 +1229,9 @@ def build_summary(repo, view, desc, files, ctx, commits, empty, commit_set):
     links = f'<p class="meta">App: <a href="{app}">{esc(app)}</a></p>' if app else ""
     contrib, bugs = f"contrib-68uq+{name}@fuzzyworld.net", f"bugs-k7q4+{name}@fuzzyworld.net"
     links += (f'<p class="meta">Contribute: <a href="mailto:{esc(contrib)}">{esc(contrib)}</a>'
-              f' (<a href="/contribute/">how</a>) · Report a bug: <a href="mailto:{esc(bugs)}">{esc(bugs)}</a></p>')
+              f' (<a href="/contribute/">how</a>) · Report a bug: <a href="mailto:{esc(bugs)}">{esc(bugs)}</a>'
+              + (f' · <a href="{url(name, "issues")}/">Issues</a>' if os.path.isfile(os.path.join(SITE, name, "issues", "index.html")) else "")
+              + "</p>")
     readme_html, license_html = "", ""
     if not empty:
         blobs = BlobReader(repo)
diff --git a/server/install.sh b/server/install.sh
index 4b03510..881fe41 100755
--- a/server/install.sh
+++ b/server/install.sh
@@ -18,7 +18,7 @@ git config --global pack.threads 1
 git config --global pack.windowMemory 64m
 git config --global pack.deltaCacheSize 16m
 git config --global core.bigFileThreshold 16m
-mkdir -p ~/bin ~/fw/state ~/fw/logs ~/fw/queue/pending ~/fw/queue/running ~/fw/ci
+mkdir -p ~/bin ~/fw/state ~/fw/logs ~/fw/queue/pending ~/fw/queue/running ~/fw/ci ~/fw/issues
 chmod +x ~/fw/tools/server/runner-api ~/fw/tools/server/mirror-only
 # Pygments colors source code on the file pages (gen.py works without it, uncolored).
 # Python-Markdown renders the README pages and the landing page text (`gen.py landing` needs it).
diff --git a/server/issues.py b/server/issues.py
new file mode 100644
index 0000000..97ee4be
--- /dev/null
+++ b/server/issues.py
@@ -0,0 +1,896 @@
+#!/usr/bin/env python3
+"""Bug reports by email become issue pages on code.fuzzyworld.net. Python standard library only.
+
+Usage (on the server: `fw issues <cmd>`; from the laptop: `bin/fw issues <cmd>`):
+
+  list [repo]                   issues (repo, number, state, whether the page is public, title)
+  show <repo> <n>               one issue in full (no addresses are stored, so none are shown)
+  approve <repo> <n>            mark the issue public (what `reviewed` mode waits for)
+  hide <repo> <n>               take the issue off the site and keep it off (even in `public` mode)
+  close <repo> <n> / reopen <repo> <n>
+  publish <repo>                rewrite <repo>/issues/ on the site from the stored issues (--locked: the caller holds the repo lock)
+  publish-all                   the same for every repo that has issues
+  ingest                        read new mail from the bugs mailbox (IMAP if imap.json exists, else Maildir)
+  imap-check                    connect to the IMAP mailbox and print what it holds (header names only)
+  fw issues <repo> public|reviewed|private    set the repo's mode (`git config fuzzyworld.issues`; anything else = private)
+
+Modes. `private` (also the default and any unknown word): issues are stored, never published. `reviewed`: an issue
+page appears only after `approve`. `public`: an issue whose first message passed authentication appears at once.
+A private repo (fuzzyworld.visibility) never publishes issues, whatever its mode. `hide` beats both modes.
+
+Mail arrives at bugs-k7q4+<repo>@fuzzyworld.net; <repo> must be a repo that exists under ~/git, else the mail goes to
+the bad folder. A reply (In-Reply-To / References that match an earlier Message-ID) is added to its issue.
+
+State, outside every web root (override the directory with FW_ISSUES_DIR):
+  ~/fw/issues/imap.json         IMAP login (mode 600), same format as comments' imap.json; finished mail goes to
+                                <prefix>issues-done / <prefix>issues-bad. Without it: Maildir ~/Maildir/.bugs
+                                (override with FW_ISSUES_MAILDIR), with .bugs.done / .bugs.bad beside it.
+  ~/fw/issues/secret            random salt for sender hashes (mode 600)
+  ~/fw/issues/msgids.json       sha256(Message-ID) -> [repo, n, message_index], so replies find their issue; never published
+  ~/fw/issues/keys.json         sha256(raw mail) -> [repo, n, message_index], so a mail is never stored twice; an entry
+                                counts only if that message holds the same mkey (crash recovery)
+  ~/fw/issues/rejected.log      "id date reason" per rejected mail (rotated at 1 MB)
+  ~/fw/issues/lock, ingest.lock held for every change to the store / for a whole ingest run
+  ~/fw/issues/repos/<repo>/<n>/issue.json   {n, repo, title, created, state, approved, hidden, record, record_msg,
+                                messages: [{date, author, text, sender_hash, authenticated, approved, mkey, mid}]};
+                                numbers count up per repo; record_msg is the message that carried the record
+  ~/fw/issues/repos/<repo>/<n>/record.json  the run record, only when one passed the checks (see below)
+Published (when visible): <site>/<repo>/issues/index.html and <site>/<repo>/issues/<n>/index.html, plus
+<n>/record.json. The whole issues/ folder is rebuilt on every publish, so a hidden issue disappears.
+
+Privacy. No address, name, Message-ID or sender hash is ever written to a page: the author is always "Reporter",
+every address-shaped string in the title and text is replaced by "[address hidden]", lines starting "Device:" or
+"Run record:" are left out of the text, and all text is HTML-escaped inside <pre class="issue-text">.
+Authentication is the same as for comments (comments.authenticated, topmost Authentication-Results from the
+"authserv_id" in sites.json). Unauthenticated mail is stored with authenticated:false and never shown by `public`
+mode alone; it needs `approve`. Each sender may start or continue at most 5 issues per 24 hours in a `public` or `reviewed` repo.
+
+Run records. A line "Run record: {json}" in the text part is read from the FULL decoded part (before the text is cut
+to 4000 characters), because a record is ~9 KB. The first line that passes the checks wins (a later "Run record:
+too long for an email link" footer is ignored): at most 64 KB, valid JSON, an object with integer seed and
+simVersion and a list "actions". It is stored re-serialized (never as mail text). Only the first record of an issue is
+kept. A record from an unauthenticated reply is ignored. record.json is published only while the issue is visible.
+Only the keys simVersion, seed, startLevel, startCharge, options, actions are kept, and a record with an address-like
+string is refused. `approve` marks the messages present at that time; later ones need another approve (or, in `public`
+mode, an authenticated sender). The bugs mailbox must not be the comments mailbox (same host, user and folder is refused).
+The issue page links to the app's replay viewer when the app's .fuzzyworld.json has a `replay` template.
+"""
+
+import hashlib
+import json
+import os
+import quopri
+import re
+import secrets
+import shutil
+import subprocess
+import sys
+import time
+import unicodedata
+from contextlib import contextmanager
+from email import message_from_bytes, policy
+from email.header import decode_header, make_header
+from email.utils import getaddresses
+from urllib.parse import quote
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+import comments   # noqa: E402
+import gen        # noqa: E402
+
+try:
+    import fcntl
+except ImportError:  # not on Windows servers; only the lock is lost
+    fcntl = None
+
+Error = comments.CommentError
+REPO_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
+BUGS_RE = re.compile(r"^bugs-k7q4\+([A-Za-z0-9][A-Za-z0-9._-]*)@fuzzyworld\.net$", re.I)
+ADDRESS_RE = re.compile(r"[^\s@<>]+@[^\s@<>]+")
+RECORD_RE = re.compile(r"^Run record: (\{.*\})\s*$", re.M)
+RECORD_MAX = 64 * 1024
+RECORD_DEPTH = 32
+RECORD_KEYS = ("simVersion", "seed", "startLevel", "startCharge", "options", "actions")   # every key of the real records; the rest is dropped
+MAX_MAIL = 5 * 1024 * 1024
+MAX_TITLE = 120
+SENDER_LIMIT = 5   # issues or replies per sender per SENDER_WINDOW seconds, in `public` and `reviewed` repos
+SENDER_WINDOW = 24 * 3600
+MODES = ("public", "reviewed", "private")
+HIDDEN = "[address hidden]"
+
+
+# ---- configuration and paths ----
+
+def state_dir():
+    return os.path.expanduser(os.environ.get("FW_ISSUES_DIR") or "~/fw/issues")
+
+
+def imap_path():
+    return os.path.join(state_dir(), "imap.json")
+
+
+def maildir_path():
+    return os.path.expanduser(os.environ.get("FW_ISSUES_MAILDIR") or "~/Maildir/.bugs")
+
+
+def check_repo(repo):
+    if not (isinstance(repo, str) and REPO_RE.match(repo) and not repo.endswith(".git")):
+        raise Error(f"invalid repo name: {repo!r}")
+    return repo
+
+
+def check_n(n):
+    if not (isinstance(n, str) and n.isascii() and n.isdigit() and 0 < int(n) < 10**9):
+        raise Error(f"invalid issue number: {n!r}")
+    return int(n)
+
+
+def repo_dir(repo):
+    return os.path.join(gen.GIT_ROOT, check_repo(repo) + ".git")
+
+
+def issue_dir(repo, n):
+    return os.path.join(state_dir(), "repos", check_repo(repo), str(n))
+
+
+def log_rejection(cid, reason):
+    os.makedirs(state_dir(), exist_ok=True)
+    reason = comments.clean(reason).replace("\n", " ")
+    log = os.path.join(state_dir(), "rejected.log")
+    try:
+        if os.path.getsize(log) > 1_000_000:
+            os.replace(log, log + ".1")
+    except FileNotFoundError:
+        pass
+    with open(log, "a") as f:
+        f.write(f"{cid} {comments.now_iso()} {reason}\n")
+
+
+@contextmanager
+def _lock(name):
+    os.makedirs(state_dir(), exist_ok=True)
+    with open(os.path.join(state_dir(), name), "w") as f:
+        if fcntl:
+            fcntl.flock(f, fcntl.LOCK_EX)
+        yield
+
+
+def locked():
+    return _lock("lock")
+
+
+def ingest_lock():
+    """Job-wide: one ingest at a time (a second one waits)."""
+    return _lock("ingest.lock")
+
+
+@contextmanager
+def repo_lock(repo):
+    """The lock `fw` takes around publishing one repo (STATE/locks/<repo>.lock)."""
+    d = os.path.join(gen.STATE, "locks")
+    os.makedirs(d, exist_ok=True)
+    with open(os.path.join(d, check_repo(repo) + ".lock"), "w") as f:
+        if fcntl:
+            fcntl.flock(f, fcntl.LOCK_EX)
+        yield
+
+
+def secret():
+    path = os.path.join(state_dir(), "secret")
+    try:
+        with open(path) as f:
+            value = f.read().strip()
+    except FileNotFoundError:
+        value = ""
+    if not value:
+        value = secrets.token_hex(32)
+        comments.write_atomic(path, value + "\n")
+    return value
+
+
+def sender_hash(address):
+    return hashlib.sha256((secret() + address.strip().lower()).encode()).hexdigest()
+
+
+def sha(text):
+    return hashlib.sha256(text.encode() if isinstance(text, str) else text).hexdigest()
+
+
+# ---- store ----
+
+def issue_numbers(repo):
+    d = os.path.join(state_dir(), "repos", check_repo(repo))
+    # a folder without issue.json is the leftover of a crash (a record written before the issue): not an issue
+    return sorted(int(x) for x in os.listdir(d) if x.isdigit() and os.path.isfile(os.path.join(d, x, "issue.json"))) if os.path.isdir(d) else []
+
+
+def stored_repos():
+    d = os.path.join(state_dir(), "repos")
+    return sorted(x for x in os.listdir(d) if REPO_RE.match(x)) if os.path.isdir(d) else []
+
+
+def load_issue(repo, n):
+    issue = comments.read_json(os.path.join(issue_dir(repo, n), "issue.json"), None)
+    if issue is None:
+        raise Error(f"no issue {repo}#{n}")
+    return issue
+
+
+def save_issue(issue):
+    comments.write_json(os.path.join(issue_dir(issue["repo"], issue["n"]), "issue.json"), issue)
+
+
+def load_issues(repo):
+    out = []
+    for n in issue_numbers(repo):
+        issue = comments.read_json(os.path.join(issue_dir(repo, n), "issue.json"), None)
+        if issue:
+            out.append(issue)
+    return out
+
+
+def record_path(repo, n):
+    return os.path.join(issue_dir(repo, n), "record.json")
+
+
+def recent_index():
+    """sender hash -> dates of that sender's stored messages (read once per run)."""
+    index = {}
+    for repo in stored_repos():
+        for issue in load_issues(repo):
+            for m in issue["messages"]:
+                if m.get("sender_hash"):
+                    index.setdefault(m["sender_hash"], []).append(m.get("date", ""))
+    return index
+
+
+# ---- visibility and publishing ----
+
+def issue_mode(repo_path):
+    """public | reviewed | private; anything else (a typo, an empty value) is private."""
+    mode = gen.repo_config(repo_path, "fuzzyworld.issues")
+    return mode if mode in ("public", "reviewed") else "private"
+
+
+def visible(repo_path, issue, mode=None):
+    mode = mode or issue_mode(repo_path)
+    if mode == "private" or not gen.is_public(repo_path) or issue.get("hidden"):
+        return False
+    first = issue["messages"][0] if issue.get("messages") else {}
+    return bool(issue.get("approved")) or (mode == "public" and bool(first.get("authenticated")))
+
+
+def replay_ok(t):
+    """The same rule as checkReplay in lib/deploy.mjs: a relative page of the app, containing {record}."""
+    head = t.split("?")[0] if isinstance(t, str) else ""
+    return (isinstance(t, str) and not re.search(r"[%\s\x00-\x1f\x7f]", head) and len(t) <= 200 and "{record}" in t and not t.startswith("/") and "//" not in t
+            and ".." not in t and "\\" not in t and ":" not in head)
+
+
+def replay_link(repo, n):
+    """An <a> to the repo's app replaying issue n's record, or "" when no deployed app has a `replay` template."""
+    found = []
+    try:
+        entries = sorted(os.listdir(gen.APPS))
+    except OSError:
+        return ""
+    for app in entries:
+        try:
+            with open(os.path.join(gen.APPS, app, ".fuzzyworld.json")) as f:
+                meta = json.load(f)
+        except (OSError, ValueError):
+            continue
+        if (isinstance(meta, dict) and meta.get("repo", app) == repo and replay_ok(meta.get("replay"))
+                and os.path.isfile(os.path.join(gen.APPS, app, "index.html"))):
+            found.append((app != repo, len(app), app, meta["replay"]))   # the app named like the repo first (not a -next copy)
+    if not found:
+        return ""
+    _, _, app, replay = min(found)
+    record_url = f"{gen.BASE_URL}/{repo}/issues/{n}/record.json"
+    href = f"{gen.APPS_URL}/{quote(app)}/" + replay.replace("{record}", quote(record_url, safe=""))
+    return f'<a href="{gen.esc(href)}" rel="noopener">Watch what happened</a>'
+
+
+def shown_messages(issue, mode):
+    """Messages `approve` has marked, plus (in public mode only) authenticated ones. A message that arrives after
+    an approve is not marked, so it needs another approve unless the repo is public and its sender authenticated."""
+    return [m for m in issue["messages"] if m.get("approved") or (mode == "public" and m.get("authenticated"))]
+
+
+def record_shown(issue, mode):
+    """A run record is as public as the message that carried it (issue["record_msg"], 0 for the opening report)."""
+    if not issue.get("record"):
+        return False
+    i = issue.get("record_msg", 0)
+    return 0 <= i < len(issue["messages"]) and issue["messages"][i] in shown_messages(issue, mode)
+
+
+def issue_page(repo, issue, with_record, mode):
+    n = issue["n"]
+    parts = [f'<h1>#{n} {gen.esc(issue["title"])}</h1>']
+    meta = f'Opened {gen.esc(issue["created"][:10])} · {gen.esc(issue["state"])}'
+    if with_record:
+        link = replay_link(repo, n)
+        meta += (" · " + link if link else "")
+    parts.append(f'<p class="meta">{meta}</p>')
+    for m in shown_messages(issue, mode):
+        parts.append(f'<p class="meta">{gen.esc(m["author"])} · {gen.esc(m["date"][:10])}</p>'
+                     f'<pre class="issue-text">{gen.esc(m["text"])}</pre>')
+    crumbs = [f'<a href="{gen.url(repo)}/">{gen.esc(repo)}</a>', f'<a href="{gen.url(repo, "issues")}/">Issues</a>', f"#{n}"]
+    return gen.page(f"{repo} issue #{n}", "".join(parts), crumbs=crumbs)
+
+
+def index_page(repo, issues):
+    rows = "".join(
+        f'<tr><td class="num"><a href="{gen.url(repo, "issues", str(i["n"]))}/">#{i["n"]}</a></td>'
+        f'<td>{gen.esc(i["title"])}</td><td class="date">{gen.esc(i["created"][:10])}</td><td>{gen.esc(i["state"])}</td></tr>'
+        for i in sorted(issues, key=lambda i: -i["n"]))
+    body = f'<h1>{gen.esc(repo)} issues</h1><table><tr><th>#</th><th>Title</th><th>Opened</th><th>State</th></tr>{rows}</table>'
+    return gen.page(f"{repo} issues", body, crumbs=[f'<a href="{gen.url(repo)}/">{gen.esc(repo)}</a>', "Issues"])
+
+
+def refresh_summary(repo):
+    """Rebuild the repo's pages so the summary page shows (or drops) its Issues link."""
+    subprocess.run([sys.executable, os.path.join(os.path.dirname(os.path.abspath(__file__)), "gen.py"), "repo", repo],
+                   check=False)
+
+
+def write_site(repo):
+    """Rebuild <site>/<repo>/issues/ from the store. Returns (whether index.html existed before, whether it does now)."""
+    path = repo_dir(repo)
+    with locked():
+        issues = load_issues(repo)
+    site_repo = os.path.join(gen.SITE, repo)
+    final = os.path.join(site_repo, "issues")
+    had = os.path.isfile(os.path.join(final, "index.html"))
+    mode = issue_mode(path) if os.path.isdir(path) else "private"
+    shown = [i for i in issues if os.path.isdir(path) and visible(path, i, mode)]
+    if not shown:
+        shutil.rmtree(final, ignore_errors=True)
+        return had, False
+    tmp = os.path.join(site_repo, ".issues-new")
+    shutil.rmtree(tmp, ignore_errors=True)
+    for i in shown:
+        has_record = record_shown(i, mode) and os.path.isfile(record_path(repo, i["n"]))
+        gen.write(os.path.join(tmp, str(i["n"]), "index.html"), issue_page(repo, i, has_record, mode))
+        if has_record:
+            with open(record_path(repo, i["n"])) as f:
+                gen.write(os.path.join(tmp, str(i["n"]), "record.json"), f.read())
+    gen.write(os.path.join(tmp, "index.html"), index_page(repo, shown))
+    shutil.rmtree(final, ignore_errors=True)
+    os.replace(tmp, final)
+    return had, True
+
+
+def publish(repo, caller_holds_lock=False):
+    """Rewrite the repo's issue pages. `fw publish` takes the repo lock itself, so it passes caller_holds_lock
+    (and runs gen.py repo afterwards); otherwise this takes the lock and refreshes the summary link when needed."""
+    check_repo(repo)
+    if caller_holds_lock:
+        write_site(repo)
+        return
+    with repo_lock(repo):
+        had, has = write_site(repo)
+        if had != has and gen.is_public(repo_dir(repo)):
+            refresh_summary(repo)
+
+
+# ---- email intake ----
+
+def scrub(s):
+    return ADDRESS_RE.sub(HIDDEN, s)
+
+
+def repo_from_mail(msg):
+    """(repo, None) from the bugs-k7q4+<repo>@fuzzyworld.net address, else (None, reason)."""
+    values = [str(v) for h in ("To", "Cc", "Delivered-To", "X-Original-To") for v in (msg.get_all(h) or [])]
+    names = {m.group(1) for _, a in getaddresses(values) if (m := BUGS_RE.match(a.strip()))}
+    if not names:
+        return None, "no bugs-k7q4+<repo>@ address in To, Cc, Delivered-To or X-Original-To"
+    if len(names) > 1:
+        return None, "mail addressed to more than one repo"
+    name = names.pop()
+    if name.endswith(".git") or not os.path.isdir(repo_dir(name)):
+        return None, f"no such repo {name[:60]!r}"
+    return name, None
+
+
+def message_ids(value):
+    return re.findall(r"<[^<>\s]+>", str(value or ""))
+
+
+def thread_of(msg, repo=None):
+    """(repo, n) of the issue this message replies to (via In-Reply-To / References), else None."""
+    ids = message_ids(msg.get("In-Reply-To")) + message_ids(msg.get("References"))[::-1]
+    known = comments.read_json(os.path.join(state_dir(), "msgids.json"), {})
+    for mid in ids:
+        hit = known.get(sha(mid))
+        if hit and (repo is None or hit[0] == repo) and os.path.isfile(os.path.join(issue_dir(hit[0], hit[1]), "issue.json")):
+            return hit[0], hit[1]
+    return None
+
+
+def strings_in(value):
+    """Every string (and dict key) inside a JSON value, iteratively."""
+    todo = [value]
+    while todo:
+        v = todo.pop()
+        if isinstance(v, str):
+            yield v
+        elif isinstance(v, dict):
+            todo.extend(v.keys())
+            todo.extend(v.values())
+        elif isinstance(v, list):
+            todo.extend(v)
+
+
+def check_record(s):
+    """(record dict, None) or (None, reason). The checks the replay viewer relies on. Only RECORD_KEYS survive."""
+    if len(s.encode("utf-8", "replace")) > RECORD_MAX:
+        return None, f"run record over {RECORD_MAX // 1024} KB"
+
+    def refuse(name):   # NaN and Infinity are not JSON, and the viewer would choke on them
+        raise ValueError(name)
+
+    def number(text):   # 1e400 reads as infinity
+        x = float(text)
+        if x != x or x in (float("inf"), float("-inf")):
+            raise ValueError(text)
+        return x
+    try:
+        rec = json.loads(s, parse_constant=refuse, parse_float=number)
+    except (ValueError, RecursionError):
+        return None, "run record is not valid JSON"
+    if not isinstance(rec, dict):
+        return None, "run record is not an object"
+    depth, level = 0, [rec]
+    while level:   # iterative, so a deeply nested record cannot exhaust the stack (here or in the viewer)
+        depth += 1
+        if depth > RECORD_DEPTH:
+            return None, f"run record nested deeper than {RECORD_DEPTH}"
+        level = [c for v in level for c in (v.values() if isinstance(v, dict) else v if isinstance(v, list) else ())]
+    rec = {k: rec[k] for k in RECORD_KEYS if k in rec}
+    for key in ("seed", "simVersion"):
+        v = rec.get(key)
+        if not isinstance(v, int) or isinstance(v, bool) or abs(v) > 2**53:
+            return None, f"run record needs an integer {key}"
+    if not isinstance(rec.get("actions"), list):
+        return None, "run record needs a list of actions"
+    if any(ADDRESS_RE.search(unicodedata.normalize("NFKC", t)) for t in strings_in(rec)):
+        return None, "run record holds an address-like string"
+    return rec, None
+
+
+def find_record(text, raw=b""):
+    """(record, None), or (None, "") when the text holds no "Run record:" line, or (None, reason) when none passes.
+    Every matching line is tried and the first valid one wins: the mail repeats the label in a footer."""
+    why = ""
+    sources = [text]
+    if b"quoted-printable" in raw.lower():   # fallback: decode the whole raw mail (the rule of Explodorama's report-replay.mjs)
+        sources.append(quopri.decodestring(raw).decode("utf-8", "replace"))
+    for src in sources:
+        for m in RECORD_RE.finditer(src.replace("\r\n", "\n")):
+            rec, why = check_record(m.group(1))
+            if rec:
+                return rec, None
+    return None, why
+
+
+WROTE_RE = re.compile(r"\b(wrote|schrieb|a écrit|escribió|schreef|skrev|scrisse|написал)\s?[::]$", re.I)
+COLON_RE = re.compile(r"\s?[::]$")
+ORIGINAL_RE = re.compile(r"^\s*[-_=]{2,}\s*(Original Message|Forwarded message|Urspr\w+ Nachricht)\s*[-_=]*\s*$", re.I)
+HEADERISH_RE = re.compile(r"^(Sent|Date|To|Cc|Subject|Reply-To):", re.I)
+UNDERLINE_RE = re.compile(r"^\s*[_=-]{5,}\s*$")
+# the labels of the Explodorama report template (src/main.ts bugDetails) and the like, or any short "Label:" line
+KNOWN_LINE_RE = re.compile(r"^(Seed|Version|Errors caught|Run record|Rules|Level|Turn|Modes|What happened)(?=[ :])|^[A-Z][A-Za-z ]{0,20}:")   # "Version/17.0" is a user agent, not a label
+USER_AGENT_RE = re.compile(r"\S/\S|[();]")   # what a wrapped user-agent line looks like; prose rarely has it
+
+
+BARE_VERB_RE = re.compile(r"^((a )?écrit|wrote|schrieb|escribió|schreef|skrev|scrisse|написал)\s?[::]$", re.I)
+VERB_ANY_RE = re.compile(r"\b(wrote|schrieb|écrit|escribió|schreef|skrev|scrisse|написал)\b", re.I)
+OPENER_RE = re.compile(r"^(On|Am|Le|El|Op|Il|Den|Em|Dne|W dniu)\b", re.I)
+# one "Label: value" line of a mail-header block, in any language ("Von:", "De :", "Envoyé :")
+HEADER_LINE_RE = re.compile(r"^([^\W\d_][\w .-]{0,19}?)\s?[::]\s*\S")
+HEADER_LABELS = {"from", "von", "de", "da", "van", "från", "sent", "gesendet", "envoyé", "date", "datum", "verzonden",
+                 "fecha", "enviado", "to", "an", "à", "aan", "para", "cc", "subject", "betreff", "objet", "onderwerp",
+                 "asunto", "reply-to", "antwort an"}
+TEMPLATE_LABELS = {"device", "run record", "errors caught", "seed", "version", "rules"}   # the game's own report lines
+
+
+def has_address(text):
+    return bool(ADDRESS_RE.search(unicodedata.normalize("NFKC", text)))
+
+
+def strong_attribution(line, nxt):
+    """True for a reply's "who wrote this" line in any language: a colon line that holds an address, or that opens like
+    one (On, Am, Le, ... plus a digit) and says wrote/schrieb/a écrit/..., or says it and is followed by a quoted line;
+    or an opener/address line wrapped so that only "wrote:" is on the next line. "I pressed <Enter> and wrote:" is prose."""
+    t = line.strip()
+    if line.startswith(">") or not t:
+        return False
+    if COLON_RE.search(t):
+        if has_address(t):
+            return True
+        return bool(WROTE_RE.search(t)) and ((OPENER_RE.match(t) and re.search(r"\d", t)) or nxt.startswith(">"))
+    return bool(BARE_VERB_RE.match(nxt.strip())) and bool(OPENER_RE.match(t) or has_address(t))
+
+
+def header_run_start(lines, i):
+    """True when lines[i:] begin a block of >= 3 consecutive "Label: value" lines that is a mail header (it holds an
+    address, or at least three of its labels are From/To/Subject/Date words in some language)."""
+    run = []
+    for line in lines[i:]:
+        m = HEADER_LINE_RE.match(line.strip())
+        if not m or m.group(1).strip().lower() in TEMPLATE_LABELS or line.startswith(">"):
+            break
+        run.append((m.group(1).strip().lower(), line))
+    if len(run) < 3:
+        return False
+    return any(has_address(l) for _, l in run) or sum(1 for lab, _ in run if lab in HEADER_LABELS) >= 3
+
+
+def find_cut(lines):
+    """Index of the first line of the quoted reply, attribution or signature, or None."""
+    cuts = []
+    n = len(lines)
+    quoted = lambda l: l.startswith(">")
+    for q in range(1, n):   # a wrapped attribution: up to 3 plain lines ending in a colon, directly followed by a quoted line
+        if not quoted(lines[q]) or quoted(lines[q - 1]) or not lines[q - 1].strip() or not COLON_RE.search(lines[q - 1].strip()):
+            continue
+        lo = q
+        while lo > max(q - 3, 0) and lines[lo - 1].strip() and not quoted(lines[lo - 1]):
+            lo -= 1
+        for k in range(lo, q):
+            joined = " ".join(x.strip() for x in lines[k:q])
+            if (VERB_ANY_RE.search(joined) or has_address(joined)) and (
+                    OPENER_RE.match(lines[k].strip()) or has_address(lines[k]) or "<" in lines[k]):
+                cuts.append(k)
+                break
+    for i, line in enumerate(lines):
+        nxt = lines[i + 1] if i + 1 < n else ""
+        if line.rstrip() == "--" or ORIGINAL_RE.match(line) or strong_attribution(line, nxt) or header_run_start(lines, i):
+            cuts.append(i)
+            break
+        if (COLON_RE.search(line.strip()) and nxt.startswith(">") and not quoted(line)   # a weak match: only when the rest is
+                and all(not l.strip() or quoted(l) for l in lines[i + 1:])                   # quoted, and text came before it
+                and any(l.strip() and not l.lstrip().startswith((">", "Device:", "Run record:")) for l in lines[:i])):
+            cuts.append(i)
+            break
+    return min(cuts) if cuts else None
+
+
+def unflow(lines, delsp=False):
+    """RFC 3676 format=flowed: a line ending in a space continues on the next one (quote marks aside). The space is
+    tested on the physical line, and an empty physical line always ends a paragraph."""
+    out, cur = [], None
+    for raw in lines:
+        if raw.startswith(">") or raw == "-- ":   # quotes and the signature separator are tested on the raw line
+            if cur is not None:
+                out.append(cur)
+                cur = None
+            out.append(raw)
+            continue
+        line = raw[1:] if raw.startswith(" ") else raw   # space-stuffing
+        if line == "":
+            if cur is not None:
+                out.append(cur)
+                cur = None
+            out.append("")
+            continue
+        if cur is not None:
+            if line.lstrip().startswith(("Device:", "Run record:")):
+                out.append(cur)   # these always start a line of their own
+            elif cur.lstrip().startswith("Device:") and KNOWN_LINE_RE.match(line):
+                out.append(cur)   # a Device: line never swallows the paragraph after it
+            else:
+                line = (cur[:-1] if delsp else cur) + line
+            cur = None
+        if raw.endswith(" "):
+            cur = line
+        else:
+            out.append(line)
+    if cur is not None:
+        out.append(cur)
+    return out
+
+
+def report_text(content, flowed=False, delsp=False):
+    """The reporter's words: quotes, signature, Device: and Run record: lines out, replies cut at their attribution
+    (any language, wrapped or not) or mail-header block, addresses hidden, cut to 4000. NFKC comes first, so fullwidth
+    letters and @ count. Device: and the lines that wrap it: following lines are dropped while they look like a user
+    agent (indented, or containing token/version, parentheses or semicolons); a blank line, a template label ("Seed
+    ...") or plain prose ends the block, so the reporter's own words after a Device line are kept."""
+    lines = unicodedata.normalize("NFKC", comments.clean(content)).split("\n")
+    if flowed:
+        lines = unflow(lines, delsp)
+    cut = find_cut(lines)
+    kept, in_device = [], False
+    for line in lines[:cut]:
+        s = line.lstrip()
+        if s.startswith("Device:"):
+            in_device = True
+            continue
+        if in_device:
+            if line.strip() and not KNOWN_LINE_RE.match(s) and (line[0] in " \t" or USER_AGENT_RE.search(line)):
+                continue
+            in_device = False
+        if s.startswith((">", "Run record:")):
+            continue
+        kept.append(line)
+    while cut is not None and kept and (not kept[-1].strip() or UNDERLINE_RE.match(kept[-1])):   # Outlook's ____ line
+        kept.pop()
+    return scrub("\n".join(kept).strip())[:comments.MAX_TEXT]
+
+
+def parse_report(msg, authserv_id, raw=b""):
+    """(report dict with the sender "_address", None) or (None, reason). Content problems only."""
+    froms = msg.get_all("From") or []
+    addrs = [a for a in getaddresses([str(x) for x in froms]) if a[1]]
+    if len(froms) != 1 or len(addrs) != 1 or "@" not in addrs[0][1]:
+        return None, "need exactly one From header with one address"
+    addr = addrs[0][1]
+    ok = comments.authenticated(msg, addr.rsplit("@", 1)[1].lower(), authserv_id) is None
+    body = msg.get_body(("plain",))
+    content = body.get_content() if body is not None else ""
+    flowed = body is not None and (body.get_param("format") or "").lower() == "flowed"
+    delsp = body is not None and (body.get_param("delsp") or "").lower() == "yes"
+    text = report_text(content, flowed, delsp)
+    if not text:
+        return None, "empty text"
+    subject = str(make_header(decode_header(str(msg.get("Subject", "")))))
+    title = " ".join(scrub(unicodedata.normalize("NFKC", comments.clean(subject))).split())[:MAX_TITLE].strip() or "(no subject)"
+    record, record_why = find_record(content, raw)
+    return {"title": title, "text": text, "author": "Reporter", "authenticated": ok, "date": comments.now_iso(),
+            "record": record, "record_why": record_why, "_address": addr}, None
+
+
+def next_number(repo):
+    nums = issue_numbers(repo)
+    return (nums[-1] if nums else 0) + 1
+
+
+def store_report(repo, report, thread, mkey, mid):
+    """Add the report to its thread or open a new issue. Returns (issue, new?, note)."""
+    message = {"date": report["date"], "author": report["author"], "text": report["text"],
+               "sender_hash": report["sender_hash"], "authenticated": report["authenticated"], "approved": False,
+               "mkey": mkey, "mid": sha(mid) if mid else ""}   # mkey and mid: for crash recovery, never published
+    if thread:
+        issue, new = load_issue(*thread), False
+        issue["messages"].append(message)
+    else:
+        n = next_number(repo)
+        issue = {"n": n, "repo": repo, "title": report["title"], "created": report["date"], "state": "open",
+                 "approved": False, "hidden": False, "record": False, "record_msg": 0, "messages": [message]}
+        new = True
+    note = ""
+    index = len(issue["messages"]) - 1
+    where = [repo, issue["n"], index]
+    # Remember the mail first: a crash after this leaves a key without a message, which stored() does not count,
+    # so the mail is read again (no lost report) and never stored twice (no duplicate issue).
+    keys = comments.read_json(os.path.join(state_dir(), "keys.json"), {})
+    keys[mkey] = where
+    comments.write_json(os.path.join(state_dir(), "keys.json"), keys)
+    if mid:
+        ids = comments.read_json(os.path.join(state_dir(), "msgids.json"), {})
+        ids[sha(mid)] = where
+        comments.write_json(os.path.join(state_dir(), "msgids.json"), ids)
+    write_record = None
+    if report["record"]:
+        if issue["record"]:
+            note = "second run record ignored"
+        elif not new and not report["authenticated"]:
+            note = "run record from an unauthenticated reply ignored"
+        else:
+            write_record = json.dumps(report["record"], separators=(",", ":"), allow_nan=False)
+            issue["record"], issue["record_msg"] = True, index
+    elif report["record_why"]:
+        note = report["record_why"]
+    if write_record:   # the file first, so issue.json never claims a record that is missing; issue_numbers ignores a lone folder
+        comments.write_atomic(record_path(repo, issue["n"]), write_record)
+    save_issue(issue)
+    return issue, new, note
+
+
+def stored(where, field, value):
+    """Whether the message a keys.json / msgids.json entry points at is really in its issue (same mkey / mid)."""
+    try:
+        issue = comments.read_json(os.path.join(issue_dir(where[0], where[1]), "issue.json"), None)
+        return bool(issue) and issue["messages"][where[2]].get(field) == value
+    except (Error, IndexError, TypeError, KeyError):
+        return False
+
+
+def ingest_messages(source, authserv_id):
+    with locked():
+        recent = recent_index()
+    cutoff = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(time.time() - SENDER_WINDOW))
+    touched = []
+    for key in sorted(source.keys(), key=lambda k: [int(x) if x.isdigit() else x for x in re.split(r"(\d+)", k)]):   # oldest first, so a reply finds its issue
+        with locked():   # per message, so `fw issues approve` is not starved by a big backlog
+            try:
+                raw = source.read(key)
+            except KeyError:
+                continue
+            except comments.SkipMessage as e:   # leave it in the inbox and go on with the others
+                log_rejection(comments.new_id(), f"skipped {key}: {e}")
+                print(f"skipped {key}: {e}")
+                continue
+            mkey = sha(raw)
+            keys = comments.read_json(os.path.join(state_dir(), "keys.json"), {})
+            if mkey in keys and stored(keys[mkey], "mkey", mkey):   # stored already: a crash or overlap left the mail behind
+                log_rejection(comments.new_id(), "duplicate of a stored report; moved to done")
+                source.finish(key, raw, "done")
+                continue
+            repo = report = thread = mid = None
+            try:
+                if len(raw) > MAX_MAIL:
+                    why = f"message over {MAX_MAIL // 1024} KB"
+                else:
+                    msg = message_from_bytes(raw, policy=policy.default)
+                    repo, why = repo_from_mail(msg)
+                    if repo:
+                        report, why = parse_report(msg, authserv_id, raw)
+                    if report:
+                        thread = thread_of(msg, repo)
+                        mid = (message_ids(msg.get("Message-ID")) or [None])[0]
+                        known = comments.read_json(os.path.join(state_dir(), "msgids.json"), {})
+                        if mid and sha(mid) in known and stored(known[sha(mid)], "mid", sha(mid)):   # the same Message-ID again
+                            report, why = None, "duplicate Message-ID of a stored report"
+            except (Error, OSError):   # state problems abort the run and leave the mail in the inbox
+                raise
+            except Exception as e:   # a malformed message must not stop the rest
+                report, why = None, f"unreadable message: {e}"
+            if report:
+                report["sender_hash"] = h = sender_hash(report.pop("_address"))
+                if issue_mode(repo_dir(repo)) != "private" and sum(1 for d in recent.get(h, []) if d >= cutoff) >= SENDER_LIMIT:
+                    report, why = None, f"sender limit reached ({SENDER_LIMIT} per 24 hours)"
+            if report is None:   # rejected mail is not remembered: moved back from bad, it is tried again
+                cid = comments.new_id()
+                log_rejection(cid, why)
+                print(f"bad {cid}: {why}")
+                source.finish(key, raw, "bad")
+                continue
+            issue, new, note = store_report(repo, report, thread, mkey, mid)
+            if note:
+                log_rejection(comments.new_id(), f"{repo}#{issue['n']}: {note}")
+            recent.setdefault(report["sender_hash"], []).append(report["date"])
+            print(f"{'new issue' if new else 'reply to'} {repo}#{issue['n']}" + (" with run record" if issue["record"] and new else ""))
+            if repo not in touched:
+                touched.append(repo)
+            source.finish(key, raw, "done")
+    for repo in touched:
+        try:
+            publish(repo)
+        except (Error, OSError) as e:   # the data is stored; `publish` later fixes the pages
+            print(f"issues: could not publish {repo} ({e}); run `fw issues publish {repo}` later", file=sys.stderr)
+
+
+def message_source():
+    if os.path.exists(imap_path()):
+        return comments.IMAPSource(cfg_path=imap_path(), stem="issues", max_size=MAX_MAIL, log=log_rejection)
+    return comments.MaildirSource(maildir_path())
+
+
+def check_mailbox_separate():
+    """Refuse to share the comments mailbox: the two jobs would move each other's mail to their bad folders."""
+    mine, theirs = imap_path(), comments.imap_config_path()
+    if not (os.path.exists(mine) and os.path.exists(theirs)):
+        return
+    a, b = comments.load_imap_config(mine), comments.load_imap_config(theirs)
+    if tuple(a[k].casefold() for k in ("host", "user", "folder")) == tuple(b[k].casefold() for k in ("host", "user", "folder")):
+        raise Error(f"{mine} and {theirs} point at the same mailbox ({a['user']}@{a['host']}, folder {a['folder']}); "
+                    "the bugs mailbox needs its own account or its own \"folder\"")
+
+
+def cmd_ingest():
+    source = message_source()
+    if not source.available():
+        return
+    if isinstance(source, comments.IMAPSource):
+        check_mailbox_separate()
+    authserv_id = (comments.load_config().get("authserv_id") or "").strip()
+    if not authserv_id:
+        raise Error('authserv_id is empty in sites.json; refusing to ingest (set it to the authserv-id '
+                    'your mail server puts at the start of its Authentication-Results header)')
+    try:
+        with ingest_lock():
+            ingest_messages(source, authserv_id)
+    finally:
+        source.close()
+
+
+# ---- moderation ----
+
+def cmd_list(repo=None):
+    for r in ([check_repo(repo)] if repo else stored_repos()):
+        path = repo_dir(r)
+        mode = issue_mode(path) if os.path.isdir(path) else "private"
+        for i in load_issues(r):
+            flags = ("approved " if i.get("approved") else "") + ("hidden " if i.get("hidden") else "") + ("record " if i.get("record") else "")
+            shown = "PUBLIC" if os.path.isdir(path) and visible(path, i, mode) else "-"
+            print(f"{r}#{i['n']}  {i['state']:<6} {shown:<6} {mode:<8} {flags}{i['title'][:60]}")
+
+
+def cmd_show(repo, n):
+    n = check_n(n)
+    print(json.dumps(load_issue(check_repo(repo), n), indent=2, ensure_ascii=False))
+
+
+def cmd_change(repo, n, action):
+    n = check_n(n)
+    with locked():
+        issue = load_issue(check_repo(repo), n)
+        if action == "approve":
+            issue["approved"], issue["hidden"] = True, False
+            for m in issue["messages"]:
+                m["approved"] = True
+        elif action == "hide":
+            issue["approved"], issue["hidden"] = False, True
+            for m in issue["messages"]:
+                m["approved"] = False
+        elif action == "close":
+            issue["state"] = "closed"
+        elif action == "reopen":
+            issue["state"] = "open"
+        save_issue(issue)
+    publish(repo)
+    path = repo_dir(repo)
+    print(f"{action}: {repo}#{n} ({'public' if os.path.isdir(path) and visible(path, issue) else 'not public'})")
+
+
+def cmd_publish(repo):
+    publish(repo)
+    print(f"{repo}: issues published")
+
+
+def cmd_publish_all():
+    for r in stored_repos():
+        try:
+            publish(r)
+        except Error as e:
+            print(f"issues: {r}: {e}", file=sys.stderr)
+    print("issues: published")
+
+
+def main(argv):
+    if not argv or argv[0] in ("-h", "--help", "help"):
+        print(__doc__)
+        return 0
+    cmd, args = argv[0], argv[1:]
+    flags = [a for a in args if a.startswith("--")]
+    args = [a for a in args if not a.startswith("--")]
+    try:
+        if cmd == "list" and len(args) <= 1:
+            cmd_list(args[0] if args else None)
+        elif cmd == "show" and len(args) == 2:
+            cmd_show(*args)
+        elif cmd in ("approve", "hide", "close", "reopen") and len(args) == 2:
+            cmd_change(args[0], args[1], cmd)
+        elif cmd == "publish" and len(args) == 1 and set(flags) <= {"--locked"}:
+            if "--locked" in flags:
+                publish(args[0], caller_holds_lock=True)
+            else:
+                cmd_publish(args[0])
+        elif cmd == "publish-all":
+            cmd_publish_all()
+        elif cmd == "ingest":
+            cmd_ingest()
+        elif cmd == "imap-check":
+            source = comments.IMAPSource(cfg_path=imap_path(), stem="issues", max_size=MAX_MAIL, log=log_rejection)
+            if not source.available():
+                raise Error(f"no IMAP config at {imap_path()}")
+            check_mailbox_separate()
+            try:
+                source.check()
+            finally:
+                source.close()
+        else:
+            print("usage: issues.py list|show|approve|hide|close|reopen|publish|publish-all|ingest|imap-check (try --help)", file=sys.stderr)
+            return 2
+    except (Error, OSError) as e:
+        print(f"issues: {e}", file=sys.stderr)
+        return 1
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main(sys.argv[1:]))
diff --git a/server/jobs/crontab b/server/jobs/crontab
index 7fd77f7..9e105f4 100644
--- a/server/jobs/crontab
+++ b/server/jobs/crontab
@@ -2,4 +2,5 @@
 # Each job script checks its own UTC schedule (if it has one); cron only wakes it hourly.
 0 * * * * $HOME/fw/tools/server/jobs/north-star-cleanup.sh >> $HOME/fw/logs/cron-north-star-cleanup.log 2>&1
 0 * * * * $HOME/fw/tools/server/jobs/comments-ingest.sh >> $HOME/fw/logs/cron-comments-ingest.log 2>&1
+0 * * * * $HOME/fw/tools/server/jobs/issues-ingest.sh >> $HOME/fw/logs/cron-issues-ingest.log 2>&1
 # fw:end
diff --git a/server/jobs/issues-ingest.sh b/server/jobs/issues-ingest.sh
new file mode 100755
index 0000000..49e4661
--- /dev/null
+++ b/server/jobs/issues-ingest.sh
@@ -0,0 +1,9 @@
+#!/bin/bash
+# Hourly: turn new mail in the bugs mailbox (IMAP, or ~/Maildir/.bugs) into issues (see server/issues.py).
+# Quiet unless something happened or failed.
+set -euo pipefail
+mkdir -p "$HOME/fw/state/locks"
+exec 9>"$HOME/fw/state/locks/job-issues-ingest.lock"
+flock -n 9 || exit 0
+out=$(python3 "$HOME/fw/tools/server/issues.py" ingest 2>&1) || { echo "== $(date -u '+%F %T UTC') issues ingest failed"; echo "$out"; exit 1; }
+[ -z "$out" ] || { echo "== $(date -u '+%F %T UTC') issues ingest"; echo "$out"; }
diff --git a/server/site/htaccess b/server/site/htaccess
index 2bfef89..f547d28 100644
--- a/server/site/htaccess
+++ b/server/site/htaccess
@@ -47,6 +47,11 @@ RedirectMatch 404 ^/[^/]+\.git/(config|description|hooks/|logs/|info/exclude|FET
   <If "%{REQUEST_URI} =~ m#^/[^/]+/(branch/[^/]+/)?files\.json$#">
     Header set Cache-Control "no-cache"
   </If>
+  # A bug report's run record is read by the replay viewer on apps.fuzzyworld.net.
+  <If "%{REQUEST_URI} =~ m#^/[^/]+/issues/[0-9]+/record\.json$#">
+    Header set Access-Control-Allow-Origin "https://apps.fuzzyworld.net"
+    Header set Cache-Control "no-cache"
+  </If>
   # objects/<sha> is named by its contents, so it never changes.
   <If "%{REQUEST_URI} =~ m#^/[^/]+/objects/[0-9a-f]{40}$#">
     Header set Cache-Control "public, max-age=31536000, immutable"
diff --git a/server/site/style.css b/server/site/style.css
index 2a60803..eb69758 100644
--- a/server/site/style.css
+++ b/server/site/style.css
@@ -264,7 +264,7 @@ pre {
   overflow-wrap: anywhere;
 }
 
-pre.message {
+pre.message, pre.issue-text {
   white-space: pre-wrap;
   overflow-wrap: anywhere;
 }
diff --git a/tests/deploy.test.mjs b/tests/deploy.test.mjs
new file mode 100644
index 0000000..cff1b10
--- /dev/null
+++ b/tests/deploy.test.mjs
@@ -0,0 +1,44 @@
+// node --test tests/deploy.test.mjs
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { rsyncArgs, checkReplay, readConfig } from '../lib/deploy.mjs';
+
+function configIn(fuzzyworld) {
+  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fw-deploy-test-'));
+  fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', fuzzyworld: { app: 'x', ...fuzzyworld } }));
+  return dir;
+}
+
+test('extra becomes protect filters, keep stays an exclude', () => {
+  const args = rsyncArgs({ keep: ['app.apk'], extra: ['sim/*.js'] }, 'dist', 'dh:x/');
+  assert.deepEqual(args, ['-rlptz', '--delete', '--exclude', '/app.apk', '--filter', 'P /sim/*.js', 'dist/', 'dh:x/']);
+});
+
+test('no keep or extra: plain rsync', () => {
+  assert.deepEqual(rsyncArgs({}, 'dist', 'dh:x/'), ['-rlptz', '--delete', 'dist/', 'dh:x/']);
+});
+
+test('extra entries are validated like keep', () => {
+  for (const bad of ['/abs', '../up', 'a//b', '', 5]) {
+    assert.throws(() => readConfig(configIn({ extra: [bad] })), /fuzzyworld\.extra/);
+  }
+  assert.ok(readConfig(configIn({ extra: ['sim/*.js', 'a/**/b'] })));
+});
+
+test('an entry in both keep and extra is refused', () => {
+  assert.throws(() => readConfig(configIn({ keep: ['sim/*.js'], extra: ['sim/*.js'] })), /also in keep/);
+});
+
+test('replay templates', () => {
+  checkReplay('replay.html?report={record}');
+  checkReplay('r/replay.html?x=1&report={record}');
+  for (const bad of ['replay.html', '/replay.html?r={record}', '//evil.com/?r={record}', 'https://evil.com/?r={record}',
+    '../x.html?r={record}', 'javascript:alert(1)//{record}', 'a\\b?r={record}', 'x'.repeat(201) + '{record}', 5,
+    'a%2e%2e/x.html?r={record}', 'a%00.html?r={record}', 'a b.html?r={record}', 'a\tb.html?r={record}', 'a\nb.html?r={record}']) {
+    assert.throws(() => checkReplay(bad), /fuzzyworld\.replay/, String(bad));
+  }
+  assert.throws(() => readConfig(configIn({ replay: 'nope' })), /fuzzyworld\.replay/);
+});
diff --git a/tests/fixtures/issues/explodorama-report.eml b/tests/fixtures/issues/explodorama-report.eml
new file mode 100644
index 0000000..73eb0e3
--- /dev/null
+++ b/tests/fixtures/issues/explodorama-report.eml
@@ -0,0 +1,147 @@
+Authentication-Results: mx; dkim=pass header.d=example.org; dmarc=pass header.from=example.org

+Date: Tue, 03 Nov 2026 19:51:56 -0800

+From: Reporter <reporter@example.org>

+To: bugs-k7q4+explodorama@fuzzyworld.net

+Subject: Bug report: version 0.2.0 (rules 43), level 4

+Message-ID: <B3B7666D-1090-4EEF-8F63-2DE90181EF6D@example.org>

+MIME-Version: 1.0

+Content-Type: text/plain;

+ charset=utf-8

+Content-Transfer-Encoding: quoted-printable

+

+What happened:

+

+Version 0=2E2=2E0 (rules 43), commit 805e7a9

+Device: TestPhone / Firefox/1.0

+Seed 1464004408, level 4, 66 turns taken, 3 lives, playing; modes: none

+Errors caught: none

+Run record: {"simVersion":43,"seed":1464004408,"startLevel":1,"startCharge=

+":null,"options":{"dev":false,"immortal":false},"actions":[{"id":"move","ta=

+rget":{"x":1,"y":2}},{"id":"move","target":{"x":1,"y":3}},{"id":"move","tar=

+get":{"x":1,"y":4}},{"id":"bomb","target":{"x":1,"y":5}},{"id":"move","targ=

+et":{"x":1,"y":3}},{"id":"move","target":{"x":2,"y":3}},{"id":"wait"},{"id"=

+:"wait"},{"id":"move","target":{"x":1,"y":3}},{"id":"move","target":{"x":1,=

+"y":4}},{"id":"move","target":{"x":1,"y":5}},{"id":"move","target":{"x":1,"=

+y":6}},{"id":"choose","kind":"kick"},{"id":"move","target":{"x":1,"y":5}},{=

+"id":"move","target":{"x":2,"y":5}},{"id":"bomb","target":{"x":3,"y":5}},{"=

+id":"move","target":{"x":1,"y":5}},{"id":"wait"},{"id":"wait"},{"id":"wait"=

+},{"id":"move","target":{"x":2,"y":5}},{"id":"move","target":{"x":3,"y":5}}=

+,{"id":"move","target":{"x":4,"y":5}},{"id":"bomb","target":{"x":5,"y":5}},=

+{"id":"kick","target":{"x":5,"y":5}},{"id":"wait"},{"id":"wait"},{"id":"wai=

+t"},{"id":"bomb","target":{"x":5,"y":5}},{"id":"move","target":{"x":3,"y":5=

+}},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"move","target":{"x":4,"=

+y":5}},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"wait"=

+},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"i=

+d":"bomb","target":{"x":5,"y":5}},{"id":"move","target":{"x":3,"y":5}},{"id=

+":"wait"},{"id":"wait"},{"id":"wait"},{"id":"move","target":{"x":4,"y":5}},=

+{"id":"move","target":{"x":5,"y":5}},{"id":"move","target":{"x":5,"y":6}},{=

+"id":"move","target":{"x":5,"y":7}},{"id":"move","target":{"x":5,"y":8}},{"=

+id":"move","target":{"x":5,"y":9}},{"id":"continue"},{"id":"bomb","target":=

+{"x":2,"y":1}},{"id":"kick","target":{"x":2,"y":1}},{"id":"wait"},{"id":"wa=

+it"},{"id":"wait"},{"id":"bomb","target":{"x":1,"y":2}},{"id":"kick","targe=

+t":{"x":1,"y":2}},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"bomb","t=

+arget":{"x":1,"y":2}},{"id":"kick","target":{"x":1,"y":2}},{"id":"wait"},{"=

+id":"wait"},{"id":"wait"},{"id":"bomb","target":{"x":1,"y":2}},{"id":"kick"=

+,"target":{"x":1,"y":2}},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"w=

+ait"},{"id":"move","target":{"x":1,"y":2}},{"id":"move","target":{"x":1,"y"=

+:3}},{"id":"move","target":{"x":1,"y":4}},{"id":"move","target":{"x":1,"y":=

+5}},{"id":"move","target":{"x":1,"y":6}},{"id":"move","target":{"x":1,"y":7=

+}},{"id":"move","target":{"x":2,"y":7}},{"id":"move","target":{"x":3,"y":7}=

+},{"id":"move","target":{"x":4,"y":7}},{"id":"move","target":{"x":5,"y":7}}=

+,{"id":"move","target":{"x":5,"y":6}},{"id":"move","target":{"x":5,"y":5}},=

+{"id":"move","target":{"x":5,"y":4}},{"id":"move","target":{"x":5,"y":3}},{=

+"id":"move","target":{"x":6,"y":3}},{"id":"move","target":{"x":7,"y":3}},{"=

+id":"bomb","target":{"x":7,"y":4}},{"id":"kick","target":{"x":7,"y":4}},{"i=

+d":"wait"},{"id":"wait"},{"id":"move","target":{"x":6,"y":3}},{"id":"move",=

+"target":{"x":5,"y":3}},{"id":"bomb","target":{"x":5,"y":4}},{"id":"kick","=

+target":{"x":5,"y":4}},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"wai=

+t"},{"id":"move","target":{"x":5,"y":2}},{"id":"wait"},{"id":"move","target=

+":{"x":5,"y":3}},{"id":"move","target":{"x":5,"y":4}},{"id":"move","target"=

+:{"x":5,"y":5}},{"id":"move","target":{"x":5,"y":6}},{"id":"move","target":=

+{"x":5,"y":7}},{"id":"move","target":{"x":4,"y":7}},{"id":"move","target":{=

+"x":3,"y":7}},{"id":"move","target":{"x":2,"y":7}},{"id":"move","target":{"=

+x":1,"y":7}},{"id":"bomb","target":{"x":1,"y":6}},{"id":"kick","target":{"x=

+":1,"y":6}},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"move","target"=

+:{"x":2,"y":7}},{"id":"move","target":{"x":3,"y":7}},{"id":"move","target":=

+{"x":4,"y":7}},{"id":"move","target":{"x":5,"y":7}},{"id":"move","target":{=

+"x":6,"y":7}},{"id":"move","target":{"x":7,"y":7}},{"id":"bomb","target":{"=

+x":7,"y":6}},{"id":"kick","target":{"x":7,"y":6}},{"id":"wait"},{"id":"move=

+","target":{"x":7,"y":6}},{"id":"wait"},{"id":"bomb","target":{"x":7,"y":5}=

+},{"id":"kick","target":{"x":7,"y":5}},{"id":"wait"},{"id":"wait"},{"id":"w=

+ait"},{"id":"move","target":{"x":7,"y":5}},{"id":"bomb","target":{"x":7,"y"=

+:4}},{"id":"move","target":{"x":7,"y":6}},{"id":"wait"},{"id":"wait"},{"id"=

+:"wait"},{"id":"move","target":{"x":7,"y":5}},{"id":"bomb","target":{"x":7,=

+"y":4}},{"id":"wait"},{"id":"kick","target":{"x":7,"y":4}},{"id":"wait"},{"=

+id":"move","target":{"x":7,"y":4}},{"id":"move","target":{"x":7,"y":3}},{"i=

+d":"move","target":{"x":7,"y":2}},{"id":"move","target":{"x":7,"y":3}},{"id=

+":"move","target":{"x":6,"y":3}},{"id":"move","target":{"x":5,"y":3}},{"id"=

+:"move","target":{"x":4,"y":3}},{"id":"choose","kind":"tnt"},{"id":"move","=

+target":{"x":5,"y":3}},{"id":"move","target":{"x":5,"y":4}},{"id":"move","t=

+arget":{"x":5,"y":5}},{"id":"move","target":{"x":5,"y":6}},{"id":"move","ta=

+rget":{"x":5,"y":7}},{"id":"move","target":{"x":6,"y":7}},{"id":"move","tar=

+get":{"x":7,"y":7}},{"id":"continue"},{"id":"move","target":{"x":1,"y":2}},=

+{"id":"bomb","target":{"x":1,"y":3}},{"id":"kick","target":{"x":1,"y":3}},{=

+"id":"move","target":{"x":1,"y":1}},{"id":"wait"},{"id":"wait"},{"id":"bomb=

+","target":{"x":1,"y":2}},{"id":"kick","target":{"x":1,"y":2}},{"id":"wait"=

+},{"id":"wait"},{"id":"wait"},{"id":"move","target":{"x":1,"y":2}},{"id":"m=

+ove","target":{"x":1,"y":3}},{"id":"move","target":{"x":1,"y":4}},{"id":"mo=

+ve","target":{"x":1,"y":5}},{"id":"move","target":{"x":1,"y":6}},{"id":"mov=

+e","target":{"x":1,"y":7}},{"id":"move","target":{"x":1,"y":8}},{"id":"move=

+","target":{"x":1,"y":9}},{"id":"bomb","target":{"x":2,"y":9}},{"id":"kick"=

+,"target":{"x":2,"y":9}},{"id":"move","target":{"x":1,"y":8}},{"id":"wait"}=

+,{"id":"wait"},{"id":"move","target":{"x":1,"y":9}},{"id":"move","target":{=

+"x":2,"y":9}},{"id":"move","target":{"x":3,"y":9}},{"id":"move","target":{"=

+x":4,"y":9}},{"id":"sellCharge"},{"id":"move","target":{"x":5,"y":9}},{"id"=

+:"move","target":{"x":4,"y":9}},{"id":"move","target":{"x":3,"y":9}},{"id":=

+"bomb","target":{"x":3,"y":8}},{"id":"move","target":{"x":2,"y":9}},{"id":"=

+wait"},{"id":"wait"},{"id":"wait"},{"id":"move","target":{"x":3,"y":9}},{"i=

+d":"move","target":{"x":3,"y":8}},{"id":"move","target":{"x":3,"y":7}},{"id=

+":"move","target":{"x":4,"y":7}},{"id":"move","target":{"x":5,"y":7}},{"id"=

+:"bomb","target":{"x":5,"y":6}},{"id":"kick","target":{"x":5,"y":6}},{"id":=

+"move","target":{"x":4,"y":7}},{"id":"wait"},{"id":"wait"},{"id":"move","ta=

+rget":{"x":5,"y":7}},{"id":"move","target":{"x":5,"y":6}},{"id":"move","tar=

+get":{"x":5,"y":5}},{"id":"move","target":{"x":5,"y":4}},{"id":"choose","ki=

+nd":"diagonal"},{"id":"move","target":{"x":5,"y":5}},{"id":"move","target":=

+{"x":5,"y":6}},{"id":"diagonal","target":{"x":6,"y":7}},{"id":"diagonal","t=

+arget":{"x":7,"y":8}},{"id":"continue"},{"id":"buy","kind":"bombPass"},{"id=

+":"buy","kind":"fusePlus"},{"id":"continue"},{"id":"move","target":{"x":1,"=

+y":2}},{"id":"diagonal","target":{"x":2,"y":3}},{"id":"diagonal","target":{=

+"x":3,"y":4}},{"id":"move","target":{"x":3,"y":5}},{"id":"move","target":{"=

+x":2,"y":5}},{"id":"move","target":{"x":1,"y":5}},{"id":"bomb","target":{"x=

+":1,"y":6}},{"id":"move","target":{"x":2,"y":5}},{"id":"wait"},{"id":"wait"=

+},{"id":"wait"},{"id":"wait"},{"id":"move","target":{"x":3,"y":5}},{"id":"m=

+ove","target":{"x":4,"y":5}},{"id":"move","target":{"x":5,"y":5}},{"id":"bo=

+mb","target":{"x":6,"y":5}},{"id":"move","target":{"x":5,"y":6}},{"id":"mov=

+e","target":{"x":5,"y":7}},{"id":"move","target":{"x":6,"y":7}},{"id":"move=

+","target":{"x":7,"y":7}},{"id":"move","target":{"x":7,"y":6}},{"id":"move"=

+,"target":{"x":7,"y":5}},{"id":"move","target":{"x":8,"y":5}},{"id":"move",=

+"target":{"x":7,"y":5}},{"id":"move","target":{"x":6,"y":5}},{"id":"diagona=

+l","target":{"x":5,"y":4}},{"id":"diagonal","target":{"x":6,"y":3}},{"id":"=

+bomb","target":{"x":7,"y":3}},{"id":"diagonal","target":{"x":5,"y":4}},{"id=

+":"wait"},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"diagonal","targe=

+t":{"x":4,"y":3}},{"id":"move","target":{"x":3,"y":3}},{"id":"move","target=

+":{"x":2,"y":3}},{"id":"diagonal","target":{"x":1,"y":2}},{"id":"bomb","tar=

+get":{"x":1,"y":1}},{"id":"diagonal","target":{"x":2,"y":3}},{"id":"wait"},=

+{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"diagonal","target":{"x":1,=

+"y":2}},{"id":"diagonal","target":{"x":2,"y":1}},{"id":"move","target":{"x"=

+:3,"y":1}},{"id":"move","target":{"x":2,"y":1}},{"id":"diagonal","target":{=

+"x":1,"y":2}},{"id":"diagonal","target":{"x":2,"y":3}},{"id":"diagonal","ta=

+rget":{"x":3,"y":4}},{"id":"diagonal","target":{"x":4,"y":5}},{"id":"diagon=

+al","target":{"x":5,"y":6}},{"id":"diagonal","target":{"x":6,"y":7}},{"id":=

+"diagonal","target":{"x":5,"y":6}},{"id":"move","target":{"x":5,"y":5}},{"i=

+d":"move","target":{"x":5,"y":4}},{"id":"move","target":{"x":5,"y":3}},{"id=

+":"bomb","target":{"x":5,"y":4}},{"id":"kick","target":{"x":5,"y":4}},{"id"=

+:"wait"},{"id":"wait"},{"id":"wait"},{"id":"bomb","target":{"x":5,"y":4}},{=

+"id":"move","target":{"x":5,"y":4}},{"id":"move","target":{"x":5,"y":5}},{"=

+id":"move","target":{"x":5,"y":4}}]}

+

+Got stuck: fired a bomb before the last enemy died, autopilot walked me in=

+to death=2E

+

+---

+Version 0=2E2=2E0 (rules 43), commit 805e7a9

+Device: TestPhone / Firefox/1.0

+Seed 1464004408, level 4, 66 turns taken, 3 lives, playing; modes: none

+Errors caught: none

+Run record: too long for an email link=2E The full details are on your cli=

+pboard: please paste them here=2E

diff --git a/tests/fixtures/issues/q101-record.json b/tests/fixtures/issues/q101-record.json
new file mode 100644
index 0000000..1096cb1
--- /dev/null
+++ b/tests/fixtures/issues/q101-record.json
@@ -0,0 +1 @@
+{"simVersion":43,"seed":1464004408,"startLevel":1,"startCharge":null,"options":{"dev":false,"immortal":false},"actions":[{"id":"move","target":{"x":1,"y":2}},{"id":"move","target":{"x":1,"y":3}},{"id":"move","target":{"x":1,"y":4}},{"id":"bomb","target":{"x":1,"y":5}},{"id":"move","target":{"x":1,"y":3}},{"id":"move","target":{"x":2,"y":3}},{"id":"wait"},{"id":"wait"},{"id":"move","target":{"x":1,"y":3}},{"id":"move","target":{"x":1,"y":4}},{"id":"move","target":{"x":1,"y":5}},{"id":"move","target":{"x":1,"y":6}},{"id":"choose","kind":"kick"},{"id":"move","target":{"x":1,"y":5}},{"id":"move","target":{"x":2,"y":5}},{"id":"bomb","target":{"x":3,"y":5}},{"id":"move","target":{"x":1,"y":5}},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"move","target":{"x":2,"y":5}},{"id":"move","target":{"x":3,"y":5}},{"id":"move","target":{"x":4,"y":5}},{"id":"bomb","target":{"x":5,"y":5}},{"id":"kick","target":{"x":5,"y":5}},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"bomb","target":{"x":5,"y":5}},{"id":"move","target":{"x":3,"y":5}},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"move","target":{"x":4,"y":5}},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"bomb","target":{"x":5,"y":5}},{"id":"move","target":{"x":3,"y":5}},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"move","target":{"x":4,"y":5}},{"id":"move","target":{"x":5,"y":5}},{"id":"move","target":{"x":5,"y":6}},{"id":"move","target":{"x":5,"y":7}},{"id":"move","target":{"x":5,"y":8}},{"id":"move","target":{"x":5,"y":9}},{"id":"continue"},{"id":"bomb","target":{"x":2,"y":1}},{"id":"kick","target":{"x":2,"y":1}},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"bomb","target":{"x":1,"y":2}},{"id":"kick","target":{"x":1,"y":2}},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"bomb","target":{"x":1,"y":2}},{"id":"kick","target":{"x":1,"y":2}},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"bomb","target":{"x":1,"y":2}},{"id":"kick","target":{"x":1,"y":2}},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"move","target":{"x":1,"y":2}},{"id":"move","target":{"x":1,"y":3}},{"id":"move","target":{"x":1,"y":4}},{"id":"move","target":{"x":1,"y":5}},{"id":"move","target":{"x":1,"y":6}},{"id":"move","target":{"x":1,"y":7}},{"id":"move","target":{"x":2,"y":7}},{"id":"move","target":{"x":3,"y":7}},{"id":"move","target":{"x":4,"y":7}},{"id":"move","target":{"x":5,"y":7}},{"id":"move","target":{"x":5,"y":6}},{"id":"move","target":{"x":5,"y":5}},{"id":"move","target":{"x":5,"y":4}},{"id":"move","target":{"x":5,"y":3}},{"id":"move","target":{"x":6,"y":3}},{"id":"move","target":{"x":7,"y":3}},{"id":"bomb","target":{"x":7,"y":4}},{"id":"kick","target":{"x":7,"y":4}},{"id":"wait"},{"id":"wait"},{"id":"move","target":{"x":6,"y":3}},{"id":"move","target":{"x":5,"y":3}},{"id":"bomb","target":{"x":5,"y":4}},{"id":"kick","target":{"x":5,"y":4}},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"move","target":{"x":5,"y":2}},{"id":"wait"},{"id":"move","target":{"x":5,"y":3}},{"id":"move","target":{"x":5,"y":4}},{"id":"move","target":{"x":5,"y":5}},{"id":"move","target":{"x":5,"y":6}},{"id":"move","target":{"x":5,"y":7}},{"id":"move","target":{"x":4,"y":7}},{"id":"move","target":{"x":3,"y":7}},{"id":"move","target":{"x":2,"y":7}},{"id":"move","target":{"x":1,"y":7}},{"id":"bomb","target":{"x":1,"y":6}},{"id":"kick","target":{"x":1,"y":6}},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"move","target":{"x":2,"y":7}},{"id":"move","target":{"x":3,"y":7}},{"id":"move","target":{"x":4,"y":7}},{"id":"move","target":{"x":5,"y":7}},{"id":"move","target":{"x":6,"y":7}},{"id":"move","target":{"x":7,"y":7}},{"id":"bomb","target":{"x":7,"y":6}},{"id":"kick","target":{"x":7,"y":6}},{"id":"wait"},{"id":"move","target":{"x":7,"y":6}},{"id":"wait"},{"id":"bomb","target":{"x":7,"y":5}},{"id":"kick","target":{"x":7,"y":5}},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"move","target":{"x":7,"y":5}},{"id":"bomb","target":{"x":7,"y":4}},{"id":"move","target":{"x":7,"y":6}},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"move","target":{"x":7,"y":5}},{"id":"bomb","target":{"x":7,"y":4}},{"id":"wait"},{"id":"kick","target":{"x":7,"y":4}},{"id":"wait"},{"id":"move","target":{"x":7,"y":4}},{"id":"move","target":{"x":7,"y":3}},{"id":"move","target":{"x":7,"y":2}},{"id":"move","target":{"x":7,"y":3}},{"id":"move","target":{"x":6,"y":3}},{"id":"move","target":{"x":5,"y":3}},{"id":"move","target":{"x":4,"y":3}},{"id":"choose","kind":"tnt"},{"id":"move","target":{"x":5,"y":3}},{"id":"move","target":{"x":5,"y":4}},{"id":"move","target":{"x":5,"y":5}},{"id":"move","target":{"x":5,"y":6}},{"id":"move","target":{"x":5,"y":7}},{"id":"move","target":{"x":6,"y":7}},{"id":"move","target":{"x":7,"y":7}},{"id":"continue"},{"id":"move","target":{"x":1,"y":2}},{"id":"bomb","target":{"x":1,"y":3}},{"id":"kick","target":{"x":1,"y":3}},{"id":"move","target":{"x":1,"y":1}},{"id":"wait"},{"id":"wait"},{"id":"bomb","target":{"x":1,"y":2}},{"id":"kick","target":{"x":1,"y":2}},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"move","target":{"x":1,"y":2}},{"id":"move","target":{"x":1,"y":3}},{"id":"move","target":{"x":1,"y":4}},{"id":"move","target":{"x":1,"y":5}},{"id":"move","target":{"x":1,"y":6}},{"id":"move","target":{"x":1,"y":7}},{"id":"move","target":{"x":1,"y":8}},{"id":"move","target":{"x":1,"y":9}},{"id":"bomb","target":{"x":2,"y":9}},{"id":"kick","target":{"x":2,"y":9}},{"id":"move","target":{"x":1,"y":8}},{"id":"wait"},{"id":"wait"},{"id":"move","target":{"x":1,"y":9}},{"id":"move","target":{"x":2,"y":9}},{"id":"move","target":{"x":3,"y":9}},{"id":"move","target":{"x":4,"y":9}},{"id":"sellCharge"},{"id":"move","target":{"x":5,"y":9}},{"id":"move","target":{"x":4,"y":9}},{"id":"move","target":{"x":3,"y":9}},{"id":"bomb","target":{"x":3,"y":8}},{"id":"move","target":{"x":2,"y":9}},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"move","target":{"x":3,"y":9}},{"id":"move","target":{"x":3,"y":8}},{"id":"move","target":{"x":3,"y":7}},{"id":"move","target":{"x":4,"y":7}},{"id":"move","target":{"x":5,"y":7}},{"id":"bomb","target":{"x":5,"y":6}},{"id":"kick","target":{"x":5,"y":6}},{"id":"move","target":{"x":4,"y":7}},{"id":"wait"},{"id":"wait"},{"id":"move","target":{"x":5,"y":7}},{"id":"move","target":{"x":5,"y":6}},{"id":"move","target":{"x":5,"y":5}},{"id":"move","target":{"x":5,"y":4}},{"id":"choose","kind":"diagonal"},{"id":"move","target":{"x":5,"y":5}},{"id":"move","target":{"x":5,"y":6}},{"id":"diagonal","target":{"x":6,"y":7}},{"id":"diagonal","target":{"x":7,"y":8}},{"id":"continue"},{"id":"buy","kind":"bombPass"},{"id":"buy","kind":"fusePlus"},{"id":"continue"},{"id":"move","target":{"x":1,"y":2}},{"id":"diagonal","target":{"x":2,"y":3}},{"id":"diagonal","target":{"x":3,"y":4}},{"id":"move","target":{"x":3,"y":5}},{"id":"move","target":{"x":2,"y":5}},{"id":"move","target":{"x":1,"y":5}},{"id":"bomb","target":{"x":1,"y":6}},{"id":"move","target":{"x":2,"y":5}},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"move","target":{"x":3,"y":5}},{"id":"move","target":{"x":4,"y":5}},{"id":"move","target":{"x":5,"y":5}},{"id":"bomb","target":{"x":6,"y":5}},{"id":"move","target":{"x":5,"y":6}},{"id":"move","target":{"x":5,"y":7}},{"id":"move","target":{"x":6,"y":7}},{"id":"move","target":{"x":7,"y":7}},{"id":"move","target":{"x":7,"y":6}},{"id":"move","target":{"x":7,"y":5}},{"id":"move","target":{"x":8,"y":5}},{"id":"move","target":{"x":7,"y":5}},{"id":"move","target":{"x":6,"y":5}},{"id":"diagonal","target":{"x":5,"y":4}},{"id":"diagonal","target":{"x":6,"y":3}},{"id":"bomb","target":{"x":7,"y":3}},{"id":"diagonal","target":{"x":5,"y":4}},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"diagonal","target":{"x":4,"y":3}},{"id":"move","target":{"x":3,"y":3}},{"id":"move","target":{"x":2,"y":3}},{"id":"diagonal","target":{"x":1,"y":2}},{"id":"bomb","target":{"x":1,"y":1}},{"id":"diagonal","target":{"x":2,"y":3}},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"diagonal","target":{"x":1,"y":2}},{"id":"diagonal","target":{"x":2,"y":1}},{"id":"move","target":{"x":3,"y":1}},{"id":"move","target":{"x":2,"y":1}},{"id":"diagonal","target":{"x":1,"y":2}},{"id":"diagonal","target":{"x":2,"y":3}},{"id":"diagonal","target":{"x":3,"y":4}},{"id":"diagonal","target":{"x":4,"y":5}},{"id":"diagonal","target":{"x":5,"y":6}},{"id":"diagonal","target":{"x":6,"y":7}},{"id":"diagonal","target":{"x":5,"y":6}},{"id":"move","target":{"x":5,"y":5}},{"id":"move","target":{"x":5,"y":4}},{"id":"move","target":{"x":5,"y":3}},{"id":"bomb","target":{"x":5,"y":4}},{"id":"kick","target":{"x":5,"y":4}},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"bomb","target":{"x":5,"y":4}},{"id":"move","target":{"x":5,"y":4}},{"id":"move","target":{"x":5,"y":5}},{"id":"move","target":{"x":5,"y":4}}]}
\ No newline at end of file
diff --git a/tests/test_issues.py b/tests/test_issues.py
new file mode 100644
index 0000000..fc730b1
--- /dev/null
+++ b/tests/test_issues.py
@@ -0,0 +1,873 @@
+"""Tests for server/issues.py (bug reports by email become issue pages, run records, replay links).
+
+Run with: python3 -m unittest discover tests
+"""
+
+import contextlib
+import importlib.util
+import io
+import itertools
+import json
+import mailbox
+import os
+import shutil
+import subprocess
+import tempfile
+import unittest
+from unittest import mock
+
+ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
+FIXTURES = os.path.join(ROOT, "tests", "fixtures", "issues")
+spec = importlib.util.spec_from_file_location("issues", os.path.join(ROOT, "server", "issues.py"))
+issues = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(issues)
+
+AUTH = "mx; dkim=pass header.d=example.org; dmarc=pass header.from=example.org"
+_counter = itertools.count(1)
+
+
+def mail(subject="Bug: crash on start", body="It crashed.\n", repo="demo", sender="Rita <reporter@example.org>",
+         auth=(AUTH,), extra_headers=(), mid=None, to=None):
+    mid = mid or f"<{next(_counter)}@test>"
+    lines = [f"Message-ID: {mid}", *extra_headers]
+    lines += [f"Authentication-Results: {a}" for a in auth]
+    lines += [f"From: {sender}", f"To: {to or f'bugs-k7q4+{repo}@fuzzyworld.net'}", f"Subject: {subject}",
+              "Content-Type: text/plain; charset=utf-8", "", body]
+    return "\n".join(lines).encode()
+
+
+def record(**over):
+    r = {"simVersion": 43, "seed": 7, "startLevel": 1, "options": {"dev": False}, "actions": [{"id": "wait"}]}
+    r.update(over)
+    return json.dumps(r, separators=(",", ":"))
+
+
+def rd(path):
+    with open(path) as f:
+        return f.read()
+
+
+def read_tree(root):
+    for dirpath, _, names in os.walk(root):
+        for n in names:
+            p = os.path.join(dirpath, n)
+            with open(p, "rb") as f:
+                yield p, f.read().decode("utf-8", "replace")
+
+
+class Base(unittest.TestCase):
+    def setUp(self):
+        self.tmp = tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, self.tmp)
+        j = lambda *p: os.path.join(self.tmp, *p)
+        self.git, self.site, self.apps, self.fwstate = j("git"), j("site"), j("apps"), j("fwstate")
+        self.state = j("issues")
+        self.maildir = j("Maildir", ".bugs")
+        with open(j("sites.json"), "w") as f:
+            json.dump({"authserv_id": "mx"}, f)
+        env = {"FW_ISSUES_DIR": self.state, "FW_COMMENTS_SITES": j("sites.json"), "FW_ISSUES_MAILDIR": self.maildir}
+        old = {k: os.environ.get(k) for k in env}
+        os.environ.update(env)
+        self.addCleanup(lambda: [os.environ.pop(k) if v is None else os.environ.__setitem__(k, v) for k, v in old.items()])
+        for name, value in (("GIT_ROOT", self.git), ("SITE", self.site), ("APPS", self.apps), ("STATE", self.fwstate)):
+            p = mock.patch.object(issues.gen, name, value)
+            p.start()
+            self.addCleanup(p.stop)
+        self.summaries = []
+        p = mock.patch.object(issues, "refresh_summary", lambda repo: self.summaries.append(repo))
+        p.start()
+        self.addCleanup(p.stop)
+        self.repo("demo", "public", "public")
+
+    def repo(self, name, visibility="public", mode=None):
+        path = os.path.join(self.git, name + ".git")
+        subprocess.run(["git", "init", "--bare", "--quiet", path], check=True)
+        subprocess.run(["git", "-C", path, "config", "fuzzyworld.visibility", visibility], check=True)
+        if mode is not None:
+            subprocess.run(["git", "-C", path, "config", "fuzzyworld.issues", mode], check=True)
+        return path
+
+    def ingest(self, *raws):
+        os.makedirs(os.path.dirname(self.maildir), exist_ok=True)
+        box = mailbox.Maildir(self.maildir, create=True)
+        for raw in raws:
+            box.add(raw)
+        out = io.StringIO()
+        with contextlib.redirect_stdout(out):
+            issues.cmd_ingest()
+        return out.getvalue()
+
+    def cli(self, *argv):
+        out, err = io.StringIO(), io.StringIO()
+        with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
+            code = issues.main(list(argv))
+        return code, out.getvalue(), err.getvalue()
+
+    def page(self, repo="demo", *parts):
+        return os.path.join(self.site, repo, "issues", *parts)
+
+    def count(self, outcome):
+        path = self.maildir + "." + outcome
+        return len(mailbox.Maildir(path, create=False).keys()) if os.path.isdir(path) else 0
+
+    def rejected(self):
+        try:
+            return rd(os.path.join(self.state, "rejected.log"))
+        except FileNotFoundError:
+            return ""
+
+
+class Modes(Base):
+    def test_public_publishes_at_once(self):
+        self.ingest(mail())
+        self.assertTrue(os.path.isfile(self.page("demo", "index.html")))
+        text = rd(self.page("demo", "1", "index.html"))
+        self.assertIn("crash on start", text)
+        self.assertIn("It crashed.", text)
+        self.assertIn('<pre class="issue-text">', text)
+        self.assertEqual(self.summaries, ["demo"])   # the index appeared: the summary link is refreshed
+
+    def test_reviewed_waits_for_approve_and_hide_removes(self):
+        self.repo("rev", "public", "reviewed")
+        self.ingest(mail(repo="rev"))
+        self.assertFalse(os.path.exists(self.page("rev")))
+        self.assertEqual(self.cli("approve", "rev", "1")[0], 0)
+        self.assertTrue(os.path.isfile(self.page("rev", "1", "index.html")))
+        self.assertEqual(self.cli("hide", "rev", "1")[0], 0)
+        self.assertFalse(os.path.exists(self.page("rev")))
+
+    def test_hide_beats_public_mode(self):
+        self.ingest(mail())
+        self.cli("hide", "demo", "1")
+        self.assertFalse(os.path.exists(self.page("demo")))
+        self.ingest(mail(subject="Another"))
+        self.assertFalse(os.path.exists(self.page("demo", "1")))
+        self.assertTrue(os.path.isfile(self.page("demo", "2", "index.html")))
+        self.assertNotIn("crash on start", rd(self.page("demo", "index.html")))
+
+    def test_private_and_unknown_modes_publish_nothing(self):
+        for name, mode in (("p1", "private"), ("p2", "pubic"), ("p3", ""), ("p4", None)):
+            self.repo(name, "public", mode)
+            self.ingest(mail(repo=name))
+            self.assertFalse(os.path.exists(self.page(name)), name)
+            self.assertEqual(issues.issue_mode(os.path.join(self.git, name + ".git")), "private")
+            self.assertTrue(os.path.isfile(os.path.join(self.state, "repos", name, "1", "issue.json")))
+
+    def test_private_repo_publishes_nothing_even_in_public_mode(self):
+        self.repo("secret", "private", "public")
+        self.ingest(mail(repo="secret"))
+        self.cli("approve", "secret", "1")
+        self.assertFalse(os.path.exists(os.path.join(self.site, "secret")))
+
+    def test_close_and_reopen(self):
+        self.ingest(mail())
+        self.cli("close", "demo", "1")
+        self.assertIn("closed", rd(self.page("demo", "index.html")))
+        self.cli("reopen", "demo", "1")
+        self.assertNotIn("closed", rd(self.page("demo", "index.html")))
+
+    def test_unauthenticated_mail_is_held_in_public_mode(self):
+        self.ingest(mail(auth=("mx; dkim=fail",)))
+        self.assertFalse(os.path.exists(self.page()))
+        stored = json.load(open(os.path.join(self.state, "repos", "demo", "1", "issue.json")))
+        self.assertFalse(stored["messages"][0]["authenticated"])
+        self.cli("approve", "demo", "1")
+        self.assertTrue(os.path.isfile(self.page("demo", "1", "index.html")))
+
+    def test_foreign_authentication_header_counts_as_unauthenticated(self):
+        self.ingest(mail(auth=("evil; dkim=pass header.d=example.org",)))
+        self.assertFalse(os.path.exists(self.page()))
+
+    def test_unauthenticated_reply_is_not_shown_until_approved(self):
+        first = mail(mid="<a@test>")
+        self.ingest(first, mail(mid="<b@test>", body="Spoofed reply.\n", auth=(), extra_headers=("In-Reply-To: <a@test>",)))
+        self.assertNotIn("Spoofed", rd(self.page("demo", "1", "index.html")))
+        self.cli("approve", "demo", "1")
+        self.assertIn("Spoofed", rd(self.page("demo", "1", "index.html")))
+
+    def test_sender_limit_in_public_mode(self):
+        self.ingest(*[mail(subject=f"Bug {i}") for i in range(issues.SENDER_LIMIT + 2)])
+        self.assertEqual(len(issues.issue_numbers("demo")), issues.SENDER_LIMIT)
+        self.assertEqual(self.count("bad"), 2)
+
+
+class Intake(Base):
+    def test_reply_joins_the_issue(self):
+        self.ingest(mail(mid="<first@test>"),
+                    mail(mid="<second@test>", subject="Re: Bug", body="More detail.\n",
+                         extra_headers=("In-Reply-To: <first@test>", "References: <first@test>")))
+        self.assertEqual(issues.issue_numbers("demo"), [1])
+        stored = json.load(open(os.path.join(self.state, "repos", "demo", "1", "issue.json")))
+        self.assertEqual(len(stored["messages"]), 2)
+        text = rd(self.page("demo", "1", "index.html"))
+        self.assertIn("It crashed.", text)
+        self.assertIn("More detail.", text)
+
+    def test_reply_via_references_only_and_unknown_parent(self):
+        self.ingest(mail(mid="<first@test>"), mail(mid="<x@test>", extra_headers=("References: <old@test> <first@test>",)),
+                    mail(mid="<y@test>", extra_headers=("In-Reply-To: <nobody@test>",)))
+        self.assertEqual(issues.issue_numbers("demo"), [1, 2])
+        self.assertEqual(len(issues.load_issue("demo", 1)["messages"]), 2)
+
+    def test_numbers_count_up_per_repo(self):
+        self.repo("other", "public", "public")
+        self.ingest(mail(), mail(), mail(repo="other"))
+        self.assertEqual(issues.issue_numbers("demo"), [1, 2])
+        self.assertEqual(issues.issue_numbers("other"), [1])
+
+    def test_unknown_repo_and_bad_addresses_go_to_bad(self):
+        self.ingest(mail(repo="nosuchrepo"), mail(repo="-evil"), mail(to="somebody@example.org"),
+                    mail(to="bugs-k7q4+demo@fuzzyworld.net, bugs-k7q4+other@fuzzyworld.net"))
+        self.assertEqual(self.count("bad"), 4)
+        self.assertEqual(issues.stored_repos(), [])
+        self.assertIn("no such repo", self.rejected())
+
+    def test_address_in_cc_or_x_original_to(self):
+        self.ingest(mail(to="list@example.org", extra_headers=("X-Original-To: bugs-k7q4+demo@fuzzyworld.net",)))
+        self.assertEqual(issues.issue_numbers("demo"), [1])
+
+    def test_repo_with_dot_git_in_the_address_is_refused(self):
+        self.ingest(mail(repo="demo.git"))
+        self.assertEqual(self.count("bad"), 1)
+
+    def test_same_mail_twice_is_stored_once(self):
+        raw = mail()
+        self.ingest(raw, raw)
+        self.assertEqual(issues.issue_numbers("demo"), [1])
+        self.assertEqual(len(issues.load_issue("demo", 1)["messages"]), 1)
+        self.assertEqual(self.count("done"), 2)
+
+    def test_oversize_mail_goes_to_bad(self):
+        with mock.patch.object(issues, "MAX_MAIL", 2000):
+            self.ingest(mail(body="x" * 5000))
+        self.assertEqual(self.count("bad"), 1)
+        self.assertEqual(issues.stored_repos(), [])
+
+    def test_empty_text_and_two_froms_go_to_bad(self):
+        self.ingest(mail(body="   \n"), mail(extra_headers=("From: Other <o@example.org>",)))
+        self.assertEqual(self.count("bad"), 2)
+
+    def test_no_address_reaches_any_file(self):
+        body = "Mail me at secret.person@example.net or <other@example.com>.\nDevice: Pixel (sam@example.org)\n"
+        self.ingest(mail(subject="Bug from boss@example.net", body=body, sender="Rita Reporter <reporter@example.org>",
+                         mid="<id-1@example.org>"))
+        found = 0
+        for root in (self.site, self.state):
+            for path, text in read_tree(root):
+                found += 1
+                for needle in ("@example", "Rita", "id-1"):
+                    self.assertNotIn(needle, text, path)
+        self.assertGreater(found, 3)
+        page = rd(self.page("demo", "1", "index.html"))
+        self.assertIn("[address hidden]", page)
+        self.assertNotIn("Device:", page)
+        self.assertNotIn("sender_hash", page)
+        stored = rd(os.path.join(self.state, "repos", "demo", "1", "issue.json"))
+        self.assertIn('"author": "Reporter"', stored)
+
+    def test_script_is_escaped(self):
+        self.ingest(mail(subject="<script>alert(1)</script>", body="<script>alert(2)</script> & <b>\n"))
+        for p in (self.page("demo", "index.html"), self.page("demo", "1", "index.html")):
+            text = rd(p)
+            self.assertNotIn("<script>alert", text)
+            self.assertIn("&lt;script&gt;alert", text)
+        self.assertNotIn("<script", rd(self.page("demo", "1", "index.html")).replace("&lt;script", ""))
+
+    def test_sender_hash_uses_its_own_secret(self):
+        self.ingest(mail())
+        self.assertTrue(os.path.isfile(os.path.join(self.state, "secret")))
+        self.assertEqual(oct(os.stat(os.path.join(self.state, "secret")).st_mode & 0o777), "0o600")
+
+    def test_publish_takes_the_repo_lock_unless_told(self):
+        self.ingest(mail())
+        self.assertTrue(os.path.isfile(os.path.join(self.fwstate, "locks", "demo.lock")))
+        self.assertEqual(self.cli("publish", "demo", "--locked")[0], 0)
+
+    def test_list_show_and_usage(self):
+        self.ingest(mail())
+        code, out, _ = self.cli("list")
+        self.assertIn("demo#1", out)
+        self.assertIn("PUBLIC", out)
+        self.assertIn("crash on start", self.cli("show", "demo", "1")[1])
+        self.assertEqual(self.cli("show", "demo", "9")[0], 1)
+        self.assertEqual(self.cli("show", "../x", "1")[0], 1)
+        self.assertEqual(self.cli("nonsense")[0], 2)
+        self.assertEqual(self.cli("publish-all")[0], 0)
+
+
+class Records(Base):
+    def eml(self):
+        with open(os.path.join(FIXTURES, "explodorama-report.eml"), "rb") as f:
+            return f.read()
+
+    def fixture(self):
+        with open(os.path.join(FIXTURES, "q101-record.json")) as f:
+            return json.load(f)
+
+    def setUp(self):
+        super().setUp()
+        self.repo("explodorama", "public", "reviewed")
+
+    def stored_record(self, repo="explodorama", n=1):
+        with open(os.path.join(self.state, "repos", repo, str(n), "record.json")) as f:
+            return json.load(f)
+
+    def test_record_from_the_real_mail_matches_the_fixture(self):
+        self.ingest(self.eml())
+        rec = self.stored_record()
+        self.assertEqual(rec, self.fixture())
+        self.assertEqual(rec["seed"], 1464004408)
+        self.assertEqual(len(rec["actions"]), 290)
+        self.assertEqual(len(rec["actions"]), len(self.fixture()["actions"]))
+        raw = rd(os.path.join(self.state, "repos", "explodorama", "1", "record.json"))
+        self.assertEqual(raw, json.dumps(self.fixture(), separators=(",", ":")))   # re-serialized, compact
+
+    def test_find_record_skips_the_too_long_footer(self):
+        text = "x\nRun record: " + record() + "\n\n---\nRun record: too long for an email link. Paste it.\n"
+        rec, why = issues.find_record(text)
+        self.assertEqual(rec["seed"], 7)
+        rec, why = issues.find_record("Run record: too long for an email link.\n")
+        self.assertEqual((rec, why), (None, ""))
+        # an invalid line first, a valid one later: the valid one wins
+        rec, _ = issues.find_record("Run record: {bad}\nRun record: " + record(seed=9) + "\n")
+        self.assertEqual(rec["seed"], 9)
+
+    def test_page_leaves_out_device_and_record_lines(self):
+        self.ingest(self.eml())
+        stored = rd(os.path.join(self.state, "repos", "explodorama", "1", "issue.json"))
+        self.cli("approve", "explodorama", "1")
+        for text in (stored, rd(self.page("explodorama", "1", "index.html"))):
+            self.assertNotIn("Device:", text)
+            self.assertNotIn("Firefox", text)
+            self.assertNotIn("TestPhone", text)
+            self.assertNotIn("Run record", text)
+            self.assertIn("autopilot walked me into death", text)   # the reporter's words come after the 9 KB record
+        self.assertIn("Bug report: version 0.2.0 (rules 43), level 4", rd(self.page("explodorama", "index.html")))
+
+    def test_record_json_appears_only_after_approve_in_reviewed_mode(self):
+        self.ingest(self.eml())
+        self.assertFalse(os.path.exists(self.page("explodorama")))
+        self.cli("approve", "explodorama", "1")
+        with open(self.page("explodorama", "1", "record.json")) as f:
+            self.assertEqual(json.load(f), self.fixture())
+        self.cli("hide", "explodorama", "1")
+        self.assertFalse(os.path.exists(self.page("explodorama", "1", "record.json")))
+
+    def test_record_is_published_at_once_in_public_mode(self):
+        self.repo("pub", "public", "public")
+        self.ingest(mail(repo="pub", body="It broke.\nRun record: " + record() + "\n"))
+        self.assertTrue(os.path.isfile(self.page("pub", "1", "record.json")))
+
+    def test_bad_records_are_refused_but_the_issue_is_kept(self):
+        too_big = record(note="x" * (issues.RECORD_MAX + 10))
+        cases = {
+            "over 64 KB": too_big,
+            "invalid JSON": '{"seed":1,"simVersion":1,"actions":[}',
+            "bool seed": record(seed=True),
+            "float seed": record(seed=1.5),
+            "missing actions": '{"seed":1,"simVersion":1}',
+            "actions not a list": record(actions={}),
+            "missing simVersion": '{"seed":1,"actions":[]}',
+            "NaN": '{"seed":1,"simVersion":1,"actions":NaN}',
+            "deep nesting": '{"seed":1,"simVersion":1,"actions":' + "[" * 30000 + "]" * 30000 + "}",
+        }
+        for label, line in cases.items():
+            rec, why = issues.find_record("Run record: " + line + "\n")
+            self.assertIsNone(rec, label)
+            self.assertTrue(why, label)
+        self.assertEqual(issues.check_record("[1]")[0], None)
+        self.repo("bad", "public", "public")
+        self.ingest(mail(repo="bad", body="Hello.\nRun record: " + record(seed=True) + "\n"))
+        self.assertEqual(issues.issue_numbers("bad"), [1])
+        self.assertFalse(os.path.exists(os.path.join(self.state, "repos", "bad", "1", "record.json")))
+        self.assertFalse(issues.load_issue("bad", 1)["record"])
+        self.assertIn("integer seed", self.rejected())
+
+    def test_quoted_printable_raw_fallback(self):
+        raw = b"Content-Transfer-Encoding: quoted-printable\n\nRun record: " + record().replace(":", "=3A").encode() + b"\n"
+        rec, why = issues.find_record("nothing useful here", raw)
+        self.assertEqual(rec["seed"], 7)
+        self.assertEqual(issues.find_record("nothing useful here", b"plain mail")[0], None)
+
+    def test_only_the_first_record_is_kept(self):
+        self.repo("pub", "public", "public")
+        self.ingest(mail(repo="pub", mid="<r1@test>", body="First.\nRun record: " + record(seed=1) + "\n"),
+                    mail(repo="pub", mid="<r2@test>", body="Again.\nRun record: " + record(seed=2) + "\n",
+                         extra_headers=("In-Reply-To: <r1@test>",)))
+        self.assertEqual(self.stored_record("pub")["seed"], 1)
+        self.assertIn("second run record ignored", self.rejected())
+
+    def test_record_from_an_unauthenticated_reply_is_ignored(self):
+        self.repo("pub", "public", "public")
+        self.ingest(mail(repo="pub", mid="<r1@test>"),
+                    mail(repo="pub", mid="<r2@test>", auth=(), body="Hi.\nRun record: " + record() + "\n",
+                         extra_headers=("In-Reply-To: <r1@test>",)))
+        self.assertFalse(issues.load_issue("pub", 1)["record"])
+
+
+class Review(Base):
+    """Fixes from the review: per-message approval, text and record privacy, setup checks."""
+
+    def test_nonfinite_numbers_are_refused(self):
+        for line in ('{"seed":1,"simVersion":1,"actions":[1e400]}', '{"seed":1,"simVersion":1,"actions":[-1e999]}',
+                     '{"seed":1,"simVersion":1,"actions":[Infinity]}'):
+            self.assertIsNone(issues.find_record("Run record: " + line + "\n")[0], line)
+        rec, _ = issues.check_record('{"seed":1,"simVersion":1,"actions":[1.5e10]}')
+        self.assertEqual(json.dumps(rec, allow_nan=False), '{"simVersion": 1, "seed": 1, "actions": [15000000000.0]}')
+
+    def test_later_reply_after_approve_is_not_public_in_reviewed_mode(self):
+        self.repo("rev", "public", "reviewed")
+        self.ingest(mail(repo="rev", mid="<a@test>"))
+        self.cli("approve", "rev", "1")
+        self.ingest(mail(repo="rev", mid="<b@test>", body="Sneaky follow-up.\n", extra_headers=("In-Reply-To: <a@test>",)))
+        self.assertNotIn("Sneaky", rd(self.page("rev", "1", "index.html")))
+        self.assertIn("It crashed.", rd(self.page("rev", "1", "index.html")))
+        self.cli("approve", "rev", "1")
+        self.assertIn("Sneaky", rd(self.page("rev", "1", "index.html")))
+
+    def test_public_mode_shows_later_authenticated_reply_but_never_an_unauthenticated_one(self):
+        self.ingest(mail(mid="<a@test>"))
+        self.cli("approve", "demo", "1")
+        self.ingest(mail(mid="<b@test>", body="Authentic follow-up.\n", extra_headers=("In-Reply-To: <a@test>",)),
+                    mail(mid="<c@test>", body="Forged follow-up.\n", auth=(), extra_headers=("In-Reply-To: <a@test>",)))
+        text = rd(self.page("demo", "1", "index.html"))
+        self.assertIn("Authentic follow-up.", text)
+        self.assertNotIn("Forged", text)
+
+    def test_hide_clears_message_approvals(self):
+        self.repo("rev", "public", "reviewed")
+        self.ingest(mail(repo="rev"))
+        self.cli("approve", "rev", "1")
+        self.cli("hide", "rev", "1")
+        self.assertFalse(issues.load_issue("rev", 1)["messages"][0]["approved"])
+
+    def test_sender_limit_applies_in_reviewed_mode(self):
+        self.repo("rev", "public", "reviewed")
+        self.ingest(*[mail(repo="rev", subject=f"Bug {i}") for i in range(issues.SENDER_LIMIT + 2)])
+        self.assertEqual(len(issues.issue_numbers("rev")), issues.SENDER_LIMIT)
+
+    def test_flowed_text_is_rejoined(self):
+        text = "Sorry, this was reported by boss@exa \nmple.net and it hurts. \nNew paragraph.\n"
+        out = issues.report_text(text, flowed=True)
+        self.assertIn("[address hidden]", out)
+        self.assertNotIn("exa", out)
+        self.assertIn("New paragraph.", out)
+        self.assertNotIn("boss@exa", issues.report_text(text, flowed=False).replace("[address hidden]", ""))
+
+    def test_device_continuation_line_is_dropped(self):
+        ua = "Device: Android 378x694; Mozilla/5.0 (Android 17; Mobile; rv:159.0) Gecko/"
+        out = issues.report_text(f"Hello.\n{ua}\n159.0 Firefox/159.0\nSeed 12, level 4\nBye.\n")
+        self.assertNotIn("Firefox", out)
+        self.assertNotIn("Android", out)
+        self.assertIn("Seed 12, level 4", out)
+        out = issues.report_text("Device: Pixel\n   Firefox/1.0\nafter\n")
+        self.assertEqual(out, "after")
+
+    def test_quoted_replies_are_cut_at_the_attribution(self):
+        for cut in ("On Tue, 3 Nov 2026 at 10:00, Jane Roe <jane@example.net> wrote:",
+                    "On Tue, 3 Nov 2026 at 10:00, Jane Roe\nwrote:",
+                    "-----Original Message-----",
+                    "From: Jane Roe\nSent: Tuesday\nTo: Someone"):
+            out = issues.report_text(f"My answer.\n\n{cut}\nJane Roe says secrets.\n")
+            self.assertEqual(out, "My answer.", cut)
+
+    def test_fullwidth_at_sign_is_scrubbed(self):
+        out = issues.report_text("write to boss\uff20example.net now")
+        self.assertNotIn("boss", out)
+        self.assertIn("[address hidden]", out)
+        self.ingest(mail(subject="Mail me\uff1a boss\uff20example.net", body="boss\uff20example.net\n"))
+        for p in (self.page("demo", "index.html"), self.page("demo", "1", "index.html")):
+            self.assertNotIn("boss", rd(p))
+
+    def test_record_keeps_only_the_allowlist_and_refuses_addresses(self):
+        rec, _ = issues.check_record(record(deviceId="abc", notes="x", startLevel=2, startCharge=None))
+        self.assertEqual(sorted(rec), sorted(["simVersion", "seed", "actions", "startLevel", "startCharge", "options"]))
+        for bad in (record(options={"name": "me@example.net"}), record(actions=[{"id": "say", "text": "a\uff20b.org"}]),
+                    record(options={"x@example.org": 1})):
+            self.assertIsNone(issues.check_record(bad)[0], bad)
+        with open(os.path.join(FIXTURES, "q101-record.json")) as f:
+            real = json.load(f)
+        self.assertEqual(issues.check_record(json.dumps(real))[0], real)   # nothing of the real record is lost
+        self.ingest(mail(body="Hi.\nRun record: " + record(secretKey="zzz") + "\n"))
+        self.assertNotIn("secretKey", rd(os.path.join(self.state, "repos", "demo", "1", "record.json")))
+        self.assertNotIn("secretKey", rd(self.page("demo", "1", "record.json")))
+
+    def imap_config(self, path, **over):
+        cfg = {"host": "imap.example.org", "user": "u", "password": "pw", "folder": "INBOX", **over}
+        os.makedirs(os.path.dirname(path), exist_ok=True)
+        with open(path, "w") as f:
+            json.dump(cfg, f)
+        os.chmod(path, 0o600)
+
+    def test_same_mailbox_as_comments_is_refused(self):
+        theirs = os.path.join(self.tmp, "comments", "imap.json")
+        with mock.patch.dict(os.environ, {"FW_COMMENTS_IMAP": theirs}):
+            self.imap_config(theirs)
+            self.imap_config(issues.imap_path())
+            with self.assertRaisesRegex(issues.Error, "own account or its own"):
+                issues.cmd_ingest()
+            self.assertEqual(self.cli("imap-check")[0], 1)
+            self.imap_config(issues.imap_path(), folder="INBOX.bugs")
+            issues.check_mailbox_separate()   # a different folder is fine
+            self.imap_config(issues.imap_path(), user="bugs")
+            issues.check_mailbox_separate()
+
+    def test_check_n_refuses_non_ascii_digits(self):
+        for bad in ("\u0661", "\uff11", "0", "-1", "1 ", ""):
+            with self.assertRaises(issues.Error, msg=bad):
+                issues.check_n(bad)
+        self.assertEqual(issues.check_n("12"), 12)
+        self.ingest(mail())
+        self.assertEqual(self.cli("show", "demo", "\u0661")[0], 1)
+
+    def test_replay_template_rules(self):
+        for ok in ("replay.html?report={record}", "r/p.html?x=%20&report={record}"):
+            self.assertTrue(issues.replay_ok(ok), ok)
+        for bad in ("a%2e%2e/x.html?r={record}", "a%00.html?r={record}", "a b.html?r={record}", "a\tb.html?r={record}",
+                    "a\nb.html?r={record}", "a\x7f.html?r={record}", "x.html", "/x?r={record}", "javascript:1?{record}"):
+            self.assertFalse(issues.replay_ok(bad), bad)
+
+    def test_same_message_id_is_stored_once(self):
+        self.ingest(mail(mid="<same@test>", body="One.\n"), mail(mid="<same@test>", body="Two, different bytes.\n"))
+        self.assertEqual(issues.issue_numbers("demo"), [1])
+        self.assertEqual(len(issues.load_issue("demo", 1)["messages"]), 1)
+
+    def test_a_crash_while_saving_creates_no_duplicate_and_loses_no_report(self):
+        with mock.patch.object(issues, "save_issue", side_effect=OSError("disk full")):
+            with self.assertRaises(OSError):
+                self.ingest(mail(mid="<crash@test>"))
+        self.assertEqual(self.count("done"), 0)           # the mail is still in the inbox
+        self.assertIn("keys.json", os.listdir(self.state))  # remembered before the issue was saved
+        self.ingest()                                     # next run: stored once
+        self.ingest()
+        self.assertEqual(len(issues.issue_numbers("demo")), 1)
+        self.assertEqual(len(issues.load_issue("demo", 1)["messages"]), 1)
+
+
+class Review2(Base):
+    def test_reply_record_needs_approval_in_reviewed_mode(self):
+        self.repo("rv", "public", "reviewed")
+        self.ingest(mail(repo="rv", body="First, no record.\n", mid="<a@x>"))
+        self.cli("approve", "rv", "1")
+        self.ingest(mail(repo="rv", body="Reply.\nRun record: " + record(options={"note": "unmoderated"}) + "\n",
+                         mid="<b@x>", extra_headers=("In-Reply-To: <a@x>",)))
+        self.assertTrue(issues.load_issue("rv", 1)["record"])
+        self.assertFalse(os.path.exists(self.page("rv", "1", "record.json")))
+        self.assertNotIn("Watch what happened", rd(self.page("rv", "1", "index.html")))
+        self.cli("approve", "rv", "1")
+        self.assertTrue(os.path.isfile(self.page("rv", "1", "record.json")))
+
+    def test_authenticated_reply_record_is_live_in_public_mode(self):
+        self.ingest(mail(body="No record.\n", mid="<a@x>"),
+                    mail(body="Reply.\nRun record: " + record() + "\n", mid="<b@x>", extra_headers=("In-Reply-To: <a@x>",)))
+        self.assertTrue(os.path.isfile(self.page("demo", "1", "record.json")))
+
+    ATTRIBUTIONS = (
+        "Am Mo., 3. Nov. 2026 um 10:00 Uhr schrieb Pavel Safronov <p@x.net>:",
+        "Le lun. 3 nov. 2026 \u00e0 10:00, Pavel Safronov <p@x.net> a \u00e9crit :",
+        "Op ma 3 nov 2026 om 10:00 schreef Pavel Safronov <p@x.net>:",
+        "Pavel Safronov <p@x.net> wrote:",
+        "El lun, 3 nov 2026 a las 10:00, Pavel Safronov escribi\u00f3:",
+    )
+
+    def test_attributions_in_any_language_cut_the_text(self):
+        for cut in self.ATTRIBUTIONS:
+            out = issues.report_text(f"My answer.\n\n{cut}\n> Pavel Safronov wrote this\nPavel Safronov again\n")
+            self.assertEqual(out, "My answer.", cut)
+        out = issues.report_text("My answer.\n________________________________\nFrom: Pavel\nSent: Monday\nTo: me\n\nPavel text")
+        self.assertEqual(out, "My answer.")
+        self.assertIn("Steps to reproduce:", issues.report_text("Steps to reproduce:\n1. tap\n"))   # a plain colon line stays
+
+    def test_device_block(self):
+        d65 = "Device: " + "x" * 57
+        out = issues.report_text(f"{d65}\nAppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148\nSeed 5, level 2\n")
+        self.assertEqual(out, "Seed 5, level 2")
+        d83 = "Device: Android 378x694; Mozilla/5.0 (Android 17; Mobile; rv:159.0) Gecko/159.0 Fir"
+        self.assertEqual(len(d83), 83)
+        out = issues.report_text(f"{d83}\n\nThe game froze when I tapped twice on level 3.\n")
+        self.assertEqual(out, "The game froze when I tapped twice on level 3.")
+        out = issues.report_text(f"{d83}\nThe game froze when I tapped twice on level 3.\n")   # prose right after: kept
+        self.assertEqual(out, "The game froze when I tapped twice on level 3.")
+        out = issues.report_text("Device: A\n  Firefox 1\n    more\nVersion 1.0\n")
+        self.assertEqual(out, "Version 1.0")
+
+    def test_unflow_keeps_pending_text_before_a_quote(self):
+        out = issues.report_text("Thanks for the fix \nit works now \n> quoted\n", flowed=True)
+        self.assertEqual(out, "Thanks for the fix it works now")
+
+    def test_unflow_signature_and_delsp_and_device(self):
+        out = issues.report_text("Hello there \n-- \nSignature name\n", flowed=True)
+        self.assertEqual(out, "Hello there")
+        self.assertEqual(issues.report_text("long wo \nrd end\n", flowed=True, delsp=True), "long word end")
+        self.assertEqual(issues.report_text("long wo \nrd end\n", flowed=True), "long wo rd end")
+        out = issues.report_text("Device: Pixel (Mobile; x) \n\nMy own paragraph.\n", flowed=True)
+        self.assertEqual(out, "My own paragraph.")
+        out = issues.report_text("Device: Pixel \nSeed 5, level 2\n", flowed=True)
+        self.assertEqual(out, "Seed 5, level 2")
+
+    def test_flowed_mail_end_to_end_with_delsp(self):
+        raw = mail(body="Thanks for the fix \nit works now \n> quoted\n").replace(
+            b"Content-Type: text/plain; charset=utf-8", b"Content-Type: text/plain; charset=utf-8; format=flowed; delsp=yes")
+        self.ingest(raw)
+        self.assertIn("Thanks for the fixit works now", issues.load_issue("demo", 1)["messages"][0]["text"])
+
+    def test_crash_recovery_does_not_confuse_a_reused_slot(self):
+        real = issues.save_issue
+        calls = []
+
+        def boom(issue):
+            calls.append(1)
+            if len(calls) == 1:
+                raise OSError("disk full")
+            real(issue)
+        a, b = mail(body="Report A\n", mid="<A@x>"), mail(body="Report B\n", mid="<B@x>")
+        with mock.patch.object(issues, "save_issue", boom):
+            with self.assertRaises(OSError):
+                self.ingest(a)
+        box = mailbox.Maildir(self.maildir, create=False)
+        k = list(box.keys())[0]
+        a_raw = box.get_bytes(k)
+        box.remove(k)
+        self.ingest(b)       # takes issue 1 while A's key still points at it
+        box.add(a_raw)
+        self.ingest()
+        texts = sorted(m["text"] for i in issues.load_issues("demo") for m in i["messages"])
+        self.assertEqual(texts, ["Report A", "Report B"])
+
+    def test_no_numbered_folder_without_an_issue_after_a_crash(self):
+        real = issues.save_issue
+        calls = []
+
+        def boom(issue):
+            calls.append(1)
+            if len(calls) == 1:
+                raise OSError("x")
+            real(issue)
+        with mock.patch.object(issues, "save_issue", boom):
+            with self.assertRaises(OSError):
+                self.ingest(mail(body="A\nRun record: " + record() + "\n"))
+        self.assertEqual(issues.issue_numbers("demo"), [])
+        self.ingest()
+        self.assertEqual(issues.issue_numbers("demo"), [1])
+        self.assertTrue(os.path.isfile(self.page("demo", "1", "record.json")))
+
+    def test_mailbox_comparison_ignores_case(self):
+        theirs = os.path.join(self.tmp, "comments", "imap.json")
+        with mock.patch.dict(os.environ, {"FW_COMMENTS_IMAP": theirs}):
+            Review.imap_config(self, theirs)
+            Review.imap_config(self, issues.imap_path(), host="IMAP.Example.ORG", user="U", folder="inbox")
+            with self.assertRaises(issues.Error):
+                issues.check_mailbox_separate()
+
+
+class Review3(Base):
+    def test_real_text_with_colons_and_brackets_is_not_cut(self):
+        for text in ("I pressed <Space> and\nthen this happened:\nthe bomb vanished",
+                     "The game crashed when I pressed\nthe <Enter> key:\nnothing else\n",
+                     "What happened:\nI pressed <Enter> on the menu:\nnothing",
+                     "He wrote notes and\nthen it broke:\nboom"):
+            self.assertEqual(issues.report_text(text), text.strip(), text)
+
+    def test_attribution_right_after_a_text_line(self):
+        text = ("Still happens in 0.3.0\nOn Tue, Nov 3, 2026 at 7:51 PM Code <bugs-k7q4+demo@fuzzyworld.net> wrote:\n> x\n"
+                "Code again\n")
+        self.assertEqual(issues.report_text(text), "Still happens in 0.3.0")
+
+    def test_weak_attribution_never_empties_the_text(self):
+        self.assertEqual(issues.report_text("Steps:\n> quoted by the app"), "Steps:")
+        self.assertEqual(issues.report_text("Hello\nSteps:\n> quoted"), "Hello")
+        self.assertEqual(issues.report_text("Pavel <p@x.net> wrote:\n> hi"), "")   # a real attribution may leave nothing
+
+    def test_wrapped_user_agent_starting_with_version_slash_is_dropped(self):
+        text = ("Bug here\nDevice: Mozilla/5.0 (iPhone; CPU iPhone OS 17_0) AppleWebKit/605.1.15\n"
+                "Version/17.0 Mobile/15E148 Safari/604.1\nSeed 1\n")
+        self.assertEqual(issues.report_text(text), "Bug here\nSeed 1")
+        self.assertIsNotNone(issues.KNOWN_LINE_RE.match("Version 0.2.0 (rules 43)"))
+        self.assertIsNone(issues.KNOWN_LINE_RE.match("Version/17.0 Mobile"))
+
+    def test_flowed_device_line_is_not_joined_onto_the_previous_line(self):
+        text = "Bug \nDevice: Pixel (Mobile; x)\nSeed 1\n"
+        self.assertEqual(issues.report_text(text, flowed=True), "Bug \nSeed 1")
+        self.assertEqual(issues.report_text("Bug \nRun record: {}\nafter\n", flowed=True), "Bug \nafter")
+
+
+class Review4(Base):
+    R = staticmethod(issues.report_text)
+
+    def test_blank_line_ends_a_flowed_paragraph(self):
+        self.assertEqual(issues.unflow(["Para one. ", "", "Para two."]), ["Para one. ", "", "Para two."])
+        self.assertEqual(self.R("Para one. \n\nPara two.", flowed=True), "Para one. \n\nPara two.")
+        for text in ("It broke. \n\nOn Mon, 3 Nov 2026, Bob Smith <bob@x.com> wrote:\n> old",
+                     "It broke.\n\nOn Mon, 3 Nov 2026, Bob Smith <bob@x.com> wrote:\n> old"):
+            self.assertEqual(self.R(text, flowed=True), "It broke.")
+
+    def test_wrapped_attributions_do_not_leak_names(self):
+        cases = {
+            "en_wrap_noaddr": "Bug text.\n\nOn Mon, 3 Nov 2026 at 10:00, Bob Smith\nwrote:\n> old",
+            "en_wrap_mid": "Bug text.\n\nOn Mon, 3 Nov 2026 at 10:00, Bob\nSmith <bob@x.com> wrote:\n> old",
+            "en_name_nl": "Bug text.\n\nOn Mon, 3 Nov 2026 at 10:00 Bob Smith <\nbob@x.com> wrote:\n> old",
+            "en_no_blank": "Bug text.\nOn Mon, 3 Nov 2026 at 10:00, Bob Smith <bob@x.com> wrote:\n> old",
+            "de_wrap": "Bug text.\n\nAm 03.11.2026 um 10:00 schrieb Hans M\u00fcller\n<hans@x.de>:\n> alt",
+            "de_wrap2": "Bug text.\n\nAm Mo., 3. Nov. 2026 um 10:00 Uhr schrieb Hans M\u00fcller <\nhans@x.de>:\n> alt",
+            "fr_wrap": "Bug text.\n\nLe lun. 3 nov. 2026 \u00e0 10:00, Jean Dupont <jean@x.fr> a\n\u00e9crit :\n> vieux",
+            "fr_nbsp": "Bug text.\n\nLe lun. 3 nov. 2026 \u00e0 10:00, Jean Dupont <jean@x.fr> a \u00e9crit\u00a0:\n> vieux",
+            "nl_wrap": "Bug text.\n\nOp ma 3 nov 2026 om 10:00 schreef Jan Jansen\n<jan@x.nl>:\n> oud",
+            "es_wrap": "Bug text.\n\nEl lun, 3 nov 2026 a las 10:00, Juan P\u00e9rez (<juan@x.es>)\nescribi\u00f3:\n> viejo",
+            "gmail_blank_before_quote": "Bug text.\n\nOn 3 Nov 2026, at 10:00, Bob Smith <bob@x.com> wrote:\n\n> old",
+            "schrieb_am": "Bug text.\n\nBob Smith <bob@x.com> schrieb am Mo., 3. Nov. 2026, 10:00:\n> alt",
+            "schrieb_am_wrapped": "Bug text.\n\nBob Smith <bob@x.com> schrieb am Mo., 3. Nov. 2026,\n10:00:\n> alt",
+            "wrote_plain_name": "Bug text.\n\nBob Smith wrote:\n> old",
+            "flowed": "Bug text. \n\nOn Mon, 3 Nov 2026 at 10:00, Bob Smith \n<bob@x.com> wrote:\n> old",
+        }
+        for label, text in cases.items():
+            out = self.R(text, flowed=(label == "flowed"))
+            self.assertEqual(out.strip(), "Bug text.", label)
+
+    def test_non_english_outlook_header_blocks_are_cut(self):
+        blocks = (
+            "________________________________\nVon: Bob Smith <bob@x.com>\nGesendet: Montag, 3. November 2026 10:00\nAn: bugs\nBetreff: Re: x\n\nalt",
+            "De : Bob Smith <bob@x.com>\nEnvoy\u00e9 : lundi 3 novembre 2026 10:00\n\u00c0 : bugs\nObjet : Re: x\n\nvieux",
+            "Van: Bob Smith\nVerzonden: maandag 3 november 2026 10:00\nAan: bugs\nOnderwerp: Re: x\n\noud",
+            "From: Bob Smith\nSent: Monday, November 3, 2026 10:00\nTo: bugs\nSubject: Re: x\n\nold",
+        )
+        for b in blocks:
+            self.assertEqual(self.R("Bug text.\n\n" + b), "Bug text.", b)
+
+    def test_header_block_rule_leaves_the_games_own_template_and_plain_lists_alone(self):
+        with open(os.path.join(FIXTURES, "explodorama-report.eml"), "rb") as f:
+            raw = f.read()
+        msg = issues.message_from_bytes(raw, policy=issues.policy.default)
+        text = self.R(msg.get_body(("plain",)).get_content())
+        self.assertIn("autopilot walked me into death", text)
+        self.assertIn("Seed 1464004408", text)
+        self.assertIn("Errors caught: none", text)
+        lst = "Expected: the bomb\nActual: nothing <Enter>\nSteps: tap\nNote: twice"
+        self.assertEqual(self.R(lst), lst)
+
+    def test_bare_verb_in_prose_is_not_an_attribution(self):
+        for text in ("I pressed <Enter> and wrote:\nnothing happened", "He wrote:\nthe thing broke",
+                     "On the menu screen I typed and wrote:\nnothing"):
+            self.assertEqual(self.R(text), text)
+
+    def test_weak_match_needs_everything_after_it_to_be_quoted(self):
+        self.assertEqual(self.R("It crashed.\nError shown:\n> TypeError: x\nThat was it."),
+                         "It crashed.\nError shown:\nThat was it.")
+        self.assertEqual(self.R("Error shown:\n> TypeError: x\nThat was it."), "Error shown:\nThat was it.")
+        self.assertEqual(self.R("It crashed.\nError shown:\n> TypeError: x\n\n> more"), "It crashed.")
+
+    def test_nfkc_comes_before_the_device_filter(self):
+        self.assertEqual(self.R("\uff24evice: Pavel's iPhone\nbroke"), "broke")
+        self.assertEqual(self.R("\uff32un record: {}\nafter"), "after")
+
+
+class FwScript(Base):
+    """server/fw publish_one and `fw issues <repo> <mode>` against temp directories (flock is faked: macOS has none)."""
+
+    def setUp(self):
+        super().setUp()
+        self.bin = os.path.join(self.tmp, "bin")
+        os.makedirs(self.bin)
+        shim = os.path.join(self.bin, "flock")
+        with open(shim, "w") as f:
+            f.write("#!/bin/sh\nexit 0\n")
+        os.chmod(shim, 0o755)
+        self.env = {**os.environ, "PATH": self.bin + os.pathsep + os.environ["PATH"], "HOME": self.tmp, "FW_GIT_ROOT": self.git,
+                    "FW_CODE_SITE": self.site, "FW_STATE": self.fwstate, "FW_LOGS": os.path.join(self.tmp, "logs"),
+                    "FW_APPS_SITE": self.apps, "FW_TOOLS": ROOT}
+        os.makedirs(self.site)
+
+    def fw(self, *args):
+        return subprocess.run(["bash", os.path.join(ROOT, "server", "fw"), *args], env=self.env, capture_output=True, text=True)
+
+    def config(self, repo="demo"):
+        return subprocess.run(["git", "-C", os.path.join(self.git, repo + ".git"), "config", "--get", "fuzzyworld.issues"],
+                              capture_output=True, text=True).stdout.strip()
+
+    def test_mode_setter_and_publish_one(self):
+        self.ingest(mail())
+        r = self.fw("issues", "demo", "reviewed")
+        self.assertEqual(r.returncode, 0, r.stderr)
+        self.assertEqual(self.config(), "reviewed")
+        self.assertFalse(os.path.exists(self.page()))
+        r = self.fw("issues", "demo", "public")
+        self.assertEqual(r.returncode, 0, r.stderr)
+        self.assertTrue(os.path.isfile(self.page("demo", "1", "index.html")))
+        shutil.rmtree(self.page())
+        r = self.fw("publish", "demo")   # publish_one runs `issues.py publish --locked` before gen.py repo
+        self.assertEqual(r.returncode, 0, r.stderr)
+        self.assertTrue(os.path.isfile(self.page("demo", "index.html")))
+        self.assertIn('/demo/issues/', rd(os.path.join(self.site, "demo", "index.html")))   # the summary links to Issues
+        r = self.fw("issues", "demo", "private")
+        self.assertEqual(r.returncode, 0, r.stderr)
+        self.assertFalse(os.path.exists(self.page()))
+
+    def test_other_words_and_unknown_repos_are_refused(self):
+        for args in (("issues", "demo", "bogus"), ("issues", "demo", "Public"), ("issues", "nosuch", "public"), ("issues", "demo")):
+            r = self.fw(*args)
+            self.assertNotEqual(r.returncode, 0, args)
+        self.assertEqual(self.config(), "public")   # unchanged
+
+
+class Replay(Base):
+    def app(self, name, repo="demo", replay="replay.html?report={record}", index=True):
+        d = os.path.join(self.apps, name)
+        os.makedirs(d)
+        if index:
+            with open(os.path.join(d, "index.html"), "w") as f:
+                f.write("x")
+        with open(os.path.join(d, ".fuzzyworld.json"), "w") as f:
+            json.dump({"app": name, "repo": repo, "replay": replay}, f)
+
+    def test_no_link_without_a_replay_key_or_app(self):
+        self.assertEqual(issues.replay_link("demo", 1), "")
+        self.app("demo", replay="")
+        self.assertEqual(issues.replay_link("demo", 1), "")
+
+    def test_link_is_encoded_and_prefers_the_app_named_like_the_repo(self):
+        self.app("demo-next", replay="next.html?r={record}")
+        self.app("demo")
+        link = issues.replay_link("demo", 3)
+        self.assertIn('href="https://apps.fuzzyworld.net/demo/replay.html?report='
+                      'https%3A%2F%2Fcode.fuzzyworld.net%2Fdemo%2Fissues%2F3%2Frecord.json"', link)
+        self.assertIn('rel="noopener"', link)
+        self.assertNotIn("next.html", link)
+        self.assertNotIn("<iframe", link)
+
+    def test_link_needs_an_index_page_and_a_safe_template(self):
+        self.app("demo", index=False)
+        self.assertEqual(issues.replay_link("demo", 1), "")
+        self.app("other", repo="demo", replay="javascript:alert({record})")
+        self.assertEqual(issues.replay_link("demo", 1), "")
+
+    def test_other_repos_apps_are_ignored(self):
+        self.app("game", repo="somebody-else")
+        self.assertEqual(issues.replay_link("demo", 1), "")
+
+    def test_page_shows_the_link_only_with_a_published_record(self):
+        self.app("demo")
+        self.ingest(mail(mid="<a@test>"), mail(subject="With record", body="Boom.\nRun record: " + record() + "\n"))
+        self.assertNotIn("Watch what happened", rd(self.page("demo", "1", "index.html")))
+        page = rd(self.page("demo", "2", "index.html"))
+        self.assertIn("Watch what happened", page)
+        self.assertIn("replay.html?report=", page)
+
+    def test_summary_link_refresh_follows_the_index(self):
+        self.ingest(mail())
+        self.cli("hide", "demo", "1")
+        self.assertEqual(self.summaries, ["demo", "demo"])   # appeared, then disappeared
+
+
+class ImapParameters(unittest.TestCase):
+    def test_defaults_are_the_comments_values_and_issues_can_override(self):
+        c = issues.comments.IMAPSource()
+        c.cfg, c.prefix = {"done_folder": "", "bad_folder": ""}, "INBOX."
+        self.assertEqual(c._folder("bad"), "INBOX.comments-bad")
+        self.assertEqual(c.max_size, issues.comments.IMAP_MAX_SIZE)
+        i = issues.comments.IMAPSource(cfg_path="/x/imap.json", stem="issues", max_size=5, log=print)
+        i.cfg, i.prefix = {"done_folder": "", "bad_folder": ""}, "INBOX."
+        self.assertEqual(i._folder("done"), "INBOX.issues-done")
+        self.assertFalse(i.available())
+
+
+if __name__ == "__main__":
+    unittest.main()