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:
- SPF (RFC 7208): Verify the sending IP is authorized for the envelope sender domain
- DKIM (RFC 6376): Verify cryptographic signatures on the email
- ARC (RFC 8617): Check authentication chain if present (for forwarded mail)
- DMARC (RFC 7489): Evaluate if SPF/DKIM align with the From header domain
- Make delivery decision based on combined results
Delivery decision logic following RFC 7489.
- DMARC policy is enforced first (if the sender has one)
- If DMARC passes, then accept
- If no DMARC or policy is "none", then check individual SPF/DKIM
- 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
spf_check: emailsec.spf.SPFCheck
dkim_check: emailsec.dkim.DKIMCheck
dmarc_check: emailsec.dmarc.DMARCCheck | None
arc_check: emailsec.arc.ARCCheck
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.