Sovite.DMARC (sovite v0.6.0)

Copy Markdown View Source

DMARC (RFC 7489): checks that the domain in a message's From: field is the one SPF or DKIM authenticated, and finds what its owner asks receivers to do with mail that fails.

{:ok, domain} = Sovite.DMARC.from_domain(fields)

result =
  Sovite.DMARC.check(resolver, domain,
    spf: {:pass, "bounces.example.com"},
    dkim: [{:pass, "example.com"}]
  )

result.result       #=> :pass
result.disposition  #=> :none

Policies are found with the DMARCbis DNS tree walk instead of the Public Suffix List (discover/2): _dmarc.<domain> is queried for the From domain and then for its parents. The same walk gives the Organizational Domain used for relaxed alignment (org_domain/2).

Aggregate reports are built by Sovite.DMARC.Report.

Summary

Functions

Returns whether domains a and b are aligned (RFC 7489 §3.1): :strict needs the same domain, :relaxed the same Organizational Domain. Case and a trailing dot do not matter.

Evaluates DMARC for a message from from_domain (RFC 7489 §6.6).

Finds the DMARC policy for domain (DMARCbis DNS tree walk).

Returns the RFC5322.From domain of a message, from its header fields as parsed by Sovite.Message.Headers.parse/1.

Finds the Organizational Domain of domain (DMARCbis §4.10.2), with the same tree walk as discover/2

Returns whether rua_domain accepts reports about policy_domain (RFC 7489 §7.1): always if both have the same Organizational Domain, and otherwise if <policy_domain>._report._dmarc.<rua_domain> has a TXT record starting with v=DMARC1.

Functions

aligned?(resolver, mode, a, b)

@spec aligned?(Sovite.DNS.resolver(), :strict | :relaxed, String.t(), String.t()) ::
  {:ok, boolean()} | {:error, :temperror}

Returns whether domains a and b are aligned (RFC 7489 §3.1): :strict needs the same domain, :relaxed the same Organizational Domain. Case and a trailing dot do not matter.

check(resolver, from_domain, opts \\ [])

Evaluates DMARC for a message from from_domain (RFC 7489 §6.6).

SPF counts if it passed for a domain aligned with from_domain under the policy's aspf, and DKIM if any passing signature's d= is aligned under adkim. Either one makes the result :pass.

When the result is :fail, the disposition comes from np if the policy was found at a parent and from_domain does not exist (its A, AAAA, and MX lookups all return NXDOMAIN), from sp if the policy was found at a parent, and from p otherwise. Unless the message is sampled (see Sovite.DMARC.Result), the disposition is lowered one step: :reject to :quarantine, :quarantine to :none.

Options

  • :spf - {result, domain}: the SPF result (:pass, :fail, ...) and the domain it was checked for (MAIL FROM, or HELO for the null sender), or nil. Defaults to {:none, nil}.
  • :dkim - the DKIM results, [{result, d_domain}]. Only :pass counts. Defaults to [].
  • :random - a function returning a float in [0, 1), used for pct= sampling. Defaults to :rand.uniform/0.

discover(resolver, domain)

@spec discover(Sovite.DNS.resolver(), String.t()) ::
  {:ok, Sovite.DMARC.Policy.t()}
  | {:none, org_domain :: String.t()}
  | {:error, :temperror}

Finds the DMARC policy for domain (DMARCbis DNS tree walk).

_dmarc.<domain> is queried first. Without a single valid record there, the walk continues with the parents of domain, removing one label at a time, but jumps straight to the last 7 labels of a domain with more than 8, and stops before the top-level domain. Records that do not start with v=DMARC1 are ignored, and a level with more than one DMARC record counts as having none. The first record found is the policy.

Returns {:none, org_domain} without a policy, and {:error, :temperror} if a query fails with a DNS error other than NXDOMAIN.

from_domain(fields)

@spec from_domain([Sovite.Message.Headers.field()]) ::
  {:ok, String.t()} | {:error, :missing | :multiple | :invalid}

Returns the RFC5322.From domain of a message, from its header fields as parsed by Sovite.Message.Headers.parse/1.

Fails with :missing without a From: field, with :multiple if there are several From: fields or the mailboxes in it have different domains (several mailboxes with the same domain are fine, as DMARCbis allows), and with :invalid if no domain can be read from it.

org_domain(resolver, domain)

@spec org_domain(Sovite.DNS.resolver(), String.t()) ::
  {:ok, String.t()} | {:error, :temperror}

Finds the Organizational Domain of domain (DMARCbis §4.10.2), with the same tree walk as discover/2:

  • A record with psd=n makes the domain it is at the Organizational Domain.
  • A record with psd=y marks a public suffix: the Organizational Domain is one label longer (or domain itself, if the record is at domain).
  • Otherwise it is the domain with the fewest labels that has a record, or domain itself if none does.

report_authorized?(resolver, policy_domain, rua_domain)

@spec report_authorized?(Sovite.DNS.resolver(), String.t(), String.t()) ::
  {:ok, boolean()} | {:error, :temperror}

Returns whether rua_domain accepts reports about policy_domain (RFC 7489 §7.1): always if both have the same Organizational Domain, and otherwise if <policy_domain>._report._dmarc.<rua_domain> has a TXT record starting with v=DMARC1.