- 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>
241 lines
6.3 KiB
Go
241 lines
6.3 KiB
Go
// 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:], ">."))
|
|
}
|