Skip to content

MimeMultipart.parse() drops blank preamble lines, breaks S/MIME after saveChanges() #825

@mmarmol

Description

@mmarmol

Describe the bug

MimeMultipart.parse() discards blank lines between the header/content separator and the first boundary when parsing multipart body parts. This is a lossy operation: when the content is later serialized via MimeMultipart.writeTo(), those blank lines are not restored, resulting in byte-level content modification.

This breaks S/MIME signature verification, because the signature covers the exact bytes of the signed body part. After a saveChanges() + writeTo() cycle, the signed content loses 2 bytes (\r\n), the hash no longer matches the signature, and verification fails.

The root cause is in MimeMultipart.parse() (MimeMultipart.java, line 651):

// save the preamble after skipping blank lines
if (line.length() > 0) {
    if (preamblesb == null)
        preamblesb = new StringBuilder(line.length() + 2);
    preamblesb.append(line).append(System.lineSeparator());
}

The condition if (line.length() > 0) silently drops any empty lines that appear before the first boundary. These empty lines are valid per RFC 2046 (they fall in the preamble area) and are commonly produced by Microsoft Outlook / Exchange.

When writeTo() reconstructs the multipart, preamble is null (because nothing was saved), so the boundary is written immediately after the header/content separator — omitting the original blank line(s).

To Reproduce

  1. Receive an S/MIME signed email from Microsoft Outlook / Exchange. The signed body part (multipart/alternative) contains an extra \r\n between the header/content separator and the first inner boundary. This is valid per RFC 2046:

    Content-Type: multipart/alternative;
    	boundary="----=_NextPart_001_0024_01DC8EEA.EA4A1F90"
    \r\n          ← RFC 822 header/content separator
    \r\n          ← Extra blank line (valid preamble area per RFC 2046)
    ------=_NextPart_001_0024_01DC8EEA.EA4A1F90
    Content-Type: text/plain; ...
    
  2. Parse the email with MimeMessage:

    MimeMessage message = new MimeMessage(session, inputStream);
  3. Call saveChanges() (e.g., after modifying headers):

    message.setHeader("X-Custom-Header", "value");
    message.saveChanges();
  4. Write the message back:

    ByteArrayOutputStream out = new ByteArrayOutputStream();
    message.writeTo(out);
  5. The signed body part content is now 2 bytes shorter. The extra \r\n before the first inner boundary has been removed:

    Content-Type: multipart/alternative;
    	boundary="----=_NextPart_001_0024_01DC8EEA.EA4A1F90"
    \r\n          ← header/content separator
    ------=_NextPart_001_0024_01DC8EEA.EA4A1F90    ← blank line GONE
    Content-Type: text/plain; ...
    
  6. S/MIME signature verification now fails because the signed content bytes have changed.

Expected behavior

The parse()writeTo() cycle should be byte-exact for the content of multipart body parts. Blank lines in the preamble area (before the first boundary) should be preserved, as they are part of the signed content in S/MIME messages.

After saveChanges() + writeTo(), the signed body part should retain the same bytes it had when originally parsed, and S/MIME signature verification should continue to succeed.

Proposed fix

In MimeMultipart.parse(), remove the if (line.length() > 0) guard so that blank lines are preserved as part of the preamble:

// Current code (line ~651):
if (line.length() > 0) {
    if (preamblesb == null)
        preamblesb = new StringBuilder(line.length() + 2);
    preamblesb.append(line).append(System.lineSeparator());
}

// Proposed fix:
if (preamblesb == null)
    preamblesb = new StringBuilder(line.length() + 2);
preamblesb.append(line).append("\r\n");

Two changes:

  1. Remove if (line.length() > 0) — preserve blank lines as preamble content instead of discarding them.
  2. Replace System.lineSeparator() with "\r\n" — MIME requires CRLF line endings per RFC 2046. Using System.lineSeparator() produces \n on Linux, which would lose 1 byte per line when the preamble is written back via writeTo() using raw los.write(pb).

Desktop (please complete the following information):

  • OS: Linux (Ubuntu), also reproducible on any OS
  • Java version: 17+
  • Jakarta Mail API: 2.1.3 (also verified in 2.1.5)
  • Angus Mail: 2.0.3

Mail server:

  • Protocol being used: SMTP (sending side), IMAP (receiving side)
  • Vendor/product: Microsoft Exchange / Outlook (Microsoft 365)
  • The issue is not server-specific; it affects any S/MIME signed email that contains blank lines before the first boundary in a multipart body part.

Additional context

  • The email structure with an extra blank line before the first boundary is valid per RFC 2046 Section 5.1.1, which defines the preamble area as "the area between the header and the first boundary". Any content there (including blank lines) is valid.
  • Microsoft Outlook / Exchange commonly produces this structure in S/MIME signed emails.
  • The writeTo() method in MimeMultipart already handles a non-null preamble correctly — it writes the bytes as-is and checks for trailing newline. The only issue is that parse() never stores blank-line-only preambles.
  • The System.lineSeparator() issue is a secondary pre-existing bug that becomes relevant only with this fix. Currently it is inert because blank lines never reach the preamble storage path.
  • This issue makes it impossible to verify S/MIME signatures after calling saveChanges(), which is required in many mail processing platforms before sending.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions