This guide walks through fetching an email's content over IMAP and decoding it into usable text or binary data. It assumes you have a connection in the selected state and know the UID of the message you want to read.

How email content works in IMAP

Email messages aren't flat blobs of text. A single message is a tree of MIME parts, each with its own content type, encoding, and charset. A typical message with both a plain text and HTML version plus a PDF attachment looks like this:

multipart/mixed
├── multipart/alternative
│   ├── text/plain; charset="UTF-8"         (the plain text body)
│   └── text/html; charset="UTF-8"          (the HTML body)
└── application/pdf; name="report.pdf"      (an attachment)

IMAP gives you access to this structure without downloading the entire message (see RFC 9051 Section 6.4.5 — FETCH Command). The workflow is:

  1. Fetch the body structure — ask the server for the MIME tree
  2. Find the part you want — e.g. the text/plain part, or all attachments
  3. Fetch that part's content — download just the bytes for that section
  4. Decode it — reverse the transfer encoding and convert the charset

Step 1: Fetch the body structure

The BODYSTRUCTURE fetch item is a description of the message's MIME tree. Fetching it is cheap — the server returns metadata only, not the actual content.

{:ok, [msg]} = Plover.uid_fetch(conn, uid, [:body_structure])
bs = msg.attrs.body_structure

bs is a %Plover.Response.BodyStructure{} struct. For a single-part message (plain text, no attachments), it represents the whole message directly. For multipart messages, it has a :parts list containing child structs, which can themselves be multipart — forming the tree shown above.

Key fields on each part:

FieldExampleMeaning
type"TEXT"MIME major type
subtype"PLAIN"MIME minor type
params%{"CHARSET" => "UTF-8"}Content-type parameters
encoding"QUOTED-PRINTABLE"Transfer encoding
size4286Size in bytes
disposition{"ATTACHMENT", %{"FILENAME" => "report.pdf"}}Disposition and params
parts[%BodyStructure{}, ...]Child parts (multipart only)

Step 2: Find the parts you need

Plover.BodyStructure provides helpers to navigate the tree without manual recursion.

Listing all parts

flatten/1 returns every leaf part with its section path — the string you'll pass to the server to fetch that part's content.

alias Plover.BodyStructure, as: BS

parts = BS.flatten(bs)
# [
#   {"1.1", %BodyStructure{type: "TEXT", subtype: "PLAIN", ...}},
#   {"1.2", %BodyStructure{type: "TEXT", subtype: "HTML", ...}},
#   {"2",   %BodyStructure{type: "APPLICATION", subtype: "PDF", ...}}
# ]

Section paths follow IMAP's numbering convention (defined in RFC 9051 Section 6.4.5.1): children of a multipart are numbered starting at 1, and nested levels are dot-separated. In the example above, "1.1" means "first child of the first child" — the text/plain part inside the multipart/alternative.

For a single-part message (no multipart wrapper), the section path is "".

Finding parts by MIME type

find_parts/2 searches the tree for parts matching a type pattern:

# Exact match
[{section, part}] = BS.find_parts(bs, "text/plain")

# Wildcard — all text parts
text_parts = BS.find_parts(bs, "text/*")

# No match returns an empty list
[] = BS.find_parts(bs, "audio/mpeg")

Matching is case-insensitive.

Listing attachments

attachments/1 returns a summary of each attachment part:

atts = BS.attachments(bs)
# [%{
#   section: "2",
#   filename: "report.pdf",
#   type: "APPLICATION/PDF",
#   size: 45678,
#   encoding: "BASE64"
# }]

A part is considered an attachment if its MIME disposition is ATTACHMENT, or if it has a filename parameter and isn't explicitly INLINE.

Step 3: Fetch and decode the content

Plover.fetch_parts/3 handles fetching and decoding in one call. Pass it the {section, part} tuples from step 2 — it fetches the raw content using BODY.PEEK (which does not set the \Seen flag), then decodes each part automatically:

parts = BS.find_parts(bs, "text/plain")
{:ok, [{section, text}]} = Plover.fetch_parts(conn, uid, parts)

text is a decoded UTF-8 string, ready to use.

For text parts (text/*), fetch_parts applies both transfer decoding (base64, quoted-printable) and charset conversion (e.g. ISO-8859-1 to UTF-8). For all other parts (images, PDFs, etc.), it applies transfer decoding only, returning raw bytes.

You can fetch multiple parts at once — the results come back in the same order as the input:

text_parts = BS.find_parts(bs, "text/*")
{:ok, decoded} = Plover.fetch_parts(conn, uid, text_parts)
# [{"1.1", "plain text content..."}, {"1.2", "<html>..."}]

How content is encoded on the wire

The raw content stored on IMAP servers is encoded in two layers, which fetch_parts reverses for you:

  1. Transfer encoding — how the bytes are represented for safe transport:

    EncodingWhat it doesCommon on
    BASE64Encodes arbitrary bytes as ASCII charactersAttachments, HTML bodies
    QUOTED-PRINTABLEEscapes non-ASCII bytes as =XX hex pairsText bodies with accented characters
    7BIT / 8BIT / BINARYNo encoding, raw contentPlain ASCII text
  2. Charset — the byte encoding used for text content. Plover.Content converts these to UTF-8:

    • UTF-8 / US-ASCII — passed through unchanged
    • ISO-8859-1 (Latin-1) — common in older European email
    • Windows-1252 — similar to Latin-1 with extra characters like curly quotes

    Unknown charsets are returned as-is without error, so you won't crash on unusual encodings — but the text may need further processing.

Manual decoding

If you need lower-level control — for example, to stream content to disk without holding it in memory — you can fetch and decode parts yourself:

[{section, part}] = BS.find_parts(bs, "text/plain")

{:ok, [msg]} = Plover.uid_fetch(conn, uid, [{:body_peek, section}])
raw = msg.attrs.body[section]

encoding = BS.encoding(part)  # e.g. "QUOTED-PRINTABLE"
charset = BS.charset(part)    # e.g. "ISO-8859-1"
{:ok, text} = Plover.Content.decode(raw, encoding, charset)

For binary attachments, skip charset conversion with the two-argument form:

{:ok, pdf_bytes} = Plover.Content.decode(raw, att.encoding)
File.write!("report.pdf", pdf_bytes)

Putting it all together

Reading the text body of a message

alias Plover.BodyStructure, as: BS

# 1. Get the structure
{:ok, [msg]} = Plover.uid_fetch(conn, uid, [:body_structure])
bs = msg.attrs.body_structure

# 2. Find the text/plain part (fall back to text/html)
parts =
  case BS.find_parts(bs, "text/plain") do
    [_ | _] = found -> found
    [] ->
      case BS.find_parts(bs, "text/html") do
        [_ | _] = found -> found
        [] -> raise "No text content found"
      end
  end

# 3. Fetch and decode
{:ok, [{_section, text}]} = Plover.fetch_parts(conn, uid, parts)

Downloading all attachments

alias Plover.BodyStructure, as: BS

{:ok, [msg]} = Plover.uid_fetch(conn, uid, [:body_structure])
bs = msg.attrs.body_structure

for att <- BS.attachments(bs) do
  {:ok, part} = BS.get_part(bs, att.section)
  {:ok, [{_, data}]} = Plover.fetch_parts(conn, uid, [{att.section, part}])
  File.write!(att.filename, data)
end

Fetching everything at once

If you know you'll need the structure, text, and envelope, fetch the metadata together to avoid extra round trips:

alias Plover.BodyStructure, as: BS

{:ok, [msg]} = Plover.uid_fetch(conn, uid, [:body_structure, :envelope, :flags])
bs = msg.attrs.body_structure
env = msg.attrs.envelope

# Find and fetch the text part
parts = BS.find_parts(bs, "text/plain")
{:ok, [{_section, text}]} = Plover.fetch_parts(conn, uid, parts)

IO.puts("From: #{hd(env.from).mailbox}@#{hd(env.from).host}")
IO.puts("Subject: #{env.subject}")
IO.puts("---")
IO.puts(text)

Note that you always need two fetches: one for the body structure (to learn the section paths), and one for the actual content (using those paths). The body structure itself is just metadata — the server won't include the raw bytes until you ask for a specific section.

Fetching content for many messages

When processing a batch of messages — for example, rendering a mailbox view with preview snippets — use Plover.fetch_parts_batch/3 to fetch and decode parts for multiple UIDs concurrently. It pipelines the underlying UID FETCH commands so you pay one round trip instead of N:

alias Plover.BodyStructure, as: BS

# 1. Fetch body structures for a range of messages
{:ok, messages} = Plover.uid_fetch(conn, "1:20", [:body_structure, :uid])

# 2. Build a list of {uid, [{section, part}]} for each message
parts_by_uid =
  for msg <- messages do
    bs = msg.attrs.body_structure
    parts = BS.find_parts(bs, "text/plain")
    {to_string(msg.attrs.uid), parts}
  end

# 3. Fetch and decode all parts in parallel (default: 30 concurrent)
{:ok, results} = Plover.fetch_parts_batch(conn, parts_by_uid)

# results maps each UID to {:ok, parts} or {:error, reason}
for {uid, {:ok, [{_section, text} | _]}} <- results do
  IO.puts("UID #{uid}: #{String.slice(text, 0, 80)}...")
end

The :max_concurrency option controls how many UID FETCH commands can be in-flight at once (default: 30). Lower it if the server imposes connection-level rate limits:

Plover.fetch_parts_batch(conn, parts_by_uid, max_concurrency: 5)

Per-UID errors (missing body data, malformed encoding) are reported individually so the caller can decide how to handle them. The batch only returns a top-level {:error, reason} for connection-level failures like timeouts.

Further reading