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:
178
web/templates/unsubscribe.html
Normal file
178
web/templates/unsubscribe.html
Normal 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">☾</span>
|
||||
<span class="icon-dark" aria-hidden="true">☀</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>
|
||||
Reference in New Issue
Block a user