email.message_from_binary_file() Silently Returns Empty on None (tested)

A non-blocking raw stream signals "no data ready yet" by returning None from readinto() -- distinct from the empty read that means real end-of-stream. We measured that email.message_from_binary_file() mishandles this signal the same silent way shutil.copyfileobj() and TextIOWrapper.readline() already do: a single transient None, even with a complete, well-formed email waiting on the very next read call, produces a completely empty Message object -- no Subject, no body, and critically, no exception at all.

1. A real, valid email behind one transient None

import io, email

class FlakyRaw(io.RawIOBase):
    def __init__(self, payload):
        self.calls = 0
        self.sent = False
        self.payload = payload
    def readable(self):
        return True
    def readinto(self, b):
        self.calls += 1
        if self.calls == 1:
            return None            # transient: no data ready yet
        if not self.sent:
            self.sent = True
            n = len(self.payload)
            b[:n] = self.payload
            return n               # the real, complete email
        return 0                   # real EOF after that

payload = b"Subject: hello\n\nbody text\n"
raw = FlakyRaw(payload)
br = io.BufferedReader(raw)
msg = email.message_from_binary_file(br)
print("subject:", msg.get("Subject"))
print("payload:", repr(msg.get_payload()))
print("raw calls made:", raw.calls)
subject: None
payload: ''
raw calls made: 1

Measured: no exception is raised anywhere. message_from_binary_file() made exactly one read call, got None, and silently handed back a valid-looking but completely empty Message object -- indistinguishable, at the call site, from parsing a genuinely empty file. The real Subject: hello header and body are never read at all.

2. Contrast: plain BufferedReader.read() on the identical signal

raw0 = FlakyRaw(b"irrelevant for this check")
br0 = io.BufferedReader(raw0)
print("plain read():", repr(br0.read()))
plain read(): None

Measured: the io layer preserves the None/EOF distinction correctly; email.message_from_binary_file()'s own internal line-reading loop is what silently collapses that None into "nothing more to parse" -- the same root mechanism already confirmed in shutil.copyfileobj() and TextIOWrapper.readline().

3. The fix: drain into a real buffer first, retrying on None

def drain_retrying_on_none(raw_stream, chunk_size=65536):
    out = io.BytesIO()
    br = io.BufferedReader(raw_stream)
    while True:
        chunk = br.read(chunk_size)
        if chunk is None:
            continue              # retry: not ready yet, not EOF
        if not chunk:
            break                  # real EOF
        out.write(chunk)
    out.seek(0)
    return out

raw2 = FlakyRaw(payload)
safe_buf = drain_retrying_on_none(raw2)
msg2 = email.message_from_binary_file(safe_buf)
print("safe subject:", msg2.get("Subject"))
print("safe payload:", repr(msg2.get_payload()))
safe subject: hello
safe payload: 'body text\n'

Measured: resolving the None/EOF distinction before the bytes reach message_from_binary_file() parses the header and body correctly.

Frequently asked questions

Is this documented behavior?

The email module's documentation for message_from_binary_file() does not mention non-blocking streams or a None read result at all; it assumes a blocking file object that returns either data or b'' for EOF. We measured that a None-returning source produces a silently empty result with no error of any kind.

Why is there no exception at all here, unlike gzip or ElementTree?

We measured that the email parser's internal line-reading loop checks for a falsy result to decide a message has ended, the same way shutil.copyfileobj() and TextIOWrapper.readline() do -- there's no decode or length operation downstream that a None could break, so the parser just quietly concludes the message is over with zero content.

Is this related to the other None/EOF findings?

Yes -- same family already confirmed in shutil.copyfileobj, gzip/bz2/lzma, tarfile/zipfile, TextIOWrapper.readline(), and ElementTree.parse(): a correctly-returned non-blocking None gets mishandled one layer above io itself. This is the second confirmed case (after copyfileobj and readline()) where the failure is completely silent rather than a crash or misleading error.

Written with AI assistance. Measured on 2026-10-09 with Python 3.13.5's built-in email.message_from_binary_file(), reading a genuinely valid, complete email through an io.BufferedReader over a custom non-blocking RawIOBase whose readinto() returns None once before returning the real message bytes. PlainHeaders is our extension; Python's email and io modules are built in.