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