dmarc

package module
v0.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 25, 2026 License: MIT Imports: 9 Imported by: 0

README

go-dmarc

CI Go Reference Go Report Card

The receiver side of DMARC (RFC 7489) for Go — policy lookup and parsing, identifier alignment, and aggregate (rua) report generation. Standard library only, no external dependencies.

About

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 observed back to the domain owner.

This package holds no storage or scheduling state. A mail pipeline consults the policy and alignment primitives per message and records the outcomes however it likes; a reporter later hands a slice of neutral AggregateRecord values to BuildReport to emit the RFC 7489 aggregate-report document.

Features

  • Policy discoveryDiscover resolves a From domain's policy with the RFC 7489 §6.6.3 organizational-domain fallback (a subdomain with no record of its own inherits the org domain's sp=/p=) and exposes the record's pct= sampling rate on Policy.Pct; Lookup fetches a single _dmarc.<domain> TXT record, ParsePolicy reads its p= tag (validated against none/quarantine/reject; an unrecognised or duplicated value is a malformed record, not a raw pass-through), and ParsePct reads its pct= tag (0–100, default 100) so a staged rollout can be applied to a sample of failing messages rather than always at 100% (§6.6.4).
  • Identifier alignmentAligned implements DMARC relaxed alignment (RFC 7489 §3.1) between an authenticated domain and the From domain, and AlignedMode honours a record's adkim=/aspf= mode (ParseADKIM/ ParseASPF, also on Policy.ADKIM/Policy.ASPF) so strict alignment (§6.3, §10.4) can require an exact FQDN match.
  • Aggregate reportingAggregateRecords groups per-message evaluations into report rows, BuildReport marshals the RFC 7489 aggregate-report XML, and Gzip compresses it for the application/gzip attachment (§7.2.1).
  • Injectable DNS & org-domainLookup/Discover take a TXTResolver (its signature matches net.LookupTXT) for tests and custom lookups, and Discover takes an OrgDomainFunc for organizational-domain derivation (nil uses system DNS and the built-in heuristic respectively).
  • Zero external dependencies — standard library only. Organizational-domain derivation defaults to a registry-free heuristic (DefaultOrgDomain); pass a Public Suffix List-backed OrgDomainFunc (e.g. wrapping golang.org/x/net/publicsuffix) when you need multi-label public suffixes.

Install

go get github.com/rest-mail/go-dmarc

Quickstart

Parse a published DMARC record and decide 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 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) // "none" | "quarantine" | "reject"
	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)
	// Prints: dmarc=false disposition=reject
}

Aggregate reports

Collect one AggregateRecord per evaluated message, then marshal them into the RFC 7489 aggregate-report document and gzip it for delivery:

meta := dmarc.ReportMetadata{
	OrgName:   "reporter.example",
	Email:     "dmarc@reporter.example",
	ReportID:  "1784700000.example.com@reporter.example",
	DateRange: dmarc.DateRange{Begin: begin, End: end},
}
policy := dmarc.PolicyPublished{Domain: "example.com", ADKIM: "r", ASPF: "r", P: "reject", PCT: 100}

xmlBytes, err := dmarc.BuildReport(meta, policy, records) // records []dmarc.AggregateRecord
if err != nil {
	panic(err)
}
gz, err := dmarc.Gzip(xmlBytes)
if err != nil {
	panic(err)
}
_ = gz // attach as application/gzip

BuildReport calls AggregateRecords for you (identical rows are summed into a count); call AggregateRecords directly if you want the grouped rows without marshalling. A mechanism counts as passing DMARC only when it both passed and aligned, so PolicyEvaluated reflects the DMARC-aligned result, not the raw SPF/DKIM verdict.

Documentation

Full API reference: pkg.go.dev/github.com/rest-mail/go-dmarc.

License

MIT © 2026 rest-mail

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

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func Aligned

func Aligned(authDomain, fromDomain string) bool

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

func DefaultOrgDomain(domain string) string

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

func Gzip(data []byte) ([]byte, error)

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

func ParsePct(record string) (int, error)

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

func ParsePolicy(record string) (string, error)

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 DKIMResult struct {
	Domain   string `xml:"domain"`
	Selector string `xml:"selector,omitempty"`
	Result   string `xml:"result"`
}

type DateRange

type DateRange struct {
	Begin int64 `xml:"begin"`
	End   int64 `xml:"end"`
}

DateRange is the reporting period as UNIX epoch seconds.

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 {
	EnvelopeFrom string `xml:"envelope_from"`
	HeaderFrom   string `xml:"header_from"`
}

type OrgDomainFunc added in v0.2.0

type OrgDomainFunc func(domain string) string

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 PolicyEvaluated struct {
	Disposition string `xml:"disposition"`
	DKIM        string `xml:"dkim"`
	SPF         string `xml:"spf"`
}

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 SPFResult

type SPFResult struct {
	Domain string `xml:"domain"`
	Scope  string `xml:"scope"`
	Result string `xml:"result"`
}

type TXTResolver

type TXTResolver func(name string) ([]string, error)

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL