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

- FR-11 rules editor: plain-text area at /rules, saved and versioned
  (SaveRules appends a version; restore re-saves an old version), parse
  errors surfaced, one-click re-classify. The reply model now reads the
  rules document too (FR-11).
- FR-7 unsubscribe: parse and store List-Unsubscribe / List-Unsubscribe-Post
  (migration v6), detect email and web links (internal/unsubscribe), a
  domain-level ignore list, and an explicit record of what was marked. No
  code path contacts an unsubscribe link; acting on one needs Sam's approval.
- Migration v6 is additive; unsubscribe gains url/updated_at.
- Tests: detector, parser, store migration/records/candidates, rules editor,
  unsubscribe handlers, message-detail rendering.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-10-11 22:24:38 +11:00
parent 7c66e4ad31
commit 00bb0e05c4
24 changed files with 2121 additions and 42 deletions

View File

@@ -26,6 +26,8 @@
<header class="nav-bar">
<span class="wordmark">Email Agent</span>
<div class="nav-actions">
<a class="btn btn-utility" href="/rules">Rules</a>
<a class="btn btn-utility" href="/unsubscribe">Unsubscribe</a>
<button class="btn btn-utility theme-toggle" type="button" onclick="eaToggleTheme()" aria-label="Toggle dark mode">
<span class="icon-light" aria-hidden="true">&#9790;</span>
<span class="icon-dark" aria-hidden="true">&#9728;</span>

View File

@@ -13,6 +13,18 @@
</div>
</div>
{{if .UnsubLinks}}
<section class="unsub-detected" aria-label="Unsubscribe">
<h3>Unsubscribe</h3>
{{if eq .UnsubStatus "ignored"}}<span class="pill">ignored</span>{{end}}
{{if eq .UnsubStatus "done"}}<span class="pill">recorded</span>{{end}}
<ul class="unsub-link-list">
{{range .UnsubLinks}}<li><span class="pill">{{.Method}}</span> <span class="unsub-url">{{.URL}}</span></li>{{end}}
</ul>
<a class="btn btn-utility btn-small" href="/unsubscribe">Manage unsubscribe</a>
</section>
{{end}}
<details class="detail-original" open>
<summary>Original message</summary>
<pre class="detail-text">{{.Message.BodyText}}</pre>

125
web/templates/rules.html Normal file
View File

@@ -0,0 +1,125 @@
<!doctype html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Rules · Email Agent</title>
<link rel="stylesheet" href="/static/tokens.css">
<link rel="stylesheet" href="/static/app.css">
<script>
(function () {
try {
var t = localStorage.getItem("ea-theme");
if (!t) t = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
document.documentElement.setAttribute("data-theme", t);
} catch (e) {}
})();
function eaToggleTheme() {
var el = document.documentElement;
var next = el.getAttribute("data-theme") === "dark" ? "light" : "dark";
el.setAttribute("data-theme", next);
try { localStorage.setItem("ea-theme", next); } catch (e) {}
}
</script>
</head>
<body>
<header class="nav-bar">
<span class="wordmark">Email Agent</span>
<div class="nav-actions">
<a class="btn btn-utility" href="/">Back to inbox</a>
<a class="btn btn-utility" href="/unsubscribe">Unsubscribe</a>
<button class="btn btn-utility theme-toggle" type="button" onclick="eaToggleTheme()" aria-label="Toggle dark mode">
<span class="icon-light" aria-hidden="true">&#9790;</span>
<span class="icon-dark" aria-hidden="true">&#9728;</span>
</button>
<form method="post" action="/logout">
<input type="hidden" name="csrf" value="{{.CSRFToken}}">
<button class="btn btn-utility" type="submit">Log out</button>
</form>
</div>
</header>
<main class="page">
<h1 class="greeting">Rules</h1>
{{if .Note}}<div class="scan-note" role="status">{{.Note}}</div>{{end}}
{{if .Error}}<div class="alert alert-error" role="alert">{{.Error}}</div>{{end}}
<section class="panel rules-panel" aria-label="Rules editor">
<div class="panel-head">
<h2>Rules editor</h2>
<span class="muted">version {{.Version}} · {{.RuleCount}} active rule(s) · {{.TotalMessages}} message(s)</span>
</div>
<div class="panel-body">
<p class="muted rules-help">
One rule per line: <code>&lt;condition&gt; =&gt; &lt;effects&gt;</code>.
Fields are <code>from</code>, <code>to</code>, <code>subject</code>,
<code>source</code>, and <code>body</code>; operators are
<code>contains</code>, <code>equals</code>, <code>starts_with</code>,
and <code>ends_with</code>. The first matching rule wins and overrides
the JEV fields it names. Lines starting with <code>#</code> are comments.
The classifier and the reply model both read this text.
</p>
{{if .Errors}}
<div class="alert alert-error" role="alert">
<strong>{{len .Errors}} line(s) could not be parsed and are being skipped:</strong>
<ul class="rules-errors">
{{range .Errors}}<li>{{.Error}}</li>{{end}}
</ul>
</div>
{{end}}
<form class="rules-form" method="post" action="/rules/save">
<input type="hidden" name="csrf" value="{{.CSRFToken}}">
<label class="field" for="rules-text">
<span>Rules text (plain text)</span>
</label>
<textarea class="text-input rules-textarea" id="rules-text" name="text" rows="18" spellcheck="false">{{.Text}}</textarea>
<div class="rules-actions">
<button class="btn btn-primary" type="submit">Save new version</button>
<span class="muted faint">Saving appends a version; nothing is re-classified until you re-classify.</span>
</div>
</form>
{{if .ClassifierReady}}
<form class="rules-reclassify" method="post" action="/rules/reclassify"
onsubmit="return confirm('Re-classify every stored message with the current rules?');">
<input type="hidden" name="csrf" value="{{.CSRFToken}}">
<button class="btn btn-utility" type="submit">Re-classify all now</button>
</form>
{{else}}
<p class="faint">Re-classify is unavailable until JEV_API_KEY is set.</p>
{{end}}
</div>
</section>
<section class="panel rules-panel" aria-label="Rules versions">
<div class="panel-head"><h2>Versions</h2></div>
<div class="panel-body">
<div class="rules-versions">
{{range .Revisions}}
<div class="rules-version">
<div class="rules-version-main">
<span class="pill">v{{.Version}}</span>
<span class="muted">{{.CreatedAt}}</span>
<span class="muted">{{.Lines}} rule line(s)</span>
<span class="rules-version-preview">{{.Preview}}</span>
</div>
<form method="post" action="/rules/restore"
onsubmit="return confirm('Restore version {{.Version}} as a new version?');">
<input type="hidden" name="csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="version" value="{{.Version}}">
<button class="btn btn-utility btn-small" type="submit">Restore</button>
</form>
</div>
{{else}}
<p class="muted">No versions stored yet.</p>
{{end}}
</div>
</div>
</section>
</main>
</body>
</html>

View File

@@ -0,0 +1,178 @@
<!doctype html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Unsubscribe · Email Agent</title>
<link rel="stylesheet" href="/static/tokens.css">
<link rel="stylesheet" href="/static/app.css">
<script>
(function () {
try {
var t = localStorage.getItem("ea-theme");
if (!t) t = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
document.documentElement.setAttribute("data-theme", t);
} catch (e) {}
})();
function eaToggleTheme() {
var el = document.documentElement;
var next = el.getAttribute("data-theme") === "dark" ? "light" : "dark";
el.setAttribute("data-theme", next);
try { localStorage.setItem("ea-theme", next); } catch (e) {}
}
</script>
</head>
<body>
<header class="nav-bar">
<span class="wordmark">Email Agent</span>
<div class="nav-actions">
<a class="btn btn-utility" href="/">Back to inbox</a>
<a class="btn btn-utility" href="/rules">Rules</a>
<button class="btn btn-utility theme-toggle" type="button" onclick="eaToggleTheme()" aria-label="Toggle dark mode">
<span class="icon-light" aria-hidden="true">&#9790;</span>
<span class="icon-dark" aria-hidden="true">&#9728;</span>
</button>
<form method="post" action="/logout">
<input type="hidden" name="csrf" value="{{.CSRFToken}}">
<button class="btn btn-utility" type="submit">Log out</button>
</form>
</div>
</header>
<main class="page">
<h1 class="greeting">Unsubscribe</h1>
{{if .Note}}<div class="scan-note" role="status">{{.Note}}</div>{{end}}
{{if .Error}}<div class="scan-note scan-note-error" role="alert">{{.Error}}</div>{{end}}
<section class="panel unsub-panel" aria-label="Detected unsubscribe links">
<div class="panel-head">
<h2>Detected links</h2>
<span class="muted">{{.CandidateCount}} message(s) with an unsubscribe link</span>
</div>
<div class="panel-body">
<p class="muted unsub-help">
The app only marks and records. It never opens an unsubscribe link:
acting on a real link is a manual step that needs Sam's approval.
Ignoring a domain keeps it out of the recommendation list.
</p>
<div class="unsub-list">
{{range .Items}}
<div class="unsub-row">
<div class="unsub-main">
<div class="unsub-subject">{{.Subject}}</div>
<div class="msg-from">{{.From}}</div>
{{if .SourceAddr}}<div class="msg-source">arrived at <span class="source-addr">{{.SourceAddr}}</span></div>{{end}}
<div class="unsub-links">
{{range .Links}}
<div class="unsub-link">
<span class="pill">{{.Method}}</span>
<span class="unsub-source">{{.Source}}</span>
<span class="unsub-url">{{.URL}}</span>
</div>
{{end}}
</div>
<div class="unsub-domain">domain: <strong>{{.Domain}}</strong>{{if .Category}} · {{.Category}}{{end}}{{if .When}} · {{.When}}{{end}}</div>
</div>
<div class="unsub-actions">
{{if eq .Status "ignored"}}
<span class="pill">ignored</span>
<form method="post" action="/unsubscribe/unignore">
<input type="hidden" name="csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="domain" value="{{.Domain}}">
<button class="btn btn-utility btn-small" type="submit">Un-ignore</button>
</form>
{{else if eq .Status "done"}}
<span class="pill">recorded</span>
<form method="post" action="/unsubscribe/unignore">
<input type="hidden" name="csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="domain" value="{{.Domain}}">
<button class="btn btn-utility btn-small" type="submit">Clear record</button>
</form>
{{else}}
<form method="post" action="/unsubscribe/ignore">
<input type="hidden" name="csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="domain" value="{{.Domain}}">
<input type="hidden" name="url" value="{{(index .Links 0).URL}}">
<button class="btn btn-utility btn-small" type="submit">Ignore domain</button>
</form>
<form method="post" action="/unsubscribe/mark"
onsubmit="return confirm('Record {{.Domain}} for unsubscribe? The app records it and does NOT contact the link.');">
<input type="hidden" name="csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="domain" value="{{.Domain}}">
<input type="hidden" name="url" value="{{(index .Links 0).URL}}">
<input type="hidden" name="confirm" value="yes">
<button class="btn btn-utility btn-small" type="submit">Mark for unsubscribe</button>
</form>
{{end}}
</div>
</div>
{{else}}
<div class="empty-state">
<div class="empty-title">No unsubscribe links detected</div>
<p class="empty-copy">Check mail first. Newsletter and bulk senders usually carry a List-Unsubscribe header.</p>
</div>
{{end}}
</div>
</div>
</section>
<section class="panel unsub-panel" aria-label="Ignore list">
<div class="panel-head">
<h2>Ignore list</h2>
<span class="muted">{{len .Ignored}} domain(s)</span>
</div>
<div class="panel-body">
<div class="rules-versions">
{{range .Ignored}}
<div class="rules-version">
<div class="rules-version-main">
<span class="pill">ignored</span>
<span class="unsub-domain"><strong>{{.Domain}}</strong></span>
{{if .URL}}<span class="unsub-url">{{.URL}}</span>{{end}}
</div>
<form method="post" action="/unsubscribe/unignore">
<input type="hidden" name="csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="domain" value="{{.Domain}}">
<button class="btn btn-utility btn-small" type="submit">Un-ignore</button>
</form>
</div>
{{else}}
<p class="muted">No domains ignored.</p>
{{end}}
</div>
</div>
</section>
<section class="panel unsub-panel" aria-label="Recorded unsubscribes">
<div class="panel-head">
<h2>Recorded for unsubscribe</h2>
<span class="muted">{{len .Recorded}} domain(s)</span>
</div>
<div class="panel-body">
<p class="muted">These are recorded only. The app has not contacted any link.</p>
<div class="rules-versions">
{{range .Recorded}}
<div class="rules-version">
<div class="rules-version-main">
<span class="pill">recorded</span>
<span class="unsub-domain"><strong>{{.Domain}}</strong></span>
{{if .URL}}<span class="unsub-url">{{.URL}}</span>{{end}}
<span class="muted">{{.UpdatedAt}}</span>
</div>
<form method="post" action="/unsubscribe/unignore">
<input type="hidden" name="csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="domain" value="{{.Domain}}">
<button class="btn btn-utility btn-small" type="submit">Clear record</button>
</form>
</div>
{{else}}
<p class="muted">Nothing recorded yet.</p>
{{end}}
</div>
</div>
</section>
</main>
</body>
</html>