94 lines
3.0 KiB
Go
94 lines
3.0 KiB
Go
package mailstore
|
|
|
|
import (
|
|
"context"
|
|
"net"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"golang.org/x/net/publicsuffix"
|
|
)
|
|
|
|
// DMARCPolicy is a parsed _dmarc TXT record (RFC 7489 §6.3) — only the tags this
|
|
// server actually acts on (p/sp/pct); tags like rua/ruf (aggregate/failure reporting
|
|
// addresses) are parsed by nothing here since outbound DMARC reporting is out of scope
|
|
// for this pass (see tests/todo.md).
|
|
type DMARCPolicy struct {
|
|
P string // required: "none" | "quarantine" | "reject"
|
|
SP string // subdomain policy; defaults to P if absent
|
|
Pct int // 0-100, defaults to 100
|
|
}
|
|
|
|
// LookupDMARCPolicy fetches and parses domain's _dmarc TXT record. A nil policy with a
|
|
// nil error means "no DMARC record published" — the normal case for most domains, not
|
|
// a failure worth logging; a lookup/resolver error is treated the same way (fail open,
|
|
// matching CheckSPF's "no record: neutral, not a penalty" posture elsewhere in this
|
|
// file) since a transient DNS hiccup must never itself quarantine/reject mail.
|
|
func LookupDMARCPolicy(domain string) *DMARCPolicy {
|
|
if domain == "" {
|
|
return nil
|
|
}
|
|
txts, err := net.DefaultResolver.LookupTXT(context.Background(), "_dmarc."+domain)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
for _, t := range txts {
|
|
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(t)), "v=dmarc1") {
|
|
return parseDMARCRecord(t)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func parseDMARCRecord(record string) *DMARCPolicy {
|
|
pol := &DMARCPolicy{P: "none", Pct: 100}
|
|
for _, tag := range strings.Split(record, ";") {
|
|
kv := strings.SplitN(strings.TrimSpace(tag), "=", 2)
|
|
if len(kv) != 2 {
|
|
continue
|
|
}
|
|
key, val := strings.ToLower(strings.TrimSpace(kv[0])), strings.ToLower(strings.TrimSpace(kv[1]))
|
|
switch key {
|
|
case "p":
|
|
pol.P = val
|
|
case "sp":
|
|
pol.SP = val
|
|
case "pct":
|
|
if n, err := strconv.Atoi(val); err == nil && n >= 0 && n <= 100 {
|
|
pol.Pct = n
|
|
}
|
|
}
|
|
}
|
|
if pol.SP == "" {
|
|
pol.SP = pol.P
|
|
}
|
|
return pol
|
|
}
|
|
|
|
// EffectivePolicy returns the policy that applies to a message whose From: domain is
|
|
// fromDomain, given the organizational domain the record was published under — sp
|
|
// applies for a strict subdomain of the organizational domain, p otherwise (RFC 7489
|
|
// §6.6.3).
|
|
func (pol *DMARCPolicy) EffectivePolicy(fromDomain, orgDomain string) string {
|
|
if !strings.EqualFold(fromDomain, orgDomain) {
|
|
return pol.SP
|
|
}
|
|
return pol.P
|
|
}
|
|
|
|
// OrganizationalDomain returns domain's registrable organizational domain (e.g.
|
|
// "mail.example.co.uk" -> "example.co.uk"), via the public suffix list — DMARC
|
|
// alignment is defined in terms of this, and a naive "last two labels" heuristic would
|
|
// be wrong for exactly the multi-part-TLD domains (.co.uk, .com.au, etc.) alignment
|
|
// most needs to get right. Falls back to the lowercased input unchanged if it can't be
|
|
// parsed (e.g. a bare TLD or malformed input) — same fail-open posture as the rest of
|
|
// this file's DNS-dependent checks.
|
|
func OrganizationalDomain(domain string) string {
|
|
domain = strings.ToLower(domain)
|
|
org, err := publicsuffix.EffectiveTLDPlusOne(domain)
|
|
if err != nil {
|
|
return domain
|
|
}
|
|
return org
|
|
}
|