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.
One runtime dependency — the package is a clean wrapper around
the Python stdlib email package, plus null-safe shape accessors over
the JMAP Email object. The single dependency is
idna, for UTS 46 non-transitional
domain encoding: the stdlib codec is IDNA2003, which silently folds
faß.de into the distinct registrable domain fass.de.
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, plus the parser-ambiguity markers below; 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.
Parser-ambiguity markers
A single email is parsed several times on its way to a reader — by the receiving server, by a spam filter, by a virus scanner, finally by the client that displays it. Some MIME constructs are resolved differently by each, and that gap is exploitable: a filter can be made to read past content the client goes on to render. Differential fuzzing of Postfix, SpamAssassin, ClamAV, Evolution and Thunderbird demonstrates working smuggling on exactly these constructs.1
This parser resolves each the way the stdlib does and carries on. What
it will not do is hand you a confident parse without telling you a
choice was made, so each construct also lands in _ext.defects:
| Marker | Construct | Why it matters |
|---|---|---|
DuplicateFromDefect |
more than one From |
one identity for the filter that authenticated the message, another for the human who reads it (CERT VU#517845) |
DuplicateScalarHeaderDefect |
any other RFC 5322 §3.6 max=1 header twice | Subject, Date, Message-ID, … — first wins here, last may win elsewhere |
DuplicateContentTypeDefect |
more than one Content-Type |
we take the first; a client honouring the second sees a multipart tree, and its attachments never reach you |
DuplicateTransferEncodingDefect |
more than one Content-Transfer-Encoding |
filter and client can disagree on whether the body is encoded at all |
UnrecognizedTransferEncodingDefect |
token outside RFC 2045 §6.1 (bas64, : base64, …) |
we leave the body undecoded; lenient clients guess and reveal it |
DuplicateBoundaryParameterDefect |
two boundary= parameters |
whichever we honour, the other delimits parts we never see |
MissingMimeVersionDefect |
MIME syntax with no MIME-Version |
a strict receiver reads the body as flat text and never sees the parts |
NonEmptyPreambleDefect |
text before the first boundary | RFC 2046 §5.1 says ignore it, and we do — Thunderbird and Evolution show it as the first line |
NonEmptyEpilogueDefect |
text after the closing boundary | the same gap at the other end of the body |
ConflictingAttachmentNameDefect |
part names itself twice, differently | Content-Type: name= vs Content-Disposition: filename= vs filename* — the recipient saves it under a name you never saw |
ControlCharInHeaderDefect |
NUL or control character in a MIME header | read three ways: stripped (us), truncated at it, or kept — one part, three filenames |
EmptyBoundaryDefect |
multipart with an absent or empty boundary= |
no agreed way to split the body |
EncodedWordInParameterDefect |
RFC 2047 =?…?= in a MIME parameter |
not permitted there (RFC 2231 is the mechanism); we decode it, others show it raw |
FoldInQuotedParameterDefect |
a header folds inside a quoted parameter value | filename="pay<CRLF> load.exe" — we unfold to pay load.exe, a parser dropping the whole fold reads payload.exe, one truncating at CR reads pay |
PartialMessageDefect |
message/partial |
RFC 2046 §5.2.2 splits one message across several; the payload exists only after a reassembly a per-message scanner never does |
ExternalBodyDefect |
message/external-body |
the content is fetched from elsewhere, so it is not in this message for anyone to scan |
AddressListTruncatedDefect |
address header past max_address_list_bytes |
entries past the cut are dropped, so the list we report is shorter than the wire's — and empty ([]) when no mailbox separator precedes the cap. A recipient you never see is one you cannot act on |
None is an error, and well-formed mail raises none of them. Treat them as input to a policy: score them, quarantine on them, or log them and move on.
One related scope boundary, which is not a marker because it would
fire on every forwarded message: attachments nested inside a
message/rfc822 part are not enumerated in the outer attachments
list. The nested message arrives whole, as one attachment carrying its
raw bytes in content — re-run parse_email on those bytes if you
scan attachments, or a payload one level down stays invisible to you.
The last three are the anomaly classes named by draft-chen-email-mime-ambiguity-defense that the stdlib does not already flag for you.
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). - Refuse a field above
max_header_value_bytes(default 102 400):parse_emailreturnsNonerather than truncating — there is no generally safe place to cut an arbitrary field (see the options table below). Bounding the input also sidesteps the stdlib_header_value_parser's quadratic-time hot spots on adversarial inputs (gh-136063). 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 |
Most excess input is silently truncated and logged at WARNING level.
max_header_value_bytes is the exception: a field above it makes
parse_email return None. RFC 5322 §2.2.3 puts no limit on a header
field — "an unfolded header field has no length restriction and
therefore may be indeterminately long" — so this is local policy, set to
Postfix's header_size_limit. Postfix discards the excess; we refuse the
message, because there is no generally safe place to cut an arbitrary
field. A shortened Received still looks well-formed to trust-scope
logic, and cutting an address list mid-token manufactures an address
nobody sent. max_address_list_bytes truncation, which stays below that
ceiling, therefore cuts back to a top-level mailbox separator — the same
shape as Postfix's header_address_token_limit, which discards excess
tokens rather than bytes — and records
AddressListTruncatedDefect.
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.)
DEFAULT_PARSE_OPTIONS is the instance used when you pass no options=.
Every field has a default, so you rarely need it to build options —
ParseOptions(max_mime_parts=500) already inherits the rest. It's there to
read the shipped values without constructing anything:
from jmap_email import DEFAULT_PARSE_OPTIONS
budget = DEFAULT_PARSE_OPTIONS.max_preview_chars # 256
Compose options
ComposeOptions is the compose-side peer of ParseOptions — same frozen,
hashable, dataclasses.replace-able shape, passed the same way:
from jmap_email import ComposeOptions, compose_email
raw = compose_email(jmap, options=ComposeOptions(idna_encode_domains=True))
Where ParseOptions is mostly resource caps against hostile input, these
are output-correctness choices — each names a place where "strict" is
deployment-dependent rather than universal.
| Field | Default | Effect |
|---|---|---|
emit_bcc |
False |
Emit the Bcc: header. The entire point of Bcc is that it must not be transmitted; set True only for archive reconstruction (PST import), where the list was already in the source. |
idna_encode_domains |
False |
IDNA-encode a non-ASCII domain to its A-label form (contact@exemplé.fr → contact@xn--exempl-gva.fr). |
allow_8bit |
False |
Emit non-ASCII bodies as raw 8-bit instead of base64/QP. Requires 8BITMIME (RFC 6152) on the hop. |
allow_smtputf8 |
False |
Emit UTF-8 headers (RFC 6532) and permit a non-ASCII local part. Requires SMTPUTF8 (RFC 6531) on the hop. Implies allow_8bit. |
The last two name ESMTP capabilities of the hop the bytes are headed for. This library does not discover those, and does not assume them — hence the conservative defaults, which produce pure-ASCII 7-bit output that any MTA accepts. How you learn them is yours: a relay whose configuration you own, an EHLO response you read before composing, or two variants composed up front so the delivery loop can fall back.
in_reply_to and prepend_headers stay keyword arguments on
compose_email: they are per-message data that changes on every call,
where everything in ComposeOptions is a property of the call site.
Non-ASCII addresses
idna_encode_domains governs the domain, and deliberately only the domain.
Punycode is a DNS algorithm (RFC 3492/5891) and a local part is not a DNS
label, so an IDN domain has an exact ASCII wire form — the same one the MX
lookup has to use — and a non-ASCII local part has none.
A non-ASCII local part therefore needs allow_smtputf8, not this
flag: with allow_smtputf8=True the whole addr-spec travels as UTF-8, and
without it the address raises InvalidAddressError. Carrying one requires
SMTPUTF8 (RFC 6531), which is negotiated per-hop against the receiver's
EHLO, is viral across every address in the transaction, and has no
downgrade path — RFC 6530 dropped the mechanism RFC 5504 had specified.
Support is also not transitive: a relay that accepts your transaction may
itself have to forward to a hop that doesn't. That is why the ASCII
fallback variant below still rejects such an address: there is no ASCII
form of it to fall back to.
Left at the default, a non-ASCII domain raises too, because the composer does not rewrite an address you handed it unless you ask.
Composing a fallback pair
Because SMTPUTF8 is per-hop and cannot be discovered ahead of time, the usual pattern is to compose both forms and let the delivery loop choose:
from jmap_email import ComposeOptions, ComposeError, compose_email
preferred = compose_email(jmap, options=ComposeOptions(allow_smtputf8=True))
try:
fallback = compose_email(jmap, options=ComposeOptions(idna_encode_domains=True))
except ComposeError:
fallback = None # no ASCII form exists — see below
smtp.ehlo()
if smtp.has_extn("smtputf8"):
smtp.sendmail(sender, rcpts, preferred, mail_options=["SMTPUTF8", "BODY=8BITMIME"])
elif fallback is not None:
smtp.sendmail(sender, rcpts, fallback)
else:
... # bounce: RFC 6530 provides no downgrade for a UTF-8 mailbox name
An ASCII fallback exists whenever only the domain is non-ASCII. When
the local part is non-ASCII there is none — RFC 6530 dropped the
downgrade mechanism RFC 5504 had specified — and compose_email raising
is how you find that out. Bouncing is the specified behaviour, not a
gap in this library.
Worth knowing what the standard library does here, since it is not this:
under email.policy.default it RFC 2047-encodes a non-ASCII domain,
emitting contact@=?utf-8?q?exempl=C3=A9?=.fr, which RFC 2047 §5 forbids
inside an addr-spec and no MTA routes. It never punycodes, at any layer —
smtplib.send_message buckets any non-ASCII address straight to SMTPUTF8
or raises SMTPNotSupportedError. Converting the domain is left to you.
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().
Reading raw headers
parsed["headers"] is deliberately RFC 8621 Raw form — byte-faithful,
not encoded-word-decoded (see Conformance). That keeps the
header list honest for anything that needs the wire bytes, and it means any
header you read yourself may still be RFC 2047 encoded, or be a date string
nobody has parsed. Two helpers close that gap:
from jmap_email import decode_rfc2047_header, parse_date
raw = find_header(parsed, "X-Gmail-Labels") # "=?UTF-8?Q?Re=C3=A7us?="
label = decode_rfc2047_header(raw) # "Reçus"
when = parse_date("Mon, 8 Jun 2026 14:30:00 +0200") # datetime | None
decode_rfc2047_header handles the mixed-charset, multi-word case and
returns a plain str. It is hardened against the encoded-word attacks in
the defense matrix — gh-114906 embedded newlines and
Mailsploit NUL truncation — which is the reason to use it over calling
email.header.decode_header yourself.
parse_date returns None instead of raising on the malformed dates real
archives are full of, where the stdlib parsedate_to_datetime throws. Use
it for any date header you read raw; the sentAt field is already parsed
for you, and sent_at_to_datetime converts that back to a datetime.
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.
Attachment filenames
parse_email reports every part name already sanitized.
sanitize_filename is that same pass, exposed for names that never went
through the parser — a client-supplied one, say:
from jmap_email import sanitize_filename
name = sanitize_filename(raw, max_length=255) or "unnamed"
It NFKC-normalizes, strips path components in both separator dialects
(..\..\boot.ini → boot.ini), removes every invisible character —
controls, bidi overrides, zero-width joiners, the BOM, lone surrogates —
replaces the characters filesystems reject, drops surrounding dots and
whitespace, and truncates while keeping the extension. The result is
safe to join onto a directory: no separator, no .., no leading dot (so
.gitignore comes back as gitignore), and NFKC-stable so normalizing
it later can't reintroduce any of those.
It returns None, never "", when nothing usable survives, so the
failure case can't be mistaken for a name. Pass the max_length your
storage enforces; the 255 default is a convention, not a promise about
your column.
Two of those steps are not cosmetic. The bidi strip:
annexe<U+202E>gpj.exe renders as annexe.exe.jpg in most file pickers
— the user sees an image, the OS sees an executable, and it is the
standard attachment spoof. And normalizing first: ../etc/passwd
is all fullwidth characters, so it passes any ASCII-based check
untouched, then folds to ../etc/passwd as soon as something downstream
normalizes it. Sanitize-then-normalize is the bypass; normalize-then-
sanitize is not.
A part carrying no filename is a different question. Per RFC 8621
name is String | null, and parse_email reports null rather than
inventing a placeholder. What to show instead — unnamed, a
subject-derived name, an extension inferred from the part's MIME type —
is product policy, and stays in your application.
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]
is_valid_addr_spec is its counterpart for addresses: True means one
well-formed mailbox — local@domain, no embedded whitespace, no comma
or semicolon splitting it in two, both halves non-empty — safe to put in
a header as it stands. A quoted-string local-part
("john doe"@example.com) is accepted, since the quoting is what keeps
it a single mailbox. The parser and the composer share this predicate,
so a value one side calls an address is never one the other would mangle.
It checks the shape a mailbox-list entry requires — one bare
local@domain addr-spec, nothing that would split it into two
mailboxes or need quoting on the way out — but returns True/False
instead of raising. Useful for lenient parse paths (archive importers,
inbound salvaging) that need to decide between keeping an address and
dropping it without catching an exception.
True means the value is usable as it stands. The predicate
rejects anything compose_email would refuse — a line terminator, a
control character, an unquoted special. Answering True for
"a@b.co\r\n" would be handing back a header-injection payload to
anyone who writes the value somewhere other than compose_email. The
composer enforces the same rule: a malformed email value raises
InvalidAddressError rather than being silently cleaned, so the
predicate and the composer agree — True here is exactly the set of
values the composer emits unchanged.
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.
Formatting addresses for display
format_address(name, email) and format_address_list(addresses) go the
other way — from parsed values to a display string for a human, not a
header for the wire.
from jmap_email import format_address, format_address_list
format_address("Alice", "alice@example.com") # "Alice <alice@example.com>"
format_address("Hara, Alice", "a@example.com") # '"Hara, Alice" <a@example.com>'
format_address("", "alice@example.com") # "alice@example.com"
format_address_list(parsed["to"]) # "Alice <a@x.co>, b@x.co"
A display name is quoted only when it needs to be, and an entry with no name reduces to the bare addr-spec, so the output reads the way a mail client shows it.
compose_email already formats addresses for the messages it builds, so
these are not needed on the send path. They exist for text you write
around a message: the From:/To:/Cc: block of a forwarded-message
attribution, a "replying to" line, an audit log entry.
format_address_list takes an EmailAddress[] — parsed["to"],
parsed["cc"], … — and returns "" for an empty list. It does not
accept None, which is what those fields hold when the header is absent,
so guard with parsed["cc"] or [].
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, in both its forms: the multi-tuple split ("a@b.co" <real@you.com>, where the authoritative angle-addr is taken rather than the first tuple) and the unclosed-comment variant (victim@bank.com( <attacker@evil.co>, where the comment eats the angle-addr and leaves only the display-name-as-addr-spec — refused) - CVE-2019-16056 — multiple-
@addr-spec: an allowlist keyed on the domain talked into accepting one it meant to deny - CVE-2023-36632 —
parseaddrRecursionErroron nested comments - CVE-2026-30227 (MimeKit) — CR/LF in a quoted-string local-part; the same shape reached this library as an addr-spec carrying a space or a comma, which is two mailboxes rather than one
- CVE-2023-51764 — SMTP smuggling. Some receivers accept
\n.\nas a DATA terminator, so a composer that emitted a bare LF would mint the vector out of body text; our output is strictly CRLF, asserted - CVE-2025-52488 — Unicode normalization bypass: compatibility forms
that survive an ASCII check and fold to
../afterwards (sanitize_filenamenormalizes before it sanitizes) - 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 - CVE-2026-1299 —
BytesGeneratorheader injection via unquoted newlines. Fixed in CPython 3.14.3, below this package's 3.14.6 floor, so the floor is what defends it — not our code
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 a single
pure-Python runtime dependency (
idna). 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.
-
S. B. Andarzian, M. Meyers, E. Poll, Email Smuggling with Differential Fuzzing of MIME Parsers, Radboud University, 2025. See also J. Chen et al., Inbox Invasion: Exploiting MIME Ambiguities to Evade Email Attachment Detectors, CCS '24. ↩
Metadata
Release files for jmap-email 0.3.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| jmap_email-0.3.0.tar.gz | 252.1 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| jmap_email-0.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 350.7 kB
Release files / jmap_email-0.3.0.tar.gz
| Download URL | jmap_email-0.3.0.tar.gz |
|---|---|
| Size | 252.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
d559085692bb6694631baf5ab3f75843bc3fbf44dc03bc889b4c82aa2f583f01
|
|
BLAKE2b-256 checksum How to use checksums |
94ca821eee73857d072e78160e71de0f777d0a7551c11f473d656018d1ecb422
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.6
|
Release files / jmap_email-0.3.0-py3-none-any.whl
| Download URL | jmap_email-0.3.0-py3-none-any.whl |
|---|---|
| Size | 98.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
27f30fa35782c4f4fd214387484c85cba46db68c5ccf82e1aa4daddd881cd0f9
|
|
BLAKE2b-256 checksum How to use checksums |
37f1b90eb401d3155eedf36384c6840730f87b8730302317e955e97d060c32da
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.6
|
Security for jmap-email 0.3.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.