// 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 ". 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:], ">.")) }