emailsec.auth

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

Delivery decision logic following RFC 7489.

  1. DMARC policy is enforced first (if the sender has one)
  2. If DMARC passes → Accept
  3. If no DMARC or policy is "none" → Check individual authentications
  4. Default to quarantine for unauthenticated mail

RFC 7489: "DMARC-compliant Mail Receivers typically disregard any mail-handling directive discovered as part of an authentication mechanism where a DMARC record is also discovered that specifies a policy other than 'none'"

RFC 7489 warns against rejecting on SPF fail before checking DMARC, as this could prevent legitimate mail that passes DKIM+DMARC.

@dataclass
class AuthenticationResult:
105@dataclass
106class AuthenticationResult:
107    delivery_action: DeliveryAction
108    spf_check: SPFCheck
109    dkim_check: DKIMCheck
110    dmarc_result: DMARCResult | None
111    arc_check: ARCCheck
AuthenticationResult( delivery_action: DeliveryAction, spf_check: emailsec.spf.SPFCheck, dkim_check: emailsec.dkim.DKIMCheck, dmarc_result: emailsec.dmarc.DMARCResult | None, arc_check: emailsec.arc.ARCCheck)
delivery_action: DeliveryAction
dmarc_result: emailsec.dmarc.DMARCResult | None
async def authenticate_message( smtp_context: SMTPContext, raw_email: bytes, configuration: AuthenticationConfiguration | None = None) -> AuthenticationResult:
114async def authenticate_message(
115    smtp_context: SMTPContext,
116    raw_email: bytes,
117    configuration: AuthenticationConfiguration | None = None,
118) -> AuthenticationResult:
119    """
120    Authenticate an incoming email using SPF, DKIM, and DMARC.
121
122    Authentication flow:
123    1. SPF (RFC 7208): Verify the sending IP is authorized for the envelope sender domain
124    2. DKIM (RFC 6376): Verify cryptographic signatures on the email
125    3. ARC (RFC 8617): Check authentication chain if present (for forwarded mail)
126    4. DMARC (RFC 7489): Evaluate if SPF/DKIM align with the From header domain
127    5. Make delivery decision based on combined results
128    """
129    body_and_headers = emailsec.utils.body_and_headers_for_canonicalization(raw_email)
130    header_from = emailsec.utils.header_value(body_and_headers[1], "from")
131
132    # Step 1: SPF Check (RFC 7208)
133    # RFC 7489: SPF authenticates the envelope sender domain
134    spf_check = await check_spf(
135        smtp_context.sender_ip_address,
136        smtp_context.mail_from,
137    )
138
139    # Step 2: DKIM Verification (RFC 6376)
140    # Performed independently of SPF per RFC 7489 Section 4.3
141    dkim_check = await check_dkim(raw_email)
142
143    # Step 3: ARC Processing (RFC 8617) - if ARC headers present
144    # RFC 8617 Section 7.2: "allows Internet Mail Handler to potentially base
145    # decisions of message disposition on authentication assessments"
146    arc_check = await check_arc(raw_email, body_and_headers)
147
148    # Step 4: DMARC Evaluation (RFC 7489)
149    # RFC 7489: "A message satisfies the DMARC checks if at least one of the
150    # supported authentication mechanisms produces a 'pass' result"
151    try:
152        dmarc_result = await check_dmarc(
153            header_from=header_from,
154            envelope_from=smtp_context.mail_from,
155            spf_check=spf_check,
156            dkim_check=dkim_check,
157            arc_check=arc_check,
158            configuration=configuration,
159        )
160    except errors.Temperror:
161        return AuthenticationResult(
162            delivery_action=DeliveryAction.DEFER,
163            spf_check=spf_check,
164            dkim_check=dkim_check,
165            dmarc_result=None,
166            arc_check=arc_check,
167        )
168
169    # Step 5: Make delivery decision
170    # RFC 7489: "Final disposition of a message is always a matter of local policy"
171    return AuthenticationResult(
172        delivery_action=make_delivery_decision(
173            spf_check, dkim_check, arc_check, dmarc_result
174        ),
175        spf_check=spf_check,
176        dkim_check=dkim_check,
177        dmarc_result=dmarc_result,
178        arc_check=arc_check,
179    )

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