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:
+68
-7
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user