Files
mailgoserver/internal/mailstore/dmarc.go
T

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
}