emailsec.dmarc

  1from enum import StrEnum
  2from dataclasses import dataclass
  3from typing import Literal
  4
  5import publicsuffixlist
  6from emailsec._dns_resolver import DNSResolver
  7from emailsec._alignment import is_spf_aligned, is_dkim_aligned, AlignmentMode
  8from emailsec.config import AuthenticationConfiguration
  9from emailsec import _errors as errors
 10from emailsec.spf.checker import SPFResult, SPFCheck
 11from emailsec.dkim.checker import DKIMResult, DKIMCheck
 12from emailsec.arc import ARCChainStatus, ARCCheck
 13from emailsec._authentication_results import extract_original_auth_results
 14
 15__all__ = [
 16    "check_dmarc",
 17    "get_dmarc_policy",
 18    "DMARCCheck",
 19    "DMARCResult",
 20    "DMARCPolicy",
 21    "DMARCRecord",
 22]
 23
 24
 25class DMARCPolicy(StrEnum):
 26    NONE = "none"
 27    QUARANTINE = "quarantine"
 28    REJECT = "reject"
 29
 30
 31@dataclass
 32class DMARCRecord:
 33    """Holds a parsed DMARC DNS record."""
 34
 35    policy: DMARCPolicy
 36    spf_mode: AlignmentMode
 37    dkim_mode: AlignmentMode
 38    # percentage: int
 39
 40
 41class DMARCResult(StrEnum):
 42    """Defined in RFC 7489 Section 11.2"""
 43
 44    PASS = "pass"
 45    FAIL = "fail"
 46    NONE = "none"
 47    PERMERROR = "permerror"
 48    TEMPERROR = "temperror"
 49
 50
 51@dataclass
 52class DMARCCheck:
 53    result: DMARCResult
 54    policy: DMARCPolicy | None
 55    spf_aligned: bool | None = None
 56    dkim_aligned: bool | None = None
 57    arc_override_applied: bool = False
 58
 59
 60_DMARCError = Literal[DMARCResult.TEMPERROR] | Literal[DMARCResult.PERMERROR]
 61
 62
 63async def _fetch_dmarc_record(
 64    resolver: DNSResolver, domain: str
 65) -> tuple[DMARCRecord | None, _DMARCError | None]:
 66    """Fetch and parse DMARC record for the given domain."""
 67    try:
 68        txt_records = await resolver.txt(f"_dmarc.{domain}")
 69    except errors.Permerror:
 70        return None, DMARCResult.PERMERROR
 71    except errors.Temperror:
 72        return None, DMARCResult.TEMPERROR
 73
 74    if not txt_records:
 75        return None, None
 76
 77    try:
 78        record = parse_dmarc_record(txt_records[0].text)
 79        return record, None
 80    except Exception:
 81        return None, DMARCResult.PERMERROR
 82
 83
 84async def get_dmarc_policy(
 85    domain: str,
 86) -> tuple[DMARCRecord | None, _DMARCError | None]:
 87    """Fetch DMARC policy according to RFC 7489 Section 6.1."""
 88    resolver = DNSResolver()
 89
 90    record, error = await _fetch_dmarc_record(resolver, domain)
 91
 92    # Temp error means early return
 93    if error == DMARCResult.TEMPERROR or record:
 94        return record, error
 95
 96    # RFC 7489 Section 6.6.3: "If the set is now empty, the Mail Receiver MUST query the DNS for
 97    # a DMARC TXT record at the DNS domain matching the Organizational
 98    # Domain in place of the RFC5322.From domain in the message (if
 99    # different).
100    psl = publicsuffixlist.PublicSuffixList()
101    organizational_domain = psl.privatesuffix(domain.lower()) or domain
102
103    if organizational_domain != domain:
104        org_record, org_error = await _fetch_dmarc_record(
105            resolver, organizational_domain
106        )
107        # Return org domain result if we found a record or hit a temp error
108        if org_error == DMARCResult.TEMPERROR or org_record:
109            return org_record, org_error
110
111    # No policy found anywhere, return the original domain's result
112    return record, error
113
114
115def parse_dmarc_record(record: str) -> DMARCRecord:
116    tags = {}
117    for part in record.split(";"):
118        part = part.strip()
119        if "=" in part:
120            key, value = part.split("=", 1)
121            tags[key.strip()] = value.strip()
122
123    if "v" not in tags:
124        raise ValueError("Missing mandatory v=DMARC1 tag")
125
126    if tags["v"] != "DMARC1":
127        raise ValueError(f"Invalid DMARC version: {tags['v']}, expected DMARC1")
128
129    if "p" not in tags:
130        raise ValueError("Missing mandatory p= tag")
131
132    return DMARCRecord(
133        policy=DMARCPolicy(tags.get("p", "none")),
134        spf_mode="strict" if tags.get("aspf") == "s" else "relaxed",
135        dkim_mode="strict" if tags.get("adkim") == "s" else "relaxed",
136        # percentage=int(tags.get('pct', '100'))
137    )
138
139
140async def check_dmarc(
141    header_from: str,
142    envelope_from: str,
143    spf_check: SPFCheck,
144    dkim_check: DKIMCheck,
145    arc_check: ARCCheck,
146    configuration: AuthenticationConfiguration | None = None,
147) -> DMARCCheck:
148    """
149    DMARC evaluation per RFC 7489 Section 3 (Identifier Alignment).
150
151    RFC 7489: "A message satisfies the DMARC checks if at least one of the supported
152    authentication mechanisms: 1. produces a 'pass' result, and 2. produces that
153    result based on an identifier that is in alignment"
154    """
155
156    # Get DMARC policy (RFC 7489 Section 6.1)
157    dmarc_policy, error = await get_dmarc_policy(header_from)
158
159    # Return early if we hit temp/perm errors
160    if error:
161        return DMARCCheck(result=error, policy=None)
162
163    # No policy found
164    if not dmarc_policy:
165        return DMARCCheck(result=DMARCResult.NONE, policy=None)
166
167    # Check identifier alignment (RFC 7489 Section 3.1)
168    # SPF alignment: envelope sender domain vs header from domain
169    envelope_domain = (
170        envelope_from.split("@")[-1] if "@" in envelope_from else envelope_from
171    )
172    spf_aligned = spf_check.result == SPFResult.PASS and is_spf_aligned(
173        envelope_domain, header_from, dmarc_policy.spf_mode
174    )
175
176    # DKIM alignment: signing domain (d=) vs header from domain
177    dkim_aligned = bool(
178        dkim_check.result == DKIMResult.SUCCESS
179        and dkim_check.domain
180        and is_dkim_aligned(dkim_check.domain, header_from, dmarc_policy.dkim_mode)
181    )
182
183    # RFC 7489: DMARC passes if either SPF or DKIM is aligned and passes
184    dmarc_pass = spf_aligned or dkim_aligned
185
186    # ARC override logic (RFC 8617 Section 7.2.1)
187    # RFC 8617: "a DMARC processor MAY choose to accept the authentication
188    # assessments provided by an Authenticated Received Chain"
189    arc_override_applied = False
190    if (
191        not dmarc_pass
192        and configuration
193        and configuration.trusted_arc_signers
194        and arc_check.signer in configuration.trusted_arc_signers
195        and arc_check.result == ARCChainStatus.PASS
196        and arc_check.aar_header
197    ):
198        parsed_aar = extract_original_auth_results(
199            arc_check.result, arc_check.aar_header
200        )
201        if parsed_aar and "dmarc" in parsed_aar and parsed_aar["dmarc"] == "pass":
202            dmarc_pass = True
203            arc_override_applied = True
204
205    return DMARCCheck(
206        result=DMARCResult.PASS if dmarc_pass else DMARCResult.FAIL,
207        policy=dmarc_policy.policy,
208        spf_aligned=spf_aligned,
209        dkim_aligned=dkim_aligned,
210        arc_override_applied=arc_override_applied,
211    )
async def check_dmarc( header_from: str, envelope_from: str, spf_check: emailsec.spf.SPFCheck, dkim_check: emailsec.dkim.DKIMCheck, arc_check: emailsec.arc.ARCCheck, configuration: emailsec.config.AuthenticationConfiguration | None = None) -> DMARCCheck:
141async def check_dmarc(
142    header_from: str,
143    envelope_from: str,
144    spf_check: SPFCheck,
145    dkim_check: DKIMCheck,
146    arc_check: ARCCheck,
147    configuration: AuthenticationConfiguration | None = None,
148) -> DMARCCheck:
149    """
150    DMARC evaluation per RFC 7489 Section 3 (Identifier Alignment).
151
152    RFC 7489: "A message satisfies the DMARC checks if at least one of the supported
153    authentication mechanisms: 1. produces a 'pass' result, and 2. produces that
154    result based on an identifier that is in alignment"
155    """
156
157    # Get DMARC policy (RFC 7489 Section 6.1)
158    dmarc_policy, error = await get_dmarc_policy(header_from)
159
160    # Return early if we hit temp/perm errors
161    if error:
162        return DMARCCheck(result=error, policy=None)
163
164    # No policy found
165    if not dmarc_policy:
166        return DMARCCheck(result=DMARCResult.NONE, policy=None)
167
168    # Check identifier alignment (RFC 7489 Section 3.1)
169    # SPF alignment: envelope sender domain vs header from domain
170    envelope_domain = (
171        envelope_from.split("@")[-1] if "@" in envelope_from else envelope_from
172    )
173    spf_aligned = spf_check.result == SPFResult.PASS and is_spf_aligned(
174        envelope_domain, header_from, dmarc_policy.spf_mode
175    )
176
177    # DKIM alignment: signing domain (d=) vs header from domain
178    dkim_aligned = bool(
179        dkim_check.result == DKIMResult.SUCCESS
180        and dkim_check.domain
181        and is_dkim_aligned(dkim_check.domain, header_from, dmarc_policy.dkim_mode)
182    )
183
184    # RFC 7489: DMARC passes if either SPF or DKIM is aligned and passes
185    dmarc_pass = spf_aligned or dkim_aligned
186
187    # ARC override logic (RFC 8617 Section 7.2.1)
188    # RFC 8617: "a DMARC processor MAY choose to accept the authentication
189    # assessments provided by an Authenticated Received Chain"
190    arc_override_applied = False
191    if (
192        not dmarc_pass
193        and configuration
194        and configuration.trusted_arc_signers
195        and arc_check.signer in configuration.trusted_arc_signers
196        and arc_check.result == ARCChainStatus.PASS
197        and arc_check.aar_header
198    ):
199        parsed_aar = extract_original_auth_results(
200            arc_check.result, arc_check.aar_header
201        )
202        if parsed_aar and "dmarc" in parsed_aar and parsed_aar["dmarc"] == "pass":
203            dmarc_pass = True
204            arc_override_applied = True
205
206    return DMARCCheck(
207        result=DMARCResult.PASS if dmarc_pass else DMARCResult.FAIL,
208        policy=dmarc_policy.policy,
209        spf_aligned=spf_aligned,
210        dkim_aligned=dkim_aligned,
211        arc_override_applied=arc_override_applied,
212    )

DMARC evaluation per RFC 7489 Section 3 (Identifier Alignment).

RFC 7489: "A message satisfies the DMARC checks if at least one of the supported authentication mechanisms: 1. produces a 'pass' result, and 2. produces that result based on an identifier that is in alignment"

async def get_dmarc_policy( domain: str) -> tuple[DMARCRecord | None, typing.Union[typing.Literal[<DMARCResult.TEMPERROR: 'temperror'>], typing.Literal[<DMARCResult.PERMERROR: 'permerror'>], NoneType]]:
 85async def get_dmarc_policy(
 86    domain: str,
 87) -> tuple[DMARCRecord | None, _DMARCError | None]:
 88    """Fetch DMARC policy according to RFC 7489 Section 6.1."""
 89    resolver = DNSResolver()
 90
 91    record, error = await _fetch_dmarc_record(resolver, domain)
 92
 93    # Temp error means early return
 94    if error == DMARCResult.TEMPERROR or record:
 95        return record, error
 96
 97    # RFC 7489 Section 6.6.3: "If the set is now empty, the Mail Receiver MUST query the DNS for
 98    # a DMARC TXT record at the DNS domain matching the Organizational
 99    # Domain in place of the RFC5322.From domain in the message (if
100    # different).
101    psl = publicsuffixlist.PublicSuffixList()
102    organizational_domain = psl.privatesuffix(domain.lower()) or domain
103
104    if organizational_domain != domain:
105        org_record, org_error = await _fetch_dmarc_record(
106            resolver, organizational_domain
107        )
108        # Return org domain result if we found a record or hit a temp error
109        if org_error == DMARCResult.TEMPERROR or org_record:
110            return org_record, org_error
111
112    # No policy found anywhere, return the original domain's result
113    return record, error

Fetch DMARC policy according to RFC 7489 Section 6.1.

@dataclass
class DMARCCheck:
52@dataclass
53class DMARCCheck:
54    result: DMARCResult
55    policy: DMARCPolicy | None
56    spf_aligned: bool | None = None
57    dkim_aligned: bool | None = None
58    arc_override_applied: bool = False
DMARCCheck( result: DMARCResult, policy: DMARCPolicy | None, spf_aligned: bool | None = None, dkim_aligned: bool | None = None, arc_override_applied: bool = False)
result: DMARCResult
policy: DMARCPolicy | None
spf_aligned: bool | None = None
dkim_aligned: bool | None = None
arc_override_applied: bool = False
class DMARCResult(enum.StrEnum):
42class DMARCResult(StrEnum):
43    """Defined in RFC 7489 Section 11.2"""
44
45    PASS = "pass"
46    FAIL = "fail"
47    NONE = "none"
48    PERMERROR = "permerror"
49    TEMPERROR = "temperror"

Defined in RFC 7489 Section 11.2

PASS = <DMARCResult.PASS: 'pass'>
FAIL = <DMARCResult.FAIL: 'fail'>
NONE = <DMARCResult.NONE: 'none'>
PERMERROR = <DMARCResult.PERMERROR: 'permerror'>
TEMPERROR = <DMARCResult.TEMPERROR: 'temperror'>
class DMARCPolicy(enum.StrEnum):
26class DMARCPolicy(StrEnum):
27    NONE = "none"
28    QUARANTINE = "quarantine"
29    REJECT = "reject"
NONE = <DMARCPolicy.NONE: 'none'>
QUARANTINE = <DMARCPolicy.QUARANTINE: 'quarantine'>
REJECT = <DMARCPolicy.REJECT: 'reject'>
@dataclass
class DMARCRecord:
32@dataclass
33class DMARCRecord:
34    """Holds a parsed DMARC DNS record."""
35
36    policy: DMARCPolicy
37    spf_mode: AlignmentMode
38    dkim_mode: AlignmentMode
39    # percentage: int

Holds a parsed DMARC DNS record.

DMARCRecord( policy: DMARCPolicy, spf_mode: Literal['relaxed', 'strict'], dkim_mode: Literal['relaxed', 'strict'])
policy: DMARCPolicy
spf_mode: Literal['relaxed', 'strict']
dkim_mode: Literal['relaxed', 'strict']