Documentation
¶
Overview ¶
Package dmarc implements the receiver side of DMARC (RFC 7489): looking up and parsing a domain's published policy, evaluating identifier alignment, and generating aggregate (rua) report XML. It depends only on the Go standard library.
DMARC (Domain-based Message Authentication, Reporting, and Conformance) lets the owner of a From domain publish, at _dmarc.<domain>, how receivers should treat mail that is not authenticated as coming from that domain. A receiver checks whether a passing SPF or DKIM identifier aligns with the From domain; if neither does, it applies the domain's published policy (none, quarantine, or reject) and, later, reports what it saw back to the domain owner.
The package is deliberately free of any storage or scheduling concerns. A caller records per-message evaluations however it likes, then hands a slice of neutral AggregateRecord values to BuildReport to produce the RFC 7489 aggregate-report document.
Policy discovery and evaluation ¶
Discover performs full policy discovery for a From domain, including the RFC 7489 §6.6.3 Organizational-Domain fallback: when a subdomain publishes no record of its own, it applies the organizational domain's subdomain policy (the sp= tag, or p= when sp= is absent). Lookup is the lower-level primitive that fetches the raw record at exactly _dmarc.<domain>, ParsePolicy reads a record's requested policy from the p= tag, and ParsePct reads the pct= tag (0–100, default 100) so a staged rollout can be applied to a sample of failing messages rather than always at 100%. Aligned reports whether an authenticated domain — from an SPF smtp.mailfrom or a verified DKIM signature's d= — aligns with the From domain:
policy, _ := dmarc.Discover("mail.example.com", nil, nil)
if !dmarc.Aligned(authDomain, "mail.example.com") && policy.Requested == "reject" {
// no aligned identifier passed: reject per published policy
}
Aggregate reporting ¶
A reporter collects one AggregateRecord per evaluated message, then calls BuildReport to marshal them into the RFC 7489 aggregate-report XML document. AggregateRecords performs the grouping (identical rows are summed into a count) and is called by BuildReport, but is exported for callers that want the grouped rows directly. Gzip compresses the document for delivery as the application/gzip attachment RFC 7489 §7.2.1 specifies.
Example ¶
Example parses a published DMARC record and decides the disposition for one message. A message passes DMARC only when a passing authenticated identifier (SPF smtp.mailfrom or a verified DKIM d=) also *aligns* with the From domain; when none does, the receiver applies the domain's published policy.
package main
import (
"fmt"
"github.com/rest-mail/go-dmarc"
)
func main() {
// The record published at _dmarc.example.com. In production, discover the
// policy with dmarc.Discover("example.com", nil, nil) (which also handles the
// organizational-domain fallback for subdomains); a literal keeps this DNS-free.
record := "v=DMARC1; p=reject; adkim=r; aspf=r; rua=mailto:agg@example.com"
policy, err := dmarc.ParsePolicy(record) // requested policy for failures
if err != nil {
panic(err) // a malformed or duplicated p= value is an unusable record
}
// SPF authenticated the envelope sender's domain, but it is an unrelated
// bulk-sender domain that does not align with the From domain.
fromDomain := "example.com"
spfDomain, spfPass := "bounce.marketing.net", true
dmarcPass := spfPass && dmarc.Aligned(spfDomain, fromDomain)
disposition := "none"
if !dmarcPass {
disposition = policy // apply the published policy to unauthenticated mail
}
fmt.Printf("dmarc=%v disposition=%s\n", dmarcPass, disposition)
}
Output: dmarc=false disposition=reject
Example (AggregateReport) ¶
Example_aggregateReport turns per-message evaluations into the RFC 7489 aggregate-report XML and gzips it for delivery.
package main
import (
"bytes"
"fmt"
"github.com/rest-mail/go-dmarc"
)
func main() {
records := []dmarc.AggregateRecord{
{
Domain: "example.com",
SourceIP: "192.0.2.10",
HeaderFrom: "example.com",
Disposition: "reject",
// SPF passed for the envelope-sender (bounce) domain, which does not
// align with the From domain; auth_results reports that checked domain.
SPF: []dmarc.SPFAuth{
{Domain: "bounce.marketing.net", Scope: "mfrom", Result: "pass", Aligned: false},
},
},
}
meta := dmarc.ReportMetadata{
OrgName: "reporter.example",
Email: "dmarc@reporter.example",
ReportID: "1@reporter.example",
DateRange: dmarc.DateRange{Begin: 1784700000, End: 1784786400},
}
policy := dmarc.PolicyPublished{Domain: "example.com", ADKIM: "r", ASPF: "r", P: "reject", PCT: 100}
xmlBytes, err := dmarc.BuildReport(meta, policy, records)
if err != nil {
panic(err)
}
gz, err := dmarc.Gzip(xmlBytes)
if err != nil {
panic(err)
}
fmt.Printf("xml=%v rows=%d gzipped=%v\n",
bytes.HasPrefix(xmlBytes, []byte("<?xml")),
bytes.Count(xmlBytes, []byte("<record>")),
len(gz) > 0)
}
Output: xml=true rows=1 gzipped=true
Index ¶
- func Aligned(authDomain, fromDomain string) bool
- func AlignedMode(authDomain, fromDomain string, mode AlignmentMode, orgDomain OrgDomainFunc) bool
- func AlignedOrg(authDomain, fromDomain string, orgDomain OrgDomainFunc) bool
- func BuildReport(meta ReportMetadata, policy PolicyPublished, records []AggregateRecord) ([]byte, error)
- func DefaultOrgDomain(domain string) string
- func Gzip(data []byte) ([]byte, error)
- func Lookup(domain string, resolver TXTResolver) (string, error)
- func ParsePct(record string) (int, error)
- func ParsePolicy(record string) (string, error)
- type AggregateRecord
- type AlignmentMode
- type AuthResults
- type DKIMAuth
- type DKIMResult
- type DateRange
- type Feedback
- type Identifiers
- type OrgDomainFunc
- type Policy
- type PolicyEvaluated
- type PolicyPublished
- type ReportMetadata
- type ReportRecord
- type Row
- type SPFAuth
- type SPFResult
- type TXTResolver
Examples ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func Aligned ¶
Aligned reports whether an authenticated domain aligns with the From domain under DMARC relaxed alignment (RFC 7489 §3.1): their Organizational Domains (§3.2) must be equal. Strict alignment is a plain exact match, which Aligned also reports since an exact match trivially shares an organizational domain.
Alignment is NOT a raw suffix test. Comparing organizational domains both accepts alignments the RFC requires (sibling subdomains such as em.example.com and mail.example.com, which share example.com) and rejects ones it forbids (a lookalike like evil-example.com, or an authenticated domain that is itself a public suffix — the RFC notes a DKIM signature bearing d=com never yields an aligned result).
Both identifiers are sanitized before comparison (see AlignedOrg): an empty identifier never aligns, a single trailing (root) dot is ignored, and each is folded to a single lower-case A-label form so that a Unicode domain and its xn-- punycode encoding are treated as the same name.
Aligned uses the registry-free DefaultOrgDomain heuristic, which is correct for single-label public suffixes but not multi-label ones (e.g. it treats co.uk itself as an organizational domain). For full accuracy under multi-label public suffixes, use AlignedOrg with a PSL-backed OrgDomainFunc.
func AlignedMode ¶ added in v0.3.0
func AlignedMode(authDomain, fromDomain string, mode AlignmentMode, orgDomain OrgDomainFunc) bool
AlignedMode reports whether authDomain aligns with fromDomain under the given DMARC alignment mode (RFC 7489 §3.1), with an injectable OrgDomainFunc for the relaxed org-domain derivation (nil uses DefaultOrgDomain). Callers pass the mode parsed from a record with ParseADKIM (for a DKIM d= identifier) or ParseASPF (for an SPF-authenticated identifier), or exposed on Policy.ADKIM / Policy.ASPF.
Under AlignmentRelaxed the result is exactly AlignedOrg: equal Organizational Domains align. Under AlignmentStrict the org-domain hook is not consulted — the two identifier domains must be an exact, case-insensitive FQDN match after [normalizeDomain], so a From domain and a delegated subdomain of it do NOT align. Both inputs are sanitized as in AlignedOrg; an empty identifier never aligns.
func AlignedOrg ¶ added in v0.2.0
func AlignedOrg(authDomain, fromDomain string, orgDomain OrgDomainFunc) bool
AlignedOrg is Aligned with an injectable OrgDomainFunc, so callers can supply a Public Suffix List-backed organizational-domain derivation (a small wrapper around golang.org/x/net/publicsuffix.EffectiveTLDPlusOne). A nil orgDomain uses DefaultOrgDomain. Keeping the hook injectable is what lets this package stay dependency-free while still supporting correct relaxed alignment under multi-label public suffixes such as co.uk — the same hook Discover uses for its §6.6.3 organizational-domain fallback.
Both inputs are first sanitized with [normalizeDomain]: a single trailing (root) dot is stripped, the name is lower-cased, and any Unicode labels are converted to their ASCII (xn-- punycode) A-label form. Sanitizing closes three ways the raw equality test would otherwise misfire (RFC 7489 §3.1):
- An empty identifier means "no authenticated domain" and must never align. Two empty inputs would trivially compare equal and yield a false DMARC pass, so an empty input on either side returns false up front.
- A fully-qualified name carrying the root dot ("example.com.") denotes the same domain as "example.com" and must align with it.
- The same domain written as a Unicode U-label and as its xn-- A-label is one domain; normalizing both to the A-label form keeps them from spuriously failing (or, for the org-domain comparison, spuriously matching) on encoding form alone.
Relaxed alignment (RFC 7489 §3.1) holds when the two Organizational Domains are non-empty and equal. A domain that is itself a public suffix has no registrable Organizational Domain: a PSL-backed OrgDomainFunc returns "" for it, so such a domain never aligns. Strict alignment (an exact, case-insensitive match) is always reported, independent of the hook.
func BuildReport ¶
func BuildReport(meta ReportMetadata, policy PolicyPublished, records []AggregateRecord) ([]byte, error)
BuildReport assembles an RFC 7489 aggregate report XML document (with the XML declaration prepended).
func DefaultOrgDomain ¶ added in v0.2.0
DefaultOrgDomain is the registry-free Organizational Domain heuristic used when Discover is given a nil OrgDomainFunc: it returns the last two labels of the domain (e.g. "sub.example.com" -> "example.com"). Like Aligned, it uses no Public Suffix List, so it is correct for single-label public suffixes but wrong for multi-label ones (it would treat "co.uk" itself as the org domain). For those, inject a PSL-backed OrgDomainFunc. The result is lower-cased with any trailing dot removed.
func Gzip ¶
Gzip compresses report bytes for the report attachment (reports are delivered as application/gzip per RFC 7489 §7.2.1).
func Lookup ¶
func Lookup(domain string, resolver TXTResolver) (string, error)
Lookup fetches and returns the raw DMARC record published at _dmarc.<domain>. It maps DNS outcomes to the three RFC 7489 §6.6.3 cases:
- Record found: the raw "v=DMARC1..." TXT record and a nil error.
- No DMARC policy: ("", nil). This covers a name that carries no v=DMARC1 record, a name that does not exist at all (a not-found/NXDOMAIN result is "DMARC does not apply", not a failure), and a name that carries more than one v=DMARC1 record — an ambiguous set §6.6.3 discards, so it too is "no policy" rather than a non-deterministic first-wins guess.
- Transient failure (SERVFAIL, timeout, and other non-not-found DNS errors): ("", err). Callers must treat this as temperror and not fail open — the domain's policy is unknown, not absent.
The distinction relies on the resolver reporting not-found via a *net.DNSError whose IsNotFound is set, which is what net.LookupTXT does; fakes returning such an error are classified the same way.
func ParsePct ¶ added in v0.3.0
ParsePct extracts the pct= tag from a DMARC record: the percentage (0–100) of failing messages to which the requested policy is applied during a staged rollout (RFC 7489 §6.3). It returns the default of 100 when the record carries no pct= tag, so a receiver that ignores pct still gets the correct full- enforcement value.
A pct= value that is not an integer in the range 0–100 is a malformed record and is rejected with a non-nil error rather than silently coerced; a caller can then treat the record as unusable instead of enforcing at an unintended rate. Per §6.6.4 a receiver applies the requested policy to a random pct percent of failing messages and the next-lower policy to the remainder; selecting that sample from crypto/rand is the caller's responsibility.
func ParsePolicy ¶
ParsePolicy extracts the requested policy (the p= tag) from a DMARC record. It returns "none" when no p= tag is present.
The tag name and the enumerated value (none/quarantine/reject) are matched case-insensitively per RFC 7489 §6.3/§6.4: "P=Reject" is recognised, and the value is normalised to lower case so a downstream comparison against the lower-case policy names is not defeated by a record that writes "REJECT".
A p= tag whose value is not one of the three enumerated values, or a record that carries the p= tag more than once, is malformed: ParsePolicy returns a non-nil error rather than the raw ("bogus") or first-wins value, so a caller cannot apply an unintended disposition. Per §6.6.3 such a record has no valid policy and is treated as if none were published; the caller effects that by declining to use it.
Types ¶
type AggregateRecord ¶
type AggregateRecord struct {
Domain string // header-From (RFC5322.From) domain: the reported-on domain
SourceIP string
HeaderFrom string // header_from identifier; defaults to Domain when empty
Disposition string // none|quarantine|reject (policy applied); other values normalize to none
// EnvelopeFrom is the RFC5321.MailFrom domain (the identifiers/envelope_from
// element, which RFC 7489 requires). When empty it is derived from the SPF
// mfrom check's domain; the element is always emitted.
EnvelopeFrom string
// DKIM holds the per-signature DKIM authentication results, each carrying the
// signature's own d= domain, for the auth_results section. It is empty when
// the message carried no signature (no <dkim> element is then emitted).
DKIM []DKIMAuth
// SPF holds the SPF authentication result(s), each carrying the checked
// domain (smtp.mailfrom or HELO) — never the header-From domain.
SPF []SPFAuth
}
AggregateRecord is one message's DMARC evaluation, the neutral input to AggregateRecords and BuildReport. It carries only what the aggregate report needs, so the package depends on no particular storage model.
type AlignmentMode ¶ added in v0.3.0
type AlignmentMode int
AlignmentMode is the DMARC identifier-alignment mode a record requests through its adkim= / aspf= tag (RFC 7489 §6.3): relaxed (the default) or strict.
const ( // AlignmentRelaxed is DMARC relaxed alignment (adkim=r / aspf=r — the default // applied when the tag is absent). Two identifiers align when their // Organizational Domains are equal, so sibling and parent/child subdomains of // one organizational domain align. It is the zero value, so a zero-valued // [Policy] (no published record) reports the correct default. AlignmentRelaxed AlignmentMode = iota // AlignmentStrict is DMARC strict alignment (adkim=s / aspf=s). The two // identifier domains must be an exact, case-insensitive FQDN match; a // subdomain of the From domain does NOT align. RFC 7489 §10.4 documents strict // alignment as the mitigation for a hostile delegated subdomain. AlignmentStrict )
func ParseADKIM ¶ added in v0.3.0
func ParseADKIM(record string) AlignmentMode
ParseADKIM returns the DKIM identifier-alignment mode requested by a DMARC record's adkim= tag (RFC 7489 §6.3): AlignmentStrict for a value of "s", otherwise AlignmentRelaxed. Relaxed is the default, applied when the tag is absent or carries any value other than "s" — an unknown value degrades to the default rather than being rejected. The tag name and value are matched case-insensitively and whitespace around "=" is tolerated, per the §6.4 ABNF.
func ParseASPF ¶ added in v0.3.0
func ParseASPF(record string) AlignmentMode
ParseASPF returns the SPF identifier-alignment mode requested by a DMARC record's aspf= tag; it is ParseADKIM for the aspf= tag (RFC 7489 §6.3).
func (AlignmentMode) String ¶ added in v0.3.0
func (m AlignmentMode) String() string
String returns "relaxed" or "strict"; any other value renders as AlignmentMode(<n>).
type AuthResults ¶
type AuthResults struct {
DKIM []DKIMResult `xml:"dkim,omitempty"`
SPF []SPFResult `xml:"spf,omitempty"`
}
type DKIMAuth ¶ added in v0.2.0
type DKIMAuth struct {
Domain string // the signature's d= domain (the authenticating domain)
Selector string // the signature's s= selector, if known (optional)
Result string // pass|fail|none|neutral|policy|temperror|permerror
Aligned bool // whether Domain aligns with the From domain (feeds policy_evaluated)
}
DKIMAuth is one DKIM signature's authentication result as reported in the aggregate report's auth_results (RFC 7489 Appendix C, DKIMAuthResultType).
type DKIMResult ¶
type Feedback ¶
type Feedback struct {
XMLName xml.Name `xml:"feedback"`
Version string `xml:"version,omitempty"`
ReportMetadata ReportMetadata `xml:"report_metadata"`
PolicyPublished PolicyPublished `xml:"policy_published"`
Records []ReportRecord `xml:"record"`
}
Feedback is the root element of an RFC 7489 aggregate report.
type Identifiers ¶
type OrgDomainFunc ¶ added in v0.2.0
OrgDomainFunc derives the Organizational Domain (RFC 7489 §3.2) of a domain, used for the policy-discovery fallback in Discover. Determining it correctly requires the Public Suffix List, so callers that need full accuracy (for example to protect subdomains under multi-label public suffixes such as co.uk) should pass a PSL-backed implementation — a small wrapper around golang.org/x/net/publicsuffix.EffectiveTLDPlusOne does this. A nil func passed to Discover uses DefaultOrgDomain. Keeping this injectable is what lets the package stay dependency-free while still supporting correct org-domain derivation.
type Policy ¶ added in v0.2.0
type Policy struct {
// Domain is the DNS name whose _dmarc record supplied the policy: the queried
// domain for an exact match, or its Organizational Domain when the policy was
// found via the §6.6.3 fallback.
Domain string
// Record is the raw DMARC TXT record, or "" when no policy applies.
Record string
// Requested is the policy to apply to the queried domain: the p= tag for an
// exact match, or the subdomain policy (sp= if present, otherwise p=) when the
// record was found via the Organizational-Domain fallback. It is "none" when
// no policy applies.
Requested string
// Pct is the percentage (0–100) of failing messages to which Requested is
// applied for a staged rollout, from the record's pct= tag (RFC 7489 §6.3); it
// is 100 (the default) when the record omits pct=, and 0 for a zero Policy (no
// applicable record). A receiver honouring a rollout applies Requested to a
// random Pct percent of failing messages and the next-lower policy to the rest
// (§6.6.4); enforcing Requested unconditionally ignores the requested rate. It
// is meaningful only when Record is non-empty.
Pct int
// ViaOrgDomain reports whether the record was obtained through the §6.6.3
// Organizational-Domain fallback rather than an exact match on the queried
// domain.
ViaOrgDomain bool
// ADKIM and ASPF are the DKIM and SPF identifier-alignment modes the record
// requests via its adkim= / aspf= tags (RFC 7489 §6.3): AlignmentRelaxed (the
// default) or AlignmentStrict. Pass them to [AlignedMode] to evaluate DKIM and
// SPF alignment under the mode the domain published. Both are AlignmentRelaxed
// for a zero Policy (no applicable record), the documented default.
ADKIM AlignmentMode
ASPF AlignmentMode
}
Policy is the result of DMARC policy discovery (RFC 7489 §6.6.3) for a domain. A zero Policy (empty Record, "none" Requested) means the domain publishes no applicable DMARC policy.
func Discover ¶ added in v0.2.0
func Discover(domain string, resolver TXTResolver, orgDomain OrgDomainFunc) (Policy, error)
Discover performs DMARC policy discovery for domain, including the RFC 7489 §6.6.3 Organizational-Domain fallback: it first looks up _dmarc.<domain>, and if that publishes no DMARC record it looks up _dmarc.<org-domain> and, when found, applies that record's subdomain policy (sp= if present, otherwise p=). Without this fallback a subdomain that publishes no record of its own would be treated as having no DMARC policy, letting spoofed subdomains bypass the organizational domain's p= policy.
resolver and orgDomain may be nil: resolver then uses system DNS (net.LookupTXT) and orgDomain uses DefaultOrgDomain. A resolver error is returned; a domain that simply publishes no policy is reported as a zero Policy with a nil error.
Example ¶
ExampleDiscover shows the RFC 7489 §6.6.3 organizational-domain fallback: a subdomain that publishes no _dmarc record of its own inherits the org domain's subdomain policy (sp=, else p=). The resolver is injected to keep this DNS-free; production callers pass nil to use system DNS.
package main
import (
"fmt"
"github.com/rest-mail/go-dmarc"
)
func main() {
resolver := func(name string) ([]string, error) {
// Only the organizational domain publishes a record.
if name == "_dmarc.example.com" {
return []string{"v=DMARC1; p=reject; sp=quarantine"}, nil
}
return nil, nil
}
policy, err := dmarc.Discover("newsletter.example.com", resolver, nil)
if err != nil {
panic(err)
}
fmt.Printf("via_org=%v from=%s apply=%s\n",
policy.ViaOrgDomain, policy.Domain, policy.Requested)
}
Output: via_org=true from=example.com apply=quarantine
type PolicyEvaluated ¶
type PolicyPublished ¶
type PolicyPublished struct {
Domain string `xml:"domain"`
ADKIM string `xml:"adkim,omitempty"`
ASPF string `xml:"aspf,omitempty"`
P string `xml:"p"`
SP string `xml:"sp,omitempty"`
PCT int `xml:"pct,omitempty"`
}
PolicyPublished is the DMARC record the reported-on domain published.
type ReportMetadata ¶
type ReportMetadata struct {
OrgName string `xml:"org_name"`
Email string `xml:"email"`
ReportID string `xml:"report_id"`
DateRange DateRange `xml:"date_range"`
}
ReportMetadata identifies the reporting organization and period.
type ReportRecord ¶
type ReportRecord struct {
Row Row `xml:"row"`
Identifiers Identifiers `xml:"identifiers"`
AuthResults AuthResults `xml:"auth_results"`
}
ReportRecord is one aggregated row: a source IP + evaluation + counts.
func AggregateRecords ¶
func AggregateRecords(records []AggregateRecord) []ReportRecord
AggregateRecords groups raw per-message evaluations into report rows by source IP, header-From, disposition, the DMARC-aligned dkim/spf verdict, and the full set of authentication results, summing counts.
type Row ¶
type Row struct {
SourceIP string `xml:"source_ip"`
Count int `xml:"count"`
PolicyEvaluated PolicyEvaluated `xml:"policy_evaluated"`
}
type SPFAuth ¶ added in v0.2.0
type SPFAuth struct {
Domain string // the checked domain: smtp.mailfrom, or the HELO name for scope=helo
Scope string // mfrom|helo
Result string // pass|fail|softfail|neutral|none|temperror|permerror
Aligned bool // whether Domain aligns with the From domain (feeds policy_evaluated)
}
SPFAuth is an SPF authentication result as reported in the aggregate report's auth_results (RFC 7489 Appendix C, SPFAuthResultType).
type TXTResolver ¶
TXTResolver resolves the TXT records for a name. Its signature matches net.LookupTXT, so that (or a fake in tests) can be passed directly. A nil resolver passed to Lookup falls back to net.LookupTXT.