emailsec.auth

  1from email.utils import parseaddr
  2from enum import Enum
  3from dataclasses import dataclass
  4
  5from emailsec.config import AuthenticationConfiguration
  6
  7from emailsec.spf.checker import check_spf, SPFCheck, SPFResult
  8from emailsec.dkim.checker import check_dkim, DKIMCheck, DKIMResult
  9from emailsec.dmarc import (
 10    check_dmarc,
 11    DMARCPolicy,
 12    DMARCCheck,
 13    DMARCResult,
 14)
 15from emailsec.arc import check_arc, ARCCheck, ARCChainStatus
 16import emailsec._utils
 17
 18__all__ = [
 19    "authenticate_message",
 20    "AuthenticationResult",
 21    "DeliveryAction",
 22    "SMTPContext",
 23]
 24
 25
 26class DeliveryAction(Enum):
 27    """Final delivery results based on the authentication checks."""
 28
 29    ACCEPT = "accept"
 30    QUARANTINE = "quarantine"
 31    REJECT = "reject"
 32    DEFER = "defer"  # SMTP server should return 451 4.3.0 Temporary lookup failure
 33
 34
 35@dataclass
 36class SMTPContext:
 37    """Context from the SMTP server processing the incoming email."""
 38
 39    # Connection info
 40    sender_ip_address: str
 41    client_hostname: str | None  # EHLO/HELO hostname
 42
 43    # Envelope data
 44    mail_from: str  # MAIL FROM address (envelope sender)
 45
 46    # TOOD: timestamp to check for expired signature?
 47
 48
 49def _make_delivery_decision(
 50    spf_check: SPFCheck,
 51    dkim_check: DKIMCheck,
 52    arc_check: ARCCheck,
 53    dmarc_check: DMARCCheck,
 54) -> DeliveryAction:
 55    """
 56    Delivery decision logic following RFC 7489.
 57
 58    1. DMARC policy is enforced first (if the sender has one)
 59    2. If DMARC passes → Accept
 60    3. If no DMARC or policy is "none" → Check individual authentications
 61    4. Default to quarantine for unauthenticated mail
 62
 63    RFC 7489: "DMARC-compliant Mail Receivers typically disregard any
 64    mail-handling directive discovered as part of an authentication mechanism
 65    where a DMARC record is also discovered that specifies a policy other than 'none'"
 66
 67    RFC 7489 warns against rejecting on SPF fail before checking DMARC,
 68    as this could prevent legitimate mail that passes DKIM+DMARC.
 69    """
 70    # Defer the delivery decision if SPF, DKIM, or DMARC failed with a temp error
 71    if (
 72        spf_check.result == SPFResult.TEMPERROR
 73        or dkim_check.result == DKIMResult.TEMPFAIL
 74        or dmarc_check.result == DMARCResult.TEMPERROR
 75    ):
 76        return DeliveryAction.DEFER
 77
 78    # DMARC policy takes precedence (RFC 7489 Section 6.3)
 79    if dmarc_check.result == DMARCResult.FAIL:
 80        match dmarc_check.policy:
 81            case DMARCPolicy.REJECT:
 82                # RFC 7489: "the Mail Receiver SHOULD reject the message"
 83                return DeliveryAction.REJECT
 84            case DMARCPolicy.QUARANTINE:
 85                # RFC 7489: "the Mail Receiver SHOULD place the message in
 86                # a quarantine area or folder instead of delivering it"
 87                return DeliveryAction.QUARANTINE
 88            case DMARCPolicy.NONE:
 89                # RFC 7489: "the Domain Owner requests no specific action
 90                # be taken regarding delivery of the message"
 91                pass  # Continue to fallback logic
 92            case None:
 93                # This should never happen since FAIL result requires a policy
 94                pass
 95
 96    # If DMARC passes, accept
 97    if dmarc_check.result == DMARCResult.PASS:
 98        return DeliveryAction.ACCEPT
 99
100    # Fallback logic when DMARC is not available or policy is "none"
101    # RFC 7489: "Final disposition of a message is always a matter of local policy"
102
103    # Accept if any DKIM signature passes
104    if dkim_check.result == DKIMResult.SUCCESS:
105        return DeliveryAction.ACCEPT
106
107    # Accept if SPF passes
108    if spf_check.result == SPFResult.PASS:
109        return DeliveryAction.ACCEPT
110
111    # TODO: if no DMARC policy (or none), look for a trusted ARC to fallback to accept
112
113    # Conservative default for unauthenticated mail
114    return DeliveryAction.QUARANTINE
115
116
117@dataclass
118class AuthenticationResult:
119    delivery_action: DeliveryAction
120    spf_check: SPFCheck
121    dkim_check: DKIMCheck
122    dmarc_check: DMARCCheck | None
123    arc_check: ARCCheck
124
125
126async def authenticate_message(
127    smtp_context: SMTPContext,
128    raw_email: bytes,
129    configuration: AuthenticationConfiguration | None = None,
130) -> AuthenticationResult:
131    """
132    Authenticate an incoming email using SPF, DKIM, and DMARC.
133
134    Authentication flow:
135    1. SPF (RFC 7208): Verify the sending IP is authorized for the envelope sender domain
136    2. DKIM (RFC 6376): Verify cryptographic signatures on the email
137    3. ARC (RFC 8617): Check authentication chain if present (for forwarded mail)
138    4. DMARC (RFC 7489): Evaluate if SPF/DKIM align with the From header domain
139    5. Make delivery decision based on combined results
140
141    Delivery decision logic following RFC 7489.
142
143    1. DMARC policy is enforced first (if the sender has one)
144    2. If DMARC passes, then accept
145    3. If no DMARC or policy is "none", then check individual SPF/DKIM
146    4. Default to quarantine for unauthenticated mail
147    """
148    body_and_headers = emailsec._utils.body_and_headers_for_canonicalization(raw_email)
149    header_from_raw = emailsec._utils.header_value(body_and_headers[1], "from")
150
151    # Extract domain from From header for DMARC evaluation (RFC 7489 Section 3.1)
152    # From header can be "Name <email@domain.com>" or just "email@domain.com"
153    _, email_address = parseaddr(header_from_raw)
154    if not email_address or "@" not in email_address:
155        # RFC 5322: From header must contain a valid email address
156        # Malformed From header should result in rejection
157        return AuthenticationResult(
158            delivery_action=DeliveryAction.REJECT,
159            spf_check=SPFCheck(SPFResult.NONE, "", "", "Malformed From header"),
160            dkim_check=DKIMCheck(DKIMResult.PERMFAIL, None, None),
161            dmarc_check=None,
162            arc_check=ARCCheck(ARCChainStatus.NONE, "Malformed From header"),
163        )
164
165    header_from = email_address.partition("@")[2]
166
167    # Step 1: SPF Check (RFC 7208)
168    # RFC 7489: SPF authenticates the envelope sender domain
169    spf_check = await check_spf(
170        smtp_context.sender_ip_address,
171        smtp_context.mail_from,
172    )
173
174    # Step 2: DKIM Verification (RFC 6376)
175    # Performed independently of SPF per RFC 7489 Section 4.3
176    dkim_check = await check_dkim(raw_email)
177
178    # Step 3: ARC Processing (RFC 8617) - if ARC headers present
179    # RFC 8617 Section 7.2: "allows Internet Mail Handler to potentially base
180    # decisions of message disposition on authentication assessments"
181    arc_check = await check_arc(raw_email, body_and_headers)
182
183    # Step 4: DMARC Evaluation (RFC 7489)
184    # RFC 7489: "A message satisfies the DMARC checks if at least one of the
185    # supported authentication mechanisms produces a 'pass' result"
186    dmarc_check = await check_dmarc(
187        header_from=header_from,
188        envelope_from=smtp_context.mail_from,
189        spf_check=spf_check,
190        dkim_check=dkim_check,
191        arc_check=arc_check,
192        configuration=configuration,
193    )
194
195    # Handle DMARC temp errors, defer for temporary DNS issues
196    # (treat perm errors as "no DMARC policy" and continue processing)
197    if dmarc_check.result == DMARCResult.TEMPERROR:
198        return AuthenticationResult(
199            delivery_action=DeliveryAction.DEFER,
200            spf_check=spf_check,
201            dkim_check=dkim_check,
202            dmarc_check=dmarc_check,
203            arc_check=arc_check,
204        )
205
206    # Step 5: Make delivery decision
207    # RFC 7489: "Final disposition of a message is always a matter of local policy"
208    return AuthenticationResult(
209        delivery_action=_make_delivery_decision(
210            spf_check, dkim_check, arc_check, dmarc_check
211        ),
212        spf_check=spf_check,
213        dkim_check=dkim_check,
214        dmarc_check=dmarc_check,
215        arc_check=arc_check,
216    )
async def authenticate_message( smtp_context: SMTPContext, raw_email: bytes, configuration: emailsec.config.AuthenticationConfiguration | None = None) -> AuthenticationResult:
127async def authenticate_message(
128    smtp_context: SMTPContext,
129    raw_email: bytes,
130    configuration: AuthenticationConfiguration | None = None,
131) -> AuthenticationResult:
132    """
133    Authenticate an incoming email using SPF, DKIM, and DMARC.
134
135    Authentication flow:
136    1. SPF (RFC 7208): Verify the sending IP is authorized for the envelope sender domain
137    2. DKIM (RFC 6376): Verify cryptographic signatures on the email
138    3. ARC (RFC 8617): Check authentication chain if present (for forwarded mail)
139    4. DMARC (RFC 7489): Evaluate if SPF/DKIM align with the From header domain
140    5. Make delivery decision based on combined results
141
142    Delivery decision logic following RFC 7489.
143
144    1. DMARC policy is enforced first (if the sender has one)
145    2. If DMARC passes, then accept
146    3. If no DMARC or policy is "none", then check individual SPF/DKIM
147    4. Default to quarantine for unauthenticated mail
148    """
149    body_and_headers = emailsec._utils.body_and_headers_for_canonicalization(raw_email)
150    header_from_raw = emailsec._utils.header_value(body_and_headers[1], "from")
151
152    # Extract domain from From header for DMARC evaluation (RFC 7489 Section 3.1)
153    # From header can be "Name <email@domain.com>" or just "email@domain.com"
154    _, email_address = parseaddr(header_from_raw)
155    if not email_address or "@" not in email_address:
156        # RFC 5322: From header must contain a valid email address
157        # Malformed From header should result in rejection
158        return AuthenticationResult(
159            delivery_action=DeliveryAction.REJECT,
160            spf_check=SPFCheck(SPFResult.NONE, "", "", "Malformed From header"),
161            dkim_check=DKIMCheck(DKIMResult.PERMFAIL, None, None),
162            dmarc_check=None,
163            arc_check=ARCCheck(ARCChainStatus.NONE, "Malformed From header"),
164        )
165
166    header_from = email_address.partition("@")[2]
167
168    # Step 1: SPF Check (RFC 7208)
169    # RFC 7489: SPF authenticates the envelope sender domain
170    spf_check = await check_spf(
171        smtp_context.sender_ip_address,
172        smtp_context.mail_from,
173    )
174
175    # Step 2: DKIM Verification (RFC 6376)
176    # Performed independently of SPF per RFC 7489 Section 4.3
177    dkim_check = await check_dkim(raw_email)
178
179    # Step 3: ARC Processing (RFC 8617) - if ARC headers present
180    # RFC 8617 Section 7.2: "allows Internet Mail Handler to potentially base
181    # decisions of message disposition on authentication assessments"
182    arc_check = await check_arc(raw_email, body_and_headers)
183
184    # Step 4: DMARC Evaluation (RFC 7489)
185    # RFC 7489: "A message satisfies the DMARC checks if at least one of the
186    # supported authentication mechanisms produces a 'pass' result"
187    dmarc_check = await check_dmarc(
188        header_from=header_from,
189        envelope_from=smtp_context.mail_from,
190        spf_check=spf_check,
191        dkim_check=dkim_check,
192        arc_check=arc_check,
193        configuration=configuration,
194    )
195
196    # Handle DMARC temp errors, defer for temporary DNS issues
197    # (treat perm errors as "no DMARC policy" and continue processing)
198    if dmarc_check.result == DMARCResult.TEMPERROR:
199        return AuthenticationResult(
200            delivery_action=DeliveryAction.DEFER,
201            spf_check=spf_check,
202            dkim_check=dkim_check,
203            dmarc_check=dmarc_check,
204            arc_check=arc_check,
205        )
206
207    # Step 5: Make delivery decision
208    # RFC 7489: "Final disposition of a message is always a matter of local policy"
209    return AuthenticationResult(
210        delivery_action=_make_delivery_decision(
211            spf_check, dkim_check, arc_check, dmarc_check
212        ),
213        spf_check=spf_check,
214        dkim_check=dkim_check,
215        dmarc_check=dmarc_check,
216        arc_check=arc_check,
217    )

Authenticate an incoming email using SPF, DKIM, and DMARC.

Authentication flow:

  1. SPF (RFC 7208): Verify the sending IP is authorized for the envelope sender domain
  2. DKIM (RFC 6376): Verify cryptographic signatures on the email
  3. ARC (RFC 8617): Check authentication chain if present (for forwarded mail)
  4. DMARC (RFC 7489): Evaluate if SPF/DKIM align with the From header domain
  5. Make delivery decision based on combined results

Delivery decision logic following RFC 7489.

  1. DMARC policy is enforced first (if the sender has one)
  2. If DMARC passes, then accept
  3. If no DMARC or policy is "none", then check individual SPF/DKIM
  4. Default to quarantine for unauthenticated mail
@dataclass
class AuthenticationResult:
118@dataclass
119class AuthenticationResult:
120    delivery_action: DeliveryAction
121    spf_check: SPFCheck
122    dkim_check: DKIMCheck
123    dmarc_check: DMARCCheck | None
124    arc_check: ARCCheck
AuthenticationResult( delivery_action: DeliveryAction, spf_check: emailsec.spf.SPFCheck, dkim_check: emailsec.dkim.DKIMCheck, dmarc_check: emailsec.dmarc.DMARCCheck | None, arc_check: emailsec.arc.ARCCheck)
delivery_action: DeliveryAction
dmarc_check: emailsec.dmarc.DMARCCheck | None
class DeliveryAction(enum.Enum):
27class DeliveryAction(Enum):
28    """Final delivery results based on the authentication checks."""
29
30    ACCEPT = "accept"
31    QUARANTINE = "quarantine"
32    REJECT = "reject"
33    DEFER = "defer"  # SMTP server should return 451 4.3.0 Temporary lookup failure

Final delivery results based on the authentication checks.

ACCEPT = <DeliveryAction.ACCEPT: 'accept'>
QUARANTINE = <DeliveryAction.QUARANTINE: 'quarantine'>
REJECT = <DeliveryAction.REJECT: 'reject'>
DEFER = <DeliveryAction.DEFER: 'defer'>
@dataclass
class SMTPContext:
36@dataclass
37class SMTPContext:
38    """Context from the SMTP server processing the incoming email."""
39
40    # Connection info
41    sender_ip_address: str
42    client_hostname: str | None  # EHLO/HELO hostname
43
44    # Envelope data
45    mail_from: str  # MAIL FROM address (envelope sender)
46
47    # TOOD: timestamp to check for expired signature?

Context from the SMTP server processing the incoming email.

SMTPContext(sender_ip_address: str, client_hostname: str | None, mail_from: str)
sender_ip_address: str
client_hostname: str | None
mail_from: str