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>, and ParsePolicy reads a record's requested policy from the p= tag. 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 := dmarc.ParsePolicy(record) // requested policy for failures
// 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 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 ParsePolicy(record string) string
- type AggregateRecord
- 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).
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 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.
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 both a name that exists but carries no v=DMARC1 record and a name that does not exist at all — a not-found (NXDOMAIN) result is "DMARC does not apply", not a failure.
- 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 ParsePolicy ¶
ParsePolicy extracts the requested policy (the p= tag) from a DMARC record. It returns "none" when no p= tag is present.
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)
// 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 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 Identifiers struct {
HeaderFrom string `xml:"header_from"`
}
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
// 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
}
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.