Skip to content

Close the header section when a part has an empty body - #410

Merged
jhillyerd merged 2 commits into
jhillyerd:mainfrom
hdimer:fix-empty-body-header-separator
Aug 12, 2026
Merged

Close the header section when a part has an empty body#410
jhillyerd merged 2 commits into
jhillyerd:mainfrom
hdimer:fix-empty-body-header-separator

Conversation

@hdimer

@hdimer hdimer commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Fixes #196.

The problem

Part.Encode writes the empty line that separates the header section from the body only when the part has content:

if len(p.Content) > 0 {
    b.Write(crnl)
    p.encodeContent(b, cte)
}

So a message with an empty body ends right after its last header line. Reproducing the issue report with current main:

p, _ := enmime.Builder().
    From("Name", "from@example.com").
    To("Name", "to@example.com").
    Subject("Test").
    Build()
p.Encode(os.Stdout)
"Content-Type: text/plain; charset=utf-8\r\nDate: ...\r\nFrom: ...\r\nSubject: Test\r\nTo: ...\r\n"

The concrete consequence: the header section is terminated by end of input rather than by the empty line, so net/textproto's ReadMIMEHeader returns the headers along with io.EOF, and a consumer that checks the error rejects the message.

textproto.NewReader(bufio.NewReader(strings.NewReader(
    "From: a@example.com\r\nSubject: hi\r\n"))).ReadMIMEHeader()
// => map[From:[a@example.com] Subject:[hi]], io.EOF

// same input with the trailing empty line => same header map, nil error

To be precise about the spec: RFC 5322's ABNF is message = fields [CRLF body] and RFC 2046's is body-part := MIME-part-headers [CRLF *OCTET], so a header-only message is grammatical and this is not a MUST violation. It is an interop and clarity problem: without the empty line a reader cannot distinguish "header section finished" from "input truncated mid-header", which is exactly what ReadMIMEHeader reports.

The change

Write the separator for parts with no children even when the body is empty. Parts with children are untouched, because the leading CRLF of the first boundary marker already supplies that line.

Behaviour changes

Four golden files gain the two missing bytes. One thing worth calling out explicitly:

  • testdata/encode/part-empty.golden goes from 0 bytes to a bare CRLF. That is &enmime.Part{}, with no headers at all. I deliberately did not special-case it: guarding on len(p.Header) > 0 has no basis in the spec, and it would reintroduce the bug exactly where it bites hardest, since a header-less part used as a multipart child would still emit nothing at all between delimiters. "\r\n" is the honest encoding of "empty header section, empty body". Happy to add the guard if you would rather keep that file empty.
  • build-qp-addr-headers.golden is the issue's own scenario: Builder() with no .Text()/.HTML().
  • part-header-only.golden and part-header-only-default-encoding.golden are leaf parts with headers and no body.

I re-encoded the whole testdata corpus with and without the patch: 15 files change, every one by exactly +2 bytes on a genuinely empty body, and nothing else moves. Parse → encode → parse → encode reaches the same fixed point as before, so the change adds no round-trip instability.

Tests

  • TestEncodeEmptyBodyReadableByTextproto reproduces the issue's scenario end to end and asserts ReadMIMEHeader no longer reports EOF.
  • TestEncodeEmptyChildEndsHeaders pins the multipart case, which no golden covered: it would fail if the fix emitted a stray blank line in the parent, so it guards against an over-broad version of this patch.
  • One assertion added to the existing TestEncodePartForcedCTEEmptyContent table, which already builds the nil-content and zero-byte-ContentReader fixtures but only checked headers.

All three fail on main and pass with the change. make test (go test -race ./...) and make lint are clean.

Disclosure: I used an AI assistant while investigating and writing this patch. I reproduced the bug, verified the corpus differential and the ReadMIMEHeader behaviour myself, and I stand behind the change.


Used AI assistance on this; I reviewed and tested the change myself.

Part.Encode only wrote the empty line separating the header section from
the body when the part had content, so a message built with no body ended
after its last header line. Reading that back with net/textproto's
ReadMIMEHeader returns the headers along with io.EOF, because the header
section was terminated by end of input rather than by the empty line.

Write the separator for parts without children even when the body is
empty. Parts with children are unchanged: they already get that line from
the leading CRLF of the first boundary marker.

Fixes jhillyerd#196
@coveralls

coveralls commented Aug 12, 2026

Copy link
Copy Markdown

Coverage Status

coverage: 86.576% (+0.01%) from 86.563% — hdimer:fix-empty-body-header-separator into jhillyerd:main

@hdimer
hdimer marked this pull request as ready for review August 12, 2026 03:30
testifylint's require-error rule: an error assertion that gates later
assertions should stop the test (require) rather than continue (assert).
@jhillyerd
jhillyerd merged commit fcbaa8c into jhillyerd:main Aug 12, 2026
7 checks passed
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.

Invalid email when body is empty

3 participants