Email learning series

Inside an email: MIME, HTML, attachments, and encoding

By Pedro Paiva

A message body isn’t always a single block of text. MIME gives email a way to label character sets, carry binary data through text-oriented transports, and organize multiple representations or attachments.

One message, two representations

MIME-Version: 1.0 declares MIME use. Content-Type describes a part’s media type and parameters. A multipart/alternative contains representations of the same content, typically plain text followed by HTML. A client selects a supported representation rather than displaying both as separate attachments.

This complete illustrative message has a multipart/mixed outer container: its first child is the alternative body, and its second is a text attachment. Copy it into welcome.eml, preserving blank lines. Mail generators should produce CRLF line endings.

From: hello@example.org
To: reader@example.net
Date: Sun, 25 Oct 2026 12:00:00 -0300
Message-ID: <welcome-1@example.org>
Subject: Welcome
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="outer-42"

--outer-42
Content-Type: multipart/alternative; boundary="inner-42"

--inner-42
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: quoted-printable

Welcome! Caf=C3=A9 is ready.
--inner-42
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: quoted-printable

<p>Welcome! Caf=C3=A9 is ready.</p>
--inner-42--
--outer-42
Content-Type: text/plain; charset=UTF-8; name="notes.txt"
Content-Disposition: attachment; filename="notes.txt"
Content-Transfer-Encoding: base64

SGVsbG8K
--outer-42--

A boundary delimiter starts with -- plus its declared value. The closing delimiter adds another --. The parent and child boundaries differ, and each part has its own headers, a blank line, then content. mixed means distinct parts; alternative means equivalent representations. Flattening that nesting can make a client show the HTML as an attachment.

Content-Disposition: attachment suggests attachment presentation and supplies a filename. It does not make a file safe to execute. Treat attachment names and types as untrusted input.

Encoding preserves bytes; it doesn’t hide them

Quoted-printable represents selected bytes as =HH. The UTF-8 bytes C3 A9 decode to é. A trailing = indicates a soft line break, not a literal equals sign. Base64 encodes three input bytes as four printable characters; SGVsbG8K decodes to Hello followed by a newline.

Base64 therefore adds roughly one-third before padding, line breaks and MIME headers. Very short inputs can have much larger proportional overhead. MIME base64 and quoted-printable encoded lines have a 76-character limit. Neither scheme encrypts content; anyone holding the message can decode it.

Character-set decoding comes after transfer decoding. If bytes are UTF-8 but a part declares a different charset, a client can display garbled text even though transport decoding succeeded.

For HTML plus an inline image, use multipart/related to group the HTML root and image. This focused excerpt replaces an HTML alternative with a related container; the image bytes are a valid base64-encoded one-pixel GIF:

Content-Type: multipart/related; boundary="related-42"

--related-42
Content-Type: text/html; charset=UTF-8

<p>Welcome <img src="cid:logo@example.org" alt="Logo"></p>
--related-42
Content-Type: image/gif
Content-ID: <logo@example.org>
Content-Disposition: inline
Content-Transfer-Encoding: base64

R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7
--related-42--

The cid: reference matches the Content-ID without angle brackets. Embed the entire related container as the HTML-side child of the alternative, while keeping ordinary attachments under the outer mixed container. Clients may still block or present images differently.

Inspect without sending

With Python 3.11 or later, parse the saved complete message locally using the standard library:

python3 - <<'PY'
from email import policy
from email.parser import BytesParser
with open('welcome.eml', 'rb') as source:
    message = BytesParser(policy=policy.default).parse(source)
for part in message.walk():
    print(part.get_content_type(), part.get_filename(), part.defects)
    if not part.is_multipart():
        print(repr(part.get_payload(decode=True)))
PY

Expect mixed → alternative → plain text → HTML → text attachment, no structural defects, UTF-8 bytes for Café, and b'Hello\n' for the attachment. The parser accepts common saved-message newline formats; that doesn’t validate an SMTP wire conversation. Remove welcome.eml afterward. No server or recipient is involved.

References