Merge verified Odysseus fixes

This commit is contained in:
pewdiepie-archdaemon
2026-07-23 14:49:02 +00:00
parent 4c9a8ca115
commit d8a2059df8
117 changed files with 15693 additions and 3191 deletions
+695 -30
View File
@@ -26,6 +26,7 @@ import re
import html
import io
import zipfile
from urllib.parse import parse_qs, unquote, urlparse
from html.parser import HTMLParser as _HTMLParser
import logging
import uuid
@@ -89,6 +90,86 @@ def _google_oauth_imap_transport_allowed(port: int, starttls: bool) -> bool:
def _google_oauth_smtp_transport_allowed(port: int, security: str) -> bool:
return (port == 465 and security == "ssl") or (port == 587 and security == "starttls")
def _email_style_key(account_id: str | None) -> str:
return str(account_id or "").strip()
def _get_email_writing_style_for_account(settings: dict, account_id: str | None = None) -> str:
key = _email_style_key(account_id)
by_account = settings.get("email_writing_styles_by_account") or {}
if key and isinstance(by_account, dict):
val = by_account.get(key)
if isinstance(val, str) and val.strip():
return val
return str(settings.get("email_writing_style") or "")
def _set_email_writing_style_for_account(settings: dict, style: str, account_id: str | None = None) -> None:
key = _email_style_key(account_id)
style = str(style or "")
if key:
by_account = settings.get("email_writing_styles_by_account")
if not isinstance(by_account, dict):
by_account = {}
by_account[key] = style
settings["email_writing_styles_by_account"] = by_account
return
settings["email_writing_style"] = style
_AUTO_REPLY_BOOL_KEYS = {
"email_auto_reply",
"email_auto_reply_exclude_automated",
"email_auto_reply_pause_notifications",
}
_AUTO_REPLY_TEXT_KEYS = {
"email_auto_reply_start",
"email_auto_reply_end",
"email_auto_reply_subject",
"email_auto_reply_message",
"email_auto_reply_cooldown",
"email_auto_reply_scope",
"email_auto_reply_account_id",
"email_auto_reply_enabled_at",
}
_AUTO_REPLY_KEYS = _AUTO_REPLY_BOOL_KEYS | _AUTO_REPLY_TEXT_KEYS
def _get_auto_reply_settings_for_account(settings: dict, account_id: str | None = None) -> dict:
key = _email_style_key(account_id)
out = {k: settings.get(k) for k in _AUTO_REPLY_KEYS if k in settings}
by_account = settings.get("email_auto_reply_by_account") or {}
if key and isinstance(by_account, dict) and isinstance(by_account.get(key), dict):
out.update({k: v for k, v in by_account[key].items() if k in _AUTO_REPLY_KEYS})
return out
def _set_auto_reply_settings_for_account(settings: dict, data: dict, account_id: str | None = None) -> tuple[bool, bool]:
key = _email_style_key(account_id)
target = _get_auto_reply_settings_for_account(settings, account_id) if key else settings
prev_auto_reply = bool(target.get("email_auto_reply", False))
for name in _AUTO_REPLY_BOOL_KEYS:
if name in data:
target[name] = bool(data[name])
for name in _AUTO_REPLY_TEXT_KEYS - {"email_auto_reply_enabled_at"}:
if name in data:
target[name] = str(data.get(name) or "").strip()
if "email_auto_reply" in data:
next_auto_reply = bool(target.get("email_auto_reply", False))
if next_auto_reply and (not prev_auto_reply or not str(target.get("email_auto_reply_enabled_at") or "").strip()):
target["email_auto_reply_enabled_at"] = datetime.utcnow().isoformat()
elif not next_auto_reply:
target.pop("email_auto_reply_enabled_at", None)
if key:
by_account = settings.get("email_auto_reply_by_account")
if not isinstance(by_account, dict):
by_account = {}
by_account[key] = {k: target.get(k) for k in _AUTO_REPLY_KEYS if k in target}
by_account[key]["email_auto_reply_account_id"] = key
by_account[key]["email_auto_reply_scope"] = "account"
settings["email_auto_reply_by_account"] = by_account
return prev_auto_reply, bool(target.get("email_auto_reply", False))
def _safe_attachment_zip_name(name: str, fallback: str) -> str:
"""Return a zip entry filename without path traversal or empty names."""
@@ -273,6 +354,25 @@ def _record_email_received_events(owner: str, account_id: str | None, folder: st
for _ in new_keys[:50]:
fire_event("email_received", owner)
logger.info("Fired email_received for %d new message(s)", min(len(new_keys), 50))
try:
loop = asyncio.get_running_loop()
async def _run_away_reply_check():
try:
from routes.email_pollers import _auto_summarize_pass
result = await _auto_summarize_pass(
days_back=1,
account_id=account_id,
max_process=min(max(len(new_keys), 1), 5),
away_only=True,
)
logger.info("Auto away-reply pass after email_received account=%s: %s", account_id, result)
except Exception:
logger.warning("Auto away-reply pass after email_received failed", exc_info=True)
loop.create_task(_run_away_reply_check())
except RuntimeError:
logger.debug("No running event loop for immediate away-reply check")
except Exception:
logger.debug("email_received event detection skipped", exc_info=True)
@@ -383,6 +483,148 @@ def _uid_from_fetch_meta(meta_b: bytes) -> str:
return m.group(1).decode() if m else ""
def _parse_list_unsubscribe_header(value: str | None) -> list[dict]:
"""Parse RFC List-Unsubscribe entries into safe reviewable actions.
We return mailto/http entries but only the mailto kind is executable by the
first-pass Odysseus flow. HTTP unsubscribe links are useful evidence but
often contain tracking tokens and should be opened manually unless/until we
add a browser-confirmed flow.
"""
raw = str(value or "").strip()
if not raw:
return []
pieces = re.findall(r"<([^>]+)>", raw)
if not pieces:
pieces = [p.strip() for p in raw.split(",") if p.strip()]
out: list[dict] = []
seen = set()
for piece in pieces:
target = piece.strip().strip("<>").strip()
if not target:
continue
parsed = urlparse(target)
scheme = parsed.scheme.lower()
key = target.lower()
if key in seen:
continue
seen.add(key)
if scheme == "mailto":
addr = unquote(parsed.path or "").strip()
if not addr or "\r" in addr or "\n" in addr:
continue
query = parse_qs(parsed.query or "", keep_blank_values=True)
subject = unquote((query.get("subject") or ["unsubscribe"])[0] or "unsubscribe")
body = unquote((query.get("body") or ["unsubscribe"])[0] or "unsubscribe")
subject = re.sub(r"[\r\n]+", " ", subject).strip() or "unsubscribe"
body = re.sub(r"[\r\n]+", "\n", body).strip() or "unsubscribe"
out.append({
"kind": "mailto",
"target": addr,
"subject": subject[:200],
"body": body[:1000],
"executable": True,
})
elif scheme in {"http", "https"}:
out.append({
"kind": "url",
"target": target,
"executable": False,
})
return out
def _email_unsubscribe_candidate_from_msg(msg, uid: str, folder: str, *, spam_cached: dict | None = None) -> dict | None:
sender = _decode_header(msg.get("From", ""))
sender_name, sender_addr = email.utils.parseaddr(sender)
subject = _decode_header(msg.get("Subject", "(no subject)"))
list_id = _decode_header(msg.get("List-Id", ""))
precedence = (msg.get("Precedence") or "").strip().lower()
auto_submitted = (msg.get("Auto-Submitted") or "").strip().lower()
methods = _parse_list_unsubscribe_header(msg.get("List-Unsubscribe"))
has_unsub = bool(methods)
reasons: list[str] = []
score = 0
if has_unsub:
score += 45
reasons.append("has unsubscribe header")
if list_id:
score += 20
reasons.append("mailing-list header")
if precedence in {"bulk", "junk", "list"}:
score += 20
reasons.append(f"precedence={precedence}")
if auto_submitted and auto_submitted != "no":
score += 10
reasons.append(f"auto-submitted={auto_submitted}")
if spam_cached and spam_cached.get("spam"):
score += 35
if spam_cached.get("reason"):
reasons.append(str(spam_cached.get("reason")))
else:
reasons.append("previously classified as spam")
subj_l = (subject or "").lower()
if re.search(r"\b(unsubscribe|newsletter|sale|discount|offer|promo|limited time)\b", subj_l):
score += 10
reasons.append("promotional subject")
executable = [m for m in methods if m.get("executable")]
if score < 45 or not has_unsub:
return None
return {
"uid": str(uid),
"folder": folder,
"message_id": (msg.get("Message-ID") or "").strip(),
"subject": subject,
"from_name": sender_name or sender_addr,
"from_address": sender_addr,
"list_id": list_id,
"score": min(score, 100),
"reasons": reasons[:5],
"methods": methods,
"can_execute": bool(executable),
"recommended_method": executable[0] if executable else (methods[0] if methods else None),
"spam_reason": (spam_cached or {}).get("reason") or "",
}
def _unsubscribe_candidate_dedupe_key(candidate: dict) -> tuple[str, str, str]:
list_id = str(candidate.get("list_id") or "").strip().lower()
method = candidate.get("recommended_method") or {}
method_kind = str(method.get("kind") or "").strip().lower()
method_target = str(method.get("target") or "").strip().lower()
sender = str(candidate.get("from_address") or "").strip().lower()
if list_id:
return ("list", list_id, method_target or sender)
if method_target:
return ("method", method_kind, method_target)
return ("sender", sender, str(candidate.get("subject") or "").strip().lower())
def _dedupe_unsubscribe_candidates(candidates: list[dict]) -> list[dict]:
deduped: dict[tuple[str, str, str], dict] = {}
for candidate in candidates or []:
key = _unsubscribe_candidate_dedupe_key(candidate)
existing = deduped.get(key)
if not existing:
copy = dict(candidate)
copy["duplicate_count"] = 1
copy["duplicate_uids"] = [str(candidate.get("uid") or "")]
deduped[key] = copy
continue
existing["duplicate_count"] = int(existing.get("duplicate_count") or 1) + 1
uid = str(candidate.get("uid") or "")
if uid:
existing.setdefault("duplicate_uids", []).append(uid)
if int(candidate.get("score") or 0) > int(existing.get("score") or 0):
keep_count = existing.get("duplicate_count")
keep_uids = existing.get("duplicate_uids")
replacement = dict(candidate)
replacement["duplicate_count"] = keep_count
replacement["duplicate_uids"] = keep_uids
deduped[key] = replacement
return list(deduped.values())
_FETCH_SEQ_RE = re.compile(rb"^(\d+)\s+\(")
@@ -517,6 +759,82 @@ def _email_index_rows(owner: str, account_id: str | None, folder: str, uids: lis
return out
def _email_index_list(owner: str, account_id: str | None, folder: str, filter_: str, limit: int, offset: int, has_attachments: bool = False) -> tuple[list[dict], int, str | None]:
"""Return a newest-first page from the durable local email index.
This is intentionally a paint-fast cache path for the UI, not the source of
truth. The normal IMAP list still runs after this in the browser to refresh
flags/new mail.
"""
limit = max(1, min(int(limit or 50), 200))
offset = max(0, int(offset or 0))
account_key = _account_cache_key(account_id, owner)
clauses = ["owner=?", "account_key=?", "folder=?"]
params: list = [owner or "", account_key, folder]
if filter_ == "unread":
clauses.append("(flags IS NULL OR instr(flags, '\\Seen') = 0)")
elif filter_ in {"unanswered", "undone"}:
clauses.append("(flags IS NULL OR instr(flags, '\\Answered') = 0)")
elif filter_ == "favorites":
clauses.append("instr(COALESCE(flags, ''), '\\Flagged') > 0")
elif filter_ not in {"all", "", None}:
return [], 0, None
if has_attachments:
clauses.append("has_attachments=1")
where = " AND ".join(clauses)
try:
conn = _sql3.connect(SCHEDULED_DB)
try:
total_row = conn.execute(
f"SELECT COUNT(*), MAX(updated_at) FROM email_message_index WHERE {where}",
params,
).fetchone()
total = int((total_row or [0])[0] or 0)
if not total:
return [], 0, (total_row or [None, None])[1]
rows = conn.execute(
f"""
SELECT uid, message_id, subject, from_name, from_address, to_text, cc_text,
date_iso, date_display, date_epoch, size, flags, has_attachments
FROM email_message_index
WHERE {where}
ORDER BY date_epoch DESC
LIMIT ? OFFSET ?
""",
[*params, limit, offset],
).fetchall()
finally:
conn.close()
except Exception:
logger.debug("email index list skipped", exc_info=True)
return [], 0, None
emails: list[dict] = []
for row in rows:
uid, message_id, subject, from_name, from_address, to_text, cc_text, date_iso, date_display, date_epoch, size, flags, has_attachments_raw = row
flags = flags or ""
emails.append({
"uid": str(uid),
"message_id": (message_id or "").strip(),
"subject": subject or "(no subject)",
"from_name": from_name or from_address or "",
"from_address": from_address or "",
"to": to_text or "",
"cc": cc_text or "",
"date": date_iso or "",
"date_display": date_display or "",
"date_epoch": float(date_epoch or 0),
"size": int(size or 0),
"is_read": "\\Seen" in flags,
"is_answered": "\\Answered" in flags,
"is_flagged": "\\Flagged" in flags,
"flags": flags,
"has_attachments": bool(has_attachments_raw),
"folder": folder,
})
return emails, total, (total_row or [None, None])[1]
def _email_index_search(owner: str, account_id: str | None, folder: str, query: str, limit: int, global_search: bool = True) -> tuple[list[dict], int, str | None]:
q = (query or "").strip()
if not q:
@@ -990,8 +1308,7 @@ def _normalize_addr_field(field: str) -> str:
"""Strip the malformed-but-common trailing/leading commas and stray
whitespace from a To/Cc/Bcc string before it lands in the MIME header
or the SMTP envelope. Users often paste a single address with a
trailing comma (e.g. `user@example.com,`) and most MTAs reject the
resulting `To: user@example.com,` line as a syntax error. Collapse
trailing comma, which most MTAs reject as a syntax error. Collapse
any run of separator junk between addresses too."""
if not field:
return field
@@ -1162,7 +1479,7 @@ def setup_email_routes():
_IMAP_POOL = {} # account_id → (conn, last_used_at)
_IMAP_IDLE_MAX = 60.0
_WARMING_READS = set()
_WARM_READ_LIMIT = 2
_WARM_READ_LIMIT = 6
_WARM_MAX_BYTES = 192 * 1024
_WARM_RECENT_SECONDS = 7 * 24 * 60 * 60
_pool_lock = _threading.Lock()
@@ -1245,6 +1562,10 @@ def setup_email_routes():
return None
return v[1]
def _folder_cache_get_stale(account_id, owner):
v = _FOLDER_CACHE.get((account_id or "", owner or ""))
return v[1] if v else None
def _folder_cache_put(account_id, owner, value):
_FOLDER_CACHE[(account_id or "", owner or "")] = (_time.monotonic() + _FOLDER_TTL, value)
if len(_FOLDER_CACHE) > 32:
@@ -1951,18 +2272,44 @@ def setup_email_routes():
from_addr: str | None = Query(None, alias="from"),
account_id: str | None = Query(None),
has_attachments: int = Query(0),
cached_only: int = Query(0),
cache_bust: str | None = Query(None, alias="_"),
owner: str = Depends(require_owner),
):
"""List emails. Uses an 8s in-memory cache + offloads blocking IMAP
calls to a worker thread so the event loop never stalls."""
started_at = _time.monotonic()
_deferred = getattr(_start_poller, '_deferred', None)
if _deferred:
await _deferred()
fixture_result = _fixture_email_list(folder, limit, offset, filter, from_addr, owner)
if fixture_result is not None:
return fixture_result
if cached_only and not from_addr:
indexed_emails, indexed_total, indexed_at = _email_index_list(
owner, account_id, folder, filter, limit, offset, bool(has_attachments),
)
if indexed_total:
_hide_unlinked_calendar_tags(indexed_emails)
return {
"emails": indexed_emails,
"total": indexed_total,
"folder": folder,
"offset": offset,
"sync": {
"source": "index",
"indexed": indexed_total,
"updated_at": indexed_at,
"cached_only": True,
},
}
return {
"emails": [],
"total": 0,
"folder": folder,
"offset": offset,
"sync": {"source": "index", "cached_only": True},
}
_deferred = getattr(_start_poller, '_deferred', None)
if _deferred:
await _deferred()
# SECURITY: include `owner` in the cache key so two users with
# different account scopes don't share a cached list.
ck = _list_cache_key(account_id, folder, filter, limit, offset, from_addr or "") + (int(bool(has_attachments)), owner)
@@ -2090,6 +2437,230 @@ def setup_email_routes():
logger.error(f"unflag-spam failed: {e}")
return {"ok": False, "error": "Mail operation failed"}
def _unsubscribe_spam_cache(owner: str, account_id: str | None, folder: str) -> dict[str, dict]:
out: dict[str, dict] = {}
try:
conn = _sql3.connect(SCHEDULED_DB)
try:
owner_clause, owner_params = _email_tag_owner_clause(account_id, owner)
account_clause, account_params = _email_tag_account_clause(account_id)
rows = conn.execute(
"SELECT uid, message_id, spam_verdict, spam_reason FROM email_tags "
"WHERE folder=? AND "
f"{owner_clause} AND {account_clause}",
(folder, *owner_params, *account_params),
).fetchall()
finally:
conn.close()
for uid, message_id, spam_verdict, spam_reason in rows:
payload = {"spam": bool(spam_verdict), "reason": spam_reason or ""}
if uid:
out[str(uid)] = payload
if message_id:
out[str(message_id).strip()] = payload
except Exception:
logger.debug("unsubscribe spam cache lookup skipped", exc_info=True)
return out
def _scan_unsubscribe_candidates_sync(folder: str, account_id: str | None, owner: str, limit: int, max_scan: int) -> dict:
folder = folder or "INBOX"
limit = max(1, min(int(limit or 25), 100))
max_scan = max(limit, min(int(max_scan or 150), 500))
spam_cache = _unsubscribe_spam_cache(owner, account_id, folder)
candidates: list[dict] = []
with _imap(account_id, owner=owner) as conn:
st, _ = conn.select(_q(folder), readonly=True)
if st != "OK":
return {"success": False, "error": f"Folder not found: {folder}", "candidates": []}
st, data = _imap_uid_search(conn, "ALL")
if st != "OK" or not data or not data[0]:
return {"success": True, "candidates": [], "total": 0, "scanned": 0, "folder": folder}
uids = []
for raw_uid in data[0].split():
try:
uids.append(int(raw_uid))
except Exception:
continue
uids = sorted(uids, reverse=True)[:max_scan]
if not uids:
return {"success": True, "candidates": [], "total": 0, "scanned": 0, "folder": folder}
fetch_set = ",".join(str(u) for u in uids)
st, msg_data = _imap_uid_fetch(conn, fetch_set, "(UID RFC822.HEADER)")
if st != "OK":
return {"success": False, "error": "Failed to fetch email headers", "candidates": []}
for item in msg_data or []:
if not isinstance(item, tuple) or len(item) < 2:
continue
meta_b = item[0] if isinstance(item[0], bytes) else str(item[0]).encode()
uid = _uid_from_fetch_meta(meta_b)
if not uid:
continue
try:
msg = email_mod.message_from_bytes(item[1] or b"")
except Exception:
continue
mid = (msg.get("Message-ID") or "").strip()
spam_info = spam_cache.get(uid) or (spam_cache.get(mid) if mid else None) or {}
candidate = _email_unsubscribe_candidate_from_msg(msg, uid, folder, spam_cached=spam_info)
if candidate:
candidates.append(candidate)
def _uid_sort_value(candidate: dict) -> int:
try:
return int(candidate.get("uid") or 0)
except Exception:
return 0
raw_total = len(candidates)
candidates = _dedupe_unsubscribe_candidates(candidates)
candidates.sort(key=lambda c: (int(c.get("score") or 0), int(c.get("duplicate_count") or 1), _uid_sort_value(c)), reverse=True)
return {
"success": True,
"candidates": candidates[:limit],
"total": len(candidates),
"raw_total": raw_total,
"scanned": len(uids),
"folder": folder,
"account_id": account_id or "",
}
@router.get("/unsubscribe/scan")
async def scan_unsubscribe_candidates(
folder: str = Query("INBOX"),
account_id: str | None = Query(None),
limit: int = Query(25),
max_scan: int = Query(150),
owner: str = Depends(require_owner),
):
"""Review-only scan for spam/newsletter unsubscribe candidates."""
if account_id:
_assert_owns_account(account_id, owner)
if _fixture_email_enabled():
return {"success": True, "candidates": [], "total": 0, "scanned": 0, "folder": folder, "sync": {"source": "fixture"}}
try:
return await _asyncio.to_thread(_scan_unsubscribe_candidates_sync, folder, account_id, owner, limit, max_scan)
except Exception as e:
logger.error(f"unsubscribe scan failed: {e}")
return {"success": False, "error": "Mail operation failed", "candidates": []}
@router.post("/unsubscribe/execute")
def execute_unsubscribe(data: dict, owner: str = Depends(require_owner)):
"""Execute an approved unsubscribe action.
First implementation supports mailto List-Unsubscribe only. The message
header is re-read server-side; client-supplied addresses are not trusted.
"""
uid = str((data or {}).get("uid") or "").strip()
folder = str((data or {}).get("folder") or "INBOX").strip() or "INBOX"
account_id = (data or {}).get("account_id") or None
method_index = int((data or {}).get("method_index") or 0)
move_to_spam = bool((data or {}).get("move_to_spam"))
if not uid:
raise HTTPException(400, "Missing uid")
if account_id:
_assert_owns_account(account_id, owner)
try:
with _imap(account_id, owner=owner) as conn:
st, _ = conn.select(_q(folder), readonly=True)
if st != "OK":
return {"success": False, "error": f"Folder not found: {folder}"}
st, msg_data = _imap_uid_fetch(conn, uid, "(UID RFC822.HEADER)")
if st != "OK" or not msg_data:
return {"success": False, "error": "Email not found"}
raw_header = b""
for item in msg_data or []:
if isinstance(item, tuple) and len(item) >= 2:
raw_header = item[1] or b""
break
msg = email_mod.message_from_bytes(raw_header)
candidate = _email_unsubscribe_candidate_from_msg(msg, uid, folder)
if not candidate:
return {"success": False, "error": "No unsubscribe header found for this email"}
methods = [m for m in (candidate.get("methods") or []) if m.get("kind") == "mailto" and m.get("executable")]
if not methods:
return {"success": False, "error": "This email only has web unsubscribe links; open manually for now", "candidate": candidate}
method = methods[method_index] if 0 <= method_index < len(methods) else methods[0]
target = str(method.get("target") or "").strip()
if not target or "\r" in target or "\n" in target:
return {"success": False, "error": "Invalid unsubscribe address"}
cfg = _resolve_send_config(account_id, owner=owner)
subject = str(method.get("subject") or "unsubscribe")[:200]
body = str(method.get("body") or "unsubscribe")[:1000]
msg_out = MIMEText(body or "unsubscribe", "plain", "utf-8")
msg_out["From"] = email.utils.formataddr((cfg.get("display_name") or "", cfg["from_address"]))
msg_out["To"] = target
msg_out["Subject"] = subject
msg_out["Message-ID"] = email.utils.make_msgid(domain="odysseus.local")
_apply_odysseus_headers(msg_out, "unsubscribe", uid)
_send_smtp_message(cfg, cfg["from_address"], [target], msg_out.as_string())
moved = False
if move_to_spam:
try:
with _imap(account_id, owner=owner) as conn:
conn.select(_q(folder))
moved = _move_email_message(conn, uid, "Junk", role="junk")
if moved:
_email_index_delete(owner, account_id, folder, uid)
_invalidate_list_cache(account_id)
except Exception:
logger.debug("unsubscribe move-to-spam skipped", exc_info=True)
return {
"success": True,
"method": method,
"candidate": candidate,
"moved_to_spam": moved,
}
except ValueError as e:
return {"success": False, "error": str(e)}
except Exception as e:
logger.error(f"unsubscribe execute failed uid={uid}: {e}")
return {"success": False, "error": "Mail operation failed"}
@router.post("/unsubscribe/cleanup")
def cleanup_unsubscribe_candidates(data: dict, owner: str = Depends(require_owner)):
"""Move reviewed unsubscribe candidate messages to Junk or Trash."""
folder = str((data or {}).get("folder") or "INBOX").strip() or "INBOX"
account_id = (data or {}).get("account_id") or None
action = str((data or {}).get("action") or "").strip().lower()
raw_uids = (data or {}).get("uids") or []
if action not in {"junk", "delete"}:
raise HTTPException(400, "Unsupported cleanup action")
if account_id:
_assert_owns_account(account_id, owner)
uids: list[str] = []
seen = set()
for raw_uid in raw_uids:
uid = str(raw_uid or "").strip()
if not uid or uid in seen:
continue
if not uid.isdigit():
continue
seen.add(uid)
uids.append(uid)
if not uids:
return {"success": False, "error": "No email UIDs provided", "changed": 0, "failed": 0}
role = "junk" if action == "junk" else "trash"
target = "Junk" if action == "junk" else "Trash"
changed = 0
failed = 0
try:
with _imap(account_id, owner=owner) as conn:
conn.select(_q(folder))
for uid in uids:
try:
if _move_email_message(conn, uid, target, role=role):
changed += 1
_email_index_delete(owner, account_id, folder, uid)
else:
failed += 1
except Exception:
failed += 1
logger.debug("unsubscribe cleanup failed for uid=%s", uid, exc_info=True)
if changed:
_invalidate_list_cache(account_id)
return {"success": True, "action": action, "changed": changed, "failed": failed}
except Exception as e:
logger.error(f"unsubscribe cleanup failed: {e}")
return {"success": False, "error": "Mail operation failed", "changed": changed, "failed": failed}
@router.get("/contacts")
async def list_contacts(
q: str = Query(""),
@@ -2566,7 +3137,7 @@ def setup_email_routes():
return
async def _warm():
await _asyncio.sleep(3.0)
await _asyncio.sleep(0.25)
for uid, ck in selected:
if _read_cache_get(ck) is not None:
_WARMING_READS.discard(ck)
@@ -2739,7 +3310,7 @@ def setup_email_routes():
return {"error": "Mail operation failed"}
@router.post("/attachment-as-doc/{uid}/{index}")
async def attachment_as_doc(uid: str, index: int, request: Request, folder: str = Query("INBOX"), account_id: str | None = Query(None), owner: str = Depends(require_owner)):
def attachment_as_doc(uid: str, index: int, request: Request, folder: str = Query("INBOX"), account_id: str | None = Query(None), owner: str = Depends(require_owner)):
"""Extract an email attachment and open it in the document editor.
Supported extensions:
@@ -3225,7 +3796,11 @@ def setup_email_routes():
return {"success": False, "error": "Mail operation failed"}
@router.get("/folders")
async def list_folders(account_id: str | None = Query(None), owner: str = Depends(require_owner)):
async def list_folders(
account_id: str | None = Query(None),
cached_only: int = Query(0),
owner: str = Depends(require_owner),
):
"""List IMAP folders."""
if _fixture_email_enabled():
return {"folders": ["INBOX", "Archive", "Sent"], "sync": {"source": "fixture"}}
@@ -3236,19 +3811,57 @@ def setup_email_routes():
sync_meta["source"] = "folder_cache"
payload["sync"] = sync_meta
return payload
try:
if cached_only:
stale = _folder_cache_get_stale(account_id, owner)
if stale:
payload = dict(stale)
sync_meta = dict(payload.get("sync") or {})
sync_meta["source"] = "folder_cache_stale"
payload["sync"] = sync_meta
return payload
return {
"folders": ["INBOX", "Sent", "Archive"],
"sync": {"source": "folder_cached_only_fallback"},
}
def _list_folders_sync():
with _imap(account_id, owner=owner) as conn:
status, folders = conn.list()
result = []
for f in folders:
for f in folders or []:
decoded = f.decode() if isinstance(f, bytes) else f
match = re.search(r'"([^"]*)"$|(\S+)$', decoded)
if match:
name = match.group(1) or match.group(2)
result.append(name)
payload = {"folders": result, "sync": {"source": "imap", "updated_at": datetime.utcnow().isoformat() + "Z"}}
return {
"folders": result,
"sync": {
"source": "imap",
"updated_at": datetime.utcnow().isoformat() + "Z",
"status": status.decode() if isinstance(status, bytes) else status,
},
}
try:
payload = await _asyncio.wait_for(_asyncio.to_thread(_list_folders_sync), timeout=8.0)
_folder_cache_put(account_id, owner, payload)
return payload
except _asyncio.TimeoutError:
logger.warning(f"list_folders timed out for account={account_id or 'default'} owner={owner}")
stale = _folder_cache_get_stale(account_id, owner)
if stale:
payload = dict(stale)
sync_meta = dict(payload.get("sync") or {})
sync_meta["source"] = "folder_cache_stale"
sync_meta["warning"] = "Folder list timed out"
payload["sync"] = sync_meta
return payload
return {
"folders": ["INBOX", "Sent", "Archive"],
"error": "Folder list timed out",
"sync": {"source": "folder_timeout_fallback"},
}
except Exception as e:
logger.error(f"list_folders failed: {e}")
return {"folders": [], "error": "Mail operation failed"}
@@ -4040,17 +4653,23 @@ def setup_email_routes():
return {"success": True, "message": "Draft saved"}
@router.post("/extract-style")
async def extract_writing_style(req: ExtractStyleRequest, owner: str = Depends(require_owner)):
async def extract_writing_style(
req: ExtractStyleRequest,
account_id: str | None = Query(None),
owner: str = Depends(require_owner),
):
"""Extract writing style from sent emails using LLM.
IMAP fetch is offloaded to a worker thread; the LLM call uses the
async client. Otherwise this handler froze the event loop for ~5s
on the IMAP step alone with a remote server.
"""
if account_id:
_assert_owns_account(account_id, owner)
def _gather_samples() -> tuple[list[str], str | None]:
try:
with _imap(owner=owner) as imap:
with _imap(account_id, owner=owner) as imap:
imap.select(_q(_detect_sent_folder(imap)), readonly=True)
status, data = imap.search(None, "ALL")
if status != "OK" or not data[0]:
@@ -4132,7 +4751,7 @@ def setup_email_routes():
# Save to settings
settings = _load_settings()
settings["email_writing_style"] = style
_set_email_writing_style_for_account(settings, style, account_id)
_save_settings(settings)
logger.info("Writing style extracted and saved")
@@ -4424,8 +5043,11 @@ def setup_email_routes():
message_id = (data.get("message_id") or "").strip()
source_uid = (data.get("uid") or "").strip()
source_folder = (data.get("folder") or "INBOX").strip()
account_id = (data.get("account_id") or "").strip() or None
fast_reply = bool(data.get("fast", False))
user_hint = (data.get("user_hint") or "").strip()
if account_id:
_assert_owns_account(account_id, owner)
if not original_body:
return {"success": False, "error": "No email body provided"}
@@ -4433,7 +5055,7 @@ def setup_email_routes():
# Skip cache lookup when the caller supplied a user_hint — the
# cached generic reply doesn't reflect the instructions and
# would silently override them.
if message_id and not user_hint:
if message_id and not user_hint and not account_id:
try:
_c = _sql3.connect(SCHEDULED_DB)
owner_clause, owner_params = _email_cache_owner_clause(owner)
@@ -4455,7 +5077,7 @@ def setup_email_routes():
logger.warning(f"AI reply cache lookup failed: {e}")
settings = _load_settings()
style = settings.get("email_writing_style", "")
style = _get_email_writing_style_for_account(settings, account_id)
# Try session's endpoint first if session_id provided
url = None
@@ -4576,8 +5198,11 @@ def setup_email_routes():
)
if user_hint:
user_msg += (
f"User's instructions for THIS reply (follow these — they override "
f"defaults like length/tone):\n{user_hint[:2000]}\n\n"
"User guidance for THIS reply. Treat this as intent/context to fold "
"into a normal polished email reply in the user's writing style. "
"Do not answer with only this guidance unless the user explicitly "
"asked for a one-word reply:\n"
f"{user_hint[:2000]}\n\n"
)
user_msg += "Draft a reply. Return only the reply body text."
@@ -4703,23 +5328,41 @@ def setup_email_routes():
return {"success": False, "error": "Mail operation failed"}
@router.get("/style")
async def get_writing_style(owner: str = Depends(require_user)):
async def get_writing_style(
account_id: str | None = Query(None),
owner: str = Depends(require_user),
):
"""Get the current writing style prompt."""
if account_id:
_assert_owns_account(account_id, owner)
settings = _load_settings()
return {"style": settings.get("email_writing_style", "")}
return {"style": _get_email_writing_style_for_account(settings, account_id)}
@router.put("/style")
async def update_writing_style(data: dict, owner: str = Depends(require_user)):
async def update_writing_style(
data: dict,
account_id: str | None = Query(None),
owner: str = Depends(require_user),
):
"""Manually update the writing style prompt."""
if account_id:
_assert_owns_account(account_id, owner)
settings = _load_settings()
settings["email_writing_style"] = data.get("style", "")
_set_email_writing_style_for_account(settings, data.get("style", ""), account_id)
_save_settings(settings)
return {"success": True}
@router.get("/config")
async def get_email_config(owner: str = Depends(require_user)):
async def get_email_config(
account_id: str | None = Query(None),
owner: str = Depends(require_user),
):
"""Get email configuration (passwords masked)."""
cfg = _get_email_config(owner=owner)
if account_id is not None and not isinstance(account_id, str):
account_id = None
if account_id:
_assert_owns_account(account_id, owner)
cfg = _get_email_config(account_id, owner=owner)
cfg["smtp_password"] = "***" if cfg["smtp_password"] else ""
cfg["imap_password"] = "***" if cfg["imap_password"] else ""
# `_get_email_config` includes encrypted OAuth fields for the server's
@@ -4731,11 +5374,21 @@ def setup_email_routes():
cfg.pop("oauth_token_expiry", None)
# Include preferences from settings.json
settings = _load_settings()
auto_reply_settings = _get_auto_reply_settings_for_account(settings, account_id)
cfg["email_auto_summarize"] = bool(settings.get("email_auto_summarize", False))
cfg["email_auto_reply"] = bool(settings.get("email_auto_reply", False))
cfg["email_auto_reply"] = bool(auto_reply_settings.get("email_auto_reply", False))
cfg["email_auto_tag"] = bool(settings.get("email_auto_tag", False))
cfg["email_auto_spam"] = bool(settings.get("email_auto_spam", False))
cfg["email_auto_calendar"] = bool(settings.get("email_auto_calendar", False))
cfg["email_auto_reply_start"] = auto_reply_settings.get("email_auto_reply_start", "")
cfg["email_auto_reply_end"] = auto_reply_settings.get("email_auto_reply_end", "")
cfg["email_auto_reply_subject"] = auto_reply_settings.get("email_auto_reply_subject", "(Away) {subject}")
cfg["email_auto_reply_message"] = auto_reply_settings.get("email_auto_reply_message", "")
cfg["email_auto_reply_cooldown"] = auto_reply_settings.get("email_auto_reply_cooldown", "period")
cfg["email_auto_reply_scope"] = auto_reply_settings.get("email_auto_reply_scope", "account" if account_id else "all")
cfg["email_auto_reply_account_id"] = auto_reply_settings.get("email_auto_reply_account_id", account_id or "")
cfg["email_auto_reply_exclude_automated"] = bool(auto_reply_settings.get("email_auto_reply_exclude_automated", True))
cfg["email_auto_reply_pause_notifications"] = bool(auto_reply_settings.get("email_auto_reply_pause_notifications", False))
# Email translation is owned by the background task now; opening an email
# should not trigger reader-side auto-translation from Settings.
cfg["email_auto_translate"] = False
@@ -4743,7 +5396,11 @@ def setup_email_routes():
return cfg
@router.put("/config")
async def update_email_config(data: dict, owner: str = Depends(require_owner)):
async def update_email_config(
data: dict,
account_id: str | None = Query(None),
owner: str = Depends(require_owner),
):
"""Update email configuration.
Automation flags (email_auto_*) still live in settings.json. Credentials
@@ -4751,11 +5408,19 @@ def setup_email_routes():
overwritten when a non-empty value is provided, so saving the form
without retyping the password no longer wipes it.
"""
# Automation flags stay in settings.json (they're global, not per-account)
if account_id:
_assert_owns_account(account_id, owner)
# Non-reply automation flags stay global. Away/auto-reply settings are
# account-scoped when an account_id is supplied by the Email Settings UI.
settings = _load_settings()
for key in ["email_auto_summarize", "email_auto_reply", "email_auto_tag", "email_auto_spam", "email_auto_calendar"]:
bool_keys = [
"email_auto_summarize", "email_auto_tag", "email_auto_spam", "email_auto_calendar",
]
for key in bool_keys:
if key in data:
settings[key] = data[key]
settings[key] = bool(data[key])
_set_auto_reply_settings_for_account(settings, data, account_id)
_save_settings(settings)
# Credentials go into the default account row