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:
240
internal/unsubscribe/unsubscribe.go
Normal file
240
internal/unsubscribe/unsubscribe.go
Normal file
@@ -0,0 +1,240 @@
|
||||
// Package unsubscribe detects unsubscribe links (FR-7).
|
||||
//
|
||||
// The primary source is the List-Unsubscribe header (RFC 2369) together with
|
||||
// the List-Unsubscribe-Post header (RFC 8058). When neither is present the body
|
||||
// is scanned for a URL on a line that mentions "unsubscribe".
|
||||
//
|
||||
// Detection only. This package never fetches a URL and never sends mail.
|
||||
// Acting on an unsubscribe link needs Sam's explicit approval (AGENTS.md §10
|
||||
// and AUD-72); the app only marks and records.
|
||||
package unsubscribe
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Link methods.
|
||||
const (
|
||||
MethodHTTP = "http"
|
||||
MethodMailto = "mailto"
|
||||
)
|
||||
|
||||
// Detection sources. They are stored in the action log so a marking is
|
||||
// auditable.
|
||||
const (
|
||||
SourceListUnsubscribe = "list-unsubscribe"
|
||||
SourceBody = "body"
|
||||
)
|
||||
|
||||
// Link is one detected unsubscribe target.
|
||||
type Link struct {
|
||||
// URL is the raw target, for example "https://x.example/u?id=1".
|
||||
URL string
|
||||
// Method is MethodHTTP or MethodMailto.
|
||||
Method string
|
||||
// Domain is the ignore-list key. It is the sender's brand domain when that
|
||||
// is a real domain, otherwise the link host.
|
||||
Domain string
|
||||
// Host is the host of the link itself, shown for context.
|
||||
Host string
|
||||
// Source is SourceListUnsubscribe or SourceBody.
|
||||
Source string
|
||||
}
|
||||
|
||||
// Result is the detection outcome for one message.
|
||||
type Result struct {
|
||||
Links []Link
|
||||
// Header is the raw List-Unsubscribe value, if any.
|
||||
Header string
|
||||
// OneClick reports a RFC 8058 List-Unsubscribe=One-Click header.
|
||||
OneClick bool
|
||||
}
|
||||
|
||||
// Detect returns the unsubscribe links for a message. from is the raw From
|
||||
// header; body is the stored plain-text body.
|
||||
func Detect(listUnsubscribe, listUnsubscribePost, from, body string) Result {
|
||||
res := Result{
|
||||
Header: strings.TrimSpace(listUnsubscribe),
|
||||
OneClick: isOneClick(listUnsubscribePost),
|
||||
}
|
||||
sender := brandDomain(from)
|
||||
|
||||
seen := map[string]struct{}{}
|
||||
add := func(raw, source string) {
|
||||
link, ok := makeLink(raw, source, sender)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
key := link.Method + ":" + strings.ToLower(link.URL)
|
||||
if _, dup := seen[key]; dup {
|
||||
return
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
res.Links = append(res.Links, link)
|
||||
}
|
||||
|
||||
for _, raw := range headerLinks(res.Header) {
|
||||
add(raw, SourceListUnsubscribe)
|
||||
}
|
||||
if len(res.Links) == 0 {
|
||||
for _, raw := range bodyLinks(body) {
|
||||
add(raw, SourceBody)
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// isOneClick reports a RFC 8058 one-click unsubscribe header. The documented
|
||||
// value is "List-Unsubscribe=One-Click".
|
||||
func isOneClick(v string) bool {
|
||||
return strings.Contains(strings.ToLower(v), "one-click")
|
||||
}
|
||||
|
||||
var angleRE = regexp.MustCompile(`<([^>]+)>`)
|
||||
|
||||
// headerLinks extracts the URIs from a List-Unsubscribe value. The RFC form is
|
||||
// a comma-separated list of angle-bracketed URIs; a bare URI is also accepted.
|
||||
func headerLinks(v string) []string {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, m := range angleRE.FindAllStringSubmatch(v, -1) {
|
||||
if s := strings.TrimSpace(m[1]); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
if len(out) > 0 {
|
||||
return out
|
||||
}
|
||||
for _, part := range strings.Split(v, ",") {
|
||||
if s := strings.TrimSpace(part); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var (
|
||||
bodyURLRE = regexp.MustCompile(`(?i)(https?://[^\s<>"')\]]+|mailto:[^\s<>"')\]]+)`)
|
||||
unsubRE = regexp.MustCompile(`(?i)unsubscribe|opt[\s-]?out|manage (?:your )?(?:email )?preferences`)
|
||||
)
|
||||
|
||||
// bodyLinks finds unsubscribe URLs in a plain-text body. Only lines that
|
||||
// mention unsubscribing are considered, so a random link is not reported.
|
||||
func bodyLinks(body string) []string {
|
||||
body = strings.TrimSpace(body)
|
||||
if body == "" {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
if !unsubRE.MatchString(line) {
|
||||
continue
|
||||
}
|
||||
for _, m := range bodyURLRE.FindAllString(line, -1) {
|
||||
out = append(out, trimURL(m))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// trimURL removes trailing punctuation that is part of the sentence, not the
|
||||
// URL.
|
||||
func trimURL(s string) string {
|
||||
return strings.TrimRight(strings.TrimSpace(s), ".,;:!?)\"'")
|
||||
}
|
||||
|
||||
// makeLink validates a raw target and fills in method/host/domain.
|
||||
func makeLink(raw, source, sender string) (Link, bool) {
|
||||
raw = trimURL(raw)
|
||||
raw = strings.Trim(raw, "<>")
|
||||
if raw == "" {
|
||||
return Link{}, false
|
||||
}
|
||||
link := Link{URL: raw, Source: source}
|
||||
switch {
|
||||
case strings.HasPrefix(strings.ToLower(raw), "mailto:"):
|
||||
link.Method = MethodMailto
|
||||
link.Host = mailtoHost(raw)
|
||||
case strings.HasPrefix(strings.ToLower(raw), "http://"),
|
||||
strings.HasPrefix(strings.ToLower(raw), "https://"):
|
||||
link.Method = MethodHTTP
|
||||
link.Host = urlHost(raw)
|
||||
default:
|
||||
return Link{}, false
|
||||
}
|
||||
link.Domain = sender
|
||||
if link.Domain == "" {
|
||||
link.Domain = link.Host
|
||||
}
|
||||
return link, link.Domain != ""
|
||||
}
|
||||
|
||||
// Domain returns the domain used as the ignore-list key. It is exported for
|
||||
// callers that need it without parsing a link.
|
||||
func Domain(from string) string { return brandDomain(from) }
|
||||
|
||||
func brandDomain(from string) string {
|
||||
addr := extractAddress(from)
|
||||
i := strings.LastIndex(addr, "@")
|
||||
if i < 0 || i == len(addr)-1 {
|
||||
return ""
|
||||
}
|
||||
dom := strings.ToLower(strings.Trim(strings.TrimSpace(addr[i+1:]), ">."))
|
||||
if dom == "" || freeMail[dom] {
|
||||
return ""
|
||||
}
|
||||
return dom
|
||||
}
|
||||
|
||||
// extractAddress pulls the address out of a From header such as
|
||||
// "Support <support@example.com>".
|
||||
func extractAddress(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if i := strings.LastIndex(v, "<"); i >= 0 {
|
||||
if j := strings.Index(v[i:], ">"); j > 0 {
|
||||
return strings.TrimSpace(v[i+1 : i+j])
|
||||
}
|
||||
}
|
||||
return strings.Trim(v, "<> \t")
|
||||
}
|
||||
|
||||
// freeMail domains are consumer mailboxes, not brands. A message forwarded
|
||||
// through one should key its ignore list on the real link host instead.
|
||||
var freeMail = map[string]bool{
|
||||
"gmail.com": true,
|
||||
"googlemail.com": true,
|
||||
"outlook.com": true,
|
||||
"hotmail.com": true,
|
||||
"live.com": true,
|
||||
"yahoo.com": true,
|
||||
"icloud.com": true,
|
||||
"me.com": true,
|
||||
"aol.com": true,
|
||||
"proton.me": true,
|
||||
"protonmail.com": true,
|
||||
}
|
||||
|
||||
func urlHost(raw string) string {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(u.Hostname())
|
||||
}
|
||||
|
||||
func mailtoHost(raw string) string {
|
||||
rest := raw[len("mailto:"):]
|
||||
if i := strings.IndexAny(rest, "?,"); i >= 0 {
|
||||
rest = rest[:i]
|
||||
}
|
||||
i := strings.LastIndex(rest, "@")
|
||||
if i < 0 || i == len(rest)-1 {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(strings.Trim(rest[i+1:], ">."))
|
||||
}
|
||||
Reference in New Issue
Block a user