emailsec

builds.sr.ht status PyPI version

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]
async def authenticate_message( smtp_context: SMTPContext, raw_email: bytes, configuration: 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
@dataclass
class AuthenticationConfiguration:
 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.

AuthenticationConfiguration(trusted_arc_signers: list[str] | None = None)
trusted_arc_signers: list[str] | None = None

Trusted ARC signers that will enable overriding DMARC results with the authentication results from ARC.

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    # TODO: 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
type BodyAndHeaders = tuple[bytes, dict[str, list[tuple[bytes, bytes]]]]
Body = <class 'bytes'>
Headers = dict[str, list[tuple[bytes, bytes]]]
def body_and_headers_for_canonicalization(message: bytes) -> BodyAndHeaders:
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.

AlignmentMode = typing.Literal['relaxed', 'strict']