Repository navigation
Releases: namecheap/fast_mail_parser
Release list
v0.10.0
The performance release. Against 0.9.0, measured on the x86 benchmark gate:
| 0.9.0 | 0.10.0 | ||
|---|---|---|---|
parse_email |
0.450 ms | 0.231 ms | 1.95x |
parse_many |
3.542 ms | 1.926 ms | 1.84x |
parse_email_tree |
0.419 ms | 0.232 ms | 1.81x |
| full read incl. attachments | 0.457 ms | 0.250 ms | 1.83x |
mode="lazy", nothing read |
0.071 ms | 0.046 ms | 1.55x |
| quoted-printable body | 0.342 ms | 0.226 ms | 1.51x |
| re-reading an attachment | — | — | 81x |
| re-reading the header map | — | — | 38x |
The last two are not micro-optimisations: attachment.content and mail.headers used to redo the
decode on every access and now publish one cached object, so the second read does no work at all.
Against other libraries on the same message and the same result — subject, both body lists,
attachments decoded — fast_mail_parser is now 42x faster than mail-parser and 62x faster
than the stdlib email package.
One behaviour change
Lazy modes borrow the payload instead of copying it (#239). A deferred attachment or MIME-tree
leaf no longer copies its encoded bytes out of the message; it keeps offsets into the payload you
passed in, and keeps that payload alive for as long as it is reachable. Parsing a 96 MiB
single-attachment message in mode="lazy" went from 192 MiB of peak memory to 96 MiB — but holding
one attachment now holds its message. If you want the bytes without the message, read content,
which is a decoded copy, and drop the attachment. Nothing in the API changed.
Where the speed came from
- Payloads are borrowed, not copied (#226) —
strpayloads joinedbytes, so a batch of ten
thousand messages no longer duplicates every one of them. - Attachments and tree leaves stop copying on every read (#227), and
headersis built once per
object and shared (#231). - Base64 decodes with SIMD (#228) and quoted-printable a run at a time (#229), both in the
vendored mailparse, withdata_encodingkept as the arbiter of anything the SIMD decoder turns
down. parse_manysizes its workers by bytes and the calling thread is one of them (#232).- A part's body is evaluated once (#230), and the header table allocates half as much (#238).
Correctness and maintenance
The parsing core is now its own crate with its own Rust tests (#236); the vendored mailparse delta
is a CI invariant that re-applies the patch on every run (#235); the MIME tree has one traversal
instead of two (#237) and the flat parsers share one envelope reader and one part classifier (#234);
the binding is split into modules (#233). Code placement and PGO can now be measured rather than
argued about (#240, #241).
Full changelog: https://github.com/namecheap/fast_mail_parser/blob/main/CHANGELOG.md
Compare: v0.9.0...v0.10.0
0.9.0
Highlights
The default parse is ~4–5x faster, metadata mode ~12x. Two byte-at-a-time loops in mailparse — the MIME boundary search and the base64 whitespace strip — were most of a parse and the source of the codegen cliff #120/#204 chased (their speed depended on where the linker placed them). Both are replaced in a patched copy of mailparse 0.16.1 in vendor/mailparse (memchr for the search, a dependency-free word-at-a-time strip; PATCH.md explains).
| 0.8.0 | 0.9.0 | ||
|---|---|---|---|
parse_email (full), Apple M4 vs the published 0.8.0 wheel |
1.079 ms | 0.225 ms | 4.8x |
parse_email(mode="metadata"), same |
0.365 ms | 0.030 ms | 12x |
parse_many, 8 × 767 KiB, same |
8.852 ms | 1.835 ms | 4.8x |
parse_email (full), CI runner (EPYC 7763), across the two fixes |
1.432 ms | 0.575 ms | 2.5x |
vs mail-parser / stdlib email, CI runner |
6.4x / 8.6x | 25x / 34x |
mode= on parse_many and parse_email_tree (#202): the same "full" / "lazy" / "metadata" values parse_email honours, with PyLazyMimePart and PyMimePartMetadata node types for the tree. Batch and mode compose: parse_many(mode="metadata") on 8 × 767 KiB is 0.41 ms against 4.48 ms.
Rust toolchain pin moved to 1.98.0 (#120). The "1.98.0 is 26% slower" finding was the layout effect above, not codegen: the loops' instructions were byte-identical under both compilers.
README: badges refreshed; the Benchmark section now covers every mode and batch shape from a single CI run.
Measured at release time
Per #204, a version string alone moves code layout. This commit against the previous master (nothing else changed): ±0.4% on the decoding paths on the CI runner (EPYC 9V74); +3–7% on an Apple M4. Recorded here so nobody has to rediscover it.
Wheels
13 abi3 wheels (manylinux/musllinux × x86_64, i686, aarch64, armv7, s390x, ppc64le; Windows x64/x86; macOS arm64) plus an sdist that carries the vendored crate, published via PyPI Trusted Publishing with PEP 740 attestations. Python ≥ 3.11.
Full details: CHANGELOG.
Fuzz corpus 2026-08-28
Minimised libFuzzer corpora from the 24 CPU-hour campaign of 2026-08-28 (#102) — not a release of the library; it carries no code.
fuzz-corpus-min.tar.gz (8.3 MB) unpacks to corpus-min/parse_email (3,135 inputs, 5,047 edges) and corpus-min/parse_agreement (4,234 inputs, 5,502 edges). deep-fuzz.yml seeds from the newest fuzz-corpus-* release when its cache is cold; CONTRIBUTING's Fuzzing section shows how to use it locally.
Campaign: two targets × 5 forked workers × 8,640 s on an Apple M4, ASan, -max_len=65536 -timeout=10. parse_email 12,147,230 executions, parse_agreement 5,459,553; zero crashes, timeouts or OOMs.
v0.8.0 — back on fast-mail-parser
Installed as fast-mail-parser again
pip install fast-mail-parserReleases 0.6.0–0.7.0 went out as fast-mail-parser-ng because the original PyPI name belonged to an account this project no longer controlled, frozen at 0.2.5 from June 2022. Ownership has since been transferred, so releases go back to the original name.
The import path is unchanged, as it has been throughout: from fast_mail_parser import parse_email. If you are on fast-mail-parser-ng, change the name in your requirements file and nothing else. That project stays on PyPI so nothing pinning it breaks, but it receives no further releases.
New APIs
parse_email_tree(payload) + walk(part) — the MIME tree with its structure intact. parse_email returns a flat projection, and any flattening loses something: which text/html corresponds to which text/plain sibling, or where a bounce's embedded message begins. message/rfc822 parts are now parsed rather than opaque. Tree topology is asserted against the stdlib's walk() across the whole corpus, and costs −0.5% versus a flat parse.
parse_email(payload, mode="metadata") — headers and the attachment inventory with nothing decoded, 3.7x faster on a message that is 99% attachment by content. Attachments carry encoded_size. The mode picks the return type through typed overloads, so callers of the default path see no change.
PyMail.warnings and parse_email(payload, strict=True) — the lossy repairs a parse performed, reported instead of swallowed. Five kinds, each mapped onto the existing exception hierarchy. warnings == [] is the contract, and it costs nothing measurable.
Fixes
headerskeys now come back in wire order, stably across parses. They came from a RustHashMapwhose iteration order is randomised per instance, so identical bytes produced a different key order every time — forty distinct orders in forty parses.- A missing header/body separator is repaired instead of eating the body. A real-world message that omits it used to lose its first body part silently; the stdlib calls the defect
MissingHeaderBodySeparatorDefect. - An internal panic raises
ParseErrorrather than PyO3'sPanicException, which derives fromBaseExceptionand so slipped past theexcept Exceptionaround a worker's parse call. parse_many(payloads, threads=0)raisesValueErrorinstead of silently meaning "the default".
Performance
Batch parsing is 27% faster. bytes payloads are no longer copied before parsing: parse_many used to duplicate the whole batch before parsing any of it. Batch time is now within measurement error of parsing each message individually.
This also removes the reason to avoid parse_many for large messages — it used to be ~1.5x slower than a Python thread pool there, and the two are now level, while it stays ~12x faster for the many-small-messages case a mail pipeline actually has.
Full detail in the changelog.
fast-mail-parser-ng 0.7.1 — final release before archiving
Final release of the fast-mail-parser-ng distribution, published so that its PyPI page tells people where the project went before it is archived — PyPI's own guidance for retiring a name.
This is not a release of the project. The current release is v0.8.0, published as fast-mail-parser.
What this is
pip install fast-mail-parser-ng now shows a page pointing at fast-mail-parser, with the one-line migration:
- fast-mail-parser-ng
+ fast-mail-parserThe import path is unchanged and always was fast_mail_parser, so no code changes.
Why it is built from v0.7.0
The code is byte-identical to 0.7.0 — only the description changed. That is what a final release should be. Shipping 0.8.x features under the retired name would create two sources of truth and remove the reason to migrate.
What archiving does
fast-mail-parser-ng will be archived on PyPI: read-only, no further releases, removed from search. The four versions published under it (0.6.0, 0.6.1, 0.7.0, 0.7.1) stay installable — archiving marks a project finished, it removes nothing.
v0.7.0
A breaking release. Two changes need action; everything else is additive.
⚠️ Migrating from 0.6.x
1. headers is now dict[str, list[str]]
Repeated headers used to collapse to their last value, so every earlier Received, DKIM-Signature and Received-SPF was silently discarded — which made delivery-path tracing and signature verification impossible. Each key now maps to every value it appeared with, in order.
- sender = mail.headers["From"]
+ sender = mail.headers["From"][0]
+ hops = mail.headers["Received"] # all of them now, in message orderSingle-valued headers are one-element lists, so you never branch on the type. subject and date are unaffected — they're read from the parsed headers directly.
2. attachments no longer contains body parts or MIME containers
Every node of the MIME tree used to be reported, so a one-image message returned four "attachments": the image, both text bodies, and the multipart/mixed container. Bodies and attachments are now disjoint, following RFC 2183.
- attachments = [a for a in mail.attachments if a.filename] # hand-filtering
+ attachments = mail.attachmentsIf a text/plain part is marked Content-Disposition: attachment, its content is no longer concatenated into text_plain — that previously corrupted the body.
See docs/migrating.md for the full path, including from the stdlib email module. Its snippets are executed in CI, so they can't go stale.
Added
parse_many— batch parsing in one FFI call, results in input order, GIL released once for the batch. Each slot is aPyMailor aParseErrorinstance, so one malformed message doesn't cost you the rest of the batch. See the note on throughput below.- Typed addresses —
from_,to,cc,bcc,reply_toas parsedPyAddressvalues (display_name,address). RFC 5322 groups flattened; a malformed header yields an empty list rather than raising. date_parsed—dateas a timezone-awaredatetime, computed on access so you pay nothing if you don't read it.content_idanddispositionon attachments —content_idis exposed without angle brackets, the form RFC 2392cid:URLs use, so resolving inline images is a dictionary lookup.- A
ParseErrorhierarchy —HeaderParseError,MimeStructureError,DecodeError. All subclassParseError, so existing handlers keep working, but a corrupt attachment is now distinguishable from bytes that were never an email.
Fixed
filenameis read fromContent-Disposition(including RFC 2231 encoded values), falling back toContent-Type; name. Attachments that declare a filename only via the disposition — which is whatemail.message.EmailMessageemits, so most modern mail — previously reported"".subjectanddateno longer inherit the header map's representation.
Compatibility with the stdlib
New docs/compatibility.md documents every known difference from Python's email module, each one enforced by a test that fails if it changes or disappears. The corpus now matches the stdlib byte for byte, attachment payloads included, except five documented differences — plus one case where this library is more correct: the stdlib surrogate-escapes raw UTF-8 in address headers (RFC 6532) where this decodes it.
The one most likely to affect you: body line endings are preserved, not normalised to LF.
On batch throughput
parse_many is strongly regime-dependent, so it is worth knowing which side you are on. Measured against ThreadPoolExecutor(max_workers=4) + parse_email on a 4-vCPU runner:
| Messages | Size each | vs thread pool |
|---|---|---|
| 2000 | 1 KB | 12x faster |
| 400 | 50 KB | 1.2x slower |
| 200 | 786 KB | 1.5x slower |
parse_email already releases the GIL, so a thread pool over it is already parallel. What parse_many removes on top is per-call FFI and scheduling overhead — which scales with the number of messages — while its one cost, copying each payload before parsing starts, scales with total bytes.
So it is a large win for many small messages, which is the mail-pipeline case, and currently a loss for a few very large ones. Removing that copy is tracked in #96.
(These notes originally quoted only the pessimistic half of this, from a benchmark that used 200 × 786 KB — close to the worst case the API can be handed.)
Full changelog: https://github.com/namecheap/fast_mail_parser/blob/v0.7.0/CHANGELOG.md
v0.6.1
Documentation release. No code change — the parser is byte-identical to 0.6.0.
Why it exists
A PyPI project description is immutable per release and is built from Readme.md at package time, so the rename could not be announced on the live PyPI page without publishing a version. 0.6.0's page opened with badges and a wall of benchmark output before mentioning the new package name — anyone landing there saw neither the announcement nor how to install.
Installing
pip install fast-mail-parser-ngYour code does not change — the import path is still fast_mail_parser:
from fast_mail_parser import parse_emailMigrating from fast-mail-parser? Change the name in your requirements file and nothing else.
Changes
- The README, and therefore the PyPI description, now opens with the package name, the install command, and the unchanged import path, followed by a Quickstart. The rationale moved to its own "Why the name changed" section; the benchmark output moved below both.
- README links are now absolute — repo-relative links render broken on PyPI, which serves the file outside the repository.
Full changelog: https://github.com/namecheap/fast_mail_parser/blob/v0.6.1/CHANGELOG.md
v0.6.0
⚠️ The package name has changed
This release is published to PyPI as fast-mail-parser-ng.
- pip install fast-mail-parser
+ pip install fast-mail-parser-ngYour code does not change. The import path is still fast_mail_parser:
from fast_mail_parser import parse_email, ParseErrorOnly the name in your requirements file changes.
Why
The fast-mail-parser name on PyPI is owned by a PyPI account this project does not control, and is stuck at an unmaintained 0.2.5 from June 2022. Only a project owner can register a Trusted Publisher, so releases had nowhere to go — v0.4.0 and v0.5.0 both built every wheel successfully and failed only at the upload step. The PEP 541 transfer request (pypi/support#11044) has been open and unattended since 2026-06-13.
Rather than block releases on that queue indefinitely, this project now publishes under a name it owns. If the transfer is ever granted, fast-mail-parser will resume as an alias.
Changes
- Distribution renamed to
fast-mail-parser-ng; import path unchanged (#118). - Wheels are built with a pinned Rust toolchain (1.97.1). rustc 1.98.0 makes the parser ~26% slower, so the pin keeps this release as fast as 0.5.0 (#119, tracked in #120).
No functional change from 0.5.0. The minor bump signals that consumers must update how they install.
Artifacts
13 cp311-abi3 wheels (one per platform, covering every CPython ≥ 3.11 including future versions) plus an sdist, with build provenance attestations and PEP 740 attestations via PyPI Trusted Publishing.
Full changelog: https://github.com/namecheap/fast_mail_parser/blob/v0.6.0/CHANGELOG.md
v0.5.0
Stable-ABI wheels: one wheel per platform now covers every CPython ≥ 3.11.
Changed
- Wheels target the CPython stable ABI (
cp311-abi3, via thepyo3/abi3-py311feature). A single wheel per platform supports every CPython ≥ 3.11 — including versions released after the build — so a new CPython minor no longer requires a repo change or a new release to be installable (#14, #15, #101). - The abi3 build benchmarked ~11% faster than the version-specific build (min parse time, CPython 3.12, Apple Silicon), so no hybrid version-specific wheels are shipped.
- CI builds one abi3 wheel and runs the full CPython 3.11–3.14 test matrix against that same wheel — the stable-ABI contract is verified, not assumed. The publish matrix collapsed from one wheel per interpreter to one per platform.
Not included
- Free-threaded CPython (cp313t/cp314t) wheels remain deferred: free-threaded builds don't support the stable ABI, and the required shared-state audit is sequenced after the lazy-parsing work (#97). On 3.13t/3.14t the source build still installs, with the GIL re-enabled on import.
PyPI availability
This release does not reach PyPI yet. The fast-mail-parser project on PyPI is still owned by a third party and remains at 0.2.5; the PEP 541 transfer request (pypi/support#11044) is open. Once the transfer completes and a Trusted Publisher is configured, the publish job can be re-run for this tag (skip-existing makes that safe). #14 and #15 close when the fixed release actually lands on PyPI.
v0.4.0
First release of the modernized parser (v0.3.0 was the PyO3 0.16.6 release).
⚠️ Breaking
- Drops Python 3.7–3.10 (minimum is now 3.11).
strinput decoded as lossless UTF-8 (previously truncated code points — passbytesfor exact control).- Undecodable bodies raise
ParseErrorinstead of returning empty.
Changed
- PyO3 0.16.6 → 0.29.0 (resolves RUSTSEC-2025-0020, RUSTSEC-2026-0177); mailparse 0.15.0 → 0.16.1; stable Rust + MSRV 1.83; faster
strparsing.
Added
- CPython 3.13 & 3.14 support; DoS guards (input-size + MIME-depth caps); contract / RFC-corpus / correctness tests;
CONTRIBUTING.md.
Security
- Fixed lossy
str→bytes corruption; DoS hardening; hardened CI.
See CHANGELOG.md.