package mailstore import ( "context" "net" "strings" ) // spamKeywords is a tiny, obvious-spam subject keyword list — a coarse signal only. var spamKeywords = []string{"viagra", "casino", "lottery winner", "click here now", "wire transfer urgent", "nigerian prince"} // SpamScore is a lightweight built-in heuristic — always runs, regardless of whether // rspamd (rspamd.go) is also enabled; both are additive, not either/or. Higher is more // suspicious; compare against [Mailstore] spam_reject_score. // ponytail: naive keyword/weight heuristic, not a real Bayesian/ML scorer — upgrade or // lean harder on rspamd if false-positive rate matters. func SpamScore(peerIP string, headers map[string]string, dkimPass, spfPass bool) int { score := 0 if !spfPass { score += 2 } if !dkimPass { score++ } if CheckDNSBL(peerIP) { score += 5 } subject := strings.ToLower(headers["subject"]) for _, kw := range spamKeywords { if strings.Contains(subject, kw) { score++ } } return score } // CheckDNSBL looks up peerIP against the Spamhaus ZEN DNSBL. Per RFC 5782, a listing // response is always an A record in 127.0.0.0/8 — checking for that range (rather than // "any resolution succeeded") avoids false positives from a resolver that hijacks // NXDOMAIN into a search/ad page instead of returning an error. func CheckDNSBL(peerIP string) bool { ip := net.ParseIP(peerIP) if ip == nil || ip.To4() == nil { return false } octets := strings.Split(ip.To4().String(), ".") reversed := octets[3] + "." + octets[2] + "." + octets[1] + "." + octets[0] addrs, err := net.DefaultResolver.LookupHost(context.Background(), reversed+".zen.spamhaus.org") if err != nil { return false } for _, a := range addrs { if resolved := net.ParseIP(a); resolved != nil && resolved.To4() != nil && resolved.To4()[0] == 127 { return true } } return false }