Merge pull request #117 from christianlouis/copilot/fix-imap-protocol-bugs

Fix IMAP UID SEARCH crash and missing RFC 3501 flag parentheses
This commit is contained in:
Christian Krakau-Louis
2026-03-28 22:02:12 +01:00
committed by GitHub
4 changed files with 143 additions and 41 deletions
+16
View File
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
<!-- version list --> <!-- version list -->
## [Unreleased]
### Fixed
- **IMAP: fix T-Online BYE "Too many invalid IMAP commands"** — `UID STORE` flag
arguments now use RFC 3501required 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) ## v0.4.2 (2026-03-28)
### Bug Fixes ### Bug Fixes
+37 -9
View File
@@ -5,6 +5,7 @@ Supports both POP3 and IMAP protocols with secure connections.
import asyncio import asyncio
import poplib import poplib
import re
import smtplib import smtplib
import ssl import ssl
from email import parser from email import parser
@@ -271,13 +272,18 @@ class MailProcessor:
servers to report "Too many invalid IMAP commands" (as seen with servers to report "Too many invalid IMAP commands" (as seen with
t-online). This implementation: 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 * Re-marks already-processed UIDs as \\Seen in a single batch command
instead of one STORE per message. instead of one STORE per message.
* Relies on the implicit \\Seen flag set by RFC822 FETCH so no * Relies on the implicit \\Seen flag set by RFC822 FETCH so no
additional per-message STORE is needed for newly fetched mail. additional per-message STORE is needed for newly fetched mail.
* Batches the \\Deleted STORE into a single command when * Batches the \\Deleted STORE into a single command when
``delete_after_forward`` is enabled. ``delete_after_forward`` is enabled.
* Uses RFC 3501compliant parenthesised flag syntax, e.g.
``+FLAGS (\\Seen)``, required by strict servers such as T-Online.
""" """
emails: List[bytes] = [] emails: List[bytes] = []
new_uids: List[str] = [] new_uids: List[str] = []
@@ -298,8 +304,10 @@ class MailProcessor:
await imap_client.login(self.account.username, self.password) await imap_client.login(self.account.username, self.password)
await imap_client.select("INBOX") await imap_client.select("INBOX")
# UID SEARCH returns stable UIDs instead of volatile sequence numbers. # Step 1: Standard sequence-based SEARCH for UNSEEN messages.
response = await imap_client.uid("search", "UNSEEN") # 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 ( if (
response.result != "OK" response.result != "OK"
or not response.lines or not response.lines
@@ -309,18 +317,36 @@ class MailProcessor:
# logout is handled by the finally block below # logout is handled by the finally block below
return emails, new_uids 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. # 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( logger.info(
f"Found {len(all_unseen_uids)} unread messages for account " f"Found {len(all_unseen_uids)} unread messages for account "
f"{self.account.id}" f"{self.account.id}"
) )
# Split into stale UIDs (already in our DB but still UNSEEN on the # Step 4: Split into stale UIDs (already in our DB but still
# server — e.g. a previous STORE failed) and genuinely new UIDs. # UNSEEN on the server — e.g. a previous STORE failed) and
# genuinely new UIDs.
stale_uid_bytes: List[bytes] = [] stale_uid_bytes: List[bytes] = []
uids_to_fetch: List[bytes] = [] uids_to_fetch: List[bytes] = []
for uid in all_unseen_uids: 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 # Re-mark stale UIDs as \Seen in a single batch STORE command so
# they stop appearing in UNSEEN searches without consuming one # they stop appearing in UNSEEN searches without consuming one
# round-trip per message. # round-trip per message.
# RFC 3501 requires parentheses around flag names: +FLAGS (\Seen).
if stale_uid_bytes: if stale_uid_bytes:
uid_set = b",".join(stale_uid_bytes).decode() uid_set = b",".join(stale_uid_bytes).decode()
try: try:
await imap_client.uid("store", uid_set, "+FLAGS", "\\Seen") await imap_client.uid("store", uid_set, "+FLAGS", "(\\Seen)")
except Exception as e: except Exception as e:
logger.warning( logger.warning(
f"Failed to re-mark stale messages as \\Seen for account " 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 # Batch-mark fetched messages for deletion if configured (one STORE
# command covers all UIDs instead of N individual commands). # 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: if self.account.delete_after_forward and fetched_uids:
uid_set = b",".join(fetched_uids).decode() uid_set = b",".join(fetched_uids).decode()
try: try:
await imap_client.uid("store", uid_set, "+FLAGS", "\\Deleted") await imap_client.uid("store", uid_set, "+FLAGS", "(\\Deleted)")
await imap_client.expunge() await imap_client.expunge()
except Exception as e: except Exception as e:
logger.warning( logger.warning(
+84 -32
View File
@@ -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: 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 - Batches stale-UID re-marking into a single STORE command
- Does NOT issue a per-message STORE for Seen (RFC822 sets it implicitly) - 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 - 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 an empty UNSEEN result without error
- Handles individual message fetch failures gracefully - Handles individual message fetch failures gracefully
- Always attempts logout in the finally block - 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")"]) 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 # Tests
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -83,13 +98,15 @@ class TestFetchImapEmailsUidCommands:
client.wait_hello_from_server = AsyncMock() client.wait_hello_from_server = AsyncMock()
client.login = AsyncMock() client.login = AsyncMock()
client.select = AsyncMock() client.select = AsyncMock()
client.search = AsyncMock()
client.fetch = AsyncMock()
client.uid = AsyncMock() client.uid = AsyncMock()
client.logout = AsyncMock() client.logout = AsyncMock()
return client return client
async def test_uses_uid_search_not_plain_search(self, processor, mock_imap): async def test_uses_plain_search_not_uid_search(self, processor, mock_imap):
"""SEARCH should be issued as UID SEARCH UNSEEN.""" """SEARCH must be issued as a plain SEARCH UNSEEN, not via uid()."""
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( with patch(
"app.services.mail_processor.aioimaplib.IMAP4_SSL", "app.services.mail_processor.aioimaplib.IMAP4_SSL",
@@ -97,13 +114,17 @@ class TestFetchImapEmailsUidCommands:
): ):
await processor._fetch_imap_emails(10, set()) await processor._fetch_imap_emails(10, set())
# uid("search", "UNSEEN") must be the first uid() call # search("UNSEEN") must be called
first_uid_call = mock_imap.uid.call_args_list[0] mock_imap.search.assert_awaited_once_with("UNSEEN")
assert first_uid_call == call("search", "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): async def test_no_messages_returns_empty(self, processor, mock_imap):
"""Empty UNSEEN result should return empty lists without error.""" """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( with patch(
"app.services.mail_processor.aioimaplib.IMAP4_SSL", "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): async def test_fetches_new_message_via_uid_fetch(self, processor, mock_imap):
"""New messages should be fetched with UID FETCH, not plain FETCH.""" """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" 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): def uid_side_effect(command, *args):
if command == "search":
return _make_imap_response(result="OK", lines=[uid])
if command == "fetch": if command == "fetch":
return _make_fetch_response(uid.decode(), raw_email) return _make_fetch_response(args[0], raw_email)
return _make_imap_response() return _make_imap_response()
mock_imap.uid = AsyncMock(side_effect=uid_side_effect) 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): async def test_no_per_message_store_for_seen(self, processor, mock_imap):
"""RFC822 implicitly marks \\Seen; no extra STORE per message is needed.""" """RFC822 implicitly marks \\Seen; no extra STORE per message is needed."""
raw_email = b"From: a@b.com\r\n\r\nHello" 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): def uid_side_effect(command, *args):
if command == "search":
return _make_imap_response(result="OK", lines=[uids])
if command == "fetch": if command == "fetch":
uid_str = args[0] uid_str = args[0]
return _make_fetch_response(uid_str, raw_email) return _make_fetch_response(uid_str, raw_email)
@@ -175,9 +200,10 @@ class TestFetchImapEmailsUidCommands:
# Two messages: one stale (already in DB), one new # Two messages: one stale (already in DB), one new
raw_email = b"From: x@y.com\r\n\r\nNew mail" 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): def uid_side_effect(command, *args):
if command == "search":
return _make_imap_response(result="OK", lines=[b"5 6"])
if command == "fetch": if command == "fetch":
return _make_fetch_response(args[0], raw_email) return _make_fetch_response(args[0], raw_email)
return _make_imap_response() return _make_imap_response()
@@ -197,16 +223,20 @@ class TestFetchImapEmailsUidCommands:
assert len(emails) == 1 assert len(emails) == 1
store_calls = [c for c in mock_imap.uid.call_args_list if c.args[0] == "store"] 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 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): async def test_multiple_stale_uids_batched_together(self, processor, mock_imap):
"""Multiple stale UIDs should be sent as a comma-separated set.""" """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): def uid_side_effect(command, *args):
if command == "search":
return _make_imap_response(result="OK", lines=[b"1 2 3 4"])
if command == "fetch": if command == "fetch":
return _make_fetch_response(args[0], b"email data") return _make_fetch_response(args[0], b"email data")
return _make_imap_response() return _make_imap_response()
@@ -225,6 +255,8 @@ class TestFetchImapEmailsUidCommands:
uid_set_arg = store_calls[0].args[1] uid_set_arg = store_calls[0].args[1]
parts = set(uid_set_arg.split(",")) parts = set(uid_set_arg.split(","))
assert parts == {"1", "2"} 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): async def test_delete_after_forward_uses_single_batch_store(self):
"""delete_after_forward=True must issue one UID STORE \\Deleted command.""" """delete_after_forward=True must issue one UID STORE \\Deleted command."""
@@ -239,10 +271,14 @@ class TestFetchImapEmailsUidCommands:
mock_imap.select = AsyncMock() mock_imap.select = AsyncMock()
mock_imap.expunge = AsyncMock() mock_imap.expunge = AsyncMock()
mock_imap.logout = 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): def uid_side_effect(command, *args):
if command == "search":
return _make_imap_response(result="OK", lines=[b"7 8"])
if command == "fetch": if command == "fetch":
return _make_fetch_response(args[0], b"raw email") return _make_fetch_response(args[0], b"raw email")
return _make_imap_response() return _make_imap_response()
@@ -264,7 +300,8 @@ class TestFetchImapEmailsUidCommands:
parts = set(uid_set_arg.split(",")) parts = set(uid_set_arg.split(","))
assert parts == {"7", "8"} assert parts == {"7", "8"}
assert store_calls[0].args[2] == "+FLAGS" 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 # expunge must also be called
mock_imap.expunge.assert_awaited_once() mock_imap.expunge.assert_awaited_once()
@@ -272,11 +309,16 @@ class TestFetchImapEmailsUidCommands:
"""A single message fetch error should be logged but not stop processing.""" """A single message fetch error should be logged but not stop processing."""
raw_email = b"From: ok@example.com\r\n\r\nOK" 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} call_count = {"count": 0}
def uid_side_effect(command, *args): def uid_side_effect(command, *args):
if command == "search":
return _make_imap_response(result="OK", lines=[b"20 21"])
if command == "fetch": if command == "fetch":
call_count["count"] += 1 call_count["count"] += 1
if 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): async def test_logout_called_even_on_connection_error(self, processor, mock_imap):
"""logout() must be attempted even when the connection drops mid-session.""" """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( with patch(
"app.services.mail_processor.aioimaplib.IMAP4_SSL", "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): async def test_logout_failure_does_not_mask_error(self, processor, mock_imap):
"""If logout itself raises, the original MailFetchError should propagate.""" """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")) mock_imap.logout = AsyncMock(side_effect=Exception("logout also failed"))
with patch( with patch(
@@ -329,10 +371,15 @@ class TestFetchImapEmailsUidCommands:
"""Only max_count messages should be processed.""" """Only max_count messages should be processed."""
raw_email = b"From: a@b.com\r\n\r\nHi" 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): 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": if command == "fetch":
return _make_fetch_response(args[0], raw_email) return _make_fetch_response(args[0], raw_email)
return _make_imap_response() return _make_imap_response()
@@ -347,6 +394,8 @@ class TestFetchImapEmailsUidCommands:
assert len(emails) == 3 assert len(emails) == 3
assert new_uids == ["1", "2", "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): async def test_plain_imap_uses_imap4_not_ssl(self):
"""Non-SSL IMAP accounts must use IMAP4, not IMAP4_SSL.""" """Non-SSL IMAP accounts must use IMAP4, not IMAP4_SSL."""
@@ -360,6 +409,9 @@ class TestFetchImapEmailsUidCommands:
mock_imap.login = AsyncMock() mock_imap.login = AsyncMock()
mock_imap.select = AsyncMock() mock_imap.select = AsyncMock()
mock_imap.logout = AsyncMock() mock_imap.logout = AsyncMock()
mock_imap.search = AsyncMock(
return_value=_make_imap_response(result="OK", lines=[b""])
)
mock_imap.uid = AsyncMock( mock_imap.uid = AsyncMock(
return_value=_make_imap_response(result="OK", lines=[b""]) return_value=_make_imap_response(result="OK", lines=[b""])
) )
+6
View File
@@ -4,6 +4,12 @@ Comprehensive task breakdown for repository improvements and production readines
## ✅ Recently Completed ## ✅ 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 `<img>` with `<Image />` from `next/image` in `ProviderWizard.tsx` (ESLint no-img-element). - [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 `<img>` with `<Image />` 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] **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. - [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.