Skip to content

Releases: namecheap/fast_mail_parser

v0.10.0

Choose a tag to compare

@kurok kurok released this 18 Sep 14:20
Immutable release. Only release title and notes can be modified.
49a241c

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) — str payloads joined bytes, 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 headers is 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, with data_encoding kept as the arbiter of anything the SIMD decoder turns
    down.
  • parse_many sizes 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

Choose a tag to compare

@kurok kurok released this 28 Aug 08:31
306a9f8

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

Pre-release

Choose a tag to compare

@kurok kurok released this 28 Aug 11:19
73f56da

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

Choose a tag to compare

@kurok kurok released this 27 Aug 12:38
ff50b6c

Installed as fast-mail-parser again

pip install fast-mail-parser

Releases 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

  • headers keys now come back in wire order, stably across parses. They came from a Rust HashMap whose 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 ParseError rather than PyO3's PanicException, which derives from BaseException and so slipped past the except Exception around a worker's parse call.
  • parse_many(payloads, threads=0) raises ValueError instead 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

Choose a tag to compare

@kurok kurok released this 27 Aug 14:11

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-parser

The 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

Choose a tag to compare

@kurok kurok released this 26 Aug 16:07
fb361f6

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 order

Single-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.attachments

If 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 a PyMail or a ParseError instance, 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_to as parsed PyAddress values (display_name, address). RFC 5322 groups flattened; a malformed header yields an empty list rather than raising.
  • date_parsed — date as a timezone-aware datetime, computed on access so you pay nothing if you don't read it.
  • content_id and disposition on attachments — content_id is exposed without angle brackets, the form RFC 2392 cid: URLs use, so resolving inline images is a dictionary lookup.
  • A ParseError hierarchy — HeaderParseError, MimeStructureError, DecodeError. All subclass ParseError, so existing handlers keep working, but a corrupt attachment is now distinguishable from bytes that were never an email.

Fixed

  • filename is read from Content-Disposition (including RFC 2231 encoded values), falling back to Content-Type; name. Attachments that declare a filename only via the disposition — which is what email.message.EmailMessage emits, so most modern mail — previously reported "".
  • subject and date no 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

Choose a tag to compare

@kurok kurok released this 26 Aug 11:00
8494df6

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-ng

Your code does not change — the import path is still fast_mail_parser:

from fast_mail_parser import parse_email

Migrating 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

Choose a tag to compare

@kurok kurok released this 26 Aug 10:17
b727b99

⚠️ 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-ng

Your code does not change. The import path is still fast_mail_parser:

from fast_mail_parser import parse_email, ParseError

Only 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

Choose a tag to compare

@kurok kurok released this 03 Aug 10:12

Stable-ABI wheels: one wheel per platform now covers every CPython ≥ 3.11.

Changed

  • Wheels target the CPython stable ABI (cp311-abi3, via the pyo3/abi3-py311 feature). 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

Choose a tag to compare

@kurok kurok released this 12 Jun 22:00
38c83ea

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).
  • str input decoded as lossless UTF-8 (previously truncated code points — pass bytes for exact control).
  • Undecodable bodies raise ParseError instead 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 str parsing.

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.