9.4 KiB
Phase 6 — Unsubscribe and rules editor
Status: complete. Built, tested, deployed on .13, and the DoD was
demonstrated against real mail. Parent 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)
/rulesis a plain-text editor over therulestable. 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.ParseRulescollects a bad line inErrorsand 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.Rulesis added to the OmniRoute prompt as a boundedUSER RULESblock (internal/reply/draft.go), andhandleDraftpasses the current rules text.
1.2 Unsubscribe detection and tracking (FR-7)
- Capture.
List-Unsubscribe(RFC 2369) andList-Unsubscribe-Post(RFC 8058) are parsed byinternal/mailfetchand 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/unsubscribeparses angle-bracketed and bare URIs intohttp/mailtolinks, 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 atlist-manage.comdoes not group unrelated senders). - View and act.
/unsubscribelists 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 toactions_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 <support@heritagefruittrees.com.au>.
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:
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
git revert 00bb0e0 && docker compose up -d --build.- Migration v6 is additive; the Phase 5 binary ignores the new columns and the
new tables. The
unsubscribetable andactions_logrows are left in place. - 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.