mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-10 18:22:20 +02:00
Merge verified Odysseus fixes
This commit is contained in:
+695
-30
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user