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

@@ -102,18 +102,20 @@ func (f *Fetcher) Fetch(ctx context.Context) (Summary, error) {
headersSeen[parsed.SourceHeader] = struct{}{}
}
msg := &store.Message{
GmailID: gmailID(r, uidValidity),
ThreadID: r.GmailThreadID,
MessageIDHeader: parsed.MessageID,
FromAddr: parsed.From,
ToAddr: parsed.To,
Subject: parsed.Subject,
BodyText: parsed.BodyText,
SourceAddress: parsed.SourceAddress,
DateSent: parsed.Date,
ReceivedAt: now,
IMAPUID: int64(r.UID),
IMAPUIDValidity: int64(uidValidity),
GmailID: gmailID(r, uidValidity),
ThreadID: r.GmailThreadID,
MessageIDHeader: parsed.MessageID,
FromAddr: parsed.From,
ToAddr: parsed.To,
Subject: parsed.Subject,
BodyText: parsed.BodyText,
SourceAddress: parsed.SourceAddress,
ListUnsubscribe: parsed.ListUnsubscribe,
ListUnsubscribePost: parsed.ListUnsubscribePost,
DateSent: parsed.Date,
ReceivedAt: now,
IMAPUID: int64(r.UID),
IMAPUIDValidity: int64(uidValidity),
}
if _, err := f.store.UpsertMessage(ctx, msg); err != nil {
return sum, err

View File

@@ -27,8 +27,13 @@ type ParsedMessage struct {
Date time.Time
// MessageID is the RFC822 Message-ID header, used for reply threading
// (Phase 5, FR-8).
MessageID string
SourceAddress string
MessageID string
// ListUnsubscribe and ListUnsubscribePost are the RFC 2369/8058 headers that
// name the unsubscribe targets (Phase 6, FR-7). They are stored raw so the
// detector can run later without a refetch.
ListUnsubscribe string
ListUnsubscribePost string
SourceAddress string
// SourceHeader records which header the source address came from, so FR-2
// decisions are auditable.
SourceHeader string
@@ -43,11 +48,13 @@ func ParseMessage(raw []byte) (*ParsedMessage, error) {
}
h := msg.Header
pm := &ParsedMessage{
From: decodeHeader(h.Get("From")),
To: decodeHeader(h.Get("To")),
Subject: decodeHeader(h.Get("Subject")),
Date: parseDate(h.Get("Date")),
MessageID: strings.TrimSpace(h.Get("Message-Id")),
From: decodeHeader(h.Get("From")),
To: decodeHeader(h.Get("To")),
Subject: decodeHeader(h.Get("Subject")),
Date: parseDate(h.Get("Date")),
MessageID: strings.TrimSpace(h.Get("Message-Id")),
ListUnsubscribe: strings.TrimSpace(h.Get("List-Unsubscribe")),
ListUnsubscribePost: strings.TrimSpace(h.Get("List-Unsubscribe-Post")),
}
pm.SourceAddress, pm.SourceHeader = SourceAddress(textproto.MIMEHeader(h))
pm.BodyText = extractBody(textproto.MIMEHeader(h), msg.Body)

View File

@@ -172,3 +172,34 @@ func TestHeaderAddressParsing(t *testing.T) {
}
}
}
func TestParseMessageCapturesUnsubscribeHeaders(t *testing.T) {
raw := "From: Heritage Fruit Trees <support@heritagefruittrees.com.au>\r\n" +
"To: samuelrolfe@gmail.com\r\n" +
"X-Pm-Original-To: hello@where-woof.com\r\n" +
"Subject: New workshops\r\n" +
"List-Unsubscribe: <mailto:unsub@heritagefruittrees.com.au?subject=unsubscribe>, <https://heritagefruittrees.com.au/u?id=1>\r\n" +
"List-Unsubscribe-Post: List-Unsubscribe=One-Click\r\n" +
"Message-Id: <abc@mail.example>\r\n" +
"Date: Mon, 01 Jan 2026 10:00:00 +0000\r\n" +
"Content-Type: text/plain; charset=utf-8\r\n" +
"\r\n" +
"Hello, you can unsubscribe at https://heritagefruittrees.com.au/u?id=1.\r\n"
pm, err := ParseMessage([]byte(raw))
if err != nil {
t.Fatalf("ParseMessage() error = %v", err)
}
if !strings.Contains(pm.ListUnsubscribe, "mailto:unsub@heritagefruittrees.com.au") {
t.Errorf("ListUnsubscribe = %q", pm.ListUnsubscribe)
}
if !strings.Contains(pm.ListUnsubscribe, "https://heritagefruittrees.com.au/u?id=1") {
t.Errorf("ListUnsubscribe = %q", pm.ListUnsubscribe)
}
if pm.ListUnsubscribePost != "List-Unsubscribe=One-Click" {
t.Errorf("ListUnsubscribePost = %q", pm.ListUnsubscribePost)
}
if pm.SourceAddress != "hello@where-woof.com" {
t.Errorf("SourceAddress = %q", pm.SourceAddress)
}
}