From 32e7cec3628c75500d4f56b652c75b251a124d14 Mon Sep 17 00:00:00 2001 From: pewdiepie-archdaemon Date: Mon, 1 Jun 2026 13:08:42 +0900 Subject: [PATCH] Use stable IMAP UIDs for email actions --- routes/email_routes.py | 94 +++++++++++++++++++++++++----------------- 1 file changed, 57 insertions(+), 37 deletions(-) diff --git a/routes/email_routes.py b/routes/email_routes.py index f452651..6ec887e 100644 --- a/routes/email_routes.py +++ b/routes/email_routes.py @@ -222,6 +222,19 @@ def _uid_exists(conn, uid: str) -> bool: return False +def _imap_uid_search(conn, criteria: str): + return conn.uid("SEARCH", None, criteria) + + +def _imap_uid_fetch(conn, uid_set: str | bytes, query: str): + return conn.uid("FETCH", _uid_bytes(uid_set), query) + + +def _uid_from_fetch_meta(meta_b: bytes) -> str: + m = re.search(rb"\bUID\s+(\d+)\b", meta_b) + return m.group(1).decode() if m else "" + + def _smtp_ready(cfg: dict) -> bool: return bool(cfg.get("smtp_host") and cfg.get("smtp_user") and cfg.get("smtp_password")) @@ -587,21 +600,21 @@ def setup_email_routes(): from_clause = f' FROM "{_safe}"' if filter_ == "unread": - status, data = conn.search(None, f"(UNSEEN{from_clause})") + status, data = _imap_uid_search(conn, f"(UNSEEN{from_clause})") elif filter_ == "favorites": # Flagged/favorited emails (the star toggle sets the \Flagged flag). - status, data = conn.search(None, f"(FLAGGED{from_clause})") + status, data = _imap_uid_search(conn, f"(FLAGGED{from_clause})") elif filter_ == "unanswered": - status, data = conn.search(None, f"(UNSEEN UNANSWERED{from_clause})") + status, data = _imap_uid_search(conn, f"(UNSEEN UNANSWERED{from_clause})") elif filter_ == "undone": # All emails NOT marked as answered/done (read or unread). - status, data = conn.search(None, f"(UNANSWERED{from_clause})") + status, data = _imap_uid_search(conn, f"(UNANSWERED{from_clause})") elif filter_ == "reminders": # Prefer the Odysseus marker header, but include the subject # fallback too. The fallback uses a distinct Odysseus prefix # so ordinary emails containing "Reminder" don't get mixed in. - status, data = conn.search( - None, + status, data = _imap_uid_search( + conn, f'(OR HEADER X-Odysseus-Kind "reminder" SUBJECT "Reminder (Odysseus):"{from_clause})', ) elif filter_ == "pending_30d": @@ -609,13 +622,13 @@ def setup_email_routes(): # within the last 30 days. SINCE takes a DD-Mon-YYYY date. from datetime import datetime as _dt, timedelta as _td _since = (_dt.utcnow() - _td(days=30)).strftime("%d-%b-%Y") - status, data = conn.search(None, f'(UNANSWERED SINCE "{_since}"{from_clause})') + status, data = _imap_uid_search(conn, f'(UNANSWERED SINCE "{_since}"{from_clause})') elif filter_ == "stale_30d": # "What's been sitting too long" — UNANSWERED + delivered # MORE than 30 days ago. BEFORE excludes the cutoff date itself. from datetime import datetime as _dt, timedelta as _td _before = (_dt.utcnow() - _td(days=30)).strftime("%d-%b-%Y") - status, data = conn.search(None, f'(UNANSWERED BEFORE "{_before}"{from_clause})') + status, data = _imap_uid_search(conn, f'(UNANSWERED BEFORE "{_before}"{from_clause})') elif filter_ and filter_.startswith("tag:"): # Tag-based filter — resolve UIDs from email_tags first, then # ask IMAP for those messages by Message-ID. `tag:spam` reads @@ -675,31 +688,30 @@ def setup_email_routes(): if not _tag_message_ids and not _tag_seq_fallback: conn.logout() return {"emails": [], "total": 0, "folder": folder} - # email_tags.uid historically stores the IMAP sequence number, - # not UID. Resolve by stable Message-ID so tag filters still - # work after sequence numbers shift. Fall back to old seq rows - # only when a row has no Message-ID. + # Prefer stable Message-ID rows. Older tag rows may have only + # numeric ids; those were sequence numbers historically, but + # may be real UIDs for newer rows. Treat them as UIDs only. def _imap_search_quote(value: str) -> str: return '"' + str(value or "").replace("\\", "\\\\").replace('"', '\\"') + '"' - _seqs = set() + _uids = set() for _mid in dict.fromkeys(_tag_message_ids): if not _mid: continue - st_m, data_m = conn.search(None, f'(HEADER Message-ID {_imap_search_quote(_mid)}{from_clause})') + st_m, data_m = _imap_uid_search(conn, f'(HEADER Message-ID {_imap_search_quote(_mid)}{from_clause})') if st_m == "OK" and data_m and data_m[0]: - _seqs.update(data_m[0].split()) - for _seq in _tag_seq_fallback: - if _seq: - _seqs.add(str(_seq).encode()) - if not _seqs: + _uids.update(data_m[0].split()) + for _uid in _tag_seq_fallback: + if _uid: + _uids.add(str(_uid).encode()) + if not _uids: conn.logout() return {"emails": [], "total": 0, "folder": folder} - data = [b" ".join(sorted(_seqs, key=lambda x: int(x) if str(x, "ascii", "ignore").isdigit() else 0))] + data = [b" ".join(sorted(_uids, key=lambda x: int(x) if str(x, "ascii", "ignore").isdigit() else 0))] status = "OK" elif from_clause: - status, data = conn.search(None, f"({from_clause.strip()})") + status, data = _imap_uid_search(conn, f"({from_clause.strip()})") else: - status, data = conn.search(None, "ALL") + status, data = _imap_uid_search(conn, "ALL") if status != "OK" or not data[0]: conn.logout() @@ -753,7 +765,7 @@ def setup_email_routes(): if uid_list: fetch_set = b",".join(uid_list) try: - status, msg_data = conn.fetch(fetch_set, "(FLAGS RFC822.HEADER RFC822.SIZE)") + status, msg_data = _imap_uid_fetch(conn, fetch_set, "(UID FLAGS RFC822.HEADER RFC822.SIZE)") except Exception as e: logger.warning(f"Batch fetch failed, falling back to per-UID: {e}") status, msg_data = "NO", [] @@ -815,8 +827,9 @@ def setup_email_routes(): for meta_b, raw_header in grouped: try: meta = meta_b.decode(errors="replace") - seq_m = seq_re.match(meta_b) - seq_num = seq_m.group(1).decode() if seq_m else "" + uid_num = _uid_from_fetch_meta(meta_b) + if not uid_num: + continue flag_m = re.search(r'FLAGS \(([^)]*)\)', meta) flags = flag_m.group(1) if flag_m else "" size_m = re.search(r'RFC822\.SIZE (\d+)', meta) @@ -848,9 +861,9 @@ def setup_email_routes(): is_flagged = "\\Flagged" in flags ct = msg.get("Content-Type", "") has_attachments = "multipart/mixed" in ct.lower() or "multipart/related" in ct.lower() - tag_entry = _tag_by_message_id.get(message_id.strip()) or _tag_by_uid.get(seq_num, {}) + tag_entry = _tag_by_message_id.get(message_id.strip()) or _tag_by_uid.get(uid_num, {}) emails.append({ - "uid": seq_num, + "uid": uid_num, "message_id": message_id.strip(), "subject": subject, "from_name": sender_name or sender_addr, @@ -1028,7 +1041,7 @@ def setup_email_routes(): q_escaped = q.replace('\\', '\\\\').replace('"', '\\"') search_cmd = f'(OR FROM "{q_escaped}" TEXT "{q_escaped}")' - status, data = conn.search(None, search_cmd) + status, data = _imap_uid_search(conn, search_cmd) if status != "OK" or not data[0]: return {"emails": [], "total": 0, "query": q} @@ -1039,7 +1052,7 @@ def setup_email_routes(): emails = [] for uid in uid_list: try: - status, msg_data = conn.fetch(uid, "(FLAGS RFC822.HEADER)") + status, msg_data = _imap_uid_fetch(conn, uid, "(UID FLAGS RFC822.HEADER)") if status != "OK": continue raw_header = None @@ -1071,8 +1084,15 @@ def setup_email_routes(): ct = msg.get("Content-Type", "") has_attachments = "multipart/mixed" in ct.lower() or "multipart/related" in ct.lower() + stable_uid = "" + for part in msg_data: + if isinstance(part, tuple): + meta_b = part[0] if isinstance(part[0], bytes) else str(part[0]).encode() + stable_uid = _uid_from_fetch_meta(meta_b) or stable_uid + if not stable_uid: + continue emails.append({ - "uid": uid.decode(), + "uid": stable_uid, "message_id": message_id.strip(), "subject": subject, "from_name": sender_name or sender_addr, @@ -1113,7 +1133,7 @@ def setup_email_routes(): with _imap(account_id, owner=owner) as conn: conn.select(_q(folder), readonly=True) _t_select = _t.monotonic() - _t0 - status, msg_data = conn.fetch(uid.encode(), "(BODY.PEEK[])") + status, msg_data = _imap_uid_fetch(conn, uid, "(BODY.PEEK[])") _t_fetch = _t.monotonic() - _t0 if status != "OK": return {"error": f"Email UID {uid} not found"} @@ -1141,7 +1161,7 @@ def setup_email_routes(): try: with _imap(account_id, owner=owner) as conn2: conn2.select(_q(folder)) - conn2.store(uid.encode(), "+FLAGS", "\\Seen") + conn2.uid("STORE", _uid_bytes(uid), "+FLAGS", "\\Seen") except Exception: pass _t_total = _t.monotonic() - _t0 @@ -1267,7 +1287,7 @@ def setup_email_routes(): try: with _imap(account_id, owner=owner) as conn: conn.select(_q(folder), readonly=True) - status, msg_data = conn.fetch(uid.encode(), "(RFC822)") + status, msg_data = _imap_uid_fetch(conn, uid, "(RFC822)") if status != "OK": return {"attachments": [], "error": "Email not found"} raw = msg_data[0][1] @@ -1284,7 +1304,7 @@ def setup_email_routes(): try: with _imap(account_id, owner=owner) as conn: conn.select(_q(folder), readonly=True) - status, msg_data = conn.fetch(uid.encode(), "(RFC822)") + status, msg_data = _imap_uid_fetch(conn, uid, "(RFC822)") if status != "OK": return {"error": "Email not found"} raw = msg_data[0][1] @@ -1320,7 +1340,7 @@ def setup_email_routes(): try: with _imap(account_id, owner=owner) as conn: conn.select(_q(folder), readonly=True) - status, msg_data = conn.fetch(uid.encode(), "(RFC822)") + status, msg_data = _imap_uid_fetch(conn, uid, "(RFC822)") if status != "OK": return {"error": "Email not found"} raw = msg_data[0][1] @@ -1528,7 +1548,7 @@ def setup_email_routes(): try: with _imap(account_id, owner=owner) as conn: conn.select(_q(folder), readonly=True) - status, msg_data = conn.fetch(uid.encode(), "(RFC822)") + status, msg_data = _imap_uid_fetch(conn, uid, "(RFC822)") if status != "OK": return {"error": "Email not found"} raw = msg_data[0][1] @@ -2340,7 +2360,7 @@ def setup_email_routes(): def _fetch_atts(): with _imap(account_id, owner=owner) as conn: conn.select(_q(folder), readonly=True) - status, msg_data = conn.fetch(str(uid).encode(), "(BODY.PEEK[])") + status, msg_data = _imap_uid_fetch(conn, str(uid), "(BODY.PEEK[])") if status != "OK" or not msg_data or not msg_data[0]: return "" raw = msg_data[0][1]