A strict-JMAP RFC 8621 Email object library for Python 3.14+ with lenient RFC 5322 / MIME parsing and strict-by-design composition. Zero runtime dependencies.
Project description
jmap-email
A strict-JMAP RFC 8621 Email object library for Python 3.14+, with
lenient RFC 5322 / MIME parsing and strict-by-design composition.
Zero runtime dependencies — the package is a clean wrapper around
the Python stdlib email package, plus null-safe shape accessors over
the JMAP Email object.
The codebase came out of operating an inbound mail pipeline; every CVE
and research result in the defense matrix below has
a regression test under tests/.
Status: beta while the public API stabilizes. The wire shape conforms to RFC 8621 §4. Per SemVer, 0.x releases may still make breaking API changes — 0.2.0 renamed
ParseLimits→ParseOptionsand moved theTypedDictshapes tojmap_email.types; every breaking change is called out in the CHANGELOG.
Why a Python 3.14.6 floor?
The standard library email package receives frequent bug fixes
between patch releases, and this library wraps it directly — every fix
to header parsing, RFC 2047 encoded-words, address-list defects, etc.
surfaces immediately in our output. The 3.14.6 floor is not arbitrary:
it carries
gh-128110
(RFC 2047 §6.2 encoded-word adjacent-pair spacing under modern
policies), which materially affects the composer, plus the further
email fixes shipped in 3.14.6.
Aligning on the latest 3.14.x patch is recommended for any
production deployment. Each CPython patch release that touches
email is one less class of malformed-input edge case downstream
pipelines need to paper over manually.
Quick start
pip install jmap-email
import jmap_email
# Parse raw RFC 5322 bytes → JMAP Email object dict (RFC 8621 §4),
# or None when the input is fundamentally unparseable (empty, non-bytes,
# stdlib produced no Message, etc.). parse_email never raises — the
# failure mode is a single `is None` check at the call site.
email = jmap_email.parse_email(raw_bytes)
if email is None:
... # log + skip / 400 / quarantine — caller's choice
# Recoverable damage (a salvageable malformed header, an unknown
# charset that fell back to utf-8/replace, …) surfaces in
# email["_ext"]["defects"] when you opt into the project-extension
# namespace:
email_with_ext = jmap_email.parse_email(raw_bytes, extensions=True)
defects = (email_with_ext or {}).get("_ext", {}).get("defects") or []
email["subject"] # str | None (NFC normalised)
email["from"] # [{"name": str | None, "email": str}, ...] | None
email["sentAt"] # ISO-8601 with offset, e.g. "2026-06-08T14:30:00+02:00"
email["textBody"] # JMAP EmailBodyPart[]
email["bodyValues"] # {partId: {"value", "isEncodingProblem", "isTruncated"}}
email["headers"] # [{"name": "<wire-case>", "value": "<raw>"}, ...]
email["hasAttachment"] # bool
email["preview"] # str (≤256 chars, plain-text)
# Strict-by-design composer accepts the same JMAP shape on input.
# sentAt is required (RFC 5322 §3.6.1) — pass it explicitly.
raw = jmap_email.compose_email({
"from": [{"name": "Alice", "email": "alice@example.com"}],
"to": [{"name": "Bob", "email": "bob@example.com"}],
"subject": "hi",
"sentAt": "2026-06-08T12:00:00+00:00",
"textBody": [{"partId": "1", "type": "text/plain", "content": "hello"}],
})
# raw is RFC 5322 bytes ready for SMTP delivery (e.g.
# smtplib.SMTP.sendmail handles dot-stuffing for you).
Conformance
parse_email() produces a JMAP Email object per RFC 8621 §4 with the
following defaults, matching Email/get defaultProperties:
| Property | Default emitted? | Notes |
|---|---|---|
Email metadata (id, blobId, threadId, mailboxIds, keywords, size, receivedAt) |
No | Server-set; out of parser scope |
subject |
Yes | NFC-normalised; null when absent |
from / sender / to / cc / bcc / replyTo |
Yes | EmailAddress[] or null |
messageId / inReplyTo / references |
Yes | String[] (no <>) or null |
sentAt |
Yes | ISO-8601 with offset; null when absent |
headers |
Yes | [{name, value}] ordered; value is RFC 8621 Raw form (byte-faithful, NOT encoded-word-decoded) |
textBody / htmlBody / attachments |
Yes | EmailBodyPart[] per RFC 8621 §4.1.4 |
hasAttachment |
Yes | |
preview |
Yes | ≤256-char plain-text excerpt; HTML/MD-stripped + whitespace-normalised |
bodyValues |
Yes | {partId: EmailBodyValue} per §4.1.5; text-body parts then carry metadata only |
bodyStructure |
Opt-in | parse_email(raw, body_structure=True) |
_ext |
Opt-in | parse_email(raw, extensions=True) — project extensions; see below |
Parser-only fields (preview, bodyValues, bodyStructure,
hasAttachment, ext) are ignored on composer input — passing them
through compose_email is harmless.
Project extensions (ext)
extensions=True adds a single _ext sub-dict to the output.
These fields are NOT in RFC 8621 — they expose information the parser
already computes so consumers don't have to re-walk the message:
_ext.defects— stdlibMessageDefectclass names collected during the parse walk; useful for message-store quarantine policies (the Mailman pattern)._ext.resent— Resent-* typed projection (see below). Present only when the wire carries at least one Resent-* header.
EmailBodyPart extensions
RFC 8621 §4.1.4 lists the EmailBodyPart shape as partId, blobId,
size, headers, name, type, charset, disposition, cid,
language, location, subParts. The library extends that shape
with two project fields. Where each shows up:
| Location | content |
sha256 |
|---|---|---|
attachments[i] |
always (bytes) |
always |
textBody[i] / htmlBody[i] with body_values=False |
yes (str for text/*, base64 str for inline media) |
no |
textBody[i] / htmlBody[i] with body_values=True |
absent — content moves to bodyValues per §4.1.4 |
no |
bodyStructure and its subParts tree |
never | never |
contentexists because the library has no blob store to satisfy the spec'sblobId→ fetch-by-blob contract. Callers need the bytes somewhere on the part. Attachmentcontentis never stripped; text/htmlcontentfollows thebody_valuesflag.sha256is the hex digest of the part's decoded bytes — useful for dedup / blob storage. Attachment parts only.
bodyStructure is pure RFC 8621 shape — no project fields appear
in that tree, so a strict JMAP consumer can ingest it as-is. Strict
consumers should ignore unknown keys elsewhere. Composer input that
includes these fields is harmless — the composer ignores parser-only
metadata.
Duplicate scalar headers
RFC 5322 §3.6 marks From / Sender / Reply-To / To / Cc / Bcc /
Message-ID / In-Reply-To / References / Subject / Date as max=1 —
each may appear at most once. Real-world senders sometimes emit
duplicates anyway. The parser follows the stdlib
email.message.Message[name] convention: when a header is repeated,
the first occurrence wins for the scalar JMAP projection. Every
occurrence still appears in the headers list in document order.
Background: see "Detection of Weak Links in Authentication Chains",
USENIX Security 2020.
Resent-* projection (_ext.resent)
RFC 8621 §4.1.3 names only the 11 base header convenience properties;
Resent-* is not on that list. The library pre-computes it as a §4.1.2
typed-projection idiom and exposes it under _ext.resent so forwarded /
resent mail handling doesn't need to walk parsed["headers"]. Sub-
fields mirror the base properties — ext.resent["from"],
["sender"], ["replyTo"], ["to"], ["cc"], ["bcc"],
["messageId"], ["date"] — and the sub-dict is omitted entirely
when no Resent-* header is present on the wire.
Pragmatic deviations from RFC 8621
Two places where the parser knowingly deviates from the spec text. Both are conscious choices for downstream safety; flagging them so the contract is explicit:
-
headers[i].valueis not strictly "Raw" form. RFC 8621 §4.1.2 defines "Raw" as byte-faithful except forCRLF+WSPunfolding. We additionally:- Strip NUL (
\x00) bytes — PostgreSQLTEXTcannot store NUL, so a spec-faithful value would crash any downstream insert. Carrying them through and dropping them at the storage boundary would also be wrong (different stores would handle them differently). - Truncate at
max_header_value_bytes(default 102 400) — the stdlib_header_value_parserhas quadratic-time hot spots on adversarial inputs (gh-136063); truncating early bounds wall-clock. TheEmailBodyPart.headers[i].valuefield follows the same policy.
- Strip NUL (
-
Inline media isn't added to
attachmentsin themultipart/alternativenullified-branch case. The spec algorithm in §4.1.4 has a clauseif ((!htmlBody || !textBody) && isInlineMediaType(part)) attachments.push(part). We don't honor it. Effect: in the narrow case where amultipart/ alternativeancestor has nullified one body branch and the message contains inlineimage/*/audio/*/video/*, the inline media appears in the surviving body but not inattachments. Matches what Gmail / Apple Mail render; differs from a strict spec walker.
Parse options
Per-call options are passed via a frozen ParseOptions instance; the
default applies when no value is supplied. Most are hard caps against
adversarial input, but the bundle also carries format policy the RFC
leaves to the server, such as the preview length.
| Attribute | Default | Source |
|---|---|---|
max_mime_nesting_depth |
100 | Postfix mime_nesting_limit |
max_mime_parts |
1000 | Go multipartmaxparts |
max_header_value_bytes |
102 400 | Postfix header_size_limit |
max_address_list_bytes |
100 000 | Dovecot CVE-2024-23184 analogue |
max_preview_chars |
256 | RFC 8621 §4.1.4 preview ceiling |
max_preview_scan_bytes |
131 072 | bound preview work on markup-only input |
Excess input is silently truncated and logged at WARNING level.
A single process can host multiple workloads with different options — they travel with the call, never via shared module state:
from jmap_email import ParseOptions, parse_email
bulk = ParseOptions(max_mime_parts=5000, max_mime_nesting_depth=200)
gateway = ParseOptions(max_mime_parts=500)
parse_email(big_archive_message, options=bulk)
parse_email(inbound_smtp_bytes, options=gateway)
ParseOptions is frozen and hashable; instances can be reused freely
across threads and as cache keys. (Pre-0.2 this was ParseLimits, passed as
limits=; both were renamed outright — see the CHANGELOG.)
Strict-compose, lenient-parse
The two entry points use different stdlib email.policy instances
on purpose:
| Direction | Policy | Why |
|---|---|---|
Compose (compose_email) |
email.policy.SMTP (cloned, CTE 7-bit) |
Caller-controlled input → must produce strictly RFC-compliant output. Enforces address-list folding, RFC 2047 / 2231 encoding, CRLF, line-length limits. |
Parse (parse_email) |
email.policy.compat32 |
Real-world inbound MIME violates the spec routinely. compat32 is lenient: it returns raw header strings and recovers what it can from broken Content-Transfer-Encoding, missing charsets, malformed structural delimiters. |
Parser failure mode
parse_email is total: it returns a JmapEmail dict on success or
None on fundamental failure (empty bytes, wrong type, stdlib
producing no Message, or any unhandled internal error). All failures
log at WARNING level. No exception escapes.
The TypedDict shapes describing the return value (JmapEmail,
EmailAddress, EmailBodyPart, EmailBodyValue, EmailHeader,
Attachment, JmapEmailExt) are annotation-only and live in the
jmap_email.types submodule — from jmap_email.types import JmapEmail.
parsed = parse_email(raw)
if parsed is None:
logger.warning("dropped unparseable message")
return
... # use parsed
Recoverable damage (a salvageable malformed header, an unknown
charset, etc.) keeps the parse on track — those are surfaced in
parsed["_ext"]["defects"] when the caller opts in via
parse_email(raw, extensions=True).
Composer error hierarchy
compose_email raises a typed exception that subclasses ComposeError.
Callers that don't want to discriminate can catch ComposeError only;
callers that do can dispatch on the subclass:
ComposeError
├── InvalidAddressError # missing/malformed `from`, `to`, …
├── InvalidMessageIdError # Message-ID / In-Reply-To / References / Content-ID
├── InvalidDateError # `sentAt` missing or unparseable
├── AttachmentError # missing content, bad base64, bad MIME type, …
└── HeaderInjectionError # custom-header name not RFC 5322 ftext
The composer is strict on every input the caller controls. Silently
substituting now() for a missing sentAt, or quietly dropping a
broken attachment, would be invisible data loss for the sender.
- Want "now" for
sentAt? Use thenow_sent_at()helper:compose_email({..., "sentAt": now_sent_at(), ...}). - Handling flaky attachment input? Wrap the compose call in
try / except ComposeError(the base class catches every composer error subclass —InvalidAddressError,AttachmentError, etc. — at once).
Shape helpers
Every JMAP field is a list — from, to, messageId, headers, …
Reading them safely usually means writing parsed.get("from") or [],
then indexing, then .get. Skip that with these helpers:
from jmap_email import (
first_address, first_address_email, first_address_name,
first_msgid, msgid_chain, sent_at_to_datetime,
find_header, find_headers, has_header,
body_part_text, body_text_joined,
)
About body_part_text(parsed, part): a text body part can have its
text stored two ways depending on how parse_email was called. Either
the text is right on the part (part["content"]), or it's in a
separate map (parsed["bodyValues"][part["partId"]]["value"]). This
helper checks both, so your code keeps working if the parser default
ever flips.
About now_sent_at(): returns the current UTC time formatted as the
ISO-8601 string compose_email expects for sentAt. One-liner instead
of datetime.now(timezone.utc).isoformat().
Preview extraction
preview_text turns an HTML or html2text-style body into the single,
display-ready line RFC 8621 §4.1.4 calls preview. parse_email already
runs it for the preview field (drawn from the HTML part, falling back to
the text part); call it directly for any other snippet need:
from jmap_email import preview_text
line = preview_text(body_text) # ≤ 256 chars
short = preview_text(body_text, max_chars=140) # list-view snippet
html = preview_text(body_html, content_type="text/html") # HTML part
It strips HTML (dropping <script> / <style> / <title> / <blockquote>
payloads, decoding entities, unwrapping <https://…> autolinks), drops
>-quoted lines, strips markdown (ATX + setext headers, lists, emphasis,
links → label, …), and removes control characters, ANSI/terminal escape
sequences, and invisible "preheader spacer" format characters — collapsing
everything to one line. It's bounded on hostile input: the HTML strip stops
once it has the preview's worth of visible text, and max_scan_bytes
(128 KiB) caps a body that is almost entirely markup.
Pass the part's content_type. Two of those stages are conventions of the
plain-text wire format, not universal cleanups, so they are skipped for
text/html:
| Stage | text/plain |
text/html |
|---|---|---|
HTML strip (script / style / title / blockquote payloads) |
yes | yes |
>-quoted lines dropped |
yes | no |
| markdown syntax stripped | yes | no |
| control chars + ANSI escapes removed | yes | yes |
| whitespace collapsed, truncated | yes | yes |
In an HTML part, >, * and _ are literal characters the sender meant to
be shown, and quoted history arrives as <blockquote> (already suppressed
during the strip). Running the text/plain stages there deletes real content:
> decodes to > before the quote filter sees it, so a line of prose
opening with a chevron is dropped whole, and 2*3=6 becomes 23=6. The
default, "text/plain", is the thorough path — an unrecognised type
(text/markdown, an importer blob) is still fully cleaned, so you opt into
the literal reading rather than out of the cleaning. parse_email passes each
part's own type automatically.
The result is plain text, not HTML — it may contain <, >, & (e.g.
from x < 5 & y > 3). Escape it before rendering inside an HTML document.
Cleaning is deliberately structural and language-neutral: quoted-reply
history is dropped via > lines and <blockquote> only. Locale/product
heuristics — "view in browser" boilerplate, On … wrote: reply
attributions, forwarded-header blocks — are left to the application layer.
Validators
Want to know if a string would be accepted by compose_email as a
Message-ID without actually trying to compose? Use is_valid_msg_id:
from jmap_email import is_valid_msg_id
if is_valid_msg_id(parent_header):
reply["inReplyTo"] = [parent_header]
It applies exactly the same checks compose_email does — shape,
length ceiling, no embedded whitespace — but returns True/False
instead of raising. Useful for lenient parse paths (archive importers,
inbound salvaging) that need to decide between keeping a raw id and
falling back to synthesis without catching an exception.
Strict vs. lenient parse_address
parse_address(s) is strict by default: an input that can't be
parsed into a valid addr-spec returns ("", ""). Use this for entry-
point validation (CLI flags, web form input) — parse_address("no-at")
returning ("", "") lets the caller reject garbage without a second
"@" in result check.
Pass lenient=True for archive-import paths that must preserve the
original wire bytes even when invalid:
parse_address("no-at-sign") # → ("", "")
parse_address("no-at-sign", lenient=True) # → ("", "no-at-sign")
parse_addresses(s) is always strict per-entry: tuples whose addr-spec
fails the shape check are silently dropped — so
len(parse_addresses(header)) != header.count(",") + 1 is expected
when the header carries garbage between real entries.
Defense matrix
The parser explicitly defends against the documented attack classes
below. See the tests/ directory for regression coverage of each.
- CVE-2023-27043 —
parseaddr/getaddressesdisplay-name confusion - CVE-2024-6923 — header-injection via embedded newlines (compose)
- CVE-2024-21742 — Apache James
\r\nin fields - CVE-2024-23184 — Dovecot unbounded address-list allocation
- CVE-2002-1337 — Sendmail
crackaddrnested-comments shape - CVE-2002-2325 — Pine empty-boundary infinite loop
- gh-114906 — embedded newline in RFC 2047 encoded-word
- gh-136063 — quadratic-time hot spots in
_header_value_parser - gh-137687 — base64 padding
==truncation - PortSwigger "Splitting the Email Atom" (DEF CON 32 2024) —
encoded-word smuggling of structural chars (
@,,,<,>, NUL) - Inbox Invasion (CCS '24) — duplicate boundary parser confusion
- Mailsploit — NUL-byte truncation in encoded-words
- USENIX 2020 "Weak Links in Auth Chains" — duplicate
From:, group-syntax, CFWS-in-address handling
Compatibility
- Python 3.14.6+ (see Why a Python 3.14.6 floor?)
- Platforms tested in CI: Linux on x86_64 and arm64
- macOS / Windows / PyPy / free-threaded build: untested; expected to work since the package has zero compiled extensions and zero runtime dependencies. Reports of breakage welcome via the issue tracker.
Performance and concurrency
- Thread-safe at the public API level. Module-level state
(
_HEADER_FACTORY,_POLICY) is constructed once at import and never mutated after. - No I/O. Every entry point operates on in-memory bytes or dicts.
- No global rate limits or singletons beyond the immutable
registries above. Multiple processes / asyncio tasks may call
parse_email/compose_emailconcurrently without coordination.
Ballpark wall time on an Apple M2 (single thread, in-process): ≈ 0.4 ms per typical 5 kB inbound message; ≈ 1 ms per 100 kB MIME multipart with embedded images. Use your own corpus to measure for your workload — message-shape variation dominates.
Examples
Runnable scripts under examples/:
examples/parse_and_print.py— parse raw bytes and pretty-print the JMAP shapeexamples/import_eml_safely.py— read an.emloff disk, handle theNonefailure path, surface defects, print key fieldsexamples/compose_with_attachment.py— compose a multipart message with a regular attachmentexamples/inline_image_roundtrip.py— compose + re-parse a message with an inline image, asserting the CID survivesexamples/encoded_word_subject.py— compose a non-ASCII Subject and re-parse it
Development
The repository ships a docker-compose-based test environment so the package can be exercised against the exact Python / pytest / hypothesis versions CI uses:
make test-jmap-email # run the full test suite (zero infra deps)
make typecheck-jmap-email # static check via Astral's `ty` (Rust)
To run tests outside docker:
cd src/jmap-email
pip install -e '.[dev]'
pytest # default selection, fuzz tests excluded
pytest -m fuzz # property-based / Hypothesis fuzz
ruff check .
ruff format --check .
See CONTRIBUTING.md for the contribution workflow.
License
MIT — see LICENSE.
Versioning
Semantic. Public API is everything exported in jmap_email.__all__;
anything prefixed with _ is internal and may change between patch
releases.
__version__ is exposed at the module level.
Security
Security-sensitive reports go through GitHub Security Advisories — see
SECURITY.md for the disclosure policy.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file jmap_email-0.2.0.tar.gz.
File metadata
- Download URL: jmap_email-0.2.0.tar.gz
- Upload date:
- Size: 174.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
78f15899e1146224d7d8a17126e8edb01b95ce47c640b09566ee217276a2bf28
|
|
| MD5 |
cddd2484de23b8d14e17a2d6ad4ed996
|
|
| BLAKE2b-256 |
a1d80df4fd862642d60b537937879e08010039f7b8bba6ee1911ef318be3fc40
|
File details
Details for the file jmap_email-0.2.0-py3-none-any.whl.
File metadata
- Download URL: jmap_email-0.2.0-py3-none-any.whl
- Upload date:
- Size: 71.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4bbe555b271a59351e1e3b21622364e64209c1831a6bded21d2801e95058e2ec
|
|
| MD5 |
ec8ed08423eca782eb1fa29dc9abc181
|
|
| BLAKE2b-256 |
9f5446ce447ab4369d29065480ba7ad1ffafbb25bcfc8afb5c8fc40ec6083865
|