Skip to content

Stream IMAP message parts instead of buffering whole messages - #10

Open
ivarsb wants to merge 3 commits into
mainfrom
fix/stream-imap-message-parts
Open

Stream IMAP message parts instead of buffering whole messages#10
ivarsb wants to merge 3 commits into
mainfrom
fix/stream-imap-message-parts

Conversation

@ivarsb

@ivarsb ivarsb commented Aug 24, 2026

Copy link
Copy Markdown
Member

Problem

The mail-mcp pod was OOMKilled every ~10 minutes at a 384Mi limit — Restart Count: 101, exit code 137, ~16h of crashlooping.

Last State:     Terminated
  Reason:       OOMKilled
  Exit Code:    137
  Started:      Mon, 24 Aug 2026 13:41:45 +0300
  Finished:     Mon, 24 Aug 2026 13:51:29 +0300
Restart Count:  101

Root cause

get_message fetched RFC822, which materializes the whole message as one String, parsed it with Mail, then base64-decoded each attachment while the raw message was still reachable.

Measured on an 8.27MB message, peak live strings hit 55.6MB — 6.7× the wire size:

stage live strings >64KB
wire RFC822 8.27 MB
after Mail.new 24.80 MB
after text/html decode (forces the part tree) 49.60 MB
at the synchronous S3 upload 55.60 MB

net-imap literals are always ASCII-8BIT (documented at net/imap.rb:3119), and Mail.new on a binary source eagerly allocates two more full-size copies on top of the aliased raw_source; materializing the part tree then copies every part's base64 body again.

Why it OOMKilled

config/puma.rb runs a single worker with threads 1, 5, so that peak is per in-flight
request. Five concurrent large messages is ~460MB of peak plus boot, which clears 384Mi on
its own — measured at 614MB for five concurrent 25MB-attachment fetches. The ~10 minute
cadence is how often such a burst lands, not a steady climb.

Verified on glibc in ruby:3.4.9-slim that RSS does not accumulate across fetches — it
plateaus on the first one and stays there, with or without MALLOC_ARENA_MAX=2:

boot fetch 1 fetch 8
default arenas 42.8 MB 99.2 MB 99.3 MB
MALLOC_ARENA_MAX=2 42.8 MB 99.2 MB 99.3 MB

(20.7MB message, 15MB attachment; 56.5MB above boot ≈ 2.7× wire, matching the peak above.
The same loop on macOS malloc grows ~34.5MB per fetch without plateauing, so that behaviour
is allocator-specific and not what production hits. Container was aarch64; AKS is x86_64,
and glibc's allocator behaves broadly alike across the two.)

So this is a peak-memory problem, not a leak: raising the limit genuinely helps, and
lowering the per-request peak is what makes the limit hold.

Fix

MessageReader walks BODYSTRUCTURE and fetches each piece on its own: header, inline bodies, then each attachment via partial fetches BODY.PEEK[n]<offset.length> through an incremental base64 decoder into a Tempfile, uploaded and discarded before the next attachment is fetched.

The largest allocation in the path is now a 4MiB chunk regardless of message size, so the per-request peak no longer scales with the message — which is what makes a fixed memory limit hold. That holds by construction, independently of the numbers below.

AttachmentStore gains a streaming upload_io and memoizes its client and presigner, which were being rebuilt twice per attachment (measured: ~110ms and ~19MB for the first, ~0.8ms after).

Verification

A differential harness runs old vs new over 11 fixtures — single-part, multipart/alternative, nested related>alternative, non-UTF-8 charset, quoted-printable, attachment with no filename, inline image with a Content-ID, message/rfc822, and a multi-chunk 9MB attachment. Attachment bytes are identical in every case; no difference is unexplained. The fake IMAP server derives part addressing from Mail's own tree, so the walker is not tested against itself.

  • Five concurrent 25MB-attachment fetches peak at 221MB, against 614MB before — a 2.8× cut in the number that has to fit under the limit.
  • RSS flat across 12 sequential fetches, as before the change on glibc.
  • put_object with a Tempfile body genuinely streams: +18.6MB for a 40MB body.
  • 114 specs pass; rubocop clean.

Concurrency and peak numbers are macOS malloc; the glibc figures in Root cause come from ruby:3.4.9-slim. The peak multiplier agrees across both (~2.7–3.4× wire). The construction argument above is what does not depend on the allocator.

Behaviour changes

  • text_body on a non-multipart message. Mail#text_part searches all_parts, which is empty unless the message is multipart — so the old code returned nil for the body of every plain-text email. It is now returned. Pre-existing bug, fixed incidentally.
  • Attachments with an RFC 2231-split or RFC 2047-encoded filename now appear at all. A filename is what marks a part as an attachment, and the old lookup returned nil for both forms, so those attachments vanished from the response entirely.
  • content_type is the bare MIME type rather than the full Content-Type header; filename is already its own field.
  • A 7bit/8bit attachment keeps the line endings the server sent, where Mail#decoded normalized CRLF to LF. base64 attachments are byte-identical.
  • A missing section mid-stream raises MessageReader::IncompletePart instead of uploading a zero-byte attachment under a valid-looking presigned URL.

Body sections are requested with BODY.PEEK, so reading a message cannot set \Seen.

Deploying

/tmp in the running container is the overlay filesystem, not tmpfs — verified — so attachment spooling lands on disk and no volume change is needed. It is also on the container's writable layer, which is recreated per restart, so a spool file leaked by a SIGKILL does not accumulate.

Worth pairing with this:

  • Keep the memory limit at 1Gi initially as margin, and watch container_memory_working_set_bytes for an hour before tightening. Baseline to beat is 101 restarts / ~10 min lifetime. Since this is a peak problem rather than a leak, the curve should be sawtooth with a stable floor — a rising floor would mean something here is wrong.
  • Add ephemeral-storage requests/limits — the spool is currently untracked and the node is at 79%. Worst case is threads × largest attachment ≈ 250MB.
  • Raise timeoutSeconds/failureThreshold on both probes. The Unhealthy events seen alongside the OOMKills are a separate mechanism: put_object is synchronous and holds a Puma thread, so /health can queue behind five busy threads. This PR does not address thread occupancy. Do not lower the thread count as a memory measure — it makes probe starvation more likely.

Deliberately not in this PR

  • Streaming quoted-printable. Mail's QP decoder normalizes line endings and repairs hard breaks mis-encoded as hex across the whole string, and is empirically not decomposable across chunk boundaries — it mismatches at every slice size tested, including when only the trailing 8 bytes are split off. A hand-rolled substitute risks silently corrupting attachment content, which is worse than the bounded-but-larger memory use of an encoding only chosen for text. The one-pass path now logs when taken.
  • Capping inline body size. Inline parts are still fetched whole, so a very large inline HTML body is unbounded. Pre-existing, and truncating a body changes what the tool returns.
  • Eager S3 client init. @s3 ||= is not atomic, so a first concurrent burst can build up to five clients and discard four. Benign, but moving the ~110ms out of the first request touches config.ru.
  • search_messages uses @imap.search, which returns sequence numbers, while every other method is UID-based and the tool labels the result uids. Separate bug, cheap fix.

🤖 Generated with Claude Code

ivarsb and others added 2 commits August 24, 2026 14:18
get_message fetched RFC822, which materializes the entire message as one
String, then parsed it with Mail and base64-decoded each attachment while the
raw message was still reachable. Measured on an 8.27MB message, peak live
strings reached 55.6MB (6.7x the wire size): net-imap literals arrive as
ASCII-8BIT, and Mail.new on a binary source eagerly allocates two more
full-size copies on top of the aliased raw_source, then materializing the part
tree copies every part's base64 body again.

Worse, RSS never came back. Twelve sequential fetches of a 25MB-attachment
message grew RSS by one wire size each time (+34.5MB/fetch, 175MB to 543MB)
with the Ruby heap provably flat -- 47,579 live objects and 0.42MB of strings,
unchanged. Plain large alloc/free plateaus, so this is fragmentation specific
to the Mail parse path rather than generic allocator behaviour. In production
that ratchet OOMKilled the pod every ~10 minutes at a 384Mi limit.

Now MessageReader walks BODYSTRUCTURE, fetches the header and inline bodies on
their own, and streams each attachment with partial fetches
(BODY.PEEK[n]<offset.length>) through an incremental base64 decoder into a
Tempfile, uploading and discarding it before the next attachment is fetched.
Peak memory tracks the 4MiB chunk rather than the message: RSS is flat across
12 fetches, and five concurrent 25MB-attachment fetches peak at 276MB against
614MB before.

Verified with a differential harness over 11 fixtures (single-part,
alternative, nested related>alternative, non-UTF-8 charset, quoted-printable,
attachment without a filename, inline image with a Content-ID, message/rfc822,
multi-chunk) comparing against the old whole-message output. Attachment bytes
are identical in every case. Three intentional differences:

- text_body on a non-multipart message. Mail#text_part searches all_parts,
  which is empty unless the message is multipart, so the old code returned nil
  for the body of every plain-text email. It is now returned.
- content_type is the bare MIME type rather than the whole Content-Type header;
  filename is already its own field.
- a 7bit/8bit attachment keeps the line endings the server sent, where
  Mail#decoded normalized CRLF to LF.

Body sections are requested with BODY.PEEK so reading cannot set \Seen, and
inline parts are rebuilt from a synthesized MIME header so Mail still applies
the charset conversion. AttachmentStore gains a streaming upload_io and
memoizes its client and presigner, which were being rebuilt twice per
attachment at ~11MB of RSS each.

Deployment note: attachments are now spooled through Tempfile, so TMPDIR must
point at disk. If the container's /tmp is tmpfs the spool is anonymous memory
and counts against the cgroup limit, which would defeat the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Applied findings from a four-angle review of the previous commit.

Reuse: net-imap already reconciles the request spec against the response key,
which differ (servers answer BODY.PEEK[...] with BODY[...] and append the origin
octet on a partial fetch). FetchStruct#part/#header do this, so the hand-rolled
body_of prefix scan is gone. Its scan matched any origin, so a server echoing a
different one would have been accepted and the loop would have advanced over the
wrong bytes; #part matches exactly. A missing section is now distinguishable from
end-of-part -- empty means EOF, nil means the server answered something we did
not ask for -- so a mid-stream gap raises IncompletePart instead of uploading a
zero-byte attachment under a valid-looking presigned URL. The spec fake returns a
real Net::IMAP::FetchData so specs exercise that reconciliation rather than a
stand-in for it.

Correctness of filenames: BODYSTRUCTURE parameters arrive RFC 2231-split across
numbered keys (FILENAME*0*, FILENAME*1*) or RFC 2047 encoded. The old
casecmp?-based lookup returned nil for both, and since a filename is what marks a
part as an attachment, such attachments vanished from the response entirely.
Mail::ParameterHash plus Encodings.value_decode handles all three forms. The
comment claiming servers vary parameter-name case was also wrong: net-imap's
parser upcases them.

Efficiency: Base64Stream#push made three full-size copies of every chunk --
measured 15.00 MiB of garbage per 4 MiB chunk. It now strips and slices in place,
measured at 3.99 MiB. This required pairing with the caller, which must read
chunk.bytesize before handing the buffer over; the chunk fetch and write also
moved into their own method so the buffer is unreachable rather than held in a
loop local across the next round trip. Rebuilding an inline part now uses setters
instead of interpolating a MIME header and re-parsing it, saving about one copy of
each inline body. Five concurrent 25MB-attachment fetches now peak at 221MB,
against 276MB before this cleanup and 614MB before the streaming change.

Simplification: AttachmentStore.upload had no caller left and preserved the
fully-resident String upload this work exists to remove -- deleted, with store
inlined into upload_io. Part collapsed media_type/subtype into one mime_type
member and dropped the never-read content_id. streamable? no longer conflates
"we can decode this incrementally" with "the server reported a size"; the chunk
loop terminates on the first empty range, so a part of unreported size streams
too. Tempfile.create's block form replaces the manual ensure/close!, which also
closes a leak: the tempfile used to be created outside the begin, so a fetch
raising mid-stream held an fd and its disk until finalization. Also trimmed dead
guards in Base64Stream, a guard clause in flatten, and dead code in the fake.

The comment on the memoized S3 client claimed ~11MB per client; measured it is
~19MB and ~110ms once, then ~0.8ms -- corrected.

Deliberately not changed:

- Streaming quoted-printable. Mail's QP decoder normalizes line endings and
  repairs hard breaks mis-encoded as hex, over the whole string, and is
  empirically not decomposable across chunk boundaries -- it mismatches at every
  slice size tested, including when only the trailing 8 bytes are split off. A
  hand-rolled substitute risks silently corrupting attachment content, which is
  worse than the bounded-but-larger memory use of an encoding only chosen for
  text. The one-pass path now logs when taken, and the comment no longer implies
  large QP parts cannot occur.
- Capping inline body size. Inline parts are still fetched whole, so a very large
  inline HTML body is unbounded. Pre-existing, and truncating a body changes what
  the tool returns, so it belongs in its own change.
- Merging the two inline part fetches into one round trip. Saves one of four
  fetches but holds both raw bodies at once, on the axis this work exists to
  bound.
- Moving upload and hash assembly out of MessageReader. Offloading attachments is
  inherent to reading a message without buffering it, and splitting it back out
  pushes ImapClient over its length limit again or needs a third class.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Streams IMAP message parts to reduce memory usage during attachment processing.

Changes:

  • Adds BODYSTRUCTURE-based message reading and chunked Base64 decoding.
  • Streams attachments through temporary files to memoized S3 clients.
  • Adds coverage for MIME structures, encoding, and streaming behavior.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
lib/mail_mcp.rb Loads new streaming components.
lib/mail_mcp/attachment_store.rb Streams IO uploads and memoizes AWS clients.
lib/mail_mcp/base64_stream.rb Adds incremental Base64 decoding.
lib/mail_mcp/imap_client.rb Delegates message retrieval to MessageReader.
lib/mail_mcp/message_reader.rb Fetches and processes individual IMAP sections.
lib/mail_mcp/message_structure.rb Flattens BODYSTRUCTURE into fetchable parts.
spec/mail_mcp/attachment_store_spec.rb Tests streaming S3 uploads.
spec/mail_mcp/base64_stream_spec.rb Tests incremental decoding.
spec/mail_mcp/imap_client_spec.rb Tests per-part message retrieval.
spec/mail_mcp/message_structure_spec.rb Tests MIME structure handling.
spec/spec_helper.rb Loads the fake IMAP server.
spec/support/fake_imap_server.rb Simulates BODYSTRUCTURE and section fetches.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +34 to +37
return [leaf(body, prefix || "1")] unless body.multipart?

body.parts.flat_map.with_index(1) do |child, number|
flatten(child, [prefix, number].compact.join("."))

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked this against the old whole-message path with a multipart/related carrying Content-Disposition: attachment; filename="page.mht". The new output matches the old one exactly:

field old (RFC822 + Mail) new (per-part)
attachments [] []
text_body "real message body" "real message body"
html_body "<p>archived page</p>" "<p>archived page</p>"

So there's no regression here — and the reason is that this is Mail's own behaviour rather than an accident of the rewrite. Mail::AttachmentsList (attachments_list.rb:8-19) only ever treats leaf parts as attachments:

elsif p.parts.empty?
  p if p.attachment?     # only leaves are candidates
else
  p.attachments          # a node with children recurses; itself never included
end

The multipart node's attachment? really is true and its filename really is "page.mht", but AttachmentsList still skips it. message/rfc822 is the one special case in that method, and it already arrives here as a leaf (BodyTypeMessage#multipart? is false), so forwarded-message attachments are picked up as before — there's a fixture for that.

Emitting the multipart node as an attachment would therefore diverge from Mail and add an attachment to responses the old path never returned. Since this PR's verification is a differential against that old output, I've left the semantics as-is and added a spec so they're deliberate rather than incidental (7d3d12e).

The second half of the observation is fair on its own terms: html_body can come from inside an attached archive. That predates this branch, matches Mail#html_part, and changing it changes what the tool returns — so it belongs in its own change rather than this one.

Copilot flagged flatten's unconditional descent into multipart nodes as dropping
an attached multipart and letting its children supply text_body/html_body.

Checked against the old whole-message path with a multipart/related carrying
Content-Disposition: attachment; filename="page.mht". The new output matches the
old one exactly on all three fields: no attachment, text_body from the real body
part, html_body from inside the archive.

That is Mail's own behaviour, not an accident. Mail::AttachmentsList
(attachments_list.rb:8-19) only ever treats leaf parts as attachments -- a part
with children recurses and is never included itself, whatever its disposition
says. message/rfc822 is its one special case, and that already arrives here as a
leaf because BodyTypeMessage#multipart? is false, so it is picked up as an
attachment as before.

So there is no regression to fix, and emitting the multipart node as an
attachment would both diverge from Mail and add an attachment to responses the
old path never returned. Added a spec so the semantics are deliberate rather
than incidental.

The second half of the observation is fair on its own terms: html_body can come
from inside an attached archive. That predates this branch, matches
Mail#html_part, and changing it changes what the tool returns, so it belongs in
its own change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ivarsb

ivarsb commented Aug 24, 2026

Copy link
Copy Markdown
Member Author

Correction to the original description

I've edited the description above. The measurement it originally leaned on does not hold on the platform this actually runs on, so flagging the change rather than quietly rewriting history.

What I originally claimed: RSS grew by one message size per fetch, linearly and without plateau, so no memory limit was safe and raising it only bought time.

What is actually true: that growth is specific to macOS malloc, where I first measured it. Re-run in ruby:3.4.9-slim, RSS plateaus on the first fetch and stays flat:

boot fetch 1 fetch 8
default arenas 42.8 MB 99.2 MB 99.3 MB
MALLOC_ARENA_MAX=2 42.8 MB 99.2 MB 99.3 MB

So this is a peak problem, not accumulation. Two consequences:

  • Raising the memory limit does genuinely help, contrary to what I first wrote.
  • MALLOC_ARENA_MAX=2, which I had recommended deploying alongside this, measured no effect on glibc here. Dropped from the checklist.

What is unchanged: the peak multiplier, which is the reason for the PR. The per-request peak scaled with message size (~2.7× wire on glibc, ~3.4× on macOS, and 6.7× counting Ruby-level live strings), and with threads 1, 5 that peak is per in-flight request — five concurrent 25MB-attachment fetches measured 614MB, which clears 384Mi by itself. After this change the same five peak at 221MB, and the largest single allocation is a 4MiB chunk regardless of message size, so the peak no longer tracks the message at all. That is what makes a fixed limit hold, and it is the part that does not depend on the allocator.

The ~10 minute restart cadence is therefore how often a burst of concurrent large fetches lands, not a steady climb.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants