Use stable IMAP UIDs for email actions

This commit is contained in:
pewdiepie-archdaemon
2026-06-01 13:08:42 +09:00
parent 791939014c
commit 32e7cec362

View File

@@ -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]