Fix IMAP emails appearing empty: accept bytearray from aioimaplib literals

aioimaplib stores RFC822 literal data as bytearray in response.lines,
not bytes. The extraction loop checked isinstance(line, bytes) which
returns False for bytearray, silently dropping every email body and
causing all IMAP emails (T-Online, GMX, etc.) to appear empty.

Fix: accept (bytes, bytearray) and convert to bytes() immediately so
the rest of the pipeline is unaffected. Two new regression tests mirror
the real aioimaplib behaviour by passing bytearray as the literal.

Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/341f5c82-70f8-438d-91ca-8e906a06c400

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-28 22:08:04 +00:00
parent fd2adb08cd
commit da05016143
4 changed files with 83 additions and 2 deletions
+7 -2
View File
@@ -383,9 +383,14 @@ class MailProcessor:
# [b'<seq> (UID <uid> RFC822 {<size>}', <email_bytes>, b')']
# Skip the header line (contains "RFC822") and grab the
# first substantive bytes value.
#
# aioimaplib stores IMAP literal data (the actual email
# content) as bytearray, not bytes. We must accept both
# types; bytes() converts bytearray so the rest of the
# pipeline always receives plain bytes.
email_data: Optional[bytes] = None
for line in fetch_response.lines:
if not isinstance(line, bytes):
if not isinstance(line, (bytes, bytearray)):
continue
if b"RFC822" in line:
continue
@@ -393,7 +398,7 @@ class MailProcessor:
continue
if line in (b")", b""):
continue
email_data = line
email_data = bytes(line)
break
if email_data and email_data.strip():
@@ -479,3 +479,70 @@ class TestFetchImapEmailsUidCommands:
assert emails == []
assert new_uids == []
async def test_bytearray_literal_data_is_accepted(self, processor, mock_imap):
"""aioimaplib stores IMAP literal data as bytearray, not bytes.
This is the root cause of emails appearing empty on IMAP servers such
as T-Online and GMX: the extraction loop previously skipped bytearray
objects because ``isinstance(bytearray(...), bytes)`` is False.
The fix accepts both bytes and bytearray and converts to bytes so the
rest of the pipeline receives plain bytes as expected.
"""
raw_email = b"From: user@t-online.de\r\nSubject: Real email\r\n\r\nBody"
# aioimaplib returns the RFC822 literal as bytearray in response.lines
bytearray_response = _make_imap_response(
result="OK",
lines=[
b"1 (UID 55 RFC822 {%d}" % len(raw_email),
bytearray(raw_email),
b")",
],
)
mock_imap.search.return_value = _make_imap_response(result="OK", lines=[b"55"])
mock_imap.fetch.return_value = _make_uid_list_response([("55", "55")])
mock_imap.uid = AsyncMock(
side_effect=lambda cmd, *args: (
bytearray_response if cmd == "fetch" else _make_imap_response()
)
)
with patch(
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
return_value=mock_imap,
):
emails, new_uids = await processor._fetch_imap_emails(10, set())
# The email must be extracted and returned as plain bytes
assert new_uids == ["55"]
assert len(emails) == 1
assert emails[0] == raw_email
assert isinstance(emails[0], bytes)
async def test_bytearray_whitespace_literal_is_skipped(self, processor, mock_imap):
"""A bytearray literal that is whitespace-only must still be rejected."""
bytearray_ws_response = _make_imap_response(
result="OK",
lines=[b"1 (UID 56 RFC822 {2}", bytearray(b"\r\n"), b")"],
)
mock_imap.search.return_value = _make_imap_response(result="OK", lines=[b"56"])
mock_imap.fetch.return_value = _make_uid_list_response([("56", "56")])
mock_imap.uid = AsyncMock(
side_effect=lambda cmd, *args: (
bytearray_ws_response if cmd == "fetch" else _make_imap_response()
)
)
with patch(
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
return_value=mock_imap,
):
emails, new_uids = await processor._fetch_imap_emails(10, set())
assert emails == []
assert new_uids == []