Files
email_agent/docs/phase6-unsubscribe.md

204 lines
9.4 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 <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:
```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.