Skip to content

fix(message-parser): match parts on mime_type so multipart/related survives tracking - #3597

Open
cl77 wants to merge 1 commit into
postalserver:mainfrom
cl77:fix/message-parser-related-type-param
Open

fix(message-parser): match parts on mime_type so multipart/related survives tracking#3597
cl77 wants to merge 1 commit into
postalserver:mainfrom
cl77:fix/message-parser-related-type-param

Conversation

@cl77

@cl77 cl77 commented Jul 4, 2026

Copy link
Copy Markdown

Summary

MessageParser#parse_parts matches parts with case part.content_type, which returns the full Content-Type header value including parameters. A multipart/related container that carries the RFC 2387 type="text/html" parameter - which PHPMailer and other mainstream senders emit for HTML mails with inline CID images - therefore matches the first when /text/html/ branch instead of the multipart branch.

The consequences for any tracked message with that structure (multipart/alternative containing multipart/related with inline images):

  • the related container's raw source (child part headers, boundaries, base64 payloads) is decoded and reassigned as a text body,
  • charset = UTF-8 is stamped onto the container header,
  • on re-serialization the Mail gem rebuilds the container as multipart/alternative, with the raw source as a huge text part and the original children appended as siblings.

Since the images then live in an alternative container rather than a related one, no mail client resolves the cid: references anymore - the message arrives without inline images, and the tracking pixel never reaches the real HTML part either. We hit this in production sending via the send/raw API with open tracking enabled.

Fix

Match on part.mime_type, the bare MIME type without parameters. This is consistent with generate(), which already inspects @mail.mime_type for non-multipart messages. No behavior change for any part whose Content-Type carries no parameters that collide with the matchers.

Repro / validation

Standalone reproduction with the mail gem (structure of the re-serialized message after parse_parts):

Before (case part.content_type):
multipart/alternative > [text/plain, multipart/alternative > [text/plain (raw container source), text/html, image/png]] - related container destroyed, Content-ID lost, no pixel in HTML.

After (case part.mime_type):
multipart/alternative > [text/plain, multipart/related > [text/html (pixel injected), image/png with Content-ID]] - structure intact.

A regression spec is included: it feeds a multipart/alternative message with a nested multipart/related; type="text/html" container through the parser with a track domain enabled and asserts the related container, its image part's Content-ID and the injected tracking pixel in the HTML leaf all survive.

…rvives tracking

MessageParser#parse_parts matched on part.content_type, which is the full header value including parameters. A multipart/related container carrying the RFC 2387 type="text/html" parameter (emitted by PHPMailer and other senders for HTML mails with inline CID images) therefore matched the /text\/html/ branch first: the container's raw source was decoded and reassigned as a text body, and re-serialization rebuilt it as a broken multipart/alternative whose inline images no client renders.

Matching on part.mime_type (bare MIME type, no parameters) routes such containers into the multipart branch, consistent with how generate() already inspects @mail.mime_type for non-multipart messages. Adds a regression spec covering the nested related container with a type parameter.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants