From 062739a9a1cf839b1ff1dce6908dd1689763c9e2 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 28 Mar 2026 21:00:44 +0000
Subject: [PATCH] Fix IMAP UID SEARCH crash and RFC 3501 flag parentheses
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/a44d5455-25ea-49b9-8310-4efd6d467e84
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
---
CHANGELOG.md | 16 +++
backend/app/services/mail_processor.py | 46 +++++--
.../tests/unit/test_mail_processor_imap.py | 116 +++++++++++++-----
docs/TODO.md | 6 +
4 files changed, 143 insertions(+), 41 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e7bcbc8..110ec8d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
+## [Unreleased]
+
+### Fixed
+
+- **IMAP: fix T-Online BYE "Too many invalid IMAP commands"** — `UID STORE` flag
+ arguments now use RFC 3501–required parentheses: `+FLAGS (\Seen)` and
+ `+FLAGS (\Deleted)`. Strict servers such as T-Online reject the bare
+ `+FLAGS \Seen` syntax as a `BAD` command and forcefully drop the connection.
+- **IMAP: fix `aioimaplib` crash on `UID SEARCH`** — `aioimaplib`'s `.uid()`
+ wrapper explicitly blocks `"search"`, raising
+ `command UID only possible with COPY, FETCH, EXPUNGE (w/UIDPLUS) or STORE`.
+ The initial UNSEEN discovery now uses a plain `SEARCH UNSEEN` to obtain
+ sequence numbers, followed by a lightweight `FETCH (UID)` to resolve them
+ to stable UIDs; all subsequent operations (`FETCH`, `STORE`) continue to
+ use the UID form.
+
## v0.4.2 (2026-03-28)
### Bug Fixes
diff --git a/backend/app/services/mail_processor.py b/backend/app/services/mail_processor.py
index adcbc88..24a6964 100644
--- a/backend/app/services/mail_processor.py
+++ b/backend/app/services/mail_processor.py
@@ -5,6 +5,7 @@ Supports both POP3 and IMAP protocols with secure connections.
import asyncio
import poplib
+import re
import smtplib
import ssl
from email import parser
@@ -271,13 +272,18 @@ class MailProcessor:
servers to report "Too many invalid IMAP commands" (as seen with
t-online). This implementation:
- * Uses ``UID SEARCH``, ``UID FETCH``, and ``UID STORE`` throughout.
+ * Uses a plain ``SEARCH UNSEEN`` to retrieve sequence numbers, then a
+ lightweight ``FETCH (UID)`` to resolve them to stable UIDs.
+ (``aioimaplib``'s ``.uid()`` wrapper does not support ``SEARCH``.)
+ * Uses ``UID FETCH`` and ``UID STORE`` for all subsequent operations.
* Re-marks already-processed UIDs as \\Seen in a single batch command
instead of one STORE per message.
* Relies on the implicit \\Seen flag set by RFC822 FETCH so no
additional per-message STORE is needed for newly fetched mail.
* Batches the \\Deleted STORE into a single command when
``delete_after_forward`` is enabled.
+ * Uses RFC 3501–compliant parenthesised flag syntax, e.g.
+ ``+FLAGS (\\Seen)``, required by strict servers such as T-Online.
"""
emails: List[bytes] = []
new_uids: List[str] = []
@@ -298,8 +304,10 @@ class MailProcessor:
await imap_client.login(self.account.username, self.password)
await imap_client.select("INBOX")
- # UID SEARCH returns stable UIDs instead of volatile sequence numbers.
- response = await imap_client.uid("search", "UNSEEN")
+ # Step 1: Standard sequence-based SEARCH for UNSEEN messages.
+ # aioimaplib's .uid() wrapper explicitly blocks "search", so we
+ # use the plain SEARCH command and resolve to UIDs in step 2.
+ response = await imap_client.search("UNSEEN")
if (
response.result != "OK"
or not response.lines
@@ -309,18 +317,36 @@ class MailProcessor:
# logout is handled by the finally block below
return emails, new_uids
- all_unseen_uids: List[bytes] = response.lines[0].split()
+ seq_nums: List[bytes] = response.lines[0].split()
+ if not seq_nums:
+ logger.info(f"Found 0 unread messages for account {self.account.id}")
+ return emails, new_uids
# Limit to max_count before doing any further work.
- all_unseen_uids = all_unseen_uids[:max_count]
+ seq_nums = seq_nums[:max_count]
+
+ # Step 2: Resolve sequence numbers to stable UIDs with a
+ # lightweight FETCH (UID) so all subsequent commands are UID-based.
+ seq_string = b",".join(seq_nums).decode()
+ uid_resp = await imap_client.fetch(seq_string, "(UID)")
+
+ # Step 3: Parse UIDs from response lines.
+ # Each line looks like: b'1 (UID 42)'
+ all_unseen_uids: List[bytes] = []
+ for line in uid_resp.lines:
+ if isinstance(line, bytes):
+ m = re.search(rb"\bUID\s+(\d+)", line)
+ if m:
+ all_unseen_uids.append(m.group(1))
logger.info(
f"Found {len(all_unseen_uids)} unread messages for account "
f"{self.account.id}"
)
- # Split into stale UIDs (already in our DB but still UNSEEN on the
- # server — e.g. a previous STORE failed) and genuinely new UIDs.
+ # Step 4: Split into stale UIDs (already in our DB but still
+ # UNSEEN on the server — e.g. a previous STORE failed) and
+ # genuinely new UIDs.
stale_uid_bytes: List[bytes] = []
uids_to_fetch: List[bytes] = []
for uid in all_unseen_uids:
@@ -333,10 +359,11 @@ class MailProcessor:
# Re-mark stale UIDs as \Seen in a single batch STORE command so
# they stop appearing in UNSEEN searches without consuming one
# round-trip per message.
+ # RFC 3501 requires parentheses around flag names: +FLAGS (\Seen).
if stale_uid_bytes:
uid_set = b",".join(stale_uid_bytes).decode()
try:
- await imap_client.uid("store", uid_set, "+FLAGS", "\\Seen")
+ await imap_client.uid("store", uid_set, "+FLAGS", "(\\Seen)")
except Exception as e:
logger.warning(
f"Failed to re-mark stale messages as \\Seen for account "
@@ -387,10 +414,11 @@ class MailProcessor:
# Batch-mark fetched messages for deletion if configured (one STORE
# command covers all UIDs instead of N individual commands).
+ # RFC 3501 requires parentheses around flag names: +FLAGS (\Deleted).
if self.account.delete_after_forward and fetched_uids:
uid_set = b",".join(fetched_uids).decode()
try:
- await imap_client.uid("store", uid_set, "+FLAGS", "\\Deleted")
+ await imap_client.uid("store", uid_set, "+FLAGS", "(\\Deleted)")
await imap_client.expunge()
except Exception as e:
logger.warning(
diff --git a/backend/tests/unit/test_mail_processor_imap.py b/backend/tests/unit/test_mail_processor_imap.py
index d4aa794..aff76f6 100644
--- a/backend/tests/unit/test_mail_processor_imap.py
+++ b/backend/tests/unit/test_mail_processor_imap.py
@@ -1,11 +1,14 @@
"""
-Unit tests for the UID-based IMAP fetch logic in MailProcessor.
+Unit tests for the IMAP fetch logic in MailProcessor.
These tests verify that _fetch_imap_emails:
- - Uses UID SEARCH / UID FETCH / UID STORE commands (not sequence numbers)
+ - Uses a plain SEARCH UNSEEN (not uid("search")) to get sequence numbers
+ - Resolves sequence numbers to UIDs via a lightweight FETCH (UID)
+ - Uses UID FETCH / UID STORE for all subsequent operations
- Batches stale-UID re-marking into a single STORE command
- Does NOT issue a per-message STORE for Seen (RFC822 sets it implicitly)
- Batches Deleted STORE into a single command when delete_after_forward=True
+ - Uses RFC 3501 parenthesised flag syntax, e.g. +FLAGS (\\Seen)
- Handles an empty UNSEEN result without error
- Handles individual message fetch failures gracefully
- Always attempts logout in the finally block
@@ -61,6 +64,18 @@ def _make_fetch_response(uid_str: str, email_bytes: bytes):
return _make_imap_response(result="OK", lines=[header, email_bytes, b")"])
+def _make_uid_list_response(seq_uid_pairs):
+ """
+ Simulate the response from fetch(seq_string, "(UID)").
+
+ seq_uid_pairs is a list of (seq_num_str, uid_str) tuples. Each entry
+ produces a line like b'1 (UID 42)' which the production code parses with
+ a regex.
+ """
+ lines = [f"{seq} (UID {uid})".encode() for seq, uid in seq_uid_pairs]
+ return _make_imap_response(result="OK", lines=lines)
+
+
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
@@ -83,13 +98,15 @@ class TestFetchImapEmailsUidCommands:
client.wait_hello_from_server = AsyncMock()
client.login = AsyncMock()
client.select = AsyncMock()
+ client.search = AsyncMock()
+ client.fetch = AsyncMock()
client.uid = AsyncMock()
client.logout = AsyncMock()
return client
- async def test_uses_uid_search_not_plain_search(self, processor, mock_imap):
- """SEARCH should be issued as UID SEARCH UNSEEN."""
- mock_imap.uid.return_value = _make_imap_response(result="OK", lines=[b""])
+ async def test_uses_plain_search_not_uid_search(self, processor, mock_imap):
+ """SEARCH must be issued as a plain SEARCH UNSEEN, not via uid()."""
+ mock_imap.search.return_value = _make_imap_response(result="OK", lines=[b""])
with patch(
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
@@ -97,13 +114,17 @@ class TestFetchImapEmailsUidCommands:
):
await processor._fetch_imap_emails(10, set())
- # uid("search", "UNSEEN") must be the first uid() call
- first_uid_call = mock_imap.uid.call_args_list[0]
- assert first_uid_call == call("search", "UNSEEN")
+ # search("UNSEEN") must be called
+ mock_imap.search.assert_awaited_once_with("UNSEEN")
+ # uid("search", ...) must NOT be called
+ search_uid_calls = [
+ c for c in mock_imap.uid.call_args_list if c.args[0] == "search"
+ ]
+ assert search_uid_calls == []
async def test_no_messages_returns_empty(self, processor, mock_imap):
"""Empty UNSEEN result should return empty lists without error."""
- mock_imap.uid.return_value = _make_imap_response(result="OK", lines=[b""])
+ mock_imap.search.return_value = _make_imap_response(result="OK", lines=[b""])
with patch(
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
@@ -117,13 +138,13 @@ class TestFetchImapEmailsUidCommands:
async def test_fetches_new_message_via_uid_fetch(self, processor, mock_imap):
"""New messages should be fetched with UID FETCH, not plain FETCH."""
raw_email = b"From: sender@example.com\r\nSubject: Test\r\n\r\nBody"
- uid = b"42"
+
+ mock_imap.search.return_value = _make_imap_response(result="OK", lines=[b"42"])
+ mock_imap.fetch.return_value = _make_uid_list_response([("42", "42")])
def uid_side_effect(command, *args):
- if command == "search":
- return _make_imap_response(result="OK", lines=[uid])
if command == "fetch":
- return _make_fetch_response(uid.decode(), raw_email)
+ return _make_fetch_response(args[0], raw_email)
return _make_imap_response()
mock_imap.uid = AsyncMock(side_effect=uid_side_effect)
@@ -145,11 +166,15 @@ class TestFetchImapEmailsUidCommands:
async def test_no_per_message_store_for_seen(self, processor, mock_imap):
"""RFC822 implicitly marks \\Seen; no extra STORE per message is needed."""
raw_email = b"From: a@b.com\r\n\r\nHello"
- uids = b"10 11 12"
+
+ mock_imap.search.return_value = _make_imap_response(
+ result="OK", lines=[b"10 11 12"]
+ )
+ mock_imap.fetch.return_value = _make_uid_list_response(
+ [("10", "10"), ("11", "11"), ("12", "12")]
+ )
def uid_side_effect(command, *args):
- if command == "search":
- return _make_imap_response(result="OK", lines=[uids])
if command == "fetch":
uid_str = args[0]
return _make_fetch_response(uid_str, raw_email)
@@ -175,9 +200,10 @@ class TestFetchImapEmailsUidCommands:
# Two messages: one stale (already in DB), one new
raw_email = b"From: x@y.com\r\n\r\nNew mail"
+ mock_imap.search.return_value = _make_imap_response(result="OK", lines=[b"5 6"])
+ mock_imap.fetch.return_value = _make_uid_list_response([("5", "5"), ("6", "6")])
+
def uid_side_effect(command, *args):
- if command == "search":
- return _make_imap_response(result="OK", lines=[b"5 6"])
if command == "fetch":
return _make_fetch_response(args[0], raw_email)
return _make_imap_response()
@@ -197,16 +223,20 @@ class TestFetchImapEmailsUidCommands:
assert len(emails) == 1
store_calls = [c for c in mock_imap.uid.call_args_list if c.args[0] == "store"]
- # Exactly one STORE for the stale UID
+ # Exactly one STORE for the stale UID with RFC 3501 parenthesised syntax
assert len(store_calls) == 1
- assert store_calls[0] == call("store", "5", "+FLAGS", "\\Seen")
+ assert store_calls[0] == call("store", "5", "+FLAGS", "(\\Seen)")
async def test_multiple_stale_uids_batched_together(self, processor, mock_imap):
"""Multiple stale UIDs should be sent as a comma-separated set."""
+ mock_imap.search.return_value = _make_imap_response(
+ result="OK", lines=[b"1 2 3 4"]
+ )
+ mock_imap.fetch.return_value = _make_uid_list_response(
+ [("1", "1"), ("2", "2"), ("3", "3"), ("4", "4")]
+ )
def uid_side_effect(command, *args):
- if command == "search":
- return _make_imap_response(result="OK", lines=[b"1 2 3 4"])
if command == "fetch":
return _make_fetch_response(args[0], b"email data")
return _make_imap_response()
@@ -225,6 +255,8 @@ class TestFetchImapEmailsUidCommands:
uid_set_arg = store_calls[0].args[1]
parts = set(uid_set_arg.split(","))
assert parts == {"1", "2"}
+ # RFC 3501 parenthesised flag syntax
+ assert store_calls[0].args[3] == "(\\Seen)"
async def test_delete_after_forward_uses_single_batch_store(self):
"""delete_after_forward=True must issue one UID STORE \\Deleted command."""
@@ -239,10 +271,14 @@ class TestFetchImapEmailsUidCommands:
mock_imap.select = AsyncMock()
mock_imap.expunge = AsyncMock()
mock_imap.logout = AsyncMock()
+ mock_imap.search = AsyncMock(
+ return_value=_make_imap_response(result="OK", lines=[b"7 8"])
+ )
+ mock_imap.fetch = AsyncMock(
+ return_value=_make_uid_list_response([("7", "7"), ("8", "8")])
+ )
def uid_side_effect(command, *args):
- if command == "search":
- return _make_imap_response(result="OK", lines=[b"7 8"])
if command == "fetch":
return _make_fetch_response(args[0], b"raw email")
return _make_imap_response()
@@ -264,7 +300,8 @@ class TestFetchImapEmailsUidCommands:
parts = set(uid_set_arg.split(","))
assert parts == {"7", "8"}
assert store_calls[0].args[2] == "+FLAGS"
- assert store_calls[0].args[3] == "\\Deleted"
+ # RFC 3501 parenthesised flag syntax
+ assert store_calls[0].args[3] == "(\\Deleted)"
# expunge must also be called
mock_imap.expunge.assert_awaited_once()
@@ -272,11 +309,16 @@ class TestFetchImapEmailsUidCommands:
"""A single message fetch error should be logged but not stop processing."""
raw_email = b"From: ok@example.com\r\n\r\nOK"
+ mock_imap.search.return_value = _make_imap_response(
+ result="OK", lines=[b"20 21"]
+ )
+ mock_imap.fetch.return_value = _make_uid_list_response(
+ [("20", "20"), ("21", "21")]
+ )
+
call_count = {"count": 0}
def uid_side_effect(command, *args):
- if command == "search":
- return _make_imap_response(result="OK", lines=[b"20 21"])
if command == "fetch":
call_count["count"] += 1
if call_count["count"] == 1:
@@ -298,7 +340,7 @@ class TestFetchImapEmailsUidCommands:
async def test_logout_called_even_on_connection_error(self, processor, mock_imap):
"""logout() must be attempted even when the connection drops mid-session."""
- mock_imap.uid = AsyncMock(side_effect=Exception("BYE server gone"))
+ mock_imap.search = AsyncMock(side_effect=Exception("BYE server gone"))
with patch(
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
@@ -313,7 +355,7 @@ class TestFetchImapEmailsUidCommands:
async def test_logout_failure_does_not_mask_error(self, processor, mock_imap):
"""If logout itself raises, the original MailFetchError should propagate."""
- mock_imap.uid = AsyncMock(side_effect=Exception("server gone"))
+ mock_imap.search = AsyncMock(side_effect=Exception("server gone"))
mock_imap.logout = AsyncMock(side_effect=Exception("logout also failed"))
with patch(
@@ -329,10 +371,15 @@ class TestFetchImapEmailsUidCommands:
"""Only max_count messages should be processed."""
raw_email = b"From: a@b.com\r\n\r\nHi"
+ mock_imap.search.return_value = _make_imap_response(
+ result="OK", lines=[b"1 2 3 4 5"]
+ )
+ # After max_count=3, only seq 1,2,3 are resolved to UIDs
+ mock_imap.fetch.return_value = _make_uid_list_response(
+ [("1", "1"), ("2", "2"), ("3", "3")]
+ )
+
def uid_side_effect(command, *args):
- if command == "search":
- # 5 messages available
- return _make_imap_response(result="OK", lines=[b"1 2 3 4 5"])
if command == "fetch":
return _make_fetch_response(args[0], raw_email)
return _make_imap_response()
@@ -347,6 +394,8 @@ class TestFetchImapEmailsUidCommands:
assert len(emails) == 3
assert new_uids == ["1", "2", "3"]
+ # fetch for UIDs should have been called with the first 3 seq numbers
+ mock_imap.fetch.assert_awaited_once_with("1,2,3", "(UID)")
async def test_plain_imap_uses_imap4_not_ssl(self):
"""Non-SSL IMAP accounts must use IMAP4, not IMAP4_SSL."""
@@ -360,6 +409,9 @@ class TestFetchImapEmailsUidCommands:
mock_imap.login = AsyncMock()
mock_imap.select = AsyncMock()
mock_imap.logout = AsyncMock()
+ mock_imap.search = AsyncMock(
+ return_value=_make_imap_response(result="OK", lines=[b""])
+ )
mock_imap.uid = AsyncMock(
return_value=_make_imap_response(result="OK", lines=[b""])
)
diff --git a/docs/TODO.md b/docs/TODO.md
index 2172337..f061880 100644
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -4,6 +4,12 @@ Comprehensive task breakdown for repository improvements and production readines
## ✅ Recently Completed
+- [x] **IMAP RFC 3501 flag syntax & aioimaplib UID SEARCH fix**: `_fetch_imap_emails`
+ now uses a plain `SEARCH UNSEEN` + `FETCH (UID)` to resolve sequence numbers to
+ stable UIDs (aioimaplib blocks `uid("search")`), and wraps all flag names in
+ parentheses (`+FLAGS (\Seen)`, `+FLAGS (\Deleted)`) as required by RFC 3501 to
+ prevent T-Online and other strict servers from dropping the connection with
+ "Too many invalid IMAP commands".
- [x] **CI pipeline fixes**: Added `FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true` to `ci.yml` (Node.js 20 deprecation), fixed Codecov `file:` → `files:` invalid input, replaced `
` with `` from `next/image` in `ProviderWizard.tsx` (ESLint no-img-element).
- [x] **IMAP reliability: switched to UID-based commands** — `_fetch_imap_emails` now uses `UID SEARCH`, `UID FETCH`, and `UID STORE` throughout. Sequence numbers are volatile (they shift on expunge), causing "Too many invalid IMAP commands" on strict servers (e.g. T-Online). UIDs are stable. The per-message `STORE +FLAGS \Seen` (redundant — RFC822 sets it implicitly) and per-message `STORE +FLAGS \Deleted` are replaced with single batch commands. Stale already-seen UIDs are re-marked `\Seen` in one command. Logout is now in a `finally` block so a mid-session `BYE` is handled gracefully.
- [x] Fixed timezone display bug in Mailbox Activity and Admin Logs pages: ISO timestamps without a `Z` suffix were parsed as local time by JavaScript, shifting "Xm ago" / "Xh ago" displays and absolute dates by the client's UTC offset.