# Phase 6 — Unsubscribe and rules editor Status: **complete.** Built, tested, deployed on `.13`, and the DoD was demonstrated against real mail. Parent [AUD-68](/AUD/issues/AUD-68). Phase 6 of AGENTS.md §11. Commit `00bb0e0`. **DoD:** a rule written in the editor changes the classification of a real message. — met (§2.1). ## 1. What was built ### 1.1 Rules editor (FR-4, FR-11) - **`/rules`** is a plain-text editor over the `rules` table. Saving appends a new numbered version (`store.SaveRules`); nothing is overwritten. The page shows the current version, the active rule count, and a version list. - **Version history** lists the newest revisions with timestamp, line count, and a first-rule preview. **Restore** re-saves an older version as a new version, so history stays append-only. - **Parse errors** are surfaced on the page. `classify.ParseRules` collects a bad line in `Errors` and skips it; the editor shows exactly which lines were ignored and why. - **Apply** re-runs the classifier over every message (`POST /rules/reclassify`) and reports considered/classified/failed back on the same page. - **JEV and the LLM read it.** The classifier already read the rules and applied the first match as an override. The reply model now reads the same document: `reply.Request.Rules` is added to the OmniRoute prompt as a bounded `USER RULES` block (`internal/reply/draft.go`), and `handleDraft` passes the current rules text. ### 1.2 Unsubscribe detection and tracking (FR-7) - **Capture.** `List-Unsubscribe` (RFC 2369) and `List-Unsubscribe-Post` (RFC 8058) are parsed by `internal/mailfetch` and stored raw on the message (`messages.list_unsubscribe`, `messages.list_unsubscribe_post`). They are kept raw so detection can be re-run without a refetch. - **Detect.** `internal/unsubscribe` parses angle-bracketed and bare URIs into `http`/`mailto` links, recognises the one-click header, de-duplicates, and falls back to a body scan when there is no header (a URL on a line that mentions "unsubscribe"). The ignore-list key is the sender's brand domain when it is a real domain, with the link host as the fallback for free-mail senders (so a mailto endpoint at `list-manage.com` does not group unrelated senders). - **View and act.** `/unsubscribe` lists the messages with a detected link, the domain-level **ignore list**, and the domains **recorded for unsubscribe**. Sam can ignore a domain, un-ignore it, or record it for unsubscribe. Every action is a CSRF-protected POST and is written to `actions_log` (FR-13). - **No link is ever followed.** The app makes no network call to an unsubscribe URL and sends no mail. "Mark for unsubscribe" records the decision only; the page states this plainly, and following a real link stays a manual step that needs Sam's explicit approval. A pending RFC 8058 one-click POST is detected and shown, but never sent. ### 1.3 Schema (migration v6, additive) | Object | Change | |---|---| | `messages` | `list_unsubscribe`, `list_unsubscribe_post` (TEXT) | | `unsubscribe` | `url` (TEXT NOT NULL DEFAULT ''), `updated_at` (TEXT) | | `schema_migrations` | records version 6 | The `unsubscribe` table keeps its `status` values `done` (recorded) and `ignored` (ignore list). Migration v6 is additive; a Phase 5 binary ignores the new columns. ## 2. DoD evidence ### 2.1 A rule written in the editor changed a real message Real message **87**, from `Heritage Fruit Trees `. **Before.** Classified by JEV with no rule (rules version 1): | category | importance | action | confidence | rules_version | |---|---|---|---|---| | Newsletter | low | unsubscribe | 0.98 | 1 | **Rule written in the editor.** `POST /rules/save` with: ``` # Phase 6 DoD rule (AUD-72) from contains "heritagefruittrees.com.au" => category=Action, importance=high, action=file ``` The page then showed **`version 2 · 1 active rule`**. **After.** `POST /rules/reclassify` returned `Re-classified 103 of 103 message(s); 0 failed. The current rules are now applied.` Message 87's newest classification row is now: | category | importance | action | confidence | model | rules_version | |---|---|---|---|---|---| | Action | high | file | 1.00 | jev-1.13.0 | 2 | The per-message action log records the override and the matching rule line: ``` classify category=Action importance=high action=file confidence=1.00 model=jev-1.13.0 rules_version=2 rule_line=2 rule="from contains ..." ``` Message 87's history therefore shows the change: three `Newsletter/low/ unsubscribe` rows under `rules_version=1`, then `Action/high/file` under `rules_version=2`. ### 2.2 Unsubscribe detection `docker exec email-agent email-agent -fetch` refreshed 100 messages, filling the new header columns. **31 messages carried a `List-Unsubscribe` header.** The live `/unsubscribe` page reported **33 messages with an unsubscribe link** — the 31 header matches plus 2 body-fallback matches (for example `allakiyas.com`). Example detected domains in the live page: `google.com`, `spotify.com`, `subaru.com.au`, `linkedin.com`, `email.centrecom.com.au`, `r.repco.com.au`, `ozmobiles.com.au`, `heritagefruittrees.com.au`, `edm.booktopia.com.au`, `netflix.com`, `urbnsurf.com`. A `mailto:` example (`Google Alerts`) and a web example (message 87, `https://drdzlr.clicks.mlsend.com/...`) both render in the list and in the message detail pane. ### 2.3 Ignore list and record Demonstrated on the live UI with `heritagefruittrees.com.au`, then reverted (the domain-level row is deleted; the append-only log keeps the history): | Step | Result | |---|---| | `POST /unsubscribe/ignore` | 303; page shows `Ignore list · 1 domain(s)` with an `ignored` pill and the recorded URL | | `POST /unsubscribe/mark` `confirm=yes` | 303; page shows `Recorded for unsubscribe · 1 domain(s)` with a `recorded` pill, the URL, and a timestamp, and the note "The app has not contacted any link." | | `POST /unsubscribe/unignore` | 303; domain removed, `unsubscribe` table back to 0 rows | Durable `actions_log` rows (FR-13): ``` rules_save version=2 rules=1 parse_errors=0 reclassify considered=103 classified=103 failed=0 rules_version=2 unsubscribe_ignore domain=heritagefruittrees.com.au unsubscribe_marked domain=heritagefruittrees.com.au url=https://drdzlr.clicks.mlsend.com/... unsubscribe_unignore domain=heritagefruittrees.com.au ``` No email was sent, and no unsubscribe URL was contacted. ### 2.4 Live deployment | Check | Result | |---|---| | `GET /health` | `200 {"status":"ok","version":"phase6","commit":"00bb0e0","database":"ok"}` | | Container log | `database ready … schema_version: 6` (migration v6 ran in place on the Phase 1–5 volume) | | Login over public HTTPS | `https://mail.lab.audasmedia.com.au/health` → 200; `/rules` → 303 to `/login` (auth enforced) | | Dashboard | shows `Rules` and `Unsubscribe` links | | Message detail (87) | shows `action-pill">file` and the detected unsubscribe link | ### 2.5 Automated tests `gofmt -l` clean, `go vet ./...` clean, `go test ./...` green (in `golang:1.25-alpine`). New coverage: | Package | Tests | |---|---| | `internal/unsubscribe` | header parsing (mailto + http, bare, one-click, de-dup), body fallback, unrelated-link rejection, free-mail domain fallback, `Domain()` | | `internal/mailfetch` | `ParseMessage` captures both List-Unsubscribe headers and the source address | | `internal/reply` | the rules document is included in the draft prompt | | `internal/store` | migration v6 on a hand-built v5 database; unsubscribe record upsert/list/clear and status validation; candidate selection (header + body); rules version list + restore lookup | | `internal/server` | rules page renders; save creates a version; CSRF required; parse errors shown; restore creates a new version; re-classify redirects without JEV; unsubscribe detection renders; ignore/un-ignore; mark requires confirmation and records the URL; CSRF on every mutation; bad domain rejected; message detail shows the link | ## 3. Configuration No new environment variables. The rules editor, unsubscribe detection, and tracking work without any new credentials. Re-classify needs `JEV_API_KEY` (already configured); reply drafting that reads the rules needs `OMNIROUTE_API_KEY` (already configured). No secret value is committed. ## 4. Deployment On `.13`, from the repo, with the host secrets in scope: ```sh set -a; . ~/.config/environment.d/10-secrets.conf; set +a docker compose build docker compose up -d # migration v6 runs on start; data volume kept docker exec email-agent email-agent -fetch # fills the List-Unsubscribe columns ``` ## 5. Rollback 1. `git revert 00bb0e0 && docker compose up -d --build`. 2. Migration v6 is additive; the Phase 5 binary ignores the new columns and the new tables. The `unsubscribe` table and `actions_log` rows are left in place. 3. Reverting the code does not un-save a rules version. Restore the intended version from `/rules`, or re-save it. ## 6. Not done / deferred - **Following an unsubscribe link is not implemented.** This is deliberate (AUD-72): the app only marks and records. Acting on a real link — an RFC 8058 one-click POST, a mailto, or a web form — needs Sam's explicit approval and is a separate, manually approved step. - The ignore-list key is the sender's domain as seen in `From:`. Different subdomains of one brand are tracked separately; registrable-domain grouping (a public suffix list) is not implemented. - **1-month body retention** (§7) is still not implemented.