emailsec
emailsec authenticates incoming emails with SPF, DKIM, DMARC, and ARC.
This project is still in early development.
Features
Authentication Protocols
SPF (Sender Policy Framework) - RFC 7208 Verifies the sending IP address is authorized to send email for a domain
DKIM (DomainKeys Identified Mail) - RFC 6376 Validates email authenticity using cryptographic signatures
DMARC (Domain-based Message Authentication, Reporting, and Conformance) - RFC 7489 Combines SPF and DKIM results with policy enforcement
ARC (Authenticated Received Chain) - RFC 8617 Preserves authentication results across email forwarding
Reputation
- DNSBL (DNS-based Blacklists) - RFC 5782 Checks sender IP addresses against DNS blacklists for reputation filtering
Usage
Message Authentication
>>> import asyncio
>>> from emailsec import authenticate_message, SMTPContext
>>>
>>> smtp_ctx = SMTPContext(
... sender_ip_address="203.0.113.42",
... client_hostname="mail.example.com",
... mail_from="alice@example.com",
... )
>>> raw_email = b"""From: Alice <alice@example.com>
... To: Bob <bob@company.com>
... Subject: Hello from the conference
... Date: Mon, 27 Jan 2025 10:30:45 +0000
... Message-ID: <20250127103045.4A8B2@mail.example.com>
... Content-Type: text/plain; charset=UTF-8
... DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com;
... s=selector1; h=from:to:subject:date:message-id;
... bh=eKhvPb2btxd7/zv/sYlR5Z4ws09I2c1WJzPa=;
... b=C70Bf8rWjJZJt/RcOnoFJquifA1XNB/yiKVP==
...
... Hi Bob,
...
... Cheers,
... Alice
... """
>>>
>>> asyncio.run(authenticate_message(smtp_ctx, raw_email))
AuthenticationResult(
delivery_action=<DeliveryAction.ACCEPT: 'accept'>,
spf_check=SPFCheck(
result=<SPFResult.PASS: 'pass'>,
domain="example.com",
sender_ip="203.0.113.42",
exp=""
),
dkim_check=DKIMCheck(
result=<DKIMResult.SUCCESS: 'SUCCESS'>,
domain="example.com",
selector="selector1",
signature={
'v': '1',
'a': 'rsa-sha256',
'c': 'relaxed/relaxed',
'd': 'example.com',
'h': 'from:to:subject:date:message-id',
's': 'selector1',
'bh': 'eKhvPb2btxd7/zv/sYlR5Z4ws09I2c1WJzPa...',
'b': 'C70Bf8rWjJZJt/RcOnoFJquifA1XNB/yiKVP...'
}
),
dmarc_check=DMARCCheck(
result=<DMARCResult.PASS: 'pass'>,
policy=<DMARCPolicy.QUARANTINE: 'quarantine'>,
spf_aligned=True,
dkim_aligned=True,
arc_override_applied=False
),
arc_check=ARCCheck(
result=<ARCChainStatus.NONE: 'none'>,
exp="No ARC Sets",
signer=None,
aar_header=None
)
)
SPF
RFC 7208-compliant SPF (Sender Policy Framework) parser and checker.
Parser
>>> from emailsec.spf.parser import parse_record
>>> parse_record("v=spf1 +a mx/30 mx:example.org/30 -all")
[A(qualifier=<Qualifier.PASS: '+'>, domain_spec=None, cidr=None),
MX(qualifier=<Qualifier.PASS: '+'>, domain_spec=None, cidr='/30'),
MX(qualifier=<Qualifier.PASS: '+'>, domain_spec='example.org', cidr='/30'),
All(qualifier=<Qualifier.FAIL: '-'>)]
Checker
>>> import asyncio
>>> from emailsec.spf import check_spf
>>> asyncio.run(check_spf(sender_ip="192.0.2.10", sender="hello@example.com"))
SPFCheck(result=<SPFResult.PASS: 'pass'>, domain='example.com', sender_ip='192.0.2.10', exp='')
DKIM
RFC 6376-compliant DKIM (DomainKeys Identified Mail) signature verification.
>>> import asyncio
>>> from emailsec.dkim import check_dkim
>>> asyncio.run(check_dkim(raw_email))
DKIMCheck(result=<DKIMResult.SUCCESS: 'SUCCESS'>, domain='example.com', selector='selector1', ...)
DMARC
RFC 7489-compliant DMARC (Domain-based Message Authentication, Reporting, and Conformance) policy lookup and evaluation.
>>> import asyncio
>>> from emailsec.dmarc import get_dmarc_policy
>>> asyncio.run(get_dmarc_policy("example.com"))
(DMARCRecord(policy=<DMARCPolicy.REJECT: 'reject'>, spf_mode='relaxed', dkim_mode='relaxed'), None)
ARC
RFC 8617-compliant ARC (Authenticated Received Chain) validation.
>>> import asyncio
>>> from emailsec.arc import check_arc
>>> asyncio.run(check_arc(raw_email))
ARCCheck(result=<ARCChainStatus.PASS: 'pass'>, signer='forwarder.example', ...)
DNSBL
RFC 5782-compliant DNSBL (DNS-based Blacklists) lookup.
>>> from emailsec.dnsbl import DNSBLChecker
>>> asyncio.run(DNSBLChecker().check_ip("172.235.181.217", ["zen.spamhaus.org"]))
DNSBLResult(
ip_address='172.235.181.217',
is_listed=True,
listed_on=['zen.spamhaus.org'],
entries={
'zen.spamhaus.org': DNSBLEntry(
return_code='127.0.0.3',
description='Listed by CSS, see https://check.spamhaus.org/query/ip/172.235.181.217'
)
},
failed_queries=[]
)
Optimizing Multiple Checks
When running multiple checks on the same message (e.g., DKIM and ARC), you can parse the message once and reuse it:
>>> from emailsec import body_and_headers_for_canonicalization
>>> from emailsec.dkim import check_dkim
>>> from emailsec.arc import check_arc
>>>
>>> # Parse once
>>> body_and_headers = body_and_headers_for_canonicalization(raw_email)
>>>
>>> # Reuse for both checks
>>> dkim_result = await check_dkim(raw_email, body_and_headers)
>>> arc_result = await check_arc(raw_email, body_and_headers)
Documentation
Project documentation is available at https://emailsec.hexa.ninja/.
Contribution
Contributions are welcome but please open an issue to start a discussion before starting something consequent.
License
Copyright (c) 2025 Thomas Sileo and contributors. Released under the MIT license.
API Reference
Public API of the emailsec package.
1""" 2.. include:: ../../README.md 3 :start-line: 1 4 5## API Reference 6 7Public API of the `emailsec` package. 8""" 9 10from .auth import AuthenticationResult as AuthenticationResult 11from .auth import DeliveryAction as DeliveryAction 12from .auth import SMTPContext as SMTPContext 13from .auth import authenticate_message as authenticate_message 14from .config import AuthenticationConfiguration as AuthenticationConfiguration 15from ._utils import BodyAndHeaders as BodyAndHeaders 16from ._utils import Body as Body 17from ._utils import Header as Header 18from ._utils import Headers as Headers 19from ._utils import ( 20 body_and_headers_for_canonicalization as body_and_headers_for_canonicalization, 21) 22from ._alignment import AlignmentMode as AlignmentMode 23from . import spf as spf 24from . import dkim as dkim 25from . import dmarc as dmarc 26from . import arc as arc 27from . import dnsbl as dnsbl 28 29__all__ = [ 30 # High-level API 31 "authenticate_message", 32 "AuthenticationResult", 33 "AuthenticationConfiguration", 34 "DeliveryAction", 35 "SMTPContext", 36 # Message parsing utilities 37 "BodyAndHeaders", 38 "Body", 39 "Header", 40 "Headers", 41 "body_and_headers_for_canonicalization", 42 # Types 43 "AlignmentMode", 44 # Submodules 45 "spf", 46 "dkim", 47 "dmarc", 48 "arc", 49 "dnsbl", 50]
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
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
5@dataclass 6class AuthenticationConfiguration: 7 """Configuration items to tweak the authentication behavior.""" 8 9 trusted_arc_signers: list[str] | None = None 10 """Trusted ARC signers that will enable overriding DMARC results with the 11 authentication results from ARC. 12 """
Configuration items to tweak the authentication behavior.
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.
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 # TODO: timestamp to check for expired signature?
Context from the SMTP server processing the incoming email.
18def body_and_headers_for_canonicalization(message: bytes) -> BodyAndHeaders: 19 """ 20 Parse a raw email message into its body and headers for DKIM/ARC canonicalization. 21 22 This function splits the message at the first empty line and parses headers, 23 handling folded header values according to RFC 5322. 24 25 Args: 26 message: The raw email message as bytes. 27 28 Returns: 29 A tuple of (body, headers) where body is the raw body bytes and headers 30 is a dictionary mapping lowercase header names to lists of (name, value) tuples. 31 """ 32 lines = re.split(b"\r?\n", message) 33 34 headers_idx = collections.defaultdict(list) 35 headers = [] 36 for header_line in lines[: lines.index(b"")]: 37 if (m := re.match(rb"([\x21-\x7e]+?):", header_line)) is not None: 38 header_name = m.group(1) 39 header_value = header_line[m.end() :] + b"\r\n" 40 headers.append([header_name, header_value]) 41 elif header_line.startswith(b" ") or header_line.startswith(b"\t"): 42 # Unfold header values 43 headers[-1][1] += header_line + b"\r\n" 44 else: 45 raise ValueError(f"Invalid line {header_line!r}") 46 47 for header_name, header_value in headers: 48 headers_idx[header_name.decode().lower()].append((header_name, header_value)) 49 50 try: 51 # Split on the first empty line and join the remaining ones with CRLF 52 can_body = b"\r\n".join(lines[lines.index(b"") + 1 :]) 53 except ValueError: 54 # No body defaults to CRLF 55 can_body = b"\r\n" 56 57 return can_body, dict(headers_idx)
Parse a raw email message into its body and headers for DKIM/ARC canonicalization.
This function splits the message at the first empty line and parses headers, handling folded header values according to RFC 5322.
Args: message: The raw email message as bytes.
Returns: A tuple of (body, headers) where body is the raw body bytes and headers is a dictionary mapping lowercase header names to lists of (name, value) tuples.