Parsing MIME and Nested Multipart Messages Correctly
A MIME message is a recursively nested tree, not a flat document. Here is how to walk the tree, decode only at the leaves, and survive the malformed, hostile messages real senders throw at you.
EvilMail TeamJuly 31, 202611 min read
A support ticket lands: "your temp inbox is corrupting my PDF." We pull the raw message. The PDF is fine on the wire — the problem is a parser that base64-decoded a multipart/mixedcontainer instead of the application/pdf leaf underneath it, then handed the user a blob of decoded boundary lines. Same week, a different report: an order receipt where the "attachment" the UI proudly displayed was actually the HTML body, misfiled because someone keyed off the presence of a filename and nothing else.
Both bugs are the same bug. MIME is not a document with a body and some attachments bolted on. It is a recursively nested tree. Boundaries define the edges of that tree; content encoding lives only on the leaves; and every real-world message is, to some degree, malformed. Get the tree right, stay forgiving everywhere else, and most parsing pain disappears. At evilmail.pro we ingest millions of messages a day from senders who have plainly never opened RFC 2046, so this is written from that trench.
## A MIME message is a tree, not a flat blob
The rule that generates the entire structure is one sentence: **any part whose Content-Type is multipart/* has children, delimited by that part's own boundary parameter.** Everything else — text/*, image/*, application/* — is a leaf that carries actual content. One special case reopens the recursion: message/rfc822
Parsing MIME Multipart Messages Correctly: A Field Guide — EvilMail Blog
embeds a whole nested message (headers and all), which you parse exactly like a top-level message.
A realistic HTML newsletter with a logo and a PDF invoice is three levels deep before you reach a single byte of content:
The MIME tree — split at the branch, decode at the leaf
multipart/mixed
boundary=AAA
--AAA
--AAA
multipart/related
boundary=BBB
application/pdf
attachment · base64
--BBB
--BBB
multipart/alternative
boundary=CCC
image/png
inline · cid:logo
--CCC
--CCC
text/plain
7bit
text/html
quoted-printable
composite — never decode, only split on its boundary
leaf — decode here
Two defaults you must hardcode: when a part carries no
Content-Type
header at all, it is
text/plain; charset=us-ascii
. When it carries no
Content-Transfer-Encoding
, it is
7bit
. And the message itself is supposed to declare
MIME-Version: 1.0
— though in practice you accept the ones that forget to.
## Boundaries: the one rule everyone gets wrong
RFC 2046 gives the boundary grammar precisely, and the precision matters:
-
dash-boundary
=
--
+ the boundary value. This opens each part.
-
delimiter
=
CRLF
+ dash-boundary. This separates one part from the next.
-
close-delimiter
= delimiter +
--
. This ends the multipart body.
Read the delimiter definition again: the
CRLF
immediately before
--boundary
is part of the delimiter, not part of the preceding body. This is the single most-missed detail in every homegrown parser. Include that trailing
CRLF
in a base64 or binary attachment and you append two stray bytes to every file, corrupting it. Strip exactly one leading
CRLF
off the boundary; keep every byte above it.
Anatomy of a boundary transition
[ preamble before first boundary — discard ]
--frontier9a7c
Content-Type: text/plain; charset=utf-8
Content-Transfer-Encoding: quoted-printable
Hello =E2=80=94 this line is the body.
CRLF here belongs to the boundary, NOT the body
--frontier9a7c
Content-Type: application/pdf; name="inv.pdf"
Content-Transfer-Encoding: base64
JVBERi0xLjcKJc... (leaf bytes, decode these)
--frontier9a7c--
close-delimiter (trailing --)
[ epilogue after close — discard ]
The rest of the boundary rules you enforce quietly: the value is at most 70 characters drawn from a restricted
bcharsnospace
set (letters, digits, and
'()+_,-./:=?
), it must be unique and must not appear in any child part, and anything before the first boundary (the
preamble
) or after the close-delimiter (the
epilogue
) is thrown away — it exists for non-MIME clients and pre-boundary junk, never for you. One more constraint people violate constantly: a composite
multipart/*
or
message/*
part may only use
7bit
,
8bit
, or
binary
transfer encoding.
A `multipart` container is never base64.
If you see
Content-Transfer-Encoding: base64
on a
multipart/*
, the sender is broken — do not decode it, just split on the boundary.
## Decode at the leaf, never the branch
Content-Transfer-Encoding
is per-part and only meaningful on a leaf. Five legal values:
7bit
,
8bit
,
binary
(all pass-through, no decoding), plus the two that actually transform bytes —
quoted-printable
and
base64
.
Quoted-printable:
=XX
is a hex byte (tolerate lowercase hex), a lone
=
at end of line is a
soft line break
and vanishes on decode, and trailing spaces or tabs before a line break are stripped. Base64: 76-char lines by spec, but you ignore all whitespace and newlines, tolerate missing
=
padding, and treat stray non-alphabet bytes leniently rather than aborting the whole part.
The classic disaster is calling your decode routine on a container. A well-built library makes this impossible to get wrong by returning
None
for multipart parts. Python's
email
package under the modern policy does exactly that:
``
python
from email import policy
from email.parser import BytesParser
with open("message.eml", "rb") as fp:
msg = BytesParser(policy=policy.default).parse(fp)
for part in msg.walk():
if part.is_multipart():
continue # a branch — split, don't decode
ctype = part.get_content_type() # e.g. 'application/pdf'
# decode=True handles base64/QP AND returns None on containers:
data = part.get_payload(decode=True)
fname = part.get_filename() # already RFC 2047/2231-decoded
print(ctype, fname, len(data) if data else 0)
Two things make this correct that a regex-and-
split
reimplementation gets wrong:
policy.default
(not the legacy
compat32
) decodes header words for you, and
get_payload(decode=True)
refuses to decode branches. If you must write your own parser, mirror this: a hard type check that a part is a leaf before any transfer-decoding runs.
## Headers lie in ASCII: RFC 2047 and RFC 2231
Header values are ASCII on the wire, so any non-ASCII subject or display name arrives wrapped. RFC 2047 **encoded-words** look like
=?charset?B?...?=
(base64) or
=?charset?Q?...?=
(Q-encoding, where
_
means the space
0x20
and
=XX
is hex). Each encoded-word is capped at 75 characters total. The concatenation rule bites people: whitespace **between two adjacent encoded-words** is deleted when you join them, but whitespace between an encoded-word and ordinary text is kept. Split a multi-byte UTF-8 character across two encoded-words — which senders do to stay under 75 chars — and a naive decoder that joins on the wrong seam produces mojibake.
Parameters like
filename
use a different mechanism, RFC 2231, for continuation and charset:
prefix plus percent-encoding gives you the bytes. The messy reality: Outlook and legacy senders routinely dump raw non-ASCII bytes into
filename=
, or illegally cram an RFC 2047 encoded-word inside a quoted
filename="=?utf-8?B?...?="
where 2231 is required. Accept all three. Try 2231 first, fall back to a 2047 encoded-word inside the value, fall back to a raw-bytes guess with charset detection.
## Attachments are a disposition, not a type
Whether a part is an attachment is decided by
Content-Disposition
(RFC 2183), not by its media type.
attachment
means "offer as a download";
inline
means "render in place." The
filename
parameter lives here (2231-decoded). Inline images in
multipart/related
are bound to the HTML by
Content-ID
: the HTML references
and the matching part carries Content-ID: .
The most common misclassification in the wild: a text/html or text/plain part with **no filename and an inline (or absent) disposition is the message body**, not an attachment. Keying off "does it have a name?" alone will both hide real bodies and surface phantom attachments. Derive a display name in this order — Content-Disposition filename, then the Content-Typename parameter, then a synthesized part-3.bin — then **sanitize ruthlessly** before it touches a filesystem: strip path separators, NUL bytes, control characters, and the Unicode right-to-left override U+202E that flips photogpj.exe into a fake .jpg in a UI. Never open() a path built from a raw sender-supplied name.
## When boundaries are malformed
Production mail breaks in a small, predictable set of ways. Build for each:
- **Missing close-delimiter.** The stream just ends. Treat EOF as an implicit close for every open part rather than throwing.
- **Bare LF line endings** instead of CRLF. Match boundaries line-oriented and accept \n as a terminator.
- **Boundary trailing whitespace**, or a boundary re-quoted or case-mangled between the header and the body. Trim trailing whitespace on each candidate line before comparing.
- **Duplicate Content-Type headers** or raw 8-bit bytes in Subject/filename (RFC 6532 territory). Take the first Content-Type; decode 8-bit headers as UTF-8 with a lenient fallback.
- **Adversarial nesting and part explosions.** A hostile message with 10,000 nested multipart parts, or a boundary crafted to appear millions of times, is a denial-of-service, not a rendering problem.
MIME has no length prefix — nothing tells you a part's size in advance, so you cannot trust any hint to bound work. You bound it yourself. The caps we run in production: **max nesting depth ~50, max part count ~1000, cumulative decoded-byte cap ~50 MB, and a wall-clock parse timeout.** Hit any limit and you flatten or reject the message rather than recursing further. A parser without these is one crafted .eml away from pinning a worker.
## Field checklist
- Strip exactly one CRLF immediately before every boundary — it belongs to the boundary, not the body.
- Discard the preamble before the first boundary and the epilogue after the close-delimiter.
- Recurse into multipart/* and message/rfc822; treat everything else as a leaf.
- Decode transfer encoding **only on leaves**; never base64-decode a composite part.
- Default missing headers to text/plain; charset=us-ascii and 7bit.
- Decode filenames RFC 2231 first, then a 2047 encoded-word inside the value, then a raw-bytes fallback.
- Classify by Content-Disposition, not by presence of a filename; an inline body is not an attachment.
- Sanitize every filename: path separators, NUL, control chars, and U+202E.
- Cap depth (~50), part count (~1000), decoded bytes (~50 MB), and wall-clock time.
- Treat EOF as an implicit close; tolerate bare LF and trailing whitespace on boundaries.
- Byte-preserve multipart/signed and multipart/encrypted parts — never re-canonicalize what a signature covers.
- Reach for a battle-tested library — Python email with policy.default, or CLI tools like reformime, ripmime -i msg.eml -d out/, and munpack` — before you write boundary-matching regex by hand.