Skip to content

v0.7.0

Choose a tag to compare

@kurok kurok released this 26 Aug 16:07
· 91 commits to master since this release
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