docs: Phase 6 unsubscribe + rules editor evidence (AUD-72)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-10-11 22:26:55 +11:00
parent 00bb0e05c4
commit c4213ebed1
2 changed files with 236 additions and 15 deletions

View File

@@ -9,18 +9,24 @@ pre-planning material and is not authoritative.
## Status
**Phase 5 — Reply drafts.** The Go skeleton, session login, and HTMX dashboard
(Phase 1), real mail over **IMAP with an app password, forced to IPv4** (Phase
2), **JEV classification** (Phase 3), **bulk triage actions** (Phase 4), and now
**reply drafts** (Phase 5). Open a message to read it, draft a reply with the LLM
through **OmniRoute** from a template, edit it, and send it over Gmail SMTP
(IPv4 + STARTTLS). A reply is sent **as the address the mail arrived at**
(`X-Pm-Original-To`, FR-15) and returns to the original sender; the reply sets
`In-Reply-To`/`References` so Gmail keeps the thread, and the original is filed
under its `Replied-to/<address>` label. Sending is a deliberate UI action with a
confirmation — there is no auto-send (§10). Every action, including drafts and
sends, is appended to `actions_log` (FR-13). See
[`docs/phase5-replies.md`](docs/phase5-replies.md) for the phase evidence and
**Phase 6 — Unsubscribe and rules editor.** The Go skeleton, session login,
and HTMX dashboard (Phase 1), real mail over **IMAP with an app password,
forced to IPv4** (Phase 2), **JEV classification** (Phase 3), **bulk triage
actions** (Phase 4), **reply drafts** (Phase 5), and now **unsubscribe
detection and the rules editor** (Phase 6). Open a message to read it, draft a
reply with the LLM through **OmniRoute** from a template, edit it, and send it
over Gmail SMTP (IPv4 + STARTTLS). A reply is sent **as the address the mail
arrived at** (`X-Pm-Original-To`, FR-15) and returns to the original sender; the
reply sets `In-Reply-To`/`References` so Gmail keeps the thread, and the
original is filed under its `Replied-to/<address>` label. Sending is a
deliberate UI action with a confirmation — there is no auto-send (§10). The
**rules editor** (`/rules`) saves and versions the plain-text rules that JEV and
the reply model read. **Unsubscribe detection** (`/unsubscribe`) finds RFC
2369/8058 links and body links, keeps a domain ignore list, and records what Sam
marks — it **never follows a link**. Every action, including drafts, sends,
rules edits, and unsubscribe marks, is appended to `actions_log` (FR-13). See
[`docs/phase6-unsubscribe.md`](docs/phase6-unsubscribe.md) for the phase evidence,
[`docs/phase5-replies.md`](docs/phase5-replies.md) for Phase 5, and
[`docs/phase4-triage.md`](docs/phase4-triage.md) for Phase 4.
## Layout
@@ -35,6 +41,7 @@ internal/reply/ LLM reply drafts through OmniRoute, reply templates
internal/session/ server-side session store + signed cookie values
internal/store/ SQLite open + schema (internal/store/schema.sql)
internal/server/ HTTP routes, auth middleware, HTMX handlers
internal/unsubscribe/ RFC 2369/8058 + body unsubscribe-link detection (no network)
internal/version/ build metadata (set via -ldflags)
web/static/ embedded design tokens, Inter fonts, htmx, app.css
web/templates/ embedded server-rendered HTML
@@ -123,8 +130,9 @@ from contains "amazon.com" => category=Newsletter, action=unsubscribe
Conditions join with ` and ` over `from`, `to`, `subject`, `source`, `body`
using `contains`, `equals`, `starts_with`, `ends_with`. The first matching rule
wins and overrides only the fields it names. The rules editor UI is Phase 6; the
classifier already reads the stored text.
wins and overrides only the fields it names. Edit and version the rules at
`/rules` (Phase 6); the classifier and the reply model both read the newest
version.
### Categories (FR-14)
@@ -231,6 +239,14 @@ Caddy on `.35` proxies `mail.lab.audasmedia.com.au` to this port.
| `POST` | `/categories/add` | session + CSRF | Adds a category |
| `POST` | `/categories/rename` | session + CSRF | Renames a category and updates existing classifications |
| `POST` | `/categories/remove` | session + CSRF | Removes a category and reassigns its messages |
| `GET` | `/rules` | session | Rules editor: current text, versions, parse errors (FR-11) |
| `POST` | `/rules/save` | session + CSRF | Saves the rules text as a new version |
| `POST` | `/rules/restore` | session + CSRF | Restores an older version as a new version |
| `POST` | `/rules/reclassify` | session + CSRF | Re-classifies every message with the current rules |
| `GET` | `/unsubscribe` | session | Detected unsubscribe links, ignore list, and records (FR-7) |
| `POST` | `/unsubscribe/ignore` | session + CSRF | Adds a domain to the ignore list |
| `POST` | `/unsubscribe/unignore` | session + CSRF | Removes a domain record |
| `POST` | `/unsubscribe/mark` | session + CSRF + confirm | Records a domain for unsubscribe; never contacts the link |
| `GET` | `/static/…` | public | Embedded `tokens.css`, `app.css`, Inter fonts, `htmx.min.js` |
Unauthenticated requests to `/` and `/partials/…` redirect to `/login?next=…`.
@@ -248,4 +264,6 @@ and runs on every start. Migration v2 adds `imap_uid` and `imap_uidvalidity` to
version once, so a category removed by the user is not silently re-added;
migration v4 adds `folder` and `mailbox` to `messages` for the Phase 4 triage
folders; migration v5 adds `messages.message_id_header` (reply threading) and
`drafts.sent_at`.
`drafts.sent_at`; migration v6 adds `messages.list_unsubscribe` and
`messages.list_unsubscribe_post` and extends `unsubscribe` with `url` and
`updated_at`.