Use stable IMAP UIDs for email actions
This commit is contained in:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user