Persist watcher UID cursor across reconnects

The IDLE watcher previously re-baselined last_seen_uid on every
reconnect (e.g. after iCloud's unsolicited FETCH FLAGS responses
during IDLE caused idle_check to raise). Mail that arrived during the
short disconnect window had a UID below the new baseline and was
silently dropped, so Poke never saw it.

Hoist last_seen_uid and last_uidvalidity to the outer reconnect loop
so the cursor survives transient disconnects. Reset only on first run
or when UIDVALIDITY changes (which means the server has reassigned
UIDs and the previous cursor is meaningless).

Apply the same fix to _poll_folder via a shared mutable cursor dict so
non-IDLE servers also retain their cursor across poll-error reconnects.

Fixes #5
This commit is contained in:
Kacper Kwapisz
2026-04-29 09:30:15 +02:00
parent 68c40055e7
commit c88ea40459
+68 -7
View File
@@ -503,16 +503,26 @@ async def watch_folder(
backoff = 5 backoff = 5
max_backoff = 60 max_backoff = 60
# Hoisted across reconnects so a transient disconnect (e.g. iCloud's
# unsolicited FETCH FLAGS during IDLE) doesn't re-baseline the cursor and
# silently drop mail that arrived during the reconnect window. Reset only
# on first run or when the server's UIDVALIDITY changes (which means UIDs
# have been reassigned and the previous cursor is meaningless).
last_seen_uid: Optional[int] = None
last_uidvalidity: Optional[int] = None
while not stop_event.is_set(): while not stop_event.is_set():
client = None client = None
try: try:
client = await asyncio.to_thread(get_imap_client, account) client = await asyncio.to_thread(get_imap_client, account)
await asyncio.to_thread( select_info = await asyncio.to_thread(
client.select_folder, client.select_folder,
folder, folder,
readonly=not account.get("mark_as_read", False), readonly=not account.get("mark_as_read", False),
) )
uidvalidity = select_info.get(b"UIDVALIDITY") if select_info else None
# Check IDLE support # Check IDLE support
if not client.has_capability("IDLE"): if not client.has_capability("IDLE"):
logger.warning( logger.warning(
@@ -520,13 +530,41 @@ async def watch_folder(
account["id"], account["id"],
folder, folder,
) )
# Mutable cursor state — _poll_folder updates it so reconnects
# don't re-baseline.
cursor = {
"last_seen_uid": last_seen_uid,
"last_uidvalidity": last_uidvalidity,
}
try:
await _poll_folder( await _poll_folder(
client, account, folder, webhook_url, api_key, stop_event client,
account,
folder,
webhook_url,
api_key,
stop_event,
cursor,
) )
finally:
last_seen_uid = cursor["last_seen_uid"]
last_uidvalidity = cursor["last_uidvalidity"]
# _poll_folder only returns when stop_event is set; on errors it
# raises and we fall through to the outer reconnect path.
return return
if last_seen_uid is None or uidvalidity != last_uidvalidity:
if last_uidvalidity is not None and uidvalidity != last_uidvalidity:
logger.warning(
"[%s/%s] UIDVALIDITY changed (%s%s) — resetting cursor",
account["id"],
folder,
last_uidvalidity,
uidvalidity,
)
mailbox_uids = await asyncio.to_thread(client.search, ["ALL"]) mailbox_uids = await asyncio.to_thread(client.search, ["ALL"])
last_seen_uid = mailbox_uids[-1] if mailbox_uids else 0 last_seen_uid = mailbox_uids[-1] if mailbox_uids else 0
last_uidvalidity = uidvalidity
logger.info( logger.info(
"[%s/%s] Watching for new emails via IDLE from UID %d (%d existing message(s), mark_as_read=%s)", "[%s/%s] Watching for new emails via IDLE from UID %d (%d existing message(s), mark_as_read=%s)",
account["id"], account["id"],
@@ -535,6 +573,14 @@ async def watch_folder(
len(mailbox_uids), len(mailbox_uids),
account.get("mark_as_read", False), account.get("mark_as_read", False),
) )
else:
logger.info(
"[%s/%s] Resumed IDLE watch from UID %d (mark_as_read=%s)",
account["id"],
folder,
last_seen_uid,
account.get("mark_as_read", False),
)
backoff = 5 backoff = 5
while not stop_event.is_set(): while not stop_event.is_set():
@@ -611,20 +657,35 @@ async def _poll_folder(
webhook_url: str, webhook_url: str,
api_key: str, api_key: str,
stop_event: asyncio.Event, stop_event: asyncio.Event,
cursor: dict,
): ):
"""Fallback polling for servers without IDLE support. Checks every 60 seconds.""" """Fallback polling for servers without IDLE support. Checks every 60 seconds.
`cursor` is a mutable dict with keys 'last_seen_uid' and 'last_uidvalidity'
shared with watch_folder so cursor state survives reconnects.
"""
if cursor.get("last_seen_uid") is None:
mailbox_uids = await asyncio.to_thread(client.search, ["ALL"]) mailbox_uids = await asyncio.to_thread(client.search, ["ALL"])
last_seen_uid = mailbox_uids[-1] if mailbox_uids else 0 cursor["last_seen_uid"] = mailbox_uids[-1] if mailbox_uids else 0
logger.info( logger.info(
"[%s/%s] Polling for new emails every 60s from UID %d (%d existing message(s), mark_as_read=%s)", "[%s/%s] Polling for new emails every 60s from UID %d (%d existing message(s), mark_as_read=%s)",
account["id"], account["id"],
folder, folder,
last_seen_uid, cursor["last_seen_uid"],
len(mailbox_uids), len(mailbox_uids),
account.get("mark_as_read", False), account.get("mark_as_read", False),
) )
else:
logger.info(
"[%s/%s] Resumed polling from UID %d (mark_as_read=%s)",
account["id"],
folder,
cursor["last_seen_uid"],
account.get("mark_as_read", False),
)
while not stop_event.is_set(): while not stop_event.is_set():
try: try:
last_seen_uid = cursor["last_seen_uid"]
mailbox_uids = await asyncio.to_thread(client.search, ["ALL"]) mailbox_uids = await asyncio.to_thread(client.search, ["ALL"])
new_uids = [uid for uid in mailbox_uids if uid > last_seen_uid] new_uids = [uid for uid in mailbox_uids if uid > last_seen_uid]
if new_uids: if new_uids:
@@ -639,12 +700,12 @@ async def _poll_folder(
await _forward_uid_batch( await _forward_uid_batch(
client, account, folder, webhook_url, api_key, new_uids client, account, folder, webhook_url, api_key, new_uids
) )
last_seen_uid = new_uids[-1] cursor["last_seen_uid"] = new_uids[-1]
logger.debug( logger.debug(
"[%s/%s] Advanced UID cursor to %d", "[%s/%s] Advanced UID cursor to %d",
account["id"], account["id"],
folder, folder,
last_seen_uid, cursor["last_seen_uid"],
) )
else: else:
logger.debug( logger.debug(