mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-16 05:02:21 +02:00
4671 lines
192 KiB
Python
4671 lines
192 KiB
Python
"""
|
|
email_server.py
|
|
|
|
MCP server exposing email tools: list unread/unresponded emails,
|
|
read email content, and draft replies as email documents.
|
|
Connects to local Dovecot IMAP and reads from the AI summary cache.
|
|
"""
|
|
|
|
import asyncio
|
|
import imaplib
|
|
import smtplib
|
|
import email
|
|
import email.header
|
|
import email.utils
|
|
from email.message import EmailMessage
|
|
import re
|
|
import html
|
|
import json
|
|
import sqlite3
|
|
import sys
|
|
import os
|
|
import os.path
|
|
import time
|
|
from pathlib import Path
|
|
from datetime import datetime, timedelta, timezone
|
|
import uuid
|
|
from contextvars import ContextVar
|
|
from urllib.parse import parse_qs, unquote, urlparse
|
|
|
|
from mcp.server import Server
|
|
from mcp.server.stdio import stdio_server
|
|
from mcp.types import Tool, TextContent
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
server = Server("email")
|
|
EMAIL_SOCKET_TIMEOUT = float(os.environ.get("EMAIL_SOCKET_TIMEOUT", "20"))
|
|
from src.constants import DATA_DIR as _DATA_DIR, APP_DB, EMAIL_CACHE_DB, SETTINGS_FILE as _SETTINGS_FILE, MAIL_ATTACHMENTS_DIR
|
|
try:
|
|
from src.constants import SCHEDULED_EMAILS_DB
|
|
except Exception:
|
|
SCHEDULED_EMAILS_DB = str(DATA_DIR / "scheduled_emails.db")
|
|
DATA_DIR = Path(_DATA_DIR)
|
|
|
|
|
|
def _b(value) -> bytes:
|
|
return str(value).encode()
|
|
|
|
|
|
def _q(name: str) -> str:
|
|
"""Quote an IMAP mailbox name for commands that take mailbox args."""
|
|
return '"' + (name or "").replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
|
|
|
|
def _uid_fetch_rows(data) -> list:
|
|
return [d for d in (data or []) if isinstance(d, bytes) and b"UID " in d]
|
|
|
|
|
|
def _uids_from_fetch_rows(data) -> set[str]:
|
|
found: set[str] = set()
|
|
for row in _uid_fetch_rows(data):
|
|
match = re.search(rb"\bUID\s+(\d+)\b", row)
|
|
if match:
|
|
found.add(match.group(1).decode())
|
|
return found
|
|
|
|
# ── Config ──
|
|
# Multi-account aware. Accounts live in data/app.db :: email_accounts.
|
|
# Callers can pass `account=` (match by name, user, or id) to pick a specific
|
|
# inbox; None resolves to the default row. Falls back to env vars / settings.json
|
|
# flat keys when no DB row matches (legacy single-account behaviour).
|
|
|
|
_ACCOUNT_CACHE: dict = {} # key = normalized account selector -> config dict
|
|
_EMAIL_LIST_CACHE_TTL_SECONDS = float(os.environ.get("EMAIL_LIST_CACHE_TTL_SECONDS", "20"))
|
|
_EMAIL_LIST_CACHE: dict = {}
|
|
_MCP_OWNER_ARG = "_odysseus_owner"
|
|
_MCP_SESSION_ARG = "_odysseus_session_id"
|
|
_CURRENT_OWNER: ContextVar[str | None] = ContextVar("email_mcp_owner", default=None)
|
|
_CURRENT_SESSION_ID: ContextVar[str | None] = ContextVar("email_mcp_session_id", default=None)
|
|
_OWNER_ENV_KEYS = ("ODYSSEUS_MCP_EMAIL_OWNER", "ODYSSEUS_EMAIL_OWNER")
|
|
_OWNER_SCOPE_ERROR = (
|
|
"Error: email MCP requires an authenticated owner or ODYSSEUS_MCP_EMAIL_OWNER "
|
|
"when owner-scoped email accounts are configured."
|
|
)
|
|
|
|
|
|
def _clean_header_value(value) -> str:
|
|
"""EmailMessage rejects CR/LF in assigned header values; unfold safely."""
|
|
if value is None:
|
|
return ""
|
|
return re.sub(r"[\r\n]+[ \t]*", " ", str(value)).strip()
|
|
|
|
|
|
def _db_path() -> Path:
|
|
return Path(APP_DB)
|
|
|
|
|
|
def _configured_owner() -> str | None:
|
|
for key in _OWNER_ENV_KEYS:
|
|
owner = os.environ.get(key, "").strip()
|
|
if owner:
|
|
return owner
|
|
return None
|
|
|
|
|
|
def _current_owner() -> str:
|
|
owner = _CURRENT_OWNER.get()
|
|
return str(owner or _configured_owner() or "").strip()
|
|
|
|
|
|
def _current_session_id() -> str:
|
|
return str(_CURRENT_SESSION_ID.get() or "").strip()
|
|
|
|
|
|
def _clear_email_list_cache() -> None:
|
|
_EMAIL_LIST_CACHE.clear()
|
|
|
|
|
|
def _account_owner(row: dict) -> str:
|
|
return str(row.get("owner") or "").strip()
|
|
|
|
|
|
def _has_owner_scoped_accounts(rows: list[dict]) -> bool:
|
|
return any(_account_owner(r) for r in rows)
|
|
|
|
|
|
def _account_visible_to_owner(row: dict, owner: str) -> bool:
|
|
row_owner = _account_owner(row)
|
|
if row_owner == owner:
|
|
return True
|
|
if row_owner:
|
|
return False
|
|
# Legacy ownerless accounts are only visible to a scoped caller when the
|
|
# mailbox itself matches the owner, mirroring the HTTP email route fallback.
|
|
owner_l = owner.lower()
|
|
return owner_l in {
|
|
str(row.get("imap_user") or "").strip().lower(),
|
|
str(row.get("from_address") or "").strip().lower(),
|
|
}
|
|
|
|
|
|
def _filter_accounts_for_owner(rows: list[dict]) -> list[dict]:
|
|
owner = _current_owner()
|
|
if owner:
|
|
return [r for r in rows if _account_visible_to_owner(r, owner)]
|
|
|
|
if _has_owner_scoped_accounts(rows):
|
|
return []
|
|
return rows
|
|
|
|
|
|
def _mcp_owner_required(rows: list[dict] | None = None) -> bool:
|
|
if _current_owner():
|
|
return False
|
|
rows = rows if rows is not None else _read_accounts_from_db()
|
|
return _has_owner_scoped_accounts(rows)
|
|
|
|
|
|
def _load_email_writing_style(account: str | None = None) -> str:
|
|
"""Return the saved Settings > Email > Writing Style value.
|
|
|
|
Prefer the selected account's style when one exists; fall back to the
|
|
legacy global style so older installs keep behaving as before.
|
|
"""
|
|
try:
|
|
settings_path = DATA_DIR / "settings.json"
|
|
if not settings_path.exists():
|
|
return ""
|
|
settings = json.loads(settings_path.read_text(encoding="utf-8"))
|
|
account_id = ""
|
|
if account:
|
|
try:
|
|
cfg = _load_config(account)
|
|
account_id = str(cfg.get("account_id") or account or "").strip()
|
|
except Exception:
|
|
account_id = str(account or "").strip()
|
|
by_account = settings.get("email_writing_styles_by_account") or {}
|
|
if account_id and isinstance(by_account, dict):
|
|
style = by_account.get(account_id)
|
|
if isinstance(style, str) and style.strip():
|
|
return style.strip()
|
|
return str(settings.get("email_writing_style") or "").strip()
|
|
except Exception:
|
|
return ""
|
|
|
|
|
|
def _writing_style_guidance(account: str | None = None) -> str:
|
|
style = _load_email_writing_style(account)
|
|
if not style:
|
|
return (
|
|
"No saved writing style is configured in Settings > Email > Writing Style. "
|
|
"Use a concise, natural tone and do not invent facts."
|
|
)
|
|
return (
|
|
"Use this saved writing style from Settings > Email > Writing Style when "
|
|
"drafting the body. It overrides generic tone guidance:\n"
|
|
f"{style}"
|
|
)
|
|
|
|
|
|
def _default_document_owner() -> str | None:
|
|
"""Best-effort owner for MCP-created documents.
|
|
|
|
MCP stdio tools do not receive the browser request's authenticated user,
|
|
but the document library is owner-filtered. Stamp drafts to the configured
|
|
single/default admin so assistant-created email drafts are visible.
|
|
"""
|
|
owner = os.environ.get("ODYSSEUS_DOCUMENT_OWNER", "").strip()
|
|
if owner:
|
|
return owner
|
|
try:
|
|
auth_path = DATA_DIR / "auth.json"
|
|
if not auth_path.exists():
|
|
return None
|
|
users = (json.loads(auth_path.read_text(encoding="utf-8")).get("users") or {})
|
|
if not isinstance(users, dict) or not users:
|
|
return None
|
|
admins = [name for name, data in users.items() if isinstance(data, dict) and data.get("is_admin")]
|
|
if len(admins) == 1:
|
|
return admins[0]
|
|
if len(users) == 1:
|
|
return next(iter(users))
|
|
return admins[0] if admins else next(iter(users))
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _read_accounts_from_db() -> list:
|
|
"""Return all enabled email account rows. Empty list if missing. Never raises."""
|
|
path = _db_path()
|
|
if not path.exists():
|
|
return []
|
|
try:
|
|
conn = sqlite3.connect(str(path))
|
|
conn.row_factory = sqlite3.Row
|
|
columns = {r[1] for r in conn.execute("PRAGMA table_info(email_accounts)").fetchall()}
|
|
owner_select = "owner" if "owner" in columns else "NULL AS owner"
|
|
smtp_security_select = "smtp_security" if "smtp_security" in columns else "'' AS smtp_security"
|
|
rows = conn.execute(f"""
|
|
SELECT id, {owner_select}, name, is_default, enabled,
|
|
imap_host, imap_port, imap_user, imap_password, imap_starttls,
|
|
smtp_host, smtp_port, {smtp_security_select}, smtp_user, smtp_password, from_address
|
|
FROM email_accounts WHERE enabled = 1
|
|
ORDER BY is_default DESC, created_at ASC
|
|
""").fetchall()
|
|
conn.close()
|
|
return [dict(r) for r in rows]
|
|
except sqlite3.OperationalError:
|
|
return []
|
|
except Exception:
|
|
return []
|
|
|
|
|
|
def _list_accounts_raw() -> list:
|
|
"""Return owner-visible email account rows for the active MCP call."""
|
|
return _filter_accounts_for_owner(_read_accounts_from_db())
|
|
|
|
|
|
def _resolve_account_from_rows(rows: list[dict], selector: str | None) -> dict | None:
|
|
"""Given a selector (None = default, or a name/user/id string), return the
|
|
matching row or None. Matching is case-insensitive substring on name +
|
|
imap_user + from_address, plus exact id match."""
|
|
if not rows:
|
|
return None
|
|
if not selector:
|
|
for r in rows:
|
|
if r.get("is_default"):
|
|
return r
|
|
return rows[0]
|
|
sel = selector.strip().lower()
|
|
sel_key = re.sub(r"[^a-z0-9]+", "", sel)
|
|
# Exact id match first
|
|
for r in rows:
|
|
if r["id"] == selector:
|
|
return r
|
|
for r in rows:
|
|
fields = [r.get("name") or "", r.get("imap_user") or "", r.get("from_address") or ""]
|
|
if any(sel in (f or "").lower() for f in fields):
|
|
return r
|
|
if sel_key and any(sel_key == re.sub(r"[^a-z0-9]+", "", (f or "").lower()) for f in fields):
|
|
return r
|
|
try:
|
|
from difflib import get_close_matches
|
|
candidates = []
|
|
by_candidate = {}
|
|
for r in rows:
|
|
for field in (r.get("name"), r.get("imap_user"), r.get("from_address")):
|
|
if field:
|
|
val = str(field).lower()
|
|
candidates.append(val)
|
|
by_candidate[val] = r
|
|
close = get_close_matches(sel, candidates, n=1, cutoff=0.72)
|
|
if close:
|
|
return by_candidate.get(close[0])
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def _resolve_account(selector: str | None) -> dict | None:
|
|
return _resolve_account_from_rows(_list_accounts_raw(), selector)
|
|
|
|
|
|
def _load_config(account: str | None = None) -> dict:
|
|
"""Return the full config dict for the requested account (or default).
|
|
|
|
Resolution order per-field:
|
|
1. email_accounts row (selected by `account` or default)
|
|
2. env vars + settings.json flat keys (legacy)
|
|
3. hardcoded fallbacks (localhost:31143 etc.)
|
|
"""
|
|
cache_key = (_current_owner(), (account or "").strip().lower() or "__default__")
|
|
if cache_key in _ACCOUNT_CACHE:
|
|
return _ACCOUNT_CACHE[cache_key]
|
|
|
|
cfg = {
|
|
"imap_host": os.environ.get("IMAP_HOST", "localhost"),
|
|
"imap_port": int(os.environ.get("IMAP_PORT", "31143")),
|
|
"imap_user": os.environ.get("IMAP_USER", ""),
|
|
"imap_password": os.environ.get("IMAP_PASSWORD", ""),
|
|
"imap_ssl": os.environ.get("IMAP_SSL", "false").lower() == "true",
|
|
"imap_starttls": os.environ.get("IMAP_STARTTLS", "true").lower() == "true",
|
|
"smtp_host": os.environ.get("SMTP_HOST", ""),
|
|
"smtp_port": int(os.environ.get("SMTP_PORT", "465")),
|
|
"smtp_security": os.environ.get("SMTP_SECURITY", ""),
|
|
"smtp_user": os.environ.get("SMTP_USER", ""),
|
|
"smtp_password": os.environ.get("SMTP_PASSWORD", ""),
|
|
"smtp_starttls": os.environ.get("SMTP_STARTTLS", "false").lower() == "true",
|
|
"smtp_ssl": os.environ.get("SMTP_SSL", "true").lower() == "true",
|
|
"from_address": os.environ.get("EMAIL_FROM", ""),
|
|
"archive_folder": os.environ.get("ARCHIVE_FOLDER", "Archive"),
|
|
"trash_folder": os.environ.get("TRASH_FOLDER", "Trash"),
|
|
"cache_db": os.environ.get(
|
|
"EMAIL_CACHE_DB",
|
|
EMAIL_CACHE_DB,
|
|
),
|
|
"account_id": None,
|
|
"account_name": None,
|
|
}
|
|
|
|
raw_rows = _read_accounts_from_db()
|
|
if _mcp_owner_required(raw_rows):
|
|
raise ValueError(_OWNER_SCOPE_ERROR)
|
|
rows = _filter_accounts_for_owner(raw_rows)
|
|
row = _resolve_account_from_rows(rows, account)
|
|
if _current_owner() and raw_rows and not rows:
|
|
raise ValueError("No email account is configured for the authenticated owner")
|
|
if account and rows and not row:
|
|
available = ", ".join(
|
|
f"{r.get('name') or r.get('imap_user')} <{r.get('imap_user') or r.get('from_address') or '?'}>"
|
|
for r in rows
|
|
)
|
|
raise ValueError(f"Email account not found for selector {account!r}. Available accounts: {available}")
|
|
if row:
|
|
cfg["account_id"] = row["id"]
|
|
cfg["account_name"] = row["name"]
|
|
cfg["imap_host"] = row["imap_host"] or cfg["imap_host"]
|
|
cfg["imap_port"] = int(row["imap_port"] or cfg["imap_port"])
|
|
cfg["imap_user"] = row["imap_user"] or cfg["imap_user"]
|
|
# Passwords in email_accounts are stored encrypted via
|
|
# src.secret_storage.encrypt — decrypt before handing to IMAP
|
|
# (same path email_helpers.py:369 uses). Falling back to the raw
|
|
# ciphertext is what produced AUTHENTICATIONFAILED previously.
|
|
try:
|
|
from src.secret_storage import decrypt as _decrypt
|
|
except Exception:
|
|
_decrypt = lambda v: v # noqa: E731
|
|
cfg["imap_password"] = _decrypt(row["imap_password"]) if row["imap_password"] else cfg["imap_password"]
|
|
cfg["imap_starttls"] = bool(row["imap_starttls"])
|
|
# The email_accounts table stores STARTTLS but not an explicit IMAP SSL
|
|
# flag. Port 993 is implicit TLS for IMAP providers like Gmail.
|
|
cfg["imap_ssl"] = int(cfg["imap_port"]) == 993 and not cfg["imap_starttls"]
|
|
cfg["smtp_host"] = row["smtp_host"] or cfg["smtp_host"]
|
|
cfg["smtp_port"] = int(row["smtp_port"] or cfg["smtp_port"])
|
|
cfg["smtp_security"] = row["smtp_security"] or cfg["smtp_security"] or ("starttls" if int(cfg["smtp_port"]) == 587 else "ssl")
|
|
cfg["smtp_user"] = row["smtp_user"] or cfg["smtp_user"]
|
|
cfg["smtp_password"] = _decrypt(row["smtp_password"]) if row["smtp_password"] else cfg["smtp_password"]
|
|
cfg["from_address"] = row["from_address"] or row["imap_user"] or cfg["from_address"]
|
|
else:
|
|
# Legacy fallback: settings.json flat keys
|
|
try:
|
|
settings_path = Path(_SETTINGS_FILE)
|
|
if settings_path.exists():
|
|
settings = json.loads(settings_path.read_text(encoding="utf-8"))
|
|
for key in (
|
|
"imap_host", "imap_port", "imap_user", "imap_password",
|
|
"smtp_host", "smtp_port", "smtp_user", "smtp_password",
|
|
"from_address", "archive_folder", "trash_folder",
|
|
):
|
|
if settings.get(key) not in (None, ""):
|
|
cfg[key] = int(settings[key]) if key.endswith("_port") else settings[key]
|
|
except Exception:
|
|
pass
|
|
|
|
if not cfg["from_address"]:
|
|
cfg["from_address"] = cfg["imap_user"]
|
|
|
|
_ACCOUNT_CACHE[cache_key] = cfg
|
|
return cfg
|
|
|
|
|
|
# ── IMAP helpers ──
|
|
|
|
|
|
def _imap_connect(account: str | None = None):
|
|
"""Connect to IMAP server, returns logged-in connection. account selects
|
|
the mailbox (None = default)."""
|
|
cfg = _load_config(account)
|
|
if cfg["imap_ssl"]:
|
|
conn = imaplib.IMAP4_SSL(
|
|
cfg["imap_host"],
|
|
cfg["imap_port"],
|
|
timeout=EMAIL_SOCKET_TIMEOUT,
|
|
)
|
|
else:
|
|
conn = imaplib.IMAP4(
|
|
cfg["imap_host"],
|
|
cfg["imap_port"],
|
|
timeout=EMAIL_SOCKET_TIMEOUT,
|
|
)
|
|
if cfg["imap_starttls"]:
|
|
try:
|
|
conn.starttls()
|
|
except Exception:
|
|
# Don't leak the open plain socket on a rejected STARTTLS. (#3174)
|
|
try:
|
|
conn.shutdown()
|
|
except Exception:
|
|
pass
|
|
raise
|
|
if getattr(conn, "sock", None):
|
|
conn.sock.settimeout(EMAIL_SOCKET_TIMEOUT)
|
|
try:
|
|
conn.login(cfg["imap_user"], cfg["imap_password"])
|
|
except Exception:
|
|
# A failed login otherwise orphans the connected socket; close it
|
|
# before propagating (shutdown() is the pre-auth low-level close). (#3174)
|
|
try:
|
|
conn.shutdown()
|
|
except Exception:
|
|
pass
|
|
raise
|
|
return conn
|
|
|
|
|
|
def _detect_sent_folder(conn):
|
|
"""Find the account's Sent folder name; fall back to 'Sent'."""
|
|
candidates = ("Sent", "[Gmail]/Sent Mail", "Sent Mail", "Sent Items", "INBOX.Sent")
|
|
try:
|
|
status, folders = conn.list()
|
|
if status != "OK" or not folders:
|
|
return "Sent"
|
|
names = []
|
|
for f in folders:
|
|
decoded = f.decode() if isinstance(f, bytes) else str(f)
|
|
m = re.search(r'"([^"]*)"\s*$|(\S+)\s*$', decoded)
|
|
if m:
|
|
names.append(m.group(1) or m.group(2))
|
|
for f in folders:
|
|
decoded = f.decode() if isinstance(f, bytes) else str(f)
|
|
if r"\Sent" in decoded:
|
|
m = re.search(r'"([^"]*)"\s*$|(\S+)\s*$', decoded)
|
|
if m:
|
|
return m.group(1) or m.group(2)
|
|
for c in candidates:
|
|
if c in names:
|
|
return c
|
|
except Exception:
|
|
pass
|
|
return "Sent"
|
|
|
|
|
|
def _folder_name_from_list_line(line) -> str | None:
|
|
decoded = line.decode() if isinstance(line, bytes) else str(line)
|
|
m = re.search(r'"([^"]*)"\s*$|(\S+)\s*$', decoded)
|
|
if not m:
|
|
return None
|
|
return m.group(1) or m.group(2)
|
|
|
|
|
|
def _list_folder_lines(conn) -> list:
|
|
try:
|
|
status, folders = conn.list()
|
|
if status != "OK" or not folders:
|
|
return []
|
|
return folders
|
|
except Exception:
|
|
return []
|
|
|
|
|
|
def _resolve_folder(conn, preferred: str, role: str) -> str:
|
|
"""Resolve provider-specific folder names like Gmail's [Gmail]/Trash."""
|
|
folders = _list_folder_lines(conn)
|
|
names = [name for name in (_folder_name_from_list_line(f) for f in folders) if name]
|
|
if preferred and preferred in names:
|
|
return preferred
|
|
|
|
role_flags = {
|
|
"trash": ("\\Trash",),
|
|
"archive": ("\\Archive", "\\All"),
|
|
"junk": ("\\Junk",),
|
|
}.get(role, ())
|
|
for f in folders:
|
|
decoded = f.decode() if isinstance(f, bytes) else str(f)
|
|
if any(flag in decoded for flag in role_flags):
|
|
name = _folder_name_from_list_line(f)
|
|
if name:
|
|
return name
|
|
|
|
candidates = {
|
|
"trash": ("Trash", "[Gmail]/Trash", "[Google Mail]/Trash", "Bin", "Deleted Messages", "Deleted Items"),
|
|
"archive": ("Archive", "Archives", "[Gmail]/All Mail", "[Google Mail]/All Mail"),
|
|
"junk": ("Junk", "Spam", "[Gmail]/Spam", "[Google Mail]/Spam"),
|
|
}.get(role, ())
|
|
lower_map = {n.lower(): n for n in names}
|
|
for candidate in candidates:
|
|
if candidate.lower() in lower_map:
|
|
return lower_map[candidate.lower()]
|
|
return preferred
|
|
|
|
|
|
def _folder_role_from_name(name: str) -> str:
|
|
lower = (name or "").lower()
|
|
if "trash" in lower or "bin" in lower or "deleted" in lower:
|
|
return "trash"
|
|
if "junk" in lower or "spam" in lower:
|
|
return "junk"
|
|
if "archive" in lower or "all mail" in lower:
|
|
return "archive"
|
|
return ""
|
|
|
|
|
|
def _decode_header(raw):
|
|
"""Decode MIME encoded header."""
|
|
if not raw:
|
|
return ""
|
|
try:
|
|
# make_header concatenates per RFC 2047: no spurious space between an
|
|
# encoded-word and adjacent plain text (plain runs keep their own
|
|
# whitespace), and whitespace between two adjacent encoded-words is
|
|
# dropped. The old " ".join produced "Re: Jose" style double spaces
|
|
# on every non-ASCII subject or sender.
|
|
return str(email.header.make_header(email.header.decode_header(raw)))
|
|
except Exception:
|
|
# Malformed header or unknown charset: lossy per-part decode
|
|
decoded = []
|
|
for data, charset in email.header.decode_header(raw):
|
|
if isinstance(data, bytes):
|
|
try:
|
|
decoded.append(data.decode(charset or "utf-8", errors="replace"))
|
|
except LookupError:
|
|
decoded.append(data.decode("utf-8", errors="replace"))
|
|
else:
|
|
decoded.append(data)
|
|
return "".join(decoded)
|
|
|
|
|
|
def _uid_from_fetch_meta(meta_b: bytes) -> str:
|
|
m = re.search(rb"UID\s+(\d+)", meta_b or b"")
|
|
return m.group(1).decode("ascii", errors="ignore") if m else ""
|
|
|
|
|
|
def _parse_list_unsubscribe_header(value: str | None) -> list[dict]:
|
|
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) -> 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"))
|
|
if not methods:
|
|
return None
|
|
reasons: list[str] = ["has unsubscribe header"]
|
|
score = 45
|
|
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 re.search(r"\b(unsubscribe|newsletter|sale|discount|offer|promo|limited time)\b", (subject or "").lower()):
|
|
score += 10
|
|
reasons.append("promotional subject")
|
|
executable = [m for m in methods if m.get("executable")]
|
|
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],
|
|
}
|
|
|
|
|
|
def _fixture_unsubscribe_candidate_from_row(row: dict, folder: str) -> dict | None:
|
|
msg = EmailMessage()
|
|
from_header = str(row.get("from") or row.get("from_address") or "")
|
|
if row.get("from_address") and "<" not in from_header:
|
|
from_header = f"{from_header} <{row.get('from_address')}>"
|
|
if from_header:
|
|
msg["From"] = from_header
|
|
if row.get("subject"):
|
|
msg["Subject"] = str(row.get("subject") or "")
|
|
if row.get("message_id"):
|
|
msg["Message-ID"] = str(row.get("message_id") or "")
|
|
if row.get("list_unsubscribe"):
|
|
msg["List-Unsubscribe"] = str(row.get("list_unsubscribe") or "")
|
|
if row.get("list_id"):
|
|
msg["List-Id"] = str(row.get("list_id") or "")
|
|
if row.get("precedence"):
|
|
msg["Precedence"] = str(row.get("precedence") or "")
|
|
if row.get("auto_submitted"):
|
|
msg["Auto-Submitted"] = str(row.get("auto_submitted") or "")
|
|
return _email_unsubscribe_candidate_from_msg(msg, str(row.get("uid") or ""), folder)
|
|
|
|
|
|
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()
|
|
# A sender address is the actionable identity here. Newsletter links are
|
|
# often tokenized per message, so list/url keys would show the same sender
|
|
# repeatedly and cause repeated unsubscribe attempts.
|
|
if sender:
|
|
return ("sender", sender, "")
|
|
if list_id:
|
|
return ("list", list_id, method_target)
|
|
if method_target:
|
|
return ("method", method_kind, method_target)
|
|
return ("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())
|
|
|
|
|
|
def _scan_unsubscribe_candidates(folder="INBOX", account=None, limit=25, max_scan=500) -> dict:
|
|
limit = max(1, min(int(limit or 25), 500))
|
|
requested_max_scan = int(max_scan or 0)
|
|
# Keep a normal agent call responsive. A synchronous IMAP scan of an
|
|
# unbounded mailbox can exceed the tool request budget on Gmail.
|
|
max_scan = max(limit, min(requested_max_scan or 500, 500))
|
|
folder = folder or "INBOX"
|
|
candidates: list[dict] = []
|
|
if _fixture_email_enabled():
|
|
fixture_limit = max_scan if max_scan is not None else 1000000
|
|
rows = _fixture_list_emails(folder=folder, max_results=fixture_limit, account=account) or []
|
|
for row in rows:
|
|
candidate = _fixture_unsubscribe_candidate_from_row(row, folder)
|
|
if candidate:
|
|
candidates.append(candidate)
|
|
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),
|
|
int(c.get("uid") or 0),
|
|
),
|
|
reverse=True,
|
|
)
|
|
return {
|
|
"success": True,
|
|
"candidates": candidates[:limit],
|
|
"total": len(candidates),
|
|
"raw_total": raw_total,
|
|
"scanned": len(rows),
|
|
"folder": folder,
|
|
"account": account or "",
|
|
}
|
|
conn = _imap_connect(account)
|
|
try:
|
|
status, _ = conn.select(_q(folder), readonly=True)
|
|
if status != "OK":
|
|
return {"success": False, "error": f"Folder not found: {folder}", "candidates": []}
|
|
status, data = conn.uid("SEARCH", None, "ALL")
|
|
if status != "OK":
|
|
return {"success": False, "error": "Failed to search email headers", "candidates": []}
|
|
if 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)
|
|
if max_scan is not None:
|
|
uids = uids[:max_scan]
|
|
if not uids:
|
|
return {"success": True, "candidates": [], "total": 0, "scanned": 0, "folder": folder}
|
|
msg_data = []
|
|
fetched_any = False
|
|
for start in range(0, len(uids), 100):
|
|
batch_uids = uids[start:start + 100]
|
|
try:
|
|
status, batch = conn.uid("FETCH", _b(",".join(str(u) for u in batch_uids)), "(UID RFC822.HEADER)")
|
|
except Exception:
|
|
status, batch = "NO", []
|
|
if status == "OK":
|
|
fetched_any = True
|
|
msg_data.extend(batch or [])
|
|
continue
|
|
# Some IMAP providers reject multi-UID FETCH but accept a
|
|
# single-UID request. Preserve the scan instead of failing the
|
|
# complete operation for that provider-specific limitation.
|
|
for uid in batch_uids:
|
|
try:
|
|
single_status, single = conn.uid("FETCH", _b(str(uid)), "(UID RFC822.HEADER)")
|
|
except Exception:
|
|
single_status, single = "NO", []
|
|
if single_status == "OK":
|
|
fetched_any = True
|
|
msg_data.extend(single or [])
|
|
finally:
|
|
try:
|
|
conn.logout()
|
|
except Exception:
|
|
pass
|
|
if not fetched_any:
|
|
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.message_from_bytes(item[1] or b"")
|
|
except Exception:
|
|
continue
|
|
candidate = _email_unsubscribe_candidate_from_msg(msg, uid, folder)
|
|
if candidate:
|
|
candidates.append(candidate)
|
|
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), int(c.get("uid") or 0)), reverse=True)
|
|
return {
|
|
"success": True,
|
|
"candidates": candidates[:limit],
|
|
"total": len(candidates),
|
|
"raw_total": raw_total,
|
|
"scanned": len(uids),
|
|
"scan_mode": "bounded",
|
|
"has_more": bool(len(uids) >= max_scan),
|
|
"folder": folder,
|
|
"account": account or "",
|
|
}
|
|
|
|
|
|
def _unsubscribe_email(uid, folder="INBOX", account=None, method_index=0, allow_web=False) -> dict:
|
|
uid = str(uid or "").strip()
|
|
if not uid:
|
|
return {"success": False, "error": "uid is required"}
|
|
if _fixture_email_enabled():
|
|
fixture = _fixture_read_email(uid=uid, folder=folder, account=account)
|
|
if fixture is not None:
|
|
candidate = _fixture_unsubscribe_candidate_from_row(fixture, folder)
|
|
if not candidate:
|
|
return {"success": False, "error": "No List-Unsubscribe header found"}
|
|
methods = candidate.get("methods") or []
|
|
method_index = int(method_index or 0)
|
|
method = methods[method_index] if 0 <= method_index < len(methods) else (candidate.get("recommended_method") or methods[0])
|
|
if method.get("kind") == "url":
|
|
return {
|
|
"success": False,
|
|
"requires_browser": True,
|
|
"url": method.get("target"),
|
|
"candidate": candidate,
|
|
"instructions": (
|
|
"This unsubscribe is a web link. Ask the user for approval, then use the browser/web tool "
|
|
"to open the exact URL and complete the unsubscribe page. Do not fetch unrelated links."
|
|
),
|
|
}
|
|
if method.get("kind") != "mailto" or not method.get("executable"):
|
|
return {"success": False, "error": "Unsupported unsubscribe method", "candidate": candidate}
|
|
return {
|
|
"success": True,
|
|
"fixture": True,
|
|
"method": method,
|
|
"candidate": candidate,
|
|
"pending": True,
|
|
}
|
|
conn = _imap_connect(account)
|
|
try:
|
|
status, _ = conn.select(_q(folder), readonly=True)
|
|
if status != "OK":
|
|
return {"success": False, "error": f"Folder not found: {folder}"}
|
|
status, msg_data = conn.uid("FETCH", _b(uid), "(UID RFC822.HEADER)")
|
|
finally:
|
|
try:
|
|
conn.logout()
|
|
except Exception:
|
|
pass
|
|
if status != "OK" or not msg_data:
|
|
return {"success": False, "error": f"Email not found: {uid}"}
|
|
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.message_from_bytes(raw_header)
|
|
candidate = _email_unsubscribe_candidate_from_msg(msg, uid, folder)
|
|
if not candidate:
|
|
return {"success": False, "error": "No List-Unsubscribe header found"}
|
|
methods = candidate.get("methods") or []
|
|
method_index = int(method_index or 0)
|
|
method = methods[method_index] if 0 <= method_index < len(methods) else (candidate.get("recommended_method") or methods[0])
|
|
if method.get("kind") == "url":
|
|
return {
|
|
"success": False,
|
|
"requires_browser": True,
|
|
"url": method.get("target"),
|
|
"candidate": candidate,
|
|
"instructions": (
|
|
"This unsubscribe is a web link. Ask the user for approval, then use the browser/web tool "
|
|
"to open the exact URL and complete the unsubscribe page. Do not fetch unrelated links."
|
|
),
|
|
}
|
|
if method.get("kind") != "mailto" or not method.get("executable"):
|
|
return {"success": False, "error": "Unsupported unsubscribe method", "candidate": candidate}
|
|
result = _send_email(
|
|
to=method.get("target"),
|
|
subject=method.get("subject") or "unsubscribe",
|
|
body=method.get("body") or "unsubscribe",
|
|
account=account,
|
|
)
|
|
if "error" in result:
|
|
return {"success": False, "error": result["error"], "candidate": candidate}
|
|
deleted = False
|
|
if not result.get("pending"):
|
|
# Do not leave a successfully handled newsletter in the scan source
|
|
# folder. Pending confirmation drafts are intentionally left alone.
|
|
deleted = bool(_delete_email(uid, folder=folder, account=account))
|
|
return {
|
|
"success": True,
|
|
"method": method,
|
|
"candidate": candidate,
|
|
"send_result": result,
|
|
"pending": bool(result.get("pending")),
|
|
"deleted": deleted,
|
|
}
|
|
|
|
|
|
def _extract_text(msg):
|
|
"""Extract plain text body from email message."""
|
|
if msg.is_multipart():
|
|
text_parts = []
|
|
for part in msg.walk():
|
|
ct = part.get_content_type()
|
|
cd = str(part.get("Content-Disposition", ""))
|
|
if ct == "text/plain" and "attachment" not in cd:
|
|
payload = part.get_payload(decode=True)
|
|
if payload:
|
|
charset = part.get_content_charset() or "utf-8"
|
|
text_parts.append(payload.decode(charset, errors="replace"))
|
|
elif ct == "text/html" and not text_parts and "attachment" not in cd:
|
|
payload = part.get_payload(decode=True)
|
|
if payload:
|
|
charset = part.get_content_charset() or "utf-8"
|
|
raw_html = payload.decode(charset, errors="replace")
|
|
text = re.sub(r"<br\s*/?>", "\n", raw_html, flags=re.I)
|
|
text = re.sub(r"<[^>]+>", "", text)
|
|
text = html.unescape(text)
|
|
text_parts.append(text.strip())
|
|
return "\n".join(text_parts)
|
|
else:
|
|
payload = msg.get_payload(decode=True)
|
|
if payload:
|
|
charset = msg.get_content_charset() or "utf-8"
|
|
text = payload.decode(charset, errors="replace")
|
|
if msg.get_content_type() == "text/html":
|
|
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.I)
|
|
text = re.sub(r"</(?:p|div|li|tr|h[1-6])\s*>", "\n", text, flags=re.I)
|
|
text = re.sub(r"<[^>]+>", "", text)
|
|
text = html.unescape(text)
|
|
text = re.sub(r"[ \t]+\n", "\n", text)
|
|
text = re.sub(r"\n{3,}", "\n\n", text)
|
|
return text.strip()
|
|
return ""
|
|
|
|
|
|
def _get_cached_summaries():
|
|
"""Read pre-computed summaries from SQLite cache."""
|
|
cfg = _load_config()
|
|
db_path = cfg["cache_db"]
|
|
if not os.path.exists(db_path):
|
|
return {}
|
|
try:
|
|
conn = sqlite3.connect(db_path)
|
|
rows = conn.execute(
|
|
"SELECT subject, sender, summary, suggested_reply FROM email_ai"
|
|
).fetchall()
|
|
conn.close()
|
|
result = {}
|
|
for subj, sender, summary, reply in rows:
|
|
result[subj] = {"sender": sender, "summary": summary, "reply": reply}
|
|
return result
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
def _fixture_email_file() -> Path:
|
|
return DATA_DIR / "fixture_email_messages.json"
|
|
|
|
|
|
def _blocked_senders_file() -> Path:
|
|
return DATA_DIR / "email_blocked_senders.json"
|
|
|
|
|
|
def _normalize_email_address(value: str | None) -> str:
|
|
name, addr = email.utils.parseaddr(str(value or ""))
|
|
return (addr or name or str(value or "")).strip().lower()
|
|
|
|
|
|
def _blocked_senders_payload() -> dict:
|
|
path = _blocked_senders_file()
|
|
if not path.exists():
|
|
return {"owners": {}}
|
|
try:
|
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
return {"owners": {}}
|
|
if not isinstance(raw, dict):
|
|
return {"owners": {}}
|
|
owners = raw.get("owners")
|
|
if not isinstance(owners, dict):
|
|
raw["owners"] = {}
|
|
return raw
|
|
|
|
|
|
def _write_blocked_senders_payload(payload: dict) -> None:
|
|
path = _blocked_senders_file()
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
|
|
|
|
def _blocked_sender_entries(owner: str | None = None) -> list[dict]:
|
|
payload = _blocked_senders_payload()
|
|
owner_key = str(owner or _current_owner() or "default").strip() or "default"
|
|
entries = payload.get("owners", {}).get(owner_key, [])
|
|
return entries if isinstance(entries, list) else []
|
|
|
|
|
|
def _blocked_sender_set(owner: str | None = None) -> set[str]:
|
|
out = set()
|
|
for entry in _blocked_sender_entries(owner):
|
|
if isinstance(entry, dict):
|
|
addr = _normalize_email_address(entry.get("sender"))
|
|
else:
|
|
addr = _normalize_email_address(str(entry))
|
|
if addr:
|
|
out.add(addr)
|
|
return out
|
|
|
|
|
|
def _sender_is_blocked(sender: str | None, owner: str | None = None) -> bool:
|
|
addr = _normalize_email_address(sender)
|
|
return bool(addr and addr in _blocked_sender_set(owner))
|
|
|
|
|
|
def _add_blocked_sender(sender: str, reason: str = "", account: str | None = None) -> tuple[bool, str]:
|
|
addr = _normalize_email_address(sender)
|
|
if not addr or "@" not in addr:
|
|
return False, "No valid sender email address provided."
|
|
owner_key = str(_current_owner() or "default").strip() or "default"
|
|
payload = _blocked_senders_payload()
|
|
owners = payload.setdefault("owners", {})
|
|
entries = owners.setdefault(owner_key, [])
|
|
if not isinstance(entries, list):
|
|
entries = []
|
|
owners[owner_key] = entries
|
|
for entry in entries:
|
|
if isinstance(entry, dict) and _normalize_email_address(entry.get("sender")) == addr:
|
|
return False, f"{addr} is already blocked."
|
|
entries.append({
|
|
"sender": addr,
|
|
"reason": str(reason or "").strip(),
|
|
"account": str(account or "").strip(),
|
|
"created_at": datetime.utcnow().isoformat(timespec="seconds") + "Z",
|
|
})
|
|
_write_blocked_senders_payload(payload)
|
|
return True, f"Blocked sender {addr}."
|
|
|
|
|
|
def _list_blocked_senders(account: str | None = None) -> dict:
|
|
entries = []
|
|
selector = _normalize_fixture_account_selector(account)
|
|
for entry in _blocked_sender_entries():
|
|
if isinstance(entry, dict):
|
|
sender = _normalize_email_address(entry.get("sender"))
|
|
entry_account = str(entry.get("account") or "").strip()
|
|
if selector and selector not in {
|
|
_normalize_fixture_account_selector(entry_account),
|
|
str(entry_account).strip().lower(),
|
|
}:
|
|
continue
|
|
if sender:
|
|
entries.append({
|
|
"sender": sender,
|
|
"reason": str(entry.get("reason") or "").strip(),
|
|
"account": entry_account,
|
|
"created_at": str(entry.get("created_at") or "").strip(),
|
|
})
|
|
else:
|
|
sender = _normalize_email_address(str(entry))
|
|
if sender:
|
|
entries.append({"sender": sender, "reason": "", "account": "", "created_at": ""})
|
|
entries.sort(key=lambda item: (item.get("created_at") or "", item.get("sender") or ""), reverse=True)
|
|
return {"success": True, "blocked_senders": entries}
|
|
|
|
|
|
def _unblock_sender(sender: str, account: str | None = None) -> dict:
|
|
addr = _normalize_email_address(sender)
|
|
if not addr or "@" not in addr:
|
|
return {"success": False, "error": "No valid sender email address provided."}
|
|
owner_key = str(_current_owner() or "default").strip() or "default"
|
|
payload = _blocked_senders_payload()
|
|
owners = payload.setdefault("owners", {})
|
|
entries = owners.get(owner_key, [])
|
|
if not isinstance(entries, list):
|
|
entries = []
|
|
selector = _normalize_fixture_account_selector(account)
|
|
kept = []
|
|
removed = []
|
|
for entry in entries:
|
|
entry_sender = _normalize_email_address(entry.get("sender") if isinstance(entry, dict) else str(entry))
|
|
entry_account = str(entry.get("account") or "").strip() if isinstance(entry, dict) else ""
|
|
account_matches = not selector or selector in {
|
|
_normalize_fixture_account_selector(entry_account),
|
|
str(entry_account).strip().lower(),
|
|
}
|
|
if entry_sender == addr and account_matches:
|
|
removed.append(entry)
|
|
else:
|
|
kept.append(entry)
|
|
owners[owner_key] = kept
|
|
if not removed:
|
|
return {"success": False, "error": f"{addr} is not currently blocked."}
|
|
_write_blocked_senders_payload(payload)
|
|
return {"success": True, "sender": addr, "removed": len(removed)}
|
|
|
|
|
|
def _fixture_email_enabled() -> bool:
|
|
return os.environ.get("ODYSSEUS_EMAIL_FIXTURE") == "1" and _fixture_email_file().exists()
|
|
|
|
|
|
def _fixture_folder_key(folder: str | None) -> str:
|
|
value = str(folder or "INBOX").strip().lower()
|
|
if value in {"", "inbox"}:
|
|
return "inbox"
|
|
if value in {"archive", "archived", "[gmail]/all mail", "all mail"}:
|
|
return "archive"
|
|
if value in {"all"}:
|
|
return "all"
|
|
if value in {"trash", "deleted", "bin"}:
|
|
return "trash"
|
|
return value
|
|
|
|
|
|
def _fixture_folder_matches(row_folder: str | None, requested: str | None) -> bool:
|
|
req = _fixture_folder_key(requested)
|
|
actual = _fixture_folder_key(row_folder or "INBOX")
|
|
if req == "all":
|
|
return actual != "trash"
|
|
return actual == req
|
|
|
|
|
|
def _parse_fixture_date(raw_date: str) -> tuple[str, float]:
|
|
if not raw_date:
|
|
return "", 0.0
|
|
parsed = None
|
|
try:
|
|
parsed = datetime.fromisoformat(str(raw_date).replace("Z", "+00:00"))
|
|
except Exception:
|
|
try:
|
|
parsed = email.utils.parsedate_to_datetime(str(raw_date))
|
|
except Exception:
|
|
parsed = None
|
|
if parsed:
|
|
return parsed.isoformat(), parsed.timestamp()
|
|
return str(raw_date), 0.0
|
|
|
|
|
|
def _fixture_email_record(row: dict, uid_num: int, owner: str) -> dict:
|
|
sender = str(row.get("from") or "Inbox Sender <updates@primary-inbox.local>")
|
|
sender_name, sender_addr = email.utils.parseaddr(sender)
|
|
date_str, date_epoch = _parse_fixture_date(str(row.get("date") or ""))
|
|
subject = str(row.get("subject") or "(no subject)")
|
|
body = str(row.get("body") or "")
|
|
owner_key = re.sub(r"[^A-Za-z0-9_.-]", "-", owner or "default")
|
|
uid = str(row.get("uid") or uid_num)
|
|
message_id = str(row.get("message_id") or "").strip()
|
|
folder = str(row.get("folder") or "INBOX").strip() or "INBOX"
|
|
if _fixture_folder_key(folder) == "inbox" and _sender_is_blocked(sender_addr or sender, owner):
|
|
folder = "Junk"
|
|
raw_attachments = row.get("attachments") if isinstance(row.get("attachments"), list) else []
|
|
attachments = _fixture_attachment_meta(raw_attachments)
|
|
attachment_text = "\n".join(
|
|
f"{att.get('filename') or ''}\n{att.get('content') or ''}"
|
|
for att in raw_attachments
|
|
if isinstance(att, dict)
|
|
)
|
|
headers = row.get("headers") if isinstance(row.get("headers"), dict) else {}
|
|
return {
|
|
"uid": uid,
|
|
"message_id": message_id or f"<inbox-{uid}-{owner_key}@mail.local>",
|
|
"subject": subject,
|
|
"from": sender_name or sender_addr or sender,
|
|
"from_address": sender_addr,
|
|
"date": date_str,
|
|
"date_epoch": date_epoch,
|
|
"summary": body[:240],
|
|
"body": body,
|
|
"account": str(row.get("account") or "Primary Inbox"),
|
|
"account_email": str(row.get("account_email") or row.get("to") or owner or row.get("owner") or ""),
|
|
"account_id": str(row.get("account_id") or "primary-inbox"),
|
|
"attachments": attachments,
|
|
"has_attachments": bool(attachments),
|
|
"_fixture_attachment_text": attachment_text,
|
|
"folder": folder,
|
|
"is_read": bool(row.get("read")),
|
|
"is_done": bool(row.get("done") or row.get("answered")),
|
|
"is_favorite": bool(row.get("favorite") or row.get("flagged") or row.get("starred")),
|
|
"spam_label": str(row.get("spam_label") or ""),
|
|
"spam_score": int(row.get("spam_score") or 0),
|
|
"list_unsubscribe": str(row.get("list_unsubscribe") or headers.get("List-Unsubscribe") or ""),
|
|
"list_id": str(row.get("list_id") or headers.get("List-Id") or ""),
|
|
"precedence": str(row.get("precedence") or headers.get("Precedence") or ""),
|
|
"auto_submitted": str(row.get("auto_submitted") or headers.get("Auto-Submitted") or ""),
|
|
}
|
|
|
|
|
|
def _fixture_email_rows(owner: str | None = None) -> list[dict]:
|
|
path = _fixture_email_file()
|
|
if not path.exists():
|
|
return []
|
|
try:
|
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
return []
|
|
rows = raw.get("messages") if isinstance(raw, dict) else raw
|
|
out = []
|
|
owner = str(owner or "").strip()
|
|
for i, row in enumerate(rows if isinstance(rows, list) else [], start=1):
|
|
if not isinstance(row, dict):
|
|
continue
|
|
row_owner = str(row.get("owner") or "").strip()
|
|
if owner and row_owner and row_owner != owner:
|
|
continue
|
|
out.append(_fixture_email_record(row, i, owner or row_owner))
|
|
out.sort(key=lambda item: item.get("date_epoch") or 0, reverse=True)
|
|
return out
|
|
|
|
|
|
def _fixture_owner_has_rows(owner: str | None = None) -> bool:
|
|
owner = str(owner or "").strip()
|
|
if not owner:
|
|
return True
|
|
path = _fixture_email_file()
|
|
if not path.exists():
|
|
return False
|
|
try:
|
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
return False
|
|
rows = raw.get("messages") if isinstance(raw, dict) else raw
|
|
return any(
|
|
isinstance(row, dict) and str(row.get("owner") or "").strip() == owner
|
|
for row in (rows if isinstance(rows, list) else [])
|
|
)
|
|
|
|
|
|
def _fixture_account_rows() -> list[dict]:
|
|
if not _fixture_email_enabled():
|
|
return []
|
|
owner = _current_owner()
|
|
seen = set()
|
|
accounts = []
|
|
for row in _fixture_email_rows(owner or None):
|
|
account_id = row.get("account_id") or "primary-inbox"
|
|
if account_id in seen:
|
|
continue
|
|
seen.add(account_id)
|
|
email_addr = row.get("account_email") or owner or "inbox@mail.local"
|
|
accounts.append({
|
|
"id": account_id,
|
|
"owner": owner or email_addr,
|
|
"name": row.get("account") or "Primary Inbox",
|
|
"is_default": account_id == "primary-inbox",
|
|
"imap_user": email_addr,
|
|
"from_address": email_addr,
|
|
})
|
|
if not accounts:
|
|
accounts.append({
|
|
"id": "primary-inbox",
|
|
"owner": owner or "inbox@mail.local",
|
|
"name": "Primary Inbox",
|
|
"is_default": True,
|
|
"imap_user": owner or "inbox@mail.local",
|
|
"from_address": owner or "inbox@mail.local",
|
|
})
|
|
accounts.sort(key=lambda item: (not item.get("is_default"), str(item.get("name") or "")))
|
|
return accounts
|
|
|
|
|
|
def _fixture_attachment_meta(raw_attachments: list[dict]) -> list[dict]:
|
|
out = []
|
|
for idx, att in enumerate(raw_attachments):
|
|
if not isinstance(att, dict):
|
|
continue
|
|
filename = str(att.get("filename") or f"attachment-{idx}.txt")
|
|
content = str(att.get("content") or "")
|
|
content_type = str(att.get("content_type") or "application/octet-stream")
|
|
out.append({
|
|
"index": int(att.get("index", idx) or idx),
|
|
"filename": filename,
|
|
"content_type": content_type,
|
|
"size": len(content.encode("utf-8")),
|
|
})
|
|
return out
|
|
|
|
|
|
def _fixture_attachment_haystack(item: dict) -> str:
|
|
values = []
|
|
for att in item.get("attachments") or []:
|
|
values.append(str(att.get("filename") or ""))
|
|
values.append(str(item.get("_fixture_attachment_text") or ""))
|
|
return "\n".join(values)
|
|
|
|
|
|
def _fixture_attachment_source(uid, index, folder="INBOX", account=None) -> tuple[dict, dict] | None:
|
|
if not _fixture_email_enabled():
|
|
return None
|
|
if account and _normalize_fixture_account_selector(account) not in _fixture_account_aliases():
|
|
return None
|
|
path = _fixture_email_file()
|
|
try:
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
return None
|
|
rows = payload.get("messages") if isinstance(payload, dict) else payload
|
|
owner = _current_owner()
|
|
for row_index, row in enumerate(rows if isinstance(rows, list) else [], start=1):
|
|
if not isinstance(row, dict):
|
|
continue
|
|
row_owner = str(row.get("owner") or "").strip()
|
|
if owner and row_owner and row_owner != owner:
|
|
continue
|
|
if str(row.get("uid") or row_index) != str(uid):
|
|
continue
|
|
if not _fixture_folder_matches(row.get("folder") or "INBOX", folder):
|
|
continue
|
|
attachments = row.get("attachments") if isinstance(row.get("attachments"), list) else []
|
|
for att_index, att in enumerate(attachments):
|
|
if int(att.get("index", att_index) or att_index) == int(index):
|
|
return row, att
|
|
return None
|
|
|
|
|
|
def _fixture_account_aliases() -> set[str]:
|
|
owner = _current_owner()
|
|
aliases = {"primary-inbox", "primary inbox", "inbox", str(owner or "").lower()}
|
|
for row in _fixture_email_rows(owner or None):
|
|
for key in ("account", "account_email", "account_id"):
|
|
value = str(row.get(key) or "").strip().lower()
|
|
if value:
|
|
aliases.add(value)
|
|
return aliases
|
|
|
|
|
|
def _normalize_fixture_account_selector(account=None) -> str:
|
|
selector = str(account or "").strip().lower()
|
|
match = re.search(r"<([^>]+)>", selector)
|
|
if match:
|
|
return match.group(1).strip().lower()
|
|
match = re.search(r"\(([^)]+@[^)]+)\)", selector)
|
|
if match:
|
|
return match.group(1).strip().lower()
|
|
return selector
|
|
|
|
|
|
def _fixture_row_matches_account(row: dict, account=None) -> bool:
|
|
if not account:
|
|
return True
|
|
selector = _normalize_fixture_account_selector(account)
|
|
selector_key = re.sub(r"[^a-z0-9]+", "", selector)
|
|
candidates = {
|
|
str(row.get("account") or "").strip().lower(),
|
|
str(row.get("account_email") or "").strip().lower(),
|
|
str(row.get("account_id") or "").strip().lower(),
|
|
}
|
|
candidate_keys = {re.sub(r"[^a-z0-9]+", "", value) for value in candidates if value}
|
|
return selector in {
|
|
*candidates,
|
|
*candidate_keys,
|
|
} or bool(selector_key and selector_key in candidate_keys)
|
|
|
|
|
|
def _fixture_email_matches(item: dict, query: str) -> bool:
|
|
if not query:
|
|
return True
|
|
terms = [term for term in re.split(r"\W+", str(query).lower()) if term]
|
|
haystack = "\n".join(
|
|
str(item.get(key) or "")
|
|
for key in ("subject", "from", "from_address", "body", "summary")
|
|
)
|
|
haystack = (haystack + "\n" + _fixture_attachment_haystack(item)).lower()
|
|
return all(term in haystack for term in terms)
|
|
|
|
|
|
def _spam_candidate_from_fixture(row: dict) -> dict | None:
|
|
score = int(row.get("spam_score") or 0)
|
|
label = str(row.get("spam_label") or "").strip()
|
|
body = str(row.get("body") or "")
|
|
reasons = []
|
|
for marker in re.findall(r"Red flags for training:\s*([^.\n]+)", body, flags=re.IGNORECASE):
|
|
reasons.extend(part.strip() for part in marker.split(",") if part.strip())
|
|
if label:
|
|
reasons.insert(0, label.replace("_", " "))
|
|
if score <= 0 and not reasons:
|
|
return None
|
|
return {
|
|
"uid": row.get("uid"),
|
|
"subject": row.get("subject"),
|
|
"from": row.get("from"),
|
|
"from_address": row.get("from_address"),
|
|
"date": row.get("date"),
|
|
"account": row.get("account"),
|
|
"account_email": row.get("account_email"),
|
|
"folder": row.get("folder"),
|
|
"spam_score": score,
|
|
"spam_label": label,
|
|
"reasons": reasons[:5],
|
|
"attachments": row.get("attachments") or [],
|
|
}
|
|
|
|
|
|
def _scan_spam(folder="INBOX", account=None, limit=10, max_scan=100) -> dict:
|
|
if _fixture_email_enabled():
|
|
rows = _fixture_list_emails(folder=folder, max_results=max_scan, account=account) or []
|
|
candidates = []
|
|
for row in rows:
|
|
candidate = _spam_candidate_from_fixture(row)
|
|
if candidate:
|
|
candidates.append(candidate)
|
|
candidates.sort(key=lambda item: (int(item.get("spam_score") or 0), item.get("date") or ""), reverse=True)
|
|
return {"success": True, "scanned": len(rows), "candidates": candidates[: int(limit or 10)]}
|
|
|
|
# Generic fallback for real mail: use unsubscribe/header heuristics plus
|
|
# keyword search. This is review-only; actions require explicit follow-up.
|
|
result = _scan_unsubscribe_candidates(folder=folder, account=account, limit=limit, max_scan=max_scan)
|
|
if not result.get("success"):
|
|
return result
|
|
candidates = []
|
|
for item in result.get("candidates") or []:
|
|
reasons = item.get("reasons") or []
|
|
candidates.append({
|
|
"uid": item.get("uid"),
|
|
"subject": item.get("subject"),
|
|
"from": item.get("from"),
|
|
"from_address": item.get("from_address"),
|
|
"date": item.get("date"),
|
|
"folder": item.get("folder") or folder,
|
|
"spam_score": 5,
|
|
"spam_label": "unsubscribe_candidate",
|
|
"reasons": reasons[:5],
|
|
"attachments": [],
|
|
})
|
|
return {"success": True, "scanned": result.get("scanned", 0), "candidates": candidates[: int(limit or 10)]}
|
|
|
|
|
|
def _fixture_date_in_range(row: dict, date_from=None, date_to=None) -> bool:
|
|
epoch = row.get("date_epoch") or 0
|
|
if not epoch:
|
|
return True
|
|
try:
|
|
if date_from:
|
|
start = datetime.fromisoformat(str(date_from).replace("Z", "+00:00")).timestamp()
|
|
if epoch < start:
|
|
return False
|
|
if date_to:
|
|
end = datetime.fromisoformat(str(date_to).replace("Z", "+00:00")).timestamp()
|
|
if epoch >= end:
|
|
return False
|
|
except Exception:
|
|
return True
|
|
return True
|
|
|
|
|
|
def _fixture_list_emails(folder="INBOX", max_results=20, unresponded_only=False,
|
|
unread_only=False, account=None, date_from=None,
|
|
date_to=None) -> list[dict] | None:
|
|
if not _fixture_email_enabled():
|
|
return None
|
|
if not _fixture_owner_has_rows(_current_owner()):
|
|
return None
|
|
if account and str(account).strip().lower() not in _fixture_account_aliases():
|
|
return []
|
|
rows = [
|
|
row for row in _fixture_email_rows(_current_owner())
|
|
if _fixture_folder_matches(row.get("folder"), folder)
|
|
and _fixture_row_matches_account(row, account)
|
|
and _fixture_date_in_range(row, date_from=date_from, date_to=date_to)
|
|
]
|
|
if unread_only:
|
|
rows = [row for row in rows if not row.get("is_read")]
|
|
return rows[: int(max_results or 20)]
|
|
|
|
|
|
def _fixture_search_emails(query, folders=None, max_results=20, account=None,
|
|
date_from=None, date_to=None) -> list[dict] | None:
|
|
if not _fixture_email_enabled():
|
|
return None
|
|
if not _fixture_owner_has_rows(_current_owner()):
|
|
return None
|
|
rows = _fixture_list_emails(
|
|
"INBOX",
|
|
max_results=1000,
|
|
account=account,
|
|
date_from=date_from,
|
|
date_to=date_to,
|
|
) or []
|
|
out = [
|
|
dict(
|
|
row,
|
|
_folder=row.get("folder") or "INBOX",
|
|
_account=row.get("account") or "Primary Inbox",
|
|
_account_email=row.get("account_email") or "",
|
|
_account_id=row.get("account_id") or "primary-inbox",
|
|
)
|
|
for row in rows
|
|
if _fixture_email_matches(row, str(query or ""))
|
|
]
|
|
return out[: int(max_results or 20)]
|
|
|
|
|
|
def _fixture_read_email(uid=None, message_id=None, folder="INBOX", account=None) -> dict | None:
|
|
if not _fixture_email_enabled():
|
|
return None
|
|
if not _fixture_owner_has_rows(_current_owner()):
|
|
return None
|
|
for item in _fixture_email_rows(_current_owner()):
|
|
if not _fixture_row_matches_account(item, account):
|
|
continue
|
|
if not _fixture_folder_matches(item.get("folder"), folder):
|
|
continue
|
|
if uid and str(item.get("uid")) == str(uid):
|
|
return item
|
|
if message_id and str(item.get("message_id")) == str(message_id):
|
|
return item
|
|
return {"error": f"Email not found with UID/Message-ID: {uid or message_id}"}
|
|
|
|
|
|
def _fixture_email_action_target(uid=None, folder="INBOX", account=None) -> dict | None:
|
|
item = _fixture_read_email(uid=uid, folder=folder, account=account)
|
|
if item is None:
|
|
return None
|
|
if item.get("error"):
|
|
return None
|
|
return item
|
|
|
|
|
|
def _fixture_update_email(uid=None, source_folder="INBOX", account=None, **updates) -> bool:
|
|
if not _fixture_email_enabled():
|
|
return False
|
|
if account and _normalize_fixture_account_selector(account) not in _fixture_account_aliases():
|
|
return False
|
|
path = _fixture_email_file()
|
|
try:
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
return False
|
|
rows = payload.get("messages") if isinstance(payload, dict) else payload
|
|
if not isinstance(rows, list):
|
|
return False
|
|
owner = _current_owner()
|
|
for index, row in enumerate(rows, start=1):
|
|
if not isinstance(row, dict):
|
|
continue
|
|
row_owner = str(row.get("owner") or "").strip()
|
|
if owner and row_owner and row_owner != owner:
|
|
continue
|
|
row_uid = str(row.get("uid") or index)
|
|
if str(row_uid) != str(uid):
|
|
continue
|
|
if not _fixture_folder_matches(row.get("folder") or "INBOX", source_folder):
|
|
continue
|
|
for key, value in updates.items():
|
|
if value is None:
|
|
row.pop(key, None)
|
|
else:
|
|
row[key] = value
|
|
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
return True
|
|
return False
|
|
|
|
|
|
# ── Tool implementations ──
|
|
|
|
|
|
def _indexed_list_rows_by_uids(account, folder: str, uids: list[str]) -> dict[str, dict]:
|
|
"""Return locally indexed headers for a live IMAP UID page."""
|
|
owner = _current_owner()
|
|
if not owner or not uids or not Path(SCHEDULED_EMAILS_DB).exists():
|
|
return {}
|
|
try:
|
|
account_key = _account_key(account, owner)
|
|
placeholders = ",".join("?" for _ in uids)
|
|
conn = sqlite3.connect(str(SCHEDULED_EMAILS_DB))
|
|
try:
|
|
rows = conn.execute(
|
|
f"""
|
|
SELECT uid, message_id, subject, from_name, from_address,
|
|
date_iso, date_display, attachment_names
|
|
FROM email_message_index
|
|
WHERE owner=? AND account_key=? AND folder=?
|
|
AND uid IN ({placeholders})
|
|
""",
|
|
[owner, account_key, str(folder or "INBOX"), *uids],
|
|
).fetchall()
|
|
finally:
|
|
conn.close()
|
|
except Exception:
|
|
return {}
|
|
return {
|
|
str(uid): {
|
|
"uid": str(uid),
|
|
"message_id": message_id or "",
|
|
"subject": subject or "(no subject)",
|
|
"from": from_name or from_address or "unknown",
|
|
"from_address": from_address or "",
|
|
"date": date_display or date_iso or "",
|
|
"attachments": [
|
|
{"filename": name.strip()}
|
|
for name in str(attachment_names or "").split("\n")
|
|
if name.strip()
|
|
],
|
|
}
|
|
for uid, message_id, subject, from_name, from_address,
|
|
date_iso, date_display, attachment_names in rows
|
|
}
|
|
|
|
|
|
def _indexed_latest_emails(
|
|
folder="INBOX",
|
|
max_results=20,
|
|
unresponded_only=False,
|
|
unread_only=False,
|
|
account=None,
|
|
date_from=None,
|
|
date_to=None,
|
|
) -> list[dict] | None:
|
|
"""Read the UI-maintained header index for a paint-fast inbox listing."""
|
|
owner = _current_owner()
|
|
if not owner or not Path(SCHEDULED_EMAILS_DB).exists():
|
|
return None
|
|
try:
|
|
if account:
|
|
account_rows = [
|
|
row for row in _list_accounts_raw()
|
|
if str(row.get("id") or row.get("name") or row.get("imap_user") or "").strip()
|
|
== str(_account_key(account, owner)).strip()
|
|
]
|
|
account_keys = [str(_account_key(account, owner)).strip()]
|
|
else:
|
|
account_rows = _list_accounts_raw()
|
|
account_keys = [
|
|
str(row.get("id") or row.get("name") or row.get("imap_user") or "").strip()
|
|
for row in account_rows
|
|
if str(row.get("id") or row.get("name") or row.get("imap_user") or "").strip()
|
|
]
|
|
if not account_keys:
|
|
return None
|
|
labels = {
|
|
str(row.get("id") or row.get("name") or row.get("imap_user") or "").strip():
|
|
(row.get("name") or row.get("imap_user") or "Mailbox")
|
|
for row in account_rows
|
|
}
|
|
addresses = {
|
|
str(row.get("id") or row.get("name") or row.get("imap_user") or "").strip():
|
|
(row.get("imap_user") or row.get("from_address") or "")
|
|
for row in account_rows
|
|
}
|
|
clauses = [
|
|
"owner=?",
|
|
"account_key IN (" + ",".join("?" for _ in account_keys) + ")",
|
|
"folder=?",
|
|
]
|
|
params: list = [owner, *account_keys, str(folder or "INBOX")]
|
|
if unread_only:
|
|
clauses.append("(flags IS NULL OR instr(flags, '\\Seen') = 0)")
|
|
if unresponded_only:
|
|
clauses.append("(flags IS NULL OR instr(flags, '\\Answered') = 0)")
|
|
start, end = _search_date_bounds(date_from, date_to)
|
|
if start is not None:
|
|
clauses.append("date_epoch >= ?")
|
|
params.append(start.timestamp())
|
|
if end is not None:
|
|
clauses.append("date_epoch < ?")
|
|
params.append(end.timestamp())
|
|
conn = sqlite3.connect(str(SCHEDULED_EMAILS_DB))
|
|
try:
|
|
rows = conn.execute(
|
|
f"""
|
|
SELECT account_key, uid, message_id, subject, from_name,
|
|
from_address, date_iso, date_display, attachment_names
|
|
FROM email_message_index
|
|
WHERE {' AND '.join(clauses)}
|
|
ORDER BY date_epoch DESC
|
|
LIMIT ?
|
|
""",
|
|
[*params, max(1, int(max_results or 20))],
|
|
).fetchall()
|
|
finally:
|
|
conn.close()
|
|
except Exception:
|
|
return None
|
|
if not rows:
|
|
return None
|
|
results = []
|
|
for account_key, uid, message_id, subject, from_name, from_address, date_iso, date_display, attachment_names in rows:
|
|
subject = subject or "(no subject)"
|
|
results.append({
|
|
"uid": str(uid),
|
|
"message_id": message_id or "",
|
|
"subject": subject,
|
|
"from": from_name or from_address or "unknown",
|
|
"from_address": from_address or "",
|
|
"date": date_display or date_iso or "",
|
|
# Listing must stay independent of account decryption and the
|
|
# optional AI-summary database. A later read/summary action can
|
|
# load body-derived summaries when the user actually requests it.
|
|
"summary": "",
|
|
"attachments": [
|
|
{"filename": name.strip()}
|
|
for name in str(attachment_names or "").split("\n")
|
|
if name.strip()
|
|
],
|
|
"_account": labels.get(str(account_key), str(account_key)),
|
|
"_account_email": addresses.get(str(account_key), ""),
|
|
"_account_id": str(account_key),
|
|
"_source": "index",
|
|
})
|
|
return results
|
|
|
|
|
|
def _list_header_page(conn, account, folder, uid_list):
|
|
"""Reuse indexed headers, batch remote misses, leave per-UID fallback to caller."""
|
|
indexed_rows = _indexed_list_rows_by_uids(account, folder, [uid.decode() for uid in uid_list])
|
|
headers_by_uid = {}
|
|
missing_uids = [uid for uid in uid_list if uid.decode() not in indexed_rows]
|
|
if missing_uids:
|
|
try:
|
|
uid_set = _b(",".join(uid.decode() for uid in missing_uids))
|
|
status, msg_data = conn.uid("FETCH", uid_set, "(UID RFC822.HEADER)")
|
|
except Exception:
|
|
status, msg_data = "NO", []
|
|
if status == "OK":
|
|
for item in msg_data or []:
|
|
if not isinstance(item, tuple) or len(item) < 2:
|
|
continue
|
|
fetched_uid = _uid_from_fetch_meta(item[0])
|
|
if fetched_uid and isinstance(item[1], bytes):
|
|
headers_by_uid[fetched_uid] = item[1]
|
|
return indexed_rows, headers_by_uid
|
|
|
|
|
|
def _header_date_in_bounds(value, start, end):
|
|
if start is None and end is None:
|
|
return True
|
|
try:
|
|
try:
|
|
sent = email.utils.parsedate_to_datetime(str(value or ""))
|
|
except (ValueError, TypeError):
|
|
sent = datetime.fromisoformat(str(value or "").replace("Z", "+00:00"))
|
|
if sent is None:
|
|
return False
|
|
if sent.tzinfo is None:
|
|
sent = sent.replace(tzinfo=timezone.utc)
|
|
return (start is None or sent >= start) and (end is None or sent < end)
|
|
except (ValueError, TypeError, OverflowError):
|
|
return False
|
|
|
|
|
|
def _list_emails(folder="INBOX", max_results=20, unresponded_only=False,
|
|
unread_only=False, account=None, date_from=None, date_to=None):
|
|
"""List emails newest-first. By default returns the latest messages,
|
|
including read mail, so it matches normal inbox UI expectations.
|
|
Pass unread_only=True and/or unresponded_only=True for attention scans.
|
|
account selects mailbox (None = default).
|
|
"""
|
|
start, end = _search_date_bounds(date_from, date_to)
|
|
max_results = max(1, int(max_results or 20))
|
|
fixture = _fixture_list_emails(
|
|
folder,
|
|
max_results,
|
|
unresponded_only,
|
|
unread_only,
|
|
account,
|
|
date_from=date_from,
|
|
date_to=date_to,
|
|
)
|
|
if fixture is not None:
|
|
return fixture
|
|
indexed = _indexed_latest_emails(
|
|
folder=folder,
|
|
max_results=max_results,
|
|
unresponded_only=unresponded_only,
|
|
unread_only=unread_only,
|
|
account=account,
|
|
date_from=date_from,
|
|
date_to=date_to,
|
|
)
|
|
if indexed is not None:
|
|
return indexed
|
|
conn = None
|
|
try:
|
|
conn = _imap_connect(account)
|
|
select_status, _ = conn.select(_q(folder), readonly=True)
|
|
if select_status != "OK":
|
|
raise ValueError(f"IMAP folder not found: {folder}")
|
|
|
|
if unread_only and unresponded_only:
|
|
search_cmd = "(UNSEEN UNANSWERED)"
|
|
elif unread_only:
|
|
search_cmd = "(UNSEEN)"
|
|
elif unresponded_only:
|
|
# Was missing — unresponded_only=True (without unread_only) fell through
|
|
# to "ALL" and returned answered mail too, despite the documented
|
|
# "emails without replies" behaviour.
|
|
search_cmd = "(UNANSWERED)"
|
|
else:
|
|
# Include read too — IMAP search "ALL" returns the entire folder
|
|
search_cmd = "ALL"
|
|
status, data = conn.uid("SEARCH", None, search_cmd + _imap_sent_date_criteria(start, end))
|
|
|
|
if status != "OK" or not data[0]:
|
|
return []
|
|
|
|
uid_list = list(reversed(data[0].split()))
|
|
if start is None and end is None:
|
|
uid_list = uid_list[:max_results]
|
|
cache = _get_cached_summaries()
|
|
results = []
|
|
page_size = min(50, max_results)
|
|
for offset, uid in enumerate(uid_list):
|
|
if len(results) >= max_results:
|
|
break
|
|
if offset % page_size == 0:
|
|
indexed_rows, headers_by_uid = _list_header_page(
|
|
conn, account, folder, uid_list[offset:offset + page_size])
|
|
uid_text = uid.decode()
|
|
indexed = indexed_rows.get(uid_text)
|
|
if indexed is not None:
|
|
item = dict(indexed)
|
|
if not _header_date_in_bounds(item.get("date"), start, end):
|
|
continue
|
|
item["summary"] = cache.get(item.get("subject") or "", {}).get("summary", "")
|
|
results.append(item)
|
|
continue
|
|
raw_header = headers_by_uid.get(uid_text)
|
|
if raw_header is None:
|
|
try:
|
|
status, msg_data = conn.uid("FETCH", uid, "(RFC822.HEADER)")
|
|
if status != "OK" or not msg_data or not isinstance(msg_data[0], tuple):
|
|
continue
|
|
raw_header = msg_data[0][1]
|
|
except Exception:
|
|
continue
|
|
try:
|
|
msg = email.message_from_bytes(raw_header)
|
|
|
|
subject = _decode_header(msg.get("Subject", "(no subject)"))
|
|
sender = _decode_header(msg.get("From", "unknown"))
|
|
date_str = msg.get("Date", "")
|
|
message_id = msg.get("Message-ID", "")
|
|
|
|
if not _header_date_in_bounds(date_str, start, end):
|
|
continue
|
|
|
|
# Parse sender name
|
|
sender_name, sender_addr = email.utils.parseaddr(sender)
|
|
sender_display = sender_name or sender_addr
|
|
|
|
# Check cache for summary
|
|
cached = cache.get(subject, {})
|
|
summary = cached.get("summary", "")
|
|
|
|
results.append({
|
|
"uid": uid_text,
|
|
"message_id": message_id,
|
|
"subject": subject,
|
|
"from": sender_display,
|
|
"from_address": sender_addr,
|
|
"date": date_str,
|
|
"summary": summary,
|
|
})
|
|
except Exception:
|
|
continue
|
|
|
|
return results
|
|
finally:
|
|
if conn:
|
|
try: conn.logout()
|
|
except Exception: pass
|
|
|
|
|
|
def _result_sort_time(result: dict) -> datetime:
|
|
try:
|
|
parsed = email.utils.parsedate_to_datetime(result.get("date") or "")
|
|
if parsed:
|
|
if parsed.tzinfo:
|
|
parsed = parsed.astimezone().replace(tzinfo=None)
|
|
return parsed
|
|
except Exception:
|
|
pass
|
|
return datetime.min
|
|
|
|
|
|
def _list_emails_across_accounts(folder="INBOX", max_results=20,
|
|
unresponded_only=False, unread_only=False,
|
|
date_from=None, date_to=None):
|
|
fixture = _fixture_list_emails(
|
|
folder,
|
|
max_results,
|
|
unresponded_only,
|
|
unread_only,
|
|
None,
|
|
date_from=date_from,
|
|
date_to=date_to,
|
|
)
|
|
if fixture is not None:
|
|
for item in fixture:
|
|
item["_account"] = item.get("account") or "Primary Inbox"
|
|
item["_account_email"] = item.get("account_email") or _current_owner()
|
|
item["_account_id"] = item.get("account_id") or "primary-inbox"
|
|
return fixture, []
|
|
rows = _list_accounts_raw()
|
|
combined = []
|
|
errors = []
|
|
owner = _current_owner()
|
|
cache_key = (
|
|
owner,
|
|
str(folder or "INBOX"),
|
|
int(max_results or 20),
|
|
bool(unresponded_only),
|
|
bool(unread_only),
|
|
str(date_from or ""),
|
|
str(date_to or ""),
|
|
tuple(str(row.get("id") or row.get("name") or row.get("imap_user") or "") for row in rows),
|
|
)
|
|
cached = _EMAIL_LIST_CACHE.get(cache_key)
|
|
now = time.monotonic()
|
|
if cached and now - float(cached.get("created") or 0) <= _EMAIL_LIST_CACHE_TTL_SECONDS:
|
|
return [dict(item) for item in cached.get("results") or []], list(cached.get("errors") or [])
|
|
|
|
indexed = _indexed_latest_emails(
|
|
folder=folder,
|
|
max_results=max_results,
|
|
unresponded_only=unresponded_only,
|
|
unread_only=unread_only,
|
|
date_from=date_from,
|
|
date_to=date_to,
|
|
)
|
|
if indexed is not None:
|
|
_EMAIL_LIST_CACHE[cache_key] = {
|
|
"created": time.monotonic(),
|
|
"results": [dict(item) for item in indexed],
|
|
"errors": [],
|
|
}
|
|
return indexed, []
|
|
|
|
def _list_one_account(row: dict) -> tuple[list[dict], str | None]:
|
|
account_selector = row.get("id") or row.get("name") or row.get("imap_user")
|
|
account_name = row.get("name") or row.get("imap_user") or row.get("id") or "unknown"
|
|
account_email = row.get("imap_user") or row.get("from_address") or ""
|
|
owner_token = _CURRENT_OWNER.set(owner or None)
|
|
try:
|
|
account_results = _list_emails(
|
|
folder=folder,
|
|
max_results=max_results,
|
|
unresponded_only=unresponded_only,
|
|
unread_only=unread_only,
|
|
account=account_selector,
|
|
date_from=date_from,
|
|
date_to=date_to,
|
|
)
|
|
for item in account_results:
|
|
item["_account"] = account_name
|
|
item["_account_email"] = account_email
|
|
item["_account_id"] = row.get("id")
|
|
return account_results, None
|
|
except Exception as exc:
|
|
return [], f"{account_name} ({account_email}): {exc}"
|
|
finally:
|
|
_CURRENT_OWNER.reset(owner_token)
|
|
|
|
if len(rows) <= 1:
|
|
for row in rows:
|
|
account_results, error = _list_one_account(row)
|
|
combined.extend(account_results)
|
|
if error:
|
|
errors.append(error)
|
|
else:
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
|
|
with ThreadPoolExecutor(max_workers=min(len(rows), 4)) as executor:
|
|
futures = [executor.submit(_list_one_account, row) for row in rows]
|
|
for future in as_completed(futures):
|
|
account_results, error = future.result()
|
|
combined.extend(account_results)
|
|
if error:
|
|
errors.append(error)
|
|
combined.sort(key=_result_sort_time, reverse=True)
|
|
results = combined[:max_results]
|
|
_EMAIL_LIST_CACHE[cache_key] = {
|
|
"created": time.monotonic(),
|
|
"results": [dict(item) for item in results],
|
|
"errors": list(errors),
|
|
}
|
|
return results, errors
|
|
|
|
|
|
def _email_search_terms(query: str) -> list[str]:
|
|
q = (query or "").strip()
|
|
if not q:
|
|
return []
|
|
parts: list[str] = []
|
|
consumed: list[tuple[int, int]] = []
|
|
for match in re.finditer(r'"([^"]{1,120})"', q):
|
|
phrase = match.group(1).strip()
|
|
if phrase:
|
|
parts.append(phrase)
|
|
consumed.append((match.start(), match.end()))
|
|
remainder = q
|
|
for start, end in reversed(consumed):
|
|
remainder = remainder[:start] + " " + remainder[end:]
|
|
parts.extend(re.findall(r"[^\s,;]+", remainder))
|
|
out: list[str] = []
|
|
seen: set[str] = set()
|
|
for part in parts:
|
|
part = part.strip().strip('"').strip()
|
|
if len(part) < 2:
|
|
continue
|
|
key = part.lower()
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
out.append(part)
|
|
if len(out) >= 6:
|
|
break
|
|
return out
|
|
|
|
|
|
def _account_key(account: str | None, owner: str = "") -> str:
|
|
if account:
|
|
try:
|
|
return str(_load_config(account).get("account_id") or account).strip() or "default"
|
|
except Exception:
|
|
return str(account).strip() or "default"
|
|
return "default"
|
|
|
|
|
|
def _email_index_delete_uids(account: str | None, folder: str, uids) -> None:
|
|
"""Remove successfully moved source rows from the UI-maintained index."""
|
|
owner = _current_owner()
|
|
values = [str(uid).strip() for uid in (uids or []) if str(uid).strip()]
|
|
if not owner or not values:
|
|
return
|
|
try:
|
|
conn = sqlite3.connect(str(SCHEDULED_EMAILS_DB))
|
|
try:
|
|
placeholders = ",".join("?" for _ in values)
|
|
conn.execute(
|
|
f"""
|
|
DELETE FROM email_message_index
|
|
WHERE owner=? AND account_key=? AND folder=?
|
|
AND uid IN ({placeholders})
|
|
""",
|
|
[owner, _account_key(account, owner), str(folder or "INBOX"), *values],
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _search_date_bounds(date_from=None, date_to=None):
|
|
"""Parse inclusive start/exclusive end, treating timezone-less ISO as UTC."""
|
|
bounds = []
|
|
for name, value in (("date_from", date_from), ("date_to", date_to)):
|
|
if value is None or value == "":
|
|
bounds.append(None)
|
|
continue
|
|
try:
|
|
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
bounds.append((parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)).astimezone(timezone.utc))
|
|
except (ValueError, TypeError, OverflowError) as exc:
|
|
raise ValueError(f"{name} must be an ISO date or datetime") from exc
|
|
if all(bounds) and bounds[0] >= bounds[1]:
|
|
raise ValueError("date_from must be earlier than date_to")
|
|
return tuple(bounds)
|
|
|
|
|
|
def _imap_sent_date_criteria(start, end):
|
|
"""Conservative header-date search; exact instant filtering follows FETCH."""
|
|
criteria = ""
|
|
# SENT* keys ignore time and timezone. Padding avoids excluding a header
|
|
# whose local calendar date differs from the UTC date at a boundary.
|
|
for boundary, key, padding in ((start, "SENTSINCE", -1), (end, "SENTBEFORE", 2)):
|
|
if boundary is not None:
|
|
try:
|
|
day = boundary + timedelta(days=padding)
|
|
except OverflowError:
|
|
continue # Extreme dates still receive exact filtering locally.
|
|
month = "Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec".split()[day.month - 1]
|
|
criteria += f' {key} {day.day:02d}-{month}-{day.year:04d}'
|
|
return criteria
|
|
|
|
|
|
def _indexed_search_emails(query, folders=None, max_results=20, account=None,
|
|
date_from=None, date_to=None) -> list[dict] | None:
|
|
"""Search the UI-maintained email header index before falling back to IMAP."""
|
|
terms = _email_search_terms(str(query or ""))
|
|
if not terms:
|
|
return []
|
|
db_path = Path(SCHEDULED_EMAILS_DB)
|
|
if not db_path.exists():
|
|
return None
|
|
|
|
owner = _current_owner()
|
|
max_results = max(1, min(int(max_results or 20), 100))
|
|
visible_rows = _list_accounts_raw()
|
|
account_labels = {
|
|
str(row.get("id") or ""): (
|
|
row.get("name") or row.get("imap_user") or row.get("from_address") or row.get("id") or ""
|
|
)
|
|
for row in visible_rows
|
|
}
|
|
account_emails = {
|
|
str(row.get("id") or ""): (row.get("imap_user") or row.get("from_address") or "")
|
|
for row in visible_rows
|
|
}
|
|
if account:
|
|
account_keys = [_account_key(account, owner)]
|
|
else:
|
|
account_keys = [
|
|
str(row.get("id") or row.get("name") or row.get("imap_user") or "").strip()
|
|
for row in visible_rows
|
|
if str(row.get("id") or row.get("name") or row.get("imap_user") or "").strip()
|
|
]
|
|
if not account_keys:
|
|
account_keys = [_account_key(None, owner)]
|
|
|
|
params: list[Any] = [owner or "", *account_keys]
|
|
account_clause = "account_key IN (" + ",".join("?" for _ in account_keys) + ")"
|
|
folder_values = [str(folder or "").strip() for folder in (folders or []) if str(folder or "").strip()]
|
|
folder_clause = ""
|
|
if folder_values:
|
|
folder_clause = "AND folder IN (" + ",".join("?" for _ in folder_values) + ")"
|
|
params.extend(folder_values)
|
|
date_clause = ""
|
|
start, end = _search_date_bounds(date_from, date_to)
|
|
if start is not None:
|
|
date_clause += " AND date_epoch >= ?"
|
|
params.append(start.timestamp())
|
|
if end is not None:
|
|
date_clause += " AND date_epoch < ?"
|
|
params.append(end.timestamp())
|
|
|
|
try:
|
|
conn = sqlite3.connect(str(db_path))
|
|
try:
|
|
columns = {
|
|
str(row[1]) for row in conn.execute("PRAGMA table_info(email_message_index)").fetchall()
|
|
}
|
|
searchable = [
|
|
column for column in (
|
|
"subject", "from_name", "from_address", "to_text", "cc_text",
|
|
"attachment_names",
|
|
) if column in columns
|
|
]
|
|
if not searchable:
|
|
return None
|
|
term_clauses = []
|
|
query_params = list(params)
|
|
for term in terms:
|
|
like = "%" + term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
|
|
term_clauses.append("(" + " OR ".join(
|
|
f"{column} LIKE ? ESCAPE '\\'" for column in searchable
|
|
) + ")")
|
|
query_params.extend([like] * len(searchable))
|
|
rows = conn.execute(
|
|
f"""
|
|
SELECT account_key, folder, uid, message_id, subject, from_name,
|
|
from_address, to_text, cc_text, date_iso, date_display,
|
|
date_epoch
|
|
FROM email_message_index
|
|
WHERE owner=? AND {account_clause} {folder_clause} {date_clause}
|
|
AND {' AND '.join(term_clauses)}
|
|
ORDER BY date_epoch DESC
|
|
LIMIT ?
|
|
""",
|
|
[*query_params, max_results],
|
|
).fetchall()
|
|
finally:
|
|
conn.close()
|
|
except sqlite3.OperationalError:
|
|
return None
|
|
except Exception:
|
|
return None
|
|
|
|
out: list[dict] = []
|
|
seen: set[tuple[str, str]] = set()
|
|
cache = _get_cached_summaries()
|
|
for row in rows:
|
|
(
|
|
account_key,
|
|
folder,
|
|
uid,
|
|
message_id,
|
|
subject,
|
|
from_name,
|
|
from_address,
|
|
to_text,
|
|
cc_text,
|
|
date_iso,
|
|
date_display,
|
|
_date_epoch,
|
|
) = row
|
|
key = (str(account_key or ""), str(message_id or uid or ""))
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
subject = subject or "(no subject)"
|
|
cached = cache.get(subject, {})
|
|
out.append({
|
|
"uid": str(uid or ""),
|
|
"message_id": message_id or "",
|
|
"subject": subject,
|
|
"from": from_name or from_address or "",
|
|
"from_address": from_address or "",
|
|
"to": to_text or "",
|
|
"cc": cc_text or "",
|
|
"date": date_display or date_iso or "",
|
|
"_folder": folder or "INBOX",
|
|
"_account": account_labels.get(str(account_key or ""), str(account_key or "")),
|
|
"_account_email": account_emails.get(str(account_key or ""), ""),
|
|
"summary": cached.get("summary", ""),
|
|
"_source": "index",
|
|
})
|
|
return out
|
|
|
|
|
|
def _search_emails(query, folders=None, max_results=20, account=None,
|
|
date_from=None, date_to=None):
|
|
"""IMAP-search emails by free-text query. Matches FROM, SUBJECT, and
|
|
body TEXT. Walks multiple folders so older threads outside INBOX
|
|
(Sent/Archive) are still findable. Returns the same shape as
|
|
_list_emails plus an `_folder` tag."""
|
|
if not query or not str(query).strip():
|
|
return []
|
|
start, end = _search_date_bounds(date_from, date_to)
|
|
max_results = max(1, min(int(max_results or 20), 100))
|
|
fixture = _fixture_search_emails(
|
|
query,
|
|
folders=folders,
|
|
max_results=max_results,
|
|
account=account,
|
|
date_from=date_from,
|
|
date_to=date_to,
|
|
)
|
|
if fixture is not None:
|
|
return fixture
|
|
indexed = _indexed_search_emails(query, folders=folders, max_results=max_results,
|
|
account=account, date_from=date_from, date_to=date_to)
|
|
if indexed:
|
|
return indexed
|
|
q = str(query).replace("\\", "\\\\").replace('"', '\\"')
|
|
# MIME filenames live in Content-Disposition or Content-Type parameters.
|
|
# Several providers omit those part headers from TEXT searches, so include
|
|
# both explicitly. IMAP OR is binary, hence the nested expression.
|
|
search_cmd = (
|
|
f'(OR (OR (OR FROM "{q}" SUBJECT "{q}") TEXT "{q}") '
|
|
f'(OR HEADER Content-Disposition "{q}" HEADER Content-Type "{q}"))'
|
|
)
|
|
search_cmd += _imap_sent_date_criteria(start, end)
|
|
if folders is None:
|
|
folders = ["INBOX", "Sent", "Archive"]
|
|
cache = _get_cached_summaries()
|
|
out = []
|
|
conn = _imap_connect(account)
|
|
touched = []
|
|
try:
|
|
for folder in folders:
|
|
try:
|
|
status, _ = conn.select(_q(folder), readonly=True)
|
|
if status != "OK":
|
|
continue
|
|
status, data = conn.uid("SEARCH", None, search_cmd)
|
|
if status != "OK" or not data or not data[0]:
|
|
continue
|
|
uid_list = list(reversed(data[0].split()))
|
|
folder_matches = 0
|
|
for uid in uid_list:
|
|
try:
|
|
status, msg_data = conn.uid("FETCH", uid, "(RFC822.HEADER)")
|
|
if status != "OK":
|
|
continue
|
|
raw_header = msg_data[0][1]
|
|
msg = email.message_from_bytes(raw_header)
|
|
subject = _decode_header(msg.get("Subject", "(no subject)"))
|
|
sender = _decode_header(msg.get("From", "unknown"))
|
|
date_str = msg.get("Date", "")
|
|
if start is not None or end is not None:
|
|
# Unknown dates cannot establish range membership.
|
|
sent = email.utils.parsedate_to_datetime(date_str)
|
|
if sent is None:
|
|
continue
|
|
if sent.tzinfo is None:
|
|
sent = sent.replace(tzinfo=timezone.utc)
|
|
if (start is not None and sent < start) or (end is not None and sent >= end):
|
|
continue
|
|
message_id = msg.get("Message-ID", "")
|
|
to_str = _decode_header(msg.get("To", ""))
|
|
cc_str = _decode_header(msg.get("Cc", ""))
|
|
sender_name, sender_addr = email.utils.parseaddr(sender)
|
|
sender_display = sender_name or sender_addr
|
|
cached = cache.get(subject, {})
|
|
out.append({
|
|
"uid": uid.decode(),
|
|
"message_id": message_id,
|
|
"subject": subject,
|
|
"from": sender_display,
|
|
"from_address": sender_addr,
|
|
"to": to_str,
|
|
"cc": cc_str,
|
|
"date": date_str,
|
|
"_folder": folder,
|
|
"summary": cached.get("summary", ""),
|
|
})
|
|
folder_matches += 1
|
|
if folder_matches >= max_results:
|
|
break
|
|
except Exception:
|
|
continue
|
|
except Exception:
|
|
continue
|
|
finally:
|
|
try: conn.logout()
|
|
except Exception: pass
|
|
# Cap total across folders.
|
|
return out[: max_results * len(folders)]
|
|
|
|
|
|
def _list_attachments_from_msg(msg):
|
|
"""Return attachment metadata."""
|
|
if not msg.is_multipart():
|
|
return []
|
|
attachments = []
|
|
idx = 0
|
|
for part in msg.walk():
|
|
if part.is_multipart():
|
|
continue
|
|
cd = str(part.get("Content-Disposition", ""))
|
|
ct = part.get_content_type()
|
|
if ct in ("text/plain", "text/html") and "attachment" not in cd:
|
|
continue
|
|
filename = part.get_filename()
|
|
if filename:
|
|
filename = _decode_header(filename)
|
|
else:
|
|
filename = f"attachment_{idx}"
|
|
payload = part.get_payload(decode=True)
|
|
size = len(payload) if payload else 0
|
|
attachments.append({
|
|
"index": idx,
|
|
"filename": filename,
|
|
"content_type": ct,
|
|
"size": size,
|
|
})
|
|
idx += 1
|
|
return attachments
|
|
|
|
|
|
def _extract_attachment_to_disk(msg, index, target_dir):
|
|
"""Extract a specific attachment to disk."""
|
|
if not msg.is_multipart():
|
|
return None
|
|
idx = 0
|
|
for part in msg.walk():
|
|
if part.is_multipart():
|
|
continue
|
|
cd = str(part.get("Content-Disposition", ""))
|
|
ct = part.get_content_type()
|
|
if ct in ("text/plain", "text/html") and "attachment" not in cd:
|
|
continue
|
|
if idx == index:
|
|
filename = part.get_filename()
|
|
if filename:
|
|
filename = _decode_header(filename)
|
|
else:
|
|
filename = f"attachment_{idx}"
|
|
safe_name = re.sub(r"[^\w\s\-.]", "_", filename).strip()
|
|
payload = part.get_payload(decode=True)
|
|
if not payload:
|
|
return None
|
|
os.makedirs(target_dir, exist_ok=True)
|
|
filepath = os.path.join(target_dir, safe_name)
|
|
with open(filepath, "wb") as f:
|
|
f.write(payload)
|
|
return filepath
|
|
idx += 1
|
|
return None
|
|
|
|
|
|
def _read_email(uid=None, message_id=None, folder="INBOX", account=None):
|
|
"""Read full email content by UID or message-ID. account = mailbox selector."""
|
|
fixture = _fixture_read_email(uid=uid, message_id=message_id, folder=folder, account=account)
|
|
if fixture is not None:
|
|
return fixture
|
|
cfg = _load_config(account)
|
|
conn = None
|
|
try:
|
|
conn = _imap_connect(account)
|
|
conn.select(_q(folder), readonly=True)
|
|
|
|
if message_id and not uid:
|
|
status, data = conn.uid("SEARCH", None, f'(HEADER Message-ID "{message_id}")')
|
|
if status != "OK" or not data[0]:
|
|
return {"error": f"Email not found with Message-ID: {message_id}"}
|
|
uid = data[0].split()[-1]
|
|
|
|
if not uid:
|
|
return {"error": "No UID or Message-ID provided"}
|
|
|
|
status, msg_data = conn.uid("FETCH", _b(uid), "(BODY.PEEK[])")
|
|
if status != "OK":
|
|
return {"error": f"Failed to fetch email UID {uid}"}
|
|
if not msg_data or not msg_data[0] or not isinstance(msg_data[0], tuple) or len(msg_data[0]) < 2:
|
|
return {"error": (
|
|
f"Email not found with UID {uid} in folder {folder}. "
|
|
"UIDs are folder-specific; use the account and folder from the selected search/list result."
|
|
)}
|
|
|
|
raw = msg_data[0][1]
|
|
msg = email.message_from_bytes(raw)
|
|
|
|
subject = _decode_header(msg.get("Subject", "(no subject)"))
|
|
sender = _decode_header(msg.get("From", "unknown"))
|
|
date_str = msg.get("Date", "")
|
|
message_id_header = msg.get("Message-ID", "")
|
|
body = _extract_text(msg)
|
|
attachments = _list_attachments_from_msg(msg)
|
|
|
|
sender_name, sender_addr = email.utils.parseaddr(sender)
|
|
|
|
return {
|
|
"uid": uid.decode() if isinstance(uid, bytes) else str(uid),
|
|
"account": cfg.get("account_name") or cfg.get("imap_user") or "default",
|
|
"account_email": cfg.get("imap_user") or cfg.get("from_address") or "",
|
|
"account_id": cfg.get("account_id"),
|
|
"message_id": message_id_header,
|
|
"subject": subject,
|
|
"from": sender_name or sender_addr,
|
|
"from_address": sender_addr,
|
|
"date": date_str,
|
|
"body": body[:8000],
|
|
"attachments": attachments,
|
|
}
|
|
finally:
|
|
if conn:
|
|
try: conn.logout()
|
|
except Exception: pass
|
|
|
|
|
|
def _read_email_across_accounts(uid=None, message_id=None, folder="INBOX"):
|
|
fixture = _fixture_read_email(uid=uid, message_id=message_id, folder=folder, account=None)
|
|
if fixture is not None:
|
|
return fixture
|
|
rows = _list_accounts_raw()
|
|
matches = []
|
|
errors = []
|
|
for row in rows:
|
|
account_selector = row.get("id") or row.get("name") or row.get("imap_user")
|
|
account_name = row.get("name") or row.get("imap_user") or row.get("id") or "unknown"
|
|
account_email = row.get("imap_user") or row.get("from_address") or ""
|
|
result = _read_email(
|
|
uid=uid,
|
|
message_id=message_id,
|
|
folder=folder,
|
|
account=account_selector,
|
|
)
|
|
if "error" in result:
|
|
errors.append(f"{account_name} <{account_email}>: {result['error']}")
|
|
continue
|
|
matches.append(result)
|
|
if len(matches) == 1:
|
|
return matches[0]
|
|
if len(matches) > 1:
|
|
accounts = ", ".join(
|
|
f"{m.get('account')} <{m.get('account_email')}>" for m in matches
|
|
)
|
|
return {
|
|
"error": (
|
|
f"UID {uid or message_id} exists in multiple accounts: {accounts}. "
|
|
"Call read_email again with the account name/email."
|
|
)
|
|
}
|
|
return {"error": f"Email not found in any configured account. Checked: {'; '.join(errors)}"}
|
|
|
|
|
|
def _smtp_ready(cfg: dict) -> bool:
|
|
return bool(cfg.get("smtp_host") and cfg.get("smtp_user") and cfg.get("smtp_password"))
|
|
|
|
|
|
def _resolve_send_config(account=None):
|
|
cfg = _load_config(account)
|
|
if _smtp_ready(cfg):
|
|
return account, cfg
|
|
if account:
|
|
raise ValueError(f"Email account {cfg.get('account_name') or account} has no SMTP configured")
|
|
for row in _list_accounts_raw():
|
|
selector = row.get("id") or row.get("name") or row.get("imap_user")
|
|
trial = _load_config(selector)
|
|
if _smtp_ready(trial):
|
|
return selector, trial
|
|
raise ValueError("No SMTP-capable email account configured")
|
|
|
|
|
|
def _smtp_connect(account=None, cfg=None):
|
|
"""Connect to SMTP server, returns logged-in connection."""
|
|
cfg = cfg or _load_config(account)
|
|
if not _smtp_ready(cfg):
|
|
raise ValueError(f"Email account {cfg.get('account_name') or account or 'default'} has no SMTP configured")
|
|
port = int(cfg.get("smtp_port") or 465)
|
|
security = str(cfg.get("smtp_security") or "").strip().lower()
|
|
if security not in {"ssl", "starttls", "none"}:
|
|
security = "starttls" if port == 587 else "ssl"
|
|
if security == "starttls":
|
|
conn = smtplib.SMTP(
|
|
cfg["smtp_host"],
|
|
port,
|
|
timeout=EMAIL_SOCKET_TIMEOUT,
|
|
)
|
|
try:
|
|
conn.starttls()
|
|
except Exception:
|
|
# Don't leak the open plain socket on a rejected STARTTLS. SMTP has
|
|
# no shutdown(); close() is the low-level socket close (no QUIT). (#3174)
|
|
try:
|
|
conn.close()
|
|
except Exception:
|
|
pass
|
|
raise
|
|
elif security == "ssl":
|
|
conn = smtplib.SMTP_SSL(
|
|
cfg["smtp_host"],
|
|
port,
|
|
timeout=EMAIL_SOCKET_TIMEOUT,
|
|
)
|
|
else:
|
|
conn = smtplib.SMTP(
|
|
cfg["smtp_host"],
|
|
port,
|
|
timeout=EMAIL_SOCKET_TIMEOUT,
|
|
)
|
|
if cfg["smtp_user"] and cfg["smtp_password"]:
|
|
try:
|
|
conn.login(cfg["smtp_user"], cfg["smtp_password"])
|
|
except Exception:
|
|
# A failed login otherwise orphans the connected socket; close it
|
|
# before propagating (SMTP has no shutdown(); close() = socket close). (#3174)
|
|
try:
|
|
conn.close()
|
|
except Exception:
|
|
pass
|
|
raise
|
|
return conn
|
|
|
|
|
|
def _read_agent_email_confirm_setting() -> bool:
|
|
"""True if the user wants agent send_email/reply_to_email calls to be
|
|
queued for manual approval instead of SMTPed immediately. Defaults to
|
|
True so a fresh install is safe — agents have been observed inventing
|
|
signatures and sending to real recipients without the user's review."""
|
|
try:
|
|
from src.settings import get_setting
|
|
return bool(get_setting("agent_email_confirm", True))
|
|
except Exception:
|
|
return True
|
|
|
|
|
|
def _stash_agent_draft(*, to, subject, body, in_reply_to=None, references=None,
|
|
cc=None, bcc=None, account=None) -> dict:
|
|
"""Insert the composed email into scheduled_emails with status
|
|
'agent_draft' and a far-future send_at so the scheduled-send poller
|
|
never picks it up. Returns the pending payload the model surfaces to
|
|
the user (and that the chat UI can render as an approval card)."""
|
|
try:
|
|
from src.constants import SCHEDULED_EMAILS_DB
|
|
except Exception:
|
|
return {"success": False, "error": "Pending-email storage unavailable"}
|
|
pending_id = uuid.uuid4().hex[:16]
|
|
far_future = "9999-12-31T00:00:00"
|
|
now = datetime.utcnow().isoformat()
|
|
try:
|
|
conn = sqlite3.connect(SCHEDULED_EMAILS_DB)
|
|
# Touch the schema in case the email-routes init hasn't run yet
|
|
# (MCP server can boot independently).
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS scheduled_emails (
|
|
id TEXT PRIMARY KEY,
|
|
to_addr TEXT NOT NULL,
|
|
cc TEXT,
|
|
bcc TEXT,
|
|
subject TEXT,
|
|
body TEXT NOT NULL,
|
|
in_reply_to TEXT,
|
|
references_hdr TEXT,
|
|
attachments TEXT,
|
|
send_at TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
error TEXT,
|
|
owner TEXT DEFAULT '',
|
|
account_id TEXT,
|
|
odysseus_kind TEXT
|
|
)
|
|
""")
|
|
conn.execute("""
|
|
INSERT INTO scheduled_emails
|
|
(id, to_addr, cc, bcc, subject, body, in_reply_to, references_hdr,
|
|
attachments, send_at, created_at, status, account_id, odysseus_kind, owner)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'agent_draft', ?, ?, ?)
|
|
""", (
|
|
pending_id,
|
|
to if isinstance(to, str) else ", ".join(to),
|
|
cc if isinstance(cc, str) else (", ".join(cc) if cc else None),
|
|
bcc if isinstance(bcc, str) else (", ".join(bcc) if bcc else None),
|
|
subject or "",
|
|
body or "",
|
|
in_reply_to or None,
|
|
references if isinstance(references, str) else (" ".join(references) if references else None),
|
|
"[]",
|
|
far_future,
|
|
now,
|
|
account or None,
|
|
"agent_draft",
|
|
_current_owner(),
|
|
))
|
|
conn.commit()
|
|
conn.close()
|
|
except Exception as e:
|
|
return {"success": False, "error": f"Failed to stash draft: {e}"}
|
|
return {
|
|
"success": True,
|
|
"pending": True,
|
|
"pending_id": pending_id,
|
|
"to": to if isinstance(to, str) else ", ".join(to),
|
|
"subject": subject or "",
|
|
"body": body or "",
|
|
"message": (
|
|
"✋ Draft staged for your approval — nothing has been sent yet.\n"
|
|
"Review the To/Subject/Body above. Reply 'send' to deliver, or "
|
|
"'cancel' to discard."
|
|
),
|
|
}
|
|
|
|
|
|
def _send_email(to, subject, body, in_reply_to=None, references=None, cc=None, bcc=None, account=None):
|
|
"""Send an email via SMTP. Returns dict with status.
|
|
|
|
When the `agent_email_confirm` setting is on (the default), the email
|
|
is NOT SMTPed — instead it lands in scheduled_emails as an
|
|
`agent_draft` row and the user reviews + approves it from the chat
|
|
UI. This closes the auto-send hole that let earlier models invent
|
|
signatures and ship them to real recipients without confirmation."""
|
|
if _read_agent_email_confirm_setting():
|
|
# Even confirmation-first sends must resolve the selected account now.
|
|
# Otherwise a caller could stage a pending draft against another
|
|
# owner's account selector before browser approval handles it.
|
|
cfg = _load_config(account)
|
|
return _stash_agent_draft(
|
|
to=to, subject=subject, body=body,
|
|
in_reply_to=in_reply_to, references=references,
|
|
cc=cc, bcc=bcc, account=cfg.get("account_id") or account,
|
|
)
|
|
send_account, cfg = _resolve_send_config(account)
|
|
msg = EmailMessage()
|
|
msg["From"] = _clean_header_value(cfg["from_address"])
|
|
msg["To"] = _clean_header_value(to if isinstance(to, str) else ", ".join(to))
|
|
msg["Subject"] = _clean_header_value(subject)
|
|
if cc:
|
|
msg["Cc"] = _clean_header_value(cc if isinstance(cc, str) else ", ".join(cc))
|
|
if in_reply_to:
|
|
msg["In-Reply-To"] = _clean_header_value(in_reply_to)
|
|
if references:
|
|
msg["References"] = _clean_header_value(references if isinstance(references, str) else " ".join(references))
|
|
if "Date" not in msg:
|
|
msg["Date"] = email.utils.formatdate(localtime=True)
|
|
if "Message-ID" not in msg:
|
|
msg["Message-ID"] = email.utils.make_msgid()
|
|
msg.set_content(body)
|
|
|
|
recipients = []
|
|
if isinstance(to, str):
|
|
recipients.extend([a.strip() for a in to.split(",") if a.strip()])
|
|
else:
|
|
recipients.extend(to)
|
|
if cc:
|
|
recipients.extend([a.strip() for a in cc.split(",")] if isinstance(cc, str) else cc)
|
|
if bcc:
|
|
recipients.extend([a.strip() for a in bcc.split(",")] if isinstance(bcc, str) else bcc)
|
|
|
|
conn = _smtp_connect(send_account, cfg=cfg)
|
|
try:
|
|
conn.send_message(msg, from_addr=cfg["from_address"], to_addrs=recipients)
|
|
finally:
|
|
conn.quit()
|
|
|
|
sent_folder = None
|
|
sent_uid = None
|
|
try:
|
|
imap = _imap_connect(send_account)
|
|
try:
|
|
sent_folder = _detect_sent_folder(imap)
|
|
append_st, append_data = imap.append(_q(sent_folder), "\\Seen", None, msg.as_bytes())
|
|
if append_st == "OK" and append_data:
|
|
m = re.search(rb"APPENDUID\s+\d+\s+(\d+)", append_data[0] or b"")
|
|
if m:
|
|
sent_uid = m.group(1).decode("ascii", errors="ignore")
|
|
finally:
|
|
imap.logout()
|
|
except Exception:
|
|
# Delivery already succeeded; Sent-copy failure should not turn a sent
|
|
# message into a hard failure for the user.
|
|
pass
|
|
|
|
return {
|
|
"sent": True,
|
|
"to": recipients,
|
|
"subject": subject,
|
|
"account": cfg.get("account_name"),
|
|
"account_id": cfg.get("account_id"),
|
|
"sent_folder": sent_folder,
|
|
"sent_uid": sent_uid,
|
|
"message_id": msg.get("Message-ID", ""),
|
|
}
|
|
|
|
|
|
def _build_email_document_content(
|
|
to,
|
|
subject,
|
|
body,
|
|
*,
|
|
cc=None,
|
|
bcc=None,
|
|
in_reply_to=None,
|
|
references=None,
|
|
source_uid=None,
|
|
source_folder=None,
|
|
):
|
|
header_lines = [f"To: {to or ''}"]
|
|
if cc:
|
|
header_lines.append(f"Cc: {cc}")
|
|
if bcc:
|
|
header_lines.append(f"Bcc: {bcc}")
|
|
header_lines.append(f"Subject: {subject or ''}")
|
|
if in_reply_to:
|
|
header_lines.append(f"In-Reply-To: {in_reply_to}")
|
|
if references:
|
|
header_lines.append(f"References: {references}")
|
|
if source_uid:
|
|
header_lines.append(f"X-Source-UID: {source_uid}")
|
|
if source_folder:
|
|
header_lines.append(f"X-Source-Folder: {source_folder}")
|
|
return "\n".join(header_lines) + "\n---\n" + (body or "")
|
|
|
|
|
|
def _merge_email_reply_body(existing_content: str, reply_body: str) -> str:
|
|
"""Preserve email headers and quoted chain while replacing the editable reply body."""
|
|
if "\n---\n" not in (existing_content or ""):
|
|
return reply_body or ""
|
|
head, body = existing_content.split("\n---\n", 1)
|
|
quote_markers = (
|
|
"---------- Previous message ----------",
|
|
"-----Original Message-----",
|
|
"----- Original Message -----",
|
|
)
|
|
quote_index = -1
|
|
for marker in quote_markers:
|
|
idx = body.find(marker)
|
|
if idx != -1 and (quote_index == -1 or idx < quote_index):
|
|
quote_index = idx
|
|
quote = body[quote_index:].strip() if quote_index != -1 else ""
|
|
merged_body = (reply_body or "").strip()
|
|
if quote:
|
|
merged_body = f"{merged_body}\n\n{quote}" if merged_body else quote
|
|
return f"{head}\n---\n{merged_body}"
|
|
|
|
|
|
def _create_email_draft_document(
|
|
*,
|
|
to,
|
|
subject,
|
|
body,
|
|
title=None,
|
|
cc=None,
|
|
bcc=None,
|
|
in_reply_to=None,
|
|
references=None,
|
|
source_uid=None,
|
|
source_folder=None,
|
|
account=None,
|
|
source_message_id=None,
|
|
):
|
|
"""Create an Odysseus email compose document for user review. Does not send."""
|
|
from core.database import SessionLocal, Document, DocumentVersion
|
|
try:
|
|
from src.event_bus import fire_event
|
|
except Exception:
|
|
fire_event = None
|
|
|
|
cfg = _load_config(account) if account else _load_config(None)
|
|
content = _build_email_document_content(
|
|
to,
|
|
subject,
|
|
body,
|
|
cc=cc,
|
|
bcc=bcc,
|
|
in_reply_to=in_reply_to,
|
|
references=references,
|
|
source_uid=source_uid,
|
|
source_folder=source_folder,
|
|
)
|
|
doc_id = str(uuid.uuid4())
|
|
ver_id = str(uuid.uuid4())
|
|
doc_title = (title or subject or "Email draft").strip() or "Email draft"
|
|
doc_owner = _current_owner() or _default_document_owner()
|
|
session_id = _current_session_id() or None
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
if source_uid and source_folder:
|
|
existing = (
|
|
db.query(Document)
|
|
.filter(Document.is_active == True)
|
|
.filter(Document.language == "email")
|
|
.filter(Document.owner == doc_owner)
|
|
.filter(Document.source_email_uid == str(source_uid))
|
|
.filter(Document.source_email_folder == source_folder)
|
|
.order_by(Document.updated_at.desc())
|
|
.first()
|
|
)
|
|
if existing and "\n---\n" in (existing.current_content or ""):
|
|
existing.current_content = _merge_email_reply_body(existing.current_content, body or "")
|
|
if session_id:
|
|
existing.session_id = session_id
|
|
existing.version_count = (existing.version_count or 0) + 1
|
|
ver = DocumentVersion(
|
|
id=ver_id,
|
|
document_id=existing.id,
|
|
version_number=existing.version_count,
|
|
content=existing.current_content,
|
|
summary="Updated by email MCP draft tool",
|
|
source="ai",
|
|
)
|
|
db.add(ver)
|
|
db.commit()
|
|
try:
|
|
from src.agent_tools.document_tools import set_active_document
|
|
set_active_document(existing.id)
|
|
except Exception:
|
|
pass
|
|
if fire_event:
|
|
try:
|
|
fire_event("document_updated", doc_owner)
|
|
except Exception:
|
|
pass
|
|
return {
|
|
"draft": True,
|
|
"updated": True,
|
|
"doc_id": existing.id,
|
|
"title": existing.title,
|
|
"language": existing.language,
|
|
"account": cfg.get("account_name"),
|
|
"account_id": cfg.get("account_id"),
|
|
"to": to,
|
|
"subject": subject,
|
|
}
|
|
|
|
doc = Document(
|
|
id=doc_id,
|
|
session_id=session_id,
|
|
title=doc_title,
|
|
language="email",
|
|
current_content=content,
|
|
version_count=1,
|
|
is_active=True,
|
|
owner=doc_owner,
|
|
source_email_uid=source_uid,
|
|
source_email_folder=source_folder,
|
|
source_email_account_id=cfg.get("account_id"),
|
|
source_email_message_id=source_message_id,
|
|
)
|
|
ver = DocumentVersion(
|
|
id=ver_id,
|
|
document_id=doc_id,
|
|
version_number=1,
|
|
content=content,
|
|
summary="Created by email MCP draft tool",
|
|
source="ai",
|
|
)
|
|
db.add(doc)
|
|
db.add(ver)
|
|
db.commit()
|
|
try:
|
|
from src.agent_tools.document_tools import set_active_document
|
|
set_active_document(doc_id)
|
|
except Exception:
|
|
pass
|
|
if fire_event:
|
|
try:
|
|
fire_event("document_created", doc_owner)
|
|
except Exception:
|
|
pass
|
|
return {
|
|
"draft": True,
|
|
"doc_id": doc_id,
|
|
"title": doc_title,
|
|
"language": "email",
|
|
"account": cfg.get("account_name"),
|
|
"account_id": cfg.get("account_id"),
|
|
"to": to,
|
|
"subject": subject,
|
|
}
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def _draft_reply_to_email(uid, body, folder="INBOX", reply_all=False, account=None, title=None):
|
|
"""Create a threaded Odysseus reply draft document. Does not send."""
|
|
fixture = _fixture_email_action_target(uid=uid, folder=folder, account=account)
|
|
if fixture is not None:
|
|
sender = str(fixture.get("from_address") or fixture.get("from") or "")
|
|
_, sender_addr = email.utils.parseaddr(sender)
|
|
to_addrs = sender_addr or sender
|
|
cc = None
|
|
if reply_all:
|
|
cc_addrs = []
|
|
own_addrs = {
|
|
str(fixture.get("account_email") or "").strip().lower(),
|
|
str(_current_owner() or "").strip().lower(),
|
|
}
|
|
for header_value in (
|
|
str(fixture.get("to") or ""),
|
|
str(fixture.get("cc") or ""),
|
|
):
|
|
for _, addr in email.utils.getaddresses([header_value]):
|
|
addr_l = (addr or "").strip().lower()
|
|
if addr and addr_l != (sender_addr or "").strip().lower() and addr_l not in own_addrs:
|
|
cc_addrs.append(addr)
|
|
if cc_addrs:
|
|
cc = ", ".join(dict.fromkeys(cc_addrs))
|
|
orig_subject = str(fixture.get("subject") or "")
|
|
reply_subject = orig_subject if orig_subject.lower().startswith("re:") else f"Re: {orig_subject}"
|
|
orig_message_id = str(fixture.get("message_id") or "")
|
|
orig_references = str(fixture.get("references") or "")
|
|
new_references = (orig_references + " " + orig_message_id).strip() if orig_references else orig_message_id
|
|
return _create_email_draft_document(
|
|
to=to_addrs,
|
|
subject=reply_subject,
|
|
body=body,
|
|
title=title or reply_subject,
|
|
cc=cc,
|
|
in_reply_to=orig_message_id,
|
|
references=new_references,
|
|
source_uid=uid,
|
|
source_folder=folder,
|
|
account=account or fixture.get("account_id") or fixture.get("account_email"),
|
|
source_message_id=orig_message_id,
|
|
)
|
|
|
|
conn = _imap_connect(account)
|
|
conn.select(_q(folder), readonly=True)
|
|
status, msg_data = conn.uid("FETCH", _b(uid), "(BODY.PEEK[])")
|
|
conn.logout()
|
|
if status != "OK" or not msg_data or not msg_data[0]:
|
|
return {"error": f"Failed to fetch email UID {uid}"}
|
|
raw = msg_data[0][1]
|
|
orig = email.message_from_bytes(raw)
|
|
|
|
orig_subject = _decode_header(orig.get("Subject", ""))
|
|
reply_subject = orig_subject if orig_subject.lower().startswith("re:") else f"Re: {orig_subject}"
|
|
orig_message_id = orig.get("Message-ID", "")
|
|
orig_references = orig.get("References", "")
|
|
new_references = (orig_references + " " + orig_message_id).strip() if orig_references else orig_message_id
|
|
|
|
sender = _decode_header(orig.get("From", ""))
|
|
_, sender_addr = email.utils.parseaddr(sender)
|
|
to_addrs = sender_addr
|
|
|
|
cc = None
|
|
if reply_all:
|
|
cc_addrs = []
|
|
cfg = _load_config(account)
|
|
own_addrs = {
|
|
(cfg.get("imap_user") or "").strip().lower(),
|
|
(cfg.get("from_address") or "").strip().lower(),
|
|
}
|
|
for header_name in ("To", "Cc"):
|
|
for _, addr in email.utils.getaddresses([orig.get(header_name, "")]):
|
|
addr_l = (addr or "").strip().lower()
|
|
if addr and addr != sender_addr and addr_l not in own_addrs:
|
|
cc_addrs.append(addr)
|
|
if cc_addrs:
|
|
cc = ", ".join(dict.fromkeys(cc_addrs))
|
|
|
|
return _create_email_draft_document(
|
|
to=to_addrs,
|
|
subject=reply_subject,
|
|
body=body,
|
|
title=title or reply_subject,
|
|
cc=cc,
|
|
in_reply_to=orig_message_id,
|
|
references=new_references,
|
|
source_uid=uid,
|
|
source_folder=folder,
|
|
account=account,
|
|
source_message_id=orig_message_id,
|
|
)
|
|
|
|
|
|
async def _ai_draft_reply_to_email(uid, folder="INBOX", reply_all=False, account=None, title=None):
|
|
"""Generate a reply with Odysseus' AI-reply prompt/style, then create a compose doc."""
|
|
read_result = _read_email(uid=uid, folder=folder, account=account)
|
|
if "error" in read_result:
|
|
return read_result
|
|
|
|
to_addr = read_result.get("from_address") or email.utils.parseaddr(read_result.get("from") or "")[1]
|
|
subject = read_result.get("subject") or ""
|
|
reply_subject = subject if subject.lower().startswith("re:") else f"Re: {subject}"
|
|
original_body = read_result.get("body") or ""
|
|
message_id = read_result.get("message_id") or ""
|
|
|
|
if not original_body.strip():
|
|
return {"error": "No email body available for AI reply"}
|
|
|
|
try:
|
|
from routes.email_helpers import (
|
|
_EMAIL_REPLY_SYS_PROMPT_BASE,
|
|
_apply_email_style_mechanics,
|
|
_extract_reply,
|
|
_load_settings,
|
|
)
|
|
from src.endpoint_resolver import (
|
|
resolve_endpoint,
|
|
resolve_utility_fallback_candidates,
|
|
)
|
|
from src.llm_core import llm_call_async_with_fallback
|
|
except Exception as exc:
|
|
return {"error": f"AI reply helpers unavailable: {exc}"}
|
|
|
|
style = _load_email_writing_style(account)
|
|
system_prompt = _EMAIL_REPLY_SYS_PROMPT_BASE
|
|
if style:
|
|
system_prompt += f"\n\nWRITING STYLE TO MATCH:\n{style}"
|
|
|
|
user_msg = (
|
|
f"Recipient: {to_addr}\nSubject: {reply_subject}\n\n"
|
|
f"Original email and any current draft:\n{original_body[:6000]}\n\n"
|
|
"Draft a reply. Return only the reply body text."
|
|
)
|
|
|
|
candidates = []
|
|
seen = set()
|
|
|
|
def _add(url, model, headers):
|
|
key = (url or "", model or "")
|
|
if not url or not model or key in seen:
|
|
return
|
|
seen.add(key)
|
|
candidates.append((url, model, headers))
|
|
|
|
try:
|
|
_add(*resolve_endpoint("utility", owner=None))
|
|
except Exception:
|
|
pass
|
|
try:
|
|
_add(*resolve_endpoint("default", owner=None))
|
|
except Exception:
|
|
pass
|
|
try:
|
|
utility_fallbacks = resolve_utility_fallback_candidates(owner=None) or []
|
|
except TypeError:
|
|
utility_fallbacks = resolve_utility_fallback_candidates() or []
|
|
for cand in utility_fallbacks:
|
|
_add(*cand)
|
|
if not candidates:
|
|
return {"error": "No LLM endpoint configured for AI reply"}
|
|
|
|
try:
|
|
raw_reply = await llm_call_async_with_fallback(
|
|
candidates,
|
|
messages=[
|
|
{"role": "system", "content": system_prompt},
|
|
{"role": "user", "content": user_msg},
|
|
],
|
|
temperature=0.7,
|
|
max_tokens=1024,
|
|
timeout=60,
|
|
)
|
|
except Exception as exc:
|
|
return {"error": f"AI reply generation failed: {exc}"}
|
|
|
|
reply = _apply_email_style_mechanics(_extract_reply(raw_reply or ""))
|
|
if not reply:
|
|
return {"error": "AI reply generation returned an empty response"}
|
|
|
|
return _draft_reply_to_email(
|
|
uid=uid,
|
|
body=reply,
|
|
folder=folder,
|
|
reply_all=reply_all,
|
|
account=account,
|
|
title=title or reply_subject,
|
|
)
|
|
|
|
|
|
def _reply_to_email(uid, body, folder="INBOX", reply_all=False, account=None):
|
|
"""Reply to an existing email by UID. Threads via In-Reply-To/References."""
|
|
fixture = _fixture_email_action_target(uid=uid, folder=folder, account=account)
|
|
if fixture is not None:
|
|
sender = str(fixture.get("from_address") or fixture.get("from") or "")
|
|
if reply_all:
|
|
to_addrs = sender
|
|
else:
|
|
_, sender_addr = email.utils.parseaddr(sender)
|
|
to_addrs = sender_addr or sender
|
|
orig_subject = str(fixture.get("subject") or "")
|
|
reply_subject = orig_subject if orig_subject.lower().startswith("re:") else f"Re: {orig_subject}"
|
|
return {
|
|
"to": to_addrs,
|
|
"subject": reply_subject,
|
|
"body": body,
|
|
"queued": True,
|
|
"fixture": True,
|
|
}
|
|
conn = None
|
|
try:
|
|
conn = _imap_connect(account)
|
|
conn.select(_q(folder), readonly=True)
|
|
status, msg_data = conn.uid("FETCH", _b(uid), "(BODY.PEEK[])")
|
|
finally:
|
|
if conn:
|
|
try: conn.logout()
|
|
except Exception: pass
|
|
if status != "OK" or not msg_data or not msg_data[0]:
|
|
return {"error": f"Failed to fetch email UID {uid}"}
|
|
raw = msg_data[0][1]
|
|
orig = email.message_from_bytes(raw)
|
|
|
|
orig_subject = _decode_header(orig.get("Subject", ""))
|
|
reply_subject = orig_subject if orig_subject.lower().startswith("re:") else f"Re: {orig_subject}"
|
|
orig_message_id = orig.get("Message-ID", "")
|
|
orig_references = orig.get("References", "")
|
|
new_references = (orig_references + " " + orig_message_id).strip() if orig_references else orig_message_id
|
|
|
|
sender = _decode_header(orig.get("From", ""))
|
|
_, sender_addr = email.utils.parseaddr(sender)
|
|
to_addrs = sender_addr
|
|
|
|
cc = None
|
|
if reply_all:
|
|
cc_addrs = []
|
|
for header_name in ("To", "Cc"):
|
|
for _, addr in email.utils.getaddresses([orig.get(header_name, "")]):
|
|
if addr and addr != sender_addr:
|
|
cc_addrs.append(addr)
|
|
if cc_addrs:
|
|
cc = ", ".join(cc_addrs)
|
|
|
|
return _send_email(
|
|
to=to_addrs,
|
|
subject=reply_subject,
|
|
body=body,
|
|
in_reply_to=orig_message_id,
|
|
references=new_references,
|
|
cc=cc,
|
|
account=account,
|
|
)
|
|
|
|
|
|
def _set_flag(uid, folder, flag, add=True, account=None):
|
|
"""Add or remove an IMAP flag (e.g. \\Seen, \\Answered, \\Deleted)."""
|
|
if _fixture_email_action_target(uid=uid, folder=folder, account=account) is not None:
|
|
if flag == "\\Seen":
|
|
return _fixture_update_email(uid=uid, source_folder=folder, account=account, read=bool(add))
|
|
if flag == "\\Answered":
|
|
return _fixture_update_email(uid=uid, source_folder=folder, account=account, answered=bool(add), done=bool(add))
|
|
if flag == "\\Flagged":
|
|
return _fixture_update_email(uid=uid, source_folder=folder, account=account, favorite=bool(add))
|
|
if flag == "\\Deleted" and add:
|
|
return _fixture_update_email(uid=uid, source_folder=folder, account=account, folder="Trash")
|
|
return True
|
|
conn = _imap_connect(account)
|
|
conn.select(_q(folder))
|
|
op = "+FLAGS" if add else "-FLAGS"
|
|
try:
|
|
status, data = conn.uid("STORE", _b(uid), op, flag)
|
|
if add and flag == "\\Deleted":
|
|
conn.expunge()
|
|
return status == "OK" and bool(data and data[0])
|
|
except Exception:
|
|
return False
|
|
finally:
|
|
conn.logout()
|
|
|
|
|
|
def _bulk_set_flag(uids, folder, flag, add=True, account=None):
|
|
"""Add/remove an IMAP flag on MANY messages in one connection.
|
|
`uids` is a list; we issue a single STORE over the comma-joined set
|
|
(IMAP supports message-set syntax). Returns count attempted."""
|
|
if not uids:
|
|
return 0
|
|
if _fixture_email_enabled():
|
|
changed = 0
|
|
for uid in uids:
|
|
if flag == "\\Seen":
|
|
if _fixture_update_email(uid=uid, source_folder=folder, account=account, read=bool(add)):
|
|
changed += 1
|
|
elif flag == "\\Answered":
|
|
if _fixture_update_email(uid=uid, source_folder=folder, account=account, answered=bool(add), done=bool(add)):
|
|
changed += 1
|
|
elif flag == "\\Flagged":
|
|
if _fixture_update_email(uid=uid, source_folder=folder, account=account, favorite=bool(add)):
|
|
changed += 1
|
|
elif add and flag == "\\Deleted":
|
|
if _fixture_update_email(uid=uid, source_folder=folder, account=account, deleted=True):
|
|
changed += 1
|
|
return changed
|
|
conn = _imap_connect(account)
|
|
touched = []
|
|
try:
|
|
conn.select(_q(folder))
|
|
op = "+FLAGS" if add else "-FLAGS"
|
|
msg_set = ",".join(str(u) for u in uids)
|
|
try:
|
|
status, data = conn.uid("FETCH", _b(msg_set), "(UID)")
|
|
except Exception:
|
|
return 0
|
|
touched = _uid_fetch_rows(data)
|
|
if status != "OK" or not touched:
|
|
return 0
|
|
status, data = conn.uid("STORE", _b(msg_set), op, flag)
|
|
if add and flag == "\\Deleted":
|
|
conn.expunge()
|
|
if status != "OK":
|
|
return 0
|
|
finally:
|
|
conn.logout()
|
|
return len(touched)
|
|
|
|
|
|
def _bulk_move(uids, source_folder, dest_folder, account=None, role: str = ""):
|
|
"""Move MANY messages between folders in one connection."""
|
|
if not uids:
|
|
return 0
|
|
if _fixture_email_enabled():
|
|
changed = 0
|
|
fixture_dest = dest_folder
|
|
if role == "junk":
|
|
fixture_dest = "Junk"
|
|
elif role == "archive":
|
|
fixture_dest = "Archive"
|
|
elif role == "trash":
|
|
fixture_dest = "Trash"
|
|
for uid in uids:
|
|
if _fixture_update_email(
|
|
uid=uid,
|
|
source_folder=source_folder,
|
|
account=account,
|
|
folder=fixture_dest,
|
|
):
|
|
changed += 1
|
|
return changed
|
|
conn = _imap_connect(account)
|
|
moved = 0
|
|
try:
|
|
conn.select(_q(source_folder))
|
|
dest_folder = _resolve_folder(conn, dest_folder, role or _folder_role_from_name(dest_folder))
|
|
msg_set = ",".join(str(u) for u in uids)
|
|
try:
|
|
status, data = conn.uid("FETCH", _b(msg_set), "(UID)")
|
|
except Exception:
|
|
return 0
|
|
existing_uids = _uids_from_fetch_rows(data)
|
|
if not existing_uids:
|
|
return 0
|
|
dest_arg = _q(dest_folder)
|
|
status, _ = conn.uid("MOVE", _b(msg_set), dest_arg)
|
|
if status != "OK":
|
|
# Fallback: UID copy + flag-delete + expunge
|
|
status, _ = conn.uid("COPY", _b(msg_set), dest_arg)
|
|
if status != "OK":
|
|
return 0
|
|
status, _ = conn.uid("STORE", _b(msg_set), "+FLAGS", "\\Deleted")
|
|
if status != "OK":
|
|
return 0
|
|
conn.expunge()
|
|
|
|
# Some IMAP servers return OK for a multi-UID MOVE without applying it.
|
|
# Verify the source folder and retry only the remaining UIDs one by one.
|
|
conn.select(_q(source_folder))
|
|
_, remaining_data = conn.uid("FETCH", _b(msg_set), "(UID)")
|
|
remaining_uids = _uids_from_fetch_rows(remaining_data)
|
|
for uid in (str(value) for value in uids):
|
|
if uid not in remaining_uids:
|
|
continue
|
|
conn.uid("MOVE", _b(uid), dest_arg)
|
|
|
|
# An individual MOVE can also return OK without changing the source.
|
|
# Verify again before using the portable COPY + delete fallback.
|
|
conn.select(_q(source_folder))
|
|
_, remaining_data = conn.uid("FETCH", _b(msg_set), "(UID)")
|
|
remaining_uids = _uids_from_fetch_rows(remaining_data)
|
|
copied_any = False
|
|
for uid in (str(value) for value in uids):
|
|
if uid not in remaining_uids:
|
|
continue
|
|
single_status, _ = conn.uid("COPY", _b(uid), dest_arg)
|
|
if single_status != "OK":
|
|
continue
|
|
single_status, _ = conn.uid("STORE", _b(uid), "+FLAGS", "\\Deleted")
|
|
copied_any = copied_any or single_status == "OK"
|
|
if copied_any:
|
|
conn.expunge()
|
|
|
|
conn.select(_q(source_folder))
|
|
_, final_data = conn.uid("FETCH", _b(msg_set), "(UID)")
|
|
moved_uids = existing_uids - _uids_from_fetch_rows(final_data)
|
|
moved = len(moved_uids)
|
|
_email_index_delete_uids(account, source_folder, moved_uids)
|
|
finally:
|
|
conn.logout()
|
|
return moved
|
|
|
|
|
|
def _search_uids(folder="INBOX", criteria="UNSEEN", account=None):
|
|
"""Return a list of UIDs matching an IMAP search (e.g. UNSEEN,
|
|
ALL, ANSWERED). Used to resolve selectors like all_unread → uids."""
|
|
if _fixture_email_enabled():
|
|
crit = str(criteria or "ALL").strip().upper()
|
|
rows = _fixture_list_emails(
|
|
folder,
|
|
max_results=10000,
|
|
unread_only=(crit == "UNSEEN"),
|
|
account=account,
|
|
) or []
|
|
if crit == "ANSWERED":
|
|
rows = [row for row in rows if row.get("is_done")]
|
|
elif crit == "UNANSWERED":
|
|
rows = [row for row in rows if not row.get("is_done")]
|
|
return [str(row.get("uid")) for row in rows if row.get("uid")]
|
|
conn = _imap_connect(account)
|
|
try:
|
|
conn.select(_q(folder), readonly=True)
|
|
status, data = conn.uid("SEARCH", None, criteria)
|
|
if status != "OK" or not data or not data[0]:
|
|
return []
|
|
return data[0].split()
|
|
finally:
|
|
conn.logout()
|
|
|
|
|
|
def _move_message(uid, source_folder, dest_folder, account=None, role: str = ""):
|
|
"""Move a message between folders. Tries IMAP MOVE, falls back to copy+delete."""
|
|
conn = _imap_connect(account)
|
|
conn.select(_q(source_folder))
|
|
try:
|
|
dest_folder = _resolve_folder(conn, dest_folder, role or _folder_role_from_name(dest_folder))
|
|
try:
|
|
status, data = conn.uid("FETCH", _b(uid), "(UID)")
|
|
except Exception:
|
|
return False
|
|
existing = _uid_fetch_rows(data)
|
|
if status != "OK" or not existing:
|
|
return False
|
|
dest_arg = _q(dest_folder)
|
|
status, _ = conn.uid("MOVE", _b(uid), dest_arg)
|
|
if status == "OK":
|
|
return True
|
|
# Fallback: UID copy + delete
|
|
status, _ = conn.uid("COPY", _b(uid), dest_arg)
|
|
if status != "OK":
|
|
return False
|
|
status, _ = conn.uid("STORE", _b(uid), "+FLAGS", "\\Deleted")
|
|
if status != "OK":
|
|
return False
|
|
conn.expunge()
|
|
ok = True
|
|
finally:
|
|
conn.logout()
|
|
return ok
|
|
|
|
|
|
def _delete_email(uid, folder="INBOX", permanent=False, account=None):
|
|
"""Delete an email. By default moves to Trash; permanent=True expunges."""
|
|
if _fixture_email_action_target(uid=uid, folder=folder, account=account) is not None:
|
|
if permanent:
|
|
return _fixture_update_email(uid=uid, source_folder=folder, account=account, deleted=True)
|
|
return _fixture_update_email(uid=uid, source_folder=folder, account=account, folder="Trash")
|
|
cfg = _load_config(account)
|
|
if permanent:
|
|
return _set_flag(uid, folder, "\\Deleted", add=True, account=account)
|
|
return _move_message(uid, folder, cfg["trash_folder"], account=account, role="trash")
|
|
|
|
|
|
def _archive_email(uid, folder="INBOX", account=None):
|
|
"""Move an email to the archive folder."""
|
|
if _fixture_email_action_target(uid=uid, folder=folder, account=account) is not None:
|
|
return _fixture_update_email(uid=uid, source_folder=folder, account=account, folder="Archive")
|
|
cfg = _load_config(account)
|
|
return _move_message(uid, folder, cfg["archive_folder"], account=account, role="archive")
|
|
|
|
|
|
def _unarchive_email(uid, folder="Archive", account=None):
|
|
"""Move an archived email back to the inbox."""
|
|
if _fixture_email_action_target(uid=uid, folder=folder, account=account) is not None:
|
|
return _fixture_update_email(uid=uid, source_folder=folder, account=account, folder="INBOX")
|
|
return _move_message(uid, folder, "INBOX", account=account, role="inbox")
|
|
|
|
|
|
def _block_sender(sender=None, uids=None, folder="INBOX", account=None, reason="", move_existing=True) -> dict:
|
|
selected_uids = [str(uid) for uid in (uids or []) if str(uid or "").strip()]
|
|
senders: set[str] = set()
|
|
if sender:
|
|
addr = _normalize_email_address(sender)
|
|
if addr:
|
|
senders.add(addr)
|
|
for uid in selected_uids:
|
|
item = _read_email(uid=uid, folder=folder, account=account)
|
|
if isinstance(item, dict) and not item.get("error"):
|
|
addr = _normalize_email_address(item.get("from_address") or item.get("from"))
|
|
if addr:
|
|
senders.add(addr)
|
|
if not senders:
|
|
return {"success": False, "error": "No valid sender address found to block."}
|
|
|
|
blocked = []
|
|
already = []
|
|
errors = []
|
|
for addr in sorted(senders):
|
|
changed, message = _add_blocked_sender(addr, reason=reason, account=account)
|
|
if changed:
|
|
blocked.append(addr)
|
|
elif "already blocked" in message:
|
|
already.append(addr)
|
|
else:
|
|
errors.append(message)
|
|
|
|
moved = 0
|
|
moved_uids: list[str] = []
|
|
if move_existing:
|
|
if _fixture_email_enabled():
|
|
path = _fixture_email_file()
|
|
try:
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
rows = payload.get("messages") if isinstance(payload, dict) else payload
|
|
except Exception:
|
|
rows = []
|
|
payload = {}
|
|
owner = _current_owner()
|
|
changed = False
|
|
for index, row in enumerate(rows if isinstance(rows, list) else [], start=1):
|
|
if not isinstance(row, dict):
|
|
continue
|
|
row_owner = str(row.get("owner") or "").strip()
|
|
if owner and row_owner and row_owner != owner:
|
|
continue
|
|
if not _fixture_folder_matches(row.get("folder") or "INBOX", folder):
|
|
continue
|
|
row_sender = _normalize_email_address(row.get("from"))
|
|
if row_sender not in senders:
|
|
continue
|
|
rendered = _fixture_email_record(row, index, owner or row_owner)
|
|
if account and not _fixture_row_matches_account(rendered, account):
|
|
continue
|
|
row["folder"] = "Junk"
|
|
moved += 1
|
|
moved_uids.append(str(row.get("uid") or index))
|
|
changed = True
|
|
if changed:
|
|
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
else:
|
|
cfg = _load_config(account)
|
|
junk_folder = cfg.get("junk_folder") or "Junk"
|
|
candidate_uids = list(selected_uids)
|
|
if not candidate_uids:
|
|
for addr in senders:
|
|
try:
|
|
hits = _search_emails(addr, folders=[folder], max_results=50, account=account)
|
|
except Exception:
|
|
hits = []
|
|
candidate_uids.extend(str(hit.get("uid")) for hit in hits if hit.get("uid"))
|
|
seen = set()
|
|
candidate_uids = [uid for uid in candidate_uids if not (uid in seen or seen.add(uid))]
|
|
if candidate_uids:
|
|
moved = _bulk_move(candidate_uids, folder, junk_folder, account=account, role="junk")
|
|
moved_uids = candidate_uids[:moved]
|
|
|
|
return {
|
|
"success": not errors,
|
|
"blocked": blocked,
|
|
"already_blocked": already,
|
|
"errors": errors,
|
|
"moved_to_junk": moved,
|
|
"moved_uids": moved_uids,
|
|
}
|
|
|
|
|
|
def _download_attachment(uid, index, folder="INBOX", account=None):
|
|
"""Extract a specific attachment to disk and return its local path."""
|
|
fixture = _fixture_attachment_source(uid, index, folder=folder, account=account)
|
|
if fixture is not None:
|
|
_row, att = fixture
|
|
filename = str(att.get("filename") or f"attachment-{index}.txt")
|
|
safe_name = re.sub(r"[^\w\s\-.]", "_", filename).strip() or f"attachment-{index}.txt"
|
|
content = str(att.get("content") or "")
|
|
target_dir = Path(MAIL_ATTACHMENTS_DIR) / re.sub(r"[^A-Za-z0-9._-]", "_", f"{folder}_{uid}")
|
|
path = target_dir / safe_name
|
|
size = len(content.encode("utf-8"))
|
|
try:
|
|
target_dir.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(content.encode("utf-8"))
|
|
size = path.stat().st_size
|
|
except Exception as exc:
|
|
# Fixture attachment content is the authoritative test data. If a
|
|
# stale container-created file blocks local writes, still return
|
|
# the inline content so agents can answer attachment questions.
|
|
print(f"fixture attachment write failed for {path}: {exc}", file=sys.stderr)
|
|
return {
|
|
"path": str(path),
|
|
"filename": safe_name,
|
|
"size": size,
|
|
"content": content,
|
|
"content_type": str(att.get("content_type") or "application/octet-stream"),
|
|
}
|
|
conn = None
|
|
try:
|
|
conn = _imap_connect(account)
|
|
conn.select(_q(folder), readonly=True)
|
|
status, msg_data = conn.uid("FETCH", _b(uid), "(BODY.PEEK[])")
|
|
finally:
|
|
if conn:
|
|
try: conn.logout()
|
|
except Exception: pass
|
|
if status != "OK":
|
|
return {"error": f"Failed to fetch email UID {uid}"}
|
|
raw = msg_data[0][1]
|
|
msg = email.message_from_bytes(raw)
|
|
|
|
target_dir = Path(MAIL_ATTACHMENTS_DIR) / f"{folder}_{uid}"
|
|
filepath = _extract_attachment_to_disk(msg, index, target_dir)
|
|
if not filepath:
|
|
return {"error": f"Attachment index {index} not found"}
|
|
size = os.path.getsize(filepath)
|
|
return {"path": filepath, "filename": os.path.basename(filepath), "size": size}
|
|
|
|
|
|
# ── MCP Tool Registration ──
|
|
|
|
|
|
@server.list_tools()
|
|
async def list_tools() -> list[Tool]:
|
|
# The user may have multiple IMAP accounts configured. Every tool accepts an
|
|
# optional `account` param — match by name (e.g. "work"), email address,
|
|
# or account id. Leave it out to use the default account.
|
|
ACCOUNT_PROP = {
|
|
"account": {
|
|
"type": "string",
|
|
"description": "Which email account to use (name, email, or id). "
|
|
"Omit to use the default account. Use list_email_accounts to discover available accounts.",
|
|
},
|
|
}
|
|
return [
|
|
Tool(
|
|
name="list_email_accounts",
|
|
description=(
|
|
"List the email accounts configured in Odysseus. Returns each account's "
|
|
"name, email address, and whether it's the default. Use this first when "
|
|
"the user asks about a specific inbox by name (e.g. 'check work')."
|
|
),
|
|
inputSchema={"type": "object", "properties": {}, "required": []},
|
|
),
|
|
Tool(
|
|
name="list_emails",
|
|
description=(
|
|
"List unread or unresponded emails from the inbox. "
|
|
"Returns subject, sender, date, and cached AI summary for each. "
|
|
"Use this to check what emails need attention. "
|
|
"Pass `account` to scan a non-default mailbox."
|
|
),
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"folder": {
|
|
"type": "string",
|
|
"description": "IMAP folder to check (default: INBOX)",
|
|
"default": "INBOX",
|
|
},
|
|
"max_results": {
|
|
"type": "integer",
|
|
"description": "Maximum number of emails to return (default: 20)",
|
|
"default": 20,
|
|
},
|
|
"unresponded_only": {
|
|
"type": "boolean",
|
|
"description": "Only show emails without replies (default: false)",
|
|
"default": False,
|
|
},
|
|
"unread_only": {
|
|
"type": "boolean",
|
|
"description": "Only show unread emails. Default false so latest/all inbox requests match normal mail clients.",
|
|
"default": False,
|
|
},
|
|
"date_from": {
|
|
"type": "string",
|
|
"description": "Inclusive ISO date/datetime lower bound, e.g. 2026-07-01 for last-month filtering.",
|
|
},
|
|
"date_to": {
|
|
"type": "string",
|
|
"description": "Exclusive ISO date/datetime upper bound, e.g. 2026-08-01 for last-month filtering.",
|
|
},
|
|
**ACCOUNT_PROP,
|
|
},
|
|
"required": [],
|
|
},
|
|
),
|
|
Tool(
|
|
name="scan_email_unsubscribes",
|
|
description=(
|
|
"Scan up to 500 newest email headers for likely spam/newsletter unsubscribe candidates. "
|
|
"Returns reviewable candidates with UID, sender, subject, score, reasons, and "
|
|
"List-Unsubscribe methods. This does not unsubscribe anything. For mailto "
|
|
"methods, use unsubscribe_email after user approval. For web URL methods, use "
|
|
"browser/web tools after user approval to open the exact URL and complete the page."
|
|
),
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"folder": {"type": "string", "description": "IMAP folder to scan", "default": "INBOX"},
|
|
"limit": {"type": "integer", "description": "Maximum candidates to return", "default": 25},
|
|
"max_scan": {"type": "integer", "description": "How many newest messages to inspect, capped at 500 (default 500)", "default": 500},
|
|
**ACCOUNT_PROP,
|
|
},
|
|
"required": [],
|
|
},
|
|
),
|
|
Tool(
|
|
name="scan_spam",
|
|
description=(
|
|
"Review recent inbox messages for likely spam/phishing. Returns "
|
|
"candidate spam messages with UID, sender, subject, score, and "
|
|
"reasons. This does not move/delete/block anything; ask the user "
|
|
"to confirm before using bulk_email action=junk or block_sender."
|
|
),
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"folder": {"type": "string", "description": "IMAP folder to scan", "default": "INBOX"},
|
|
"limit": {"type": "integer", "description": "Maximum candidates to return", "default": 10},
|
|
"max_scan": {"type": "integer", "description": "How many newest messages to inspect", "default": 100},
|
|
**ACCOUNT_PROP,
|
|
},
|
|
"required": [],
|
|
},
|
|
),
|
|
Tool(
|
|
name="unsubscribe_email",
|
|
description=(
|
|
"Execute one approved unsubscribe action for an email UID. Supports safe mailto "
|
|
"List-Unsubscribe directly. If the selected method is a web URL, this returns "
|
|
"requires_browser with the exact URL; use browser/web tools only after user approval."
|
|
),
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"uid": {"type": "string", "description": "Email UID from scan_email_unsubscribes/list_emails"},
|
|
"folder": {"type": "string", "description": "IMAP folder", "default": "INBOX"},
|
|
"method_index": {"type": "integer", "description": "Unsubscribe method index from scan_email_unsubscribes", "default": 0},
|
|
"allow_web": {"type": "boolean", "description": "Return web unsubscribe URL instructions when the method is URL", "default": False},
|
|
**ACCOUNT_PROP,
|
|
},
|
|
"required": ["uid"],
|
|
},
|
|
),
|
|
Tool(
|
|
name="download_attachment",
|
|
description=(
|
|
"Download an email attachment to the local disk so you can read it. "
|
|
"Returns the local file path which you can then read with read_file. "
|
|
"Use this when you need to review a document, spreadsheet, or other "
|
|
"file attached to an email."
|
|
),
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"uid": {"type": "string", "description": "Email UID from list_emails"},
|
|
"index": {"type": "integer", "description": "Attachment index (from read_email's attachments list)"},
|
|
"folder": {"type": "string", "description": "IMAP folder (default: INBOX)", "default": "INBOX"},
|
|
**ACCOUNT_PROP,
|
|
},
|
|
"required": ["uid", "index"],
|
|
},
|
|
),
|
|
Tool(
|
|
name="send_email",
|
|
description=(
|
|
"Send a new email via SMTP. Provide recipient(s), subject, and body. "
|
|
"This sends immediately; for normal assistant-written email, prefer "
|
|
"draft_email so the user can review and send from Odysseus. "
|
|
"For replying to an existing thread, use reply_to_email instead. "
|
|
"Pass `account` to send from a non-default mailbox."
|
|
),
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"to": {"type": "string", "description": "Recipient email address(es), comma-separated"},
|
|
"subject": {"type": "string", "description": "Email subject line"},
|
|
"body": {"type": "string", "description": "Plain text body"},
|
|
"cc": {"type": "string", "description": "CC address(es), comma-separated (optional)"},
|
|
"bcc": {"type": "string", "description": "BCC address(es), comma-separated (optional)"},
|
|
**ACCOUNT_PROP,
|
|
},
|
|
"required": ["to", "subject", "body"],
|
|
},
|
|
),
|
|
Tool(
|
|
name="draft_email",
|
|
description=(
|
|
"Create a new Odysseus email compose draft document. This DOES NOT send. "
|
|
"Use this as the default way to write an email for the user: it opens "
|
|
"a reviewable email document with To/Cc/Bcc/Subject/body, and the user "
|
|
"can edit or press Send in Odysseus. "
|
|
f"{_writing_style_guidance()}"
|
|
),
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"to": {"type": "string", "description": "Recipient email address(es), comma-separated"},
|
|
"subject": {"type": "string", "description": "Email subject line"},
|
|
"body": {"type": "string", "description": "Draft body"},
|
|
"cc": {"type": "string", "description": "CC address(es), comma-separated (optional)"},
|
|
"bcc": {"type": "string", "description": "BCC address(es), comma-separated (optional)"},
|
|
"title": {"type": "string", "description": "Optional Odysseus document title"},
|
|
**ACCOUNT_PROP,
|
|
},
|
|
"required": ["to", "subject", "body"],
|
|
},
|
|
),
|
|
Tool(
|
|
name="reply_to_email",
|
|
description=(
|
|
"Reply to an existing email by UID. This sends immediately. Do NOT use "
|
|
"for normal 'write/draft a reply saying X' requests; use "
|
|
"draft_email_reply so the user can review and send from Odysseus. "
|
|
"Only use this when the user explicitly says to send now. Automatically threads the reply with "
|
|
"In-Reply-To and References headers, prefixes 'Re:' on the subject, and "
|
|
"uses the original sender as the recipient. Set reply_all=true to also CC "
|
|
"the original To/Cc recipients. For follow-up 'reply ...' requests, use "
|
|
"the exact UID from the latest list_emails/read_email result; never invent UID 1."
|
|
),
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"uid": {"type": "string", "description": "Exact Email UID from list_emails/read_email; never invent UID 1"},
|
|
"body": {"type": "string", "description": "Reply body text"},
|
|
"folder": {"type": "string", "description": "IMAP folder (default: INBOX)", "default": "INBOX"},
|
|
"reply_all": {"type": "boolean", "description": "Reply to all recipients (default: false)", "default": False},
|
|
**ACCOUNT_PROP,
|
|
},
|
|
"required": ["uid", "body"],
|
|
},
|
|
),
|
|
Tool(
|
|
name="draft_email_reply",
|
|
description=(
|
|
"Create an Odysseus email reply draft document for an existing email UID. "
|
|
"This DOES NOT send. It threads the draft with In-Reply-To/References, "
|
|
"prefills the recipient and subject, and stores source email metadata so "
|
|
"the user can review and send from the normal email composer. "
|
|
f"{_writing_style_guidance()}"
|
|
),
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"uid": {"type": "string", "description": "Exact Email UID from list_emails/read_email; never invent UID 1"},
|
|
"body": {"type": "string", "description": "Draft reply body text"},
|
|
"folder": {"type": "string", "description": "IMAP folder (default: INBOX)", "default": "INBOX"},
|
|
"reply_all": {"type": "boolean", "description": "Reply to all recipients (default: false)", "default": False},
|
|
"title": {"type": "string", "description": "Optional Odysseus document title"},
|
|
**ACCOUNT_PROP,
|
|
},
|
|
"required": ["uid", "body"],
|
|
},
|
|
),
|
|
Tool(
|
|
name="ai_draft_email_reply",
|
|
description=(
|
|
"Generate an AI reply using Odysseus' existing AI Reply behavior, "
|
|
"including Settings > Email > Writing Style, then create an email "
|
|
"compose document for review. This DOES NOT send and does NOT save "
|
|
"to the mailbox Drafts folder. Use this when the user asks you to "
|
|
"write or draft a reply to an email without dictating the exact body."
|
|
),
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"uid": {"type": "string", "description": "Exact Email UID from list_emails/read_email; never invent UID 1"},
|
|
"folder": {"type": "string", "description": "IMAP folder (default: INBOX)", "default": "INBOX"},
|
|
"reply_all": {"type": "boolean", "description": "Reply to all recipients (default: false)", "default": False},
|
|
"title": {"type": "string", "description": "Optional Odysseus document title"},
|
|
**ACCOUNT_PROP,
|
|
},
|
|
"required": ["uid"],
|
|
},
|
|
),
|
|
Tool(
|
|
name="archive_email",
|
|
description="Move an email out of the inbox into the Archive folder. Use after handling an email you want to keep but no longer need in the inbox.",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"uid": {"type": "string", "description": "Email UID from list_emails"},
|
|
"folder": {"type": "string", "description": "Source folder (default: INBOX)", "default": "INBOX"},
|
|
**ACCOUNT_PROP,
|
|
},
|
|
"required": ["uid"],
|
|
},
|
|
),
|
|
Tool(
|
|
name="delete_email",
|
|
description="Delete an email. By default moves it to the Trash folder; pass permanent=true to expunge immediately.",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"uid": {"type": "string", "description": "Email UID from list_emails"},
|
|
"folder": {"type": "string", "description": "Source folder (default: INBOX)", "default": "INBOX"},
|
|
"permanent": {"type": "boolean", "description": "Hard-delete instead of move to Trash", "default": False},
|
|
**ACCOUNT_PROP,
|
|
},
|
|
"required": ["uid"],
|
|
},
|
|
),
|
|
Tool(
|
|
name="mark_email_read",
|
|
description="Mark an email as read (\\Seen flag) or unread (read=false).",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"uid": {"type": "string", "description": "Email UID"},
|
|
"folder": {"type": "string", "description": "IMAP folder", "default": "INBOX"},
|
|
"read": {"type": "boolean", "description": "True to mark read, false to mark unread", "default": True},
|
|
**ACCOUNT_PROP,
|
|
},
|
|
"required": ["uid"],
|
|
},
|
|
),
|
|
Tool(
|
|
name="manage_email_state",
|
|
description=(
|
|
"Compact reversible email state manager. Use for favorite/unfavorite, "
|
|
"unarchive, read/unread, list blocked senders, and unblock. Common "
|
|
"one-way actions still have dedicated tools: archive_email, delete_email, "
|
|
"block_sender, bulk_email."
|
|
),
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"action": {
|
|
"type": "string",
|
|
"enum": ["favorite", "unfavorite", "mark_read", "mark_unread", "mark_done", "mark_undone", "unarchive", "list_blocked", "unblock_sender"],
|
|
},
|
|
"uid": {"type": "string", "description": "Email UID for message actions"},
|
|
"sender": {"type": "string", "description": "Sender email address for unblock_sender"},
|
|
"folder": {"type": "string", "description": "Source folder, default INBOX except unarchive defaults Archive", "default": "INBOX"},
|
|
**ACCOUNT_PROP,
|
|
},
|
|
"required": ["action"],
|
|
},
|
|
),
|
|
Tool(
|
|
name="bulk_email",
|
|
description=(
|
|
"Perform one action on MANY emails at once — the efficient way to "
|
|
"'mark all as read', 'archive these', 'delete all spam', etc. Select "
|
|
"messages either by an explicit `uids` list OR by `all_unread: true` "
|
|
"(operates on every unread message in the folder). Far better than "
|
|
"calling mark_email_read / archive_email once per message."
|
|
),
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"action": {
|
|
"type": "string",
|
|
"enum": ["mark_read", "mark_unread", "archive", "delete", "junk"],
|
|
"description": "What to do to every selected message.",
|
|
},
|
|
"uids": {
|
|
"type": "array",
|
|
"items": {"type": "string"},
|
|
"description": "Explicit list of UIDs. Omit if using all_unread.",
|
|
},
|
|
"all_unread": {
|
|
"type": "boolean",
|
|
"description": "Operate on ALL unread messages in the folder (ignores uids).",
|
|
"default": False,
|
|
},
|
|
"folder": {"type": "string", "description": "IMAP folder", "default": "INBOX"},
|
|
"permanent": {"type": "boolean", "description": "For delete: expunge instead of moving to Trash.", "default": False},
|
|
**ACCOUNT_PROP,
|
|
},
|
|
"required": ["action"],
|
|
},
|
|
),
|
|
Tool(
|
|
name="block_sender",
|
|
description=(
|
|
"Block one or more email senders after user approval. Records an "
|
|
"owner-scoped block rule and optionally moves matching current "
|
|
"messages from the selected folder to Junk/Spam. For suspected spam, "
|
|
"first show the candidate messages/reasons and ask the user to confirm."
|
|
),
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"sender": {"type": "string", "description": "Sender email address to block, e.g. alerts@example.com"},
|
|
"uids": {
|
|
"type": "array",
|
|
"items": {"type": "string"},
|
|
"description": "Email UIDs whose senders should be blocked.",
|
|
},
|
|
"folder": {"type": "string", "description": "Source folder for UID lookup/current-message moves", "default": "INBOX"},
|
|
"reason": {"type": "string", "description": "Short reason, e.g. phishing or unsolicited sales"},
|
|
"move_existing": {"type": "boolean", "description": "Move matching current messages to Junk", "default": True},
|
|
**ACCOUNT_PROP,
|
|
},
|
|
"required": [],
|
|
},
|
|
),
|
|
Tool(
|
|
name="search_emails",
|
|
description=(
|
|
"Search emails by free-text query (sender, subject, or body). "
|
|
"Walks INBOX + Sent + Archive by default so older threads are findable, "
|
|
"not just recent unread. Use this whenever the user names a person or "
|
|
"topic that isn't in the most recent inbox slice — e.g. 'Sara Sotheby's', "
|
|
"'invoice from EY', 'last email about the property'. Returns matching "
|
|
"emails with their UIDs so you can read_email or reply_to_email."
|
|
),
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"query": {
|
|
"type": "string",
|
|
"description": "Free-text query. Matches FROM, SUBJECT, and body TEXT.",
|
|
},
|
|
"folders": {
|
|
"type": "array",
|
|
"items": {"type": "string"},
|
|
"description": "Folders to search (default: INBOX, Sent, Archive)",
|
|
},
|
|
"max_results": {
|
|
"type": "integer",
|
|
"description": "Max results per folder (default: 20)",
|
|
"default": 20,
|
|
},
|
|
"date_from": {
|
|
"type": "string",
|
|
"description": "Inclusive ISO date/datetime lower bound, e.g. 2026-07-01 for last-month filtering.",
|
|
},
|
|
"date_to": {
|
|
"type": "string",
|
|
"description": "Exclusive ISO date/datetime upper bound, e.g. 2026-08-01 for last-month filtering.",
|
|
},
|
|
**ACCOUNT_PROP,
|
|
},
|
|
"required": ["query"],
|
|
},
|
|
),
|
|
Tool(
|
|
name="read_email",
|
|
description=(
|
|
"Read the full content of a specific email. "
|
|
"Provide either the UID (from list_emails) or a Message-ID. "
|
|
"Returns the subject, sender, date, and full body text."
|
|
),
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"uid": {
|
|
"type": "string",
|
|
"description": "Email UID from list_emails results",
|
|
},
|
|
"message_id": {
|
|
"type": "string",
|
|
"description": "RFC Message-ID header value",
|
|
},
|
|
"folder": {
|
|
"type": "string",
|
|
"description": "IMAP folder (default: INBOX)",
|
|
"default": "INBOX",
|
|
},
|
|
**ACCOUNT_PROP,
|
|
},
|
|
"required": [],
|
|
},
|
|
),
|
|
]
|
|
|
|
|
|
@server.call_tool()
|
|
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|
arguments = dict(arguments) if isinstance(arguments, dict) else {}
|
|
owner = str(arguments.pop(_MCP_OWNER_ARG, "") or "").strip()
|
|
session_id = str(arguments.pop(_MCP_SESSION_ARG, "") or "").strip()
|
|
owner_token = _CURRENT_OWNER.set(owner or None)
|
|
session_token = _CURRENT_SESSION_ID.set(session_id or None)
|
|
try:
|
|
all_db_accounts = _read_accounts_from_db()
|
|
if _mcp_owner_required(all_db_accounts):
|
|
return [TextContent(type="text", text=_OWNER_SCOPE_ERROR)]
|
|
|
|
if name == "list_email_accounts":
|
|
rows = _filter_accounts_for_owner(all_db_accounts)
|
|
if _fixture_email_enabled():
|
|
rows = _fixture_account_rows()
|
|
if not rows:
|
|
rows = _fixture_account_rows()
|
|
if not rows:
|
|
if all_db_accounts and owner:
|
|
return [TextContent(type="text", text="No email accounts configured for this owner.")]
|
|
return [TextContent(
|
|
type="text",
|
|
text=(
|
|
"No named email accounts are configured. Default single-account mode "
|
|
"is active: omit the `account` field and continue with list_emails, "
|
|
"search_emails, or read_email. Any unavailable credentials will be "
|
|
"reported by that operation."
|
|
),
|
|
)]
|
|
lines = [f"Found {len(rows)} email account(s):\n"]
|
|
for r in rows:
|
|
star = " (default)" if r.get("is_default") else ""
|
|
lines.append(
|
|
f"- **{r['name']}**{star}\n"
|
|
f" email: {r.get('imap_user') or r.get('from_address') or '(unknown)'}\n"
|
|
f" id: {r['id']}"
|
|
)
|
|
return [TextContent(type="text", text="\n".join(lines))]
|
|
|
|
acct = arguments.get("account") # consumed by all email ops
|
|
|
|
if name == "list_emails":
|
|
# Reject invalid ranges once, before fixture/cache/account dispatch;
|
|
# they are argument errors, not an empty inbox or per-account outage.
|
|
_search_date_bounds(arguments.get("date_from"), arguments.get("date_to"))
|
|
max_results = arguments.get("max_results", arguments.get("limit", 20))
|
|
unresponded_only = arguments.get("unresponded_only", False)
|
|
unread_only = arguments.get("unread_only", False)
|
|
# Build a header note so the LLM always knows which account was hit
|
|
# AND what other accounts exist. Prevents "I can see emails" →
|
|
# user: "I have 2 inboxes" → "which one?" loop.
|
|
all_accounts = _fixture_account_rows() if _fixture_email_enabled() else _list_accounts_raw()
|
|
header_lines = []
|
|
errors = []
|
|
if len(all_accounts) >= 2 and not acct:
|
|
results, errors = _list_emails_across_accounts(
|
|
folder=arguments.get("folder", "INBOX"),
|
|
max_results=max_results,
|
|
unresponded_only=unresponded_only,
|
|
unread_only=unread_only,
|
|
date_from=arguments.get("date_from"),
|
|
date_to=arguments.get("date_to"),
|
|
)
|
|
account_names = [
|
|
f"{a.get('name') or a.get('imap_user')} <{a.get('imap_user') or a.get('from_address') or '?'}>"
|
|
for a in all_accounts
|
|
]
|
|
header_lines.append(
|
|
f"[EMAIL ACCOUNT CONTEXT: No `account` was provided, so this result is merged across configured accounts: "
|
|
f"{', '.join(account_names)}. Each row includes its source account.]\n"
|
|
)
|
|
else:
|
|
results = _list_emails(
|
|
folder=arguments.get("folder", "INBOX"),
|
|
max_results=max_results,
|
|
unresponded_only=unresponded_only,
|
|
unread_only=unread_only,
|
|
account=acct,
|
|
date_from=arguments.get("date_from"),
|
|
date_to=arguments.get("date_to"),
|
|
)
|
|
if _fixture_email_enabled():
|
|
active_cfg = next(
|
|
(
|
|
row for row in _fixture_account_rows()
|
|
if str(acct or "").strip().lower() in {
|
|
str(row.get("id") or "").strip().lower(),
|
|
str(row.get("name") or "").strip().lower(),
|
|
str(row.get("imap_user") or "").strip().lower(),
|
|
}
|
|
),
|
|
{},
|
|
)
|
|
else:
|
|
active_cfg = _load_config(acct)
|
|
if active_cfg.get("name") or active_cfg.get("account_name") or active_cfg.get("imap_user"):
|
|
for item in results:
|
|
item["_account"] = active_cfg.get("name") or active_cfg.get("account_name") or active_cfg.get("imap_user") or "default"
|
|
item["_account_email"] = active_cfg.get("imap_user") or ""
|
|
|
|
if len(all_accounts) >= 2 and acct:
|
|
if _fixture_email_enabled():
|
|
active_cfg = next(
|
|
(
|
|
row for row in _fixture_account_rows()
|
|
if str(acct or "").strip().lower() in {
|
|
str(row.get("id") or "").strip().lower(),
|
|
str(row.get("name") or "").strip().lower(),
|
|
str(row.get("imap_user") or "").strip().lower(),
|
|
}
|
|
),
|
|
{},
|
|
)
|
|
else:
|
|
active_cfg = _load_config(acct)
|
|
active_name = active_cfg.get("name") or active_cfg.get("account_name") or "default"
|
|
active_email = active_cfg.get("imap_user") or ""
|
|
other = [
|
|
f"{a['name']} <{a.get('imap_user') or a.get('from_address') or '?'}>"
|
|
for a in all_accounts
|
|
if a['id'] != active_cfg.get("account_id")
|
|
]
|
|
header_lines.append(
|
|
f"[EMAIL ACCOUNT CONTEXT: This result is ONLY from account `{active_name}` ({active_email}). "
|
|
f"Other configured accounts: {', '.join(other)}. "
|
|
f"If the user asks for Gmail/another inbox, call list_emails again with `account` set to that account name or email.]\n"
|
|
)
|
|
if errors:
|
|
header_lines.append("[EMAIL ACCOUNT ERRORS: " + "; ".join(errors) + "]\n")
|
|
|
|
if not results:
|
|
msg = "No unread/unresponded emails found."
|
|
if header_lines:
|
|
msg = "\n".join(header_lines) + msg
|
|
return [TextContent(type="text", text=msg)]
|
|
|
|
lines = header_lines + [f"Found {len(results)} email(s):\n"]
|
|
for i, em in enumerate(results, 1):
|
|
line = f"{i}. **{em['subject']}**\n From: {em['from']} ({em['from_address']})\n Date: {em['date']}\n UID: {em['uid']}"
|
|
if em.get("_account"):
|
|
account_label = em.get("_account")
|
|
if em.get("_account_email"):
|
|
account_label += f" <{em['_account_email']}>"
|
|
line += f"\n Account: {account_label}"
|
|
if em.get("summary"):
|
|
summary = re.sub(r"\s+", " ", str(em["summary"])).strip()
|
|
line += f"\n Summary: {summary}"
|
|
if em.get("attachments"):
|
|
names = ", ".join(str(a.get("filename") or f"attachment-{a.get('index')}") for a in em.get("attachments") or [])
|
|
line += f"\n Attachments: {names}"
|
|
lines.append(line)
|
|
return [TextContent(type="text", text="\n\n".join(lines))]
|
|
|
|
elif name == "scan_email_unsubscribes":
|
|
try:
|
|
result = _scan_unsubscribe_candidates(
|
|
folder=arguments.get("folder", "INBOX"),
|
|
account=acct,
|
|
limit=arguments.get("limit", 25),
|
|
max_scan=arguments.get("max_scan", 500),
|
|
)
|
|
except Exception as e:
|
|
return [TextContent(type="text", text=f"Unsubscribe scan failed: {e}")]
|
|
if not result.get("success"):
|
|
return [TextContent(type="text", text=f"Unsubscribe scan failed: {result.get('error', 'unknown error')}")]
|
|
candidates = result.get("candidates") or []
|
|
if not candidates:
|
|
return [TextContent(type="text", text=f"No unsubscribe candidates found in {result.get('scanned', 0)} scanned emails.")]
|
|
lines = [
|
|
f"Found {len(candidates)} unsubscribe candidate(s) from {result.get('scanned', 0)} scanned emails.",
|
|
"Review these with the user before executing. Mailto methods can use unsubscribe_email; URL methods require browser/web tools after approval.\n",
|
|
]
|
|
for i, cand in enumerate(candidates, 1):
|
|
lines.append(
|
|
f"{i}. **{cand.get('subject') or '(no subject)'}**\n"
|
|
f" From: {cand.get('from_name') or cand.get('from_address') or ''} ({cand.get('from_address') or ''})\n"
|
|
f" UID: {cand.get('uid')} Folder: {cand.get('folder')}\n"
|
|
f" Score: {cand.get('score')} Matching emails: {cand.get('duplicate_count', 1)} Reasons: {', '.join(cand.get('reasons') or [])}"
|
|
)
|
|
for j, method in enumerate(cand.get("methods") or []):
|
|
if method.get("kind") == "mailto":
|
|
lines.append(f" Method {j}: mailto {method.get('target')} (executable via unsubscribe_email)")
|
|
elif method.get("kind") == "url":
|
|
lines.append(f" Method {j}: web URL {method.get('target')} (use browser/web tools after approval)")
|
|
return [TextContent(type="text", text="\n".join(lines))]
|
|
|
|
elif name == "unsubscribe_email":
|
|
result = _unsubscribe_email(
|
|
uid=arguments.get("uid"),
|
|
folder=arguments.get("folder", "INBOX"),
|
|
account=acct,
|
|
method_index=arguments.get("method_index", 0),
|
|
allow_web=bool(arguments.get("allow_web", False)),
|
|
)
|
|
if result.get("requires_browser"):
|
|
return [TextContent(
|
|
type="text",
|
|
text=(
|
|
"Web unsubscribe requires browser/web navigation.\n"
|
|
f"URL: {result.get('url')}\n"
|
|
f"{result.get('instructions')}"
|
|
),
|
|
)]
|
|
if not result.get("success"):
|
|
return [TextContent(type="text", text=f"Unsubscribe failed: {result.get('error', 'unknown error')}")]
|
|
method = result.get("method") or {}
|
|
if result.get("pending"):
|
|
return [TextContent(
|
|
type="text",
|
|
text=(
|
|
f"Unsubscribe email staged for approval to {method.get('target')}. "
|
|
"Nothing has been sent until the user approves the pending email."
|
|
),
|
|
)]
|
|
if result.get("deleted"):
|
|
return [TextContent(type="text", text=f"Unsubscribe email sent to {method.get('target')}; source email moved to Trash.")]
|
|
return [TextContent(type="text", text=f"Unsubscribe email sent to {method.get('target')}, but the source email could not be moved to Trash.")]
|
|
|
|
elif name == "scan_spam":
|
|
result = _scan_spam(
|
|
folder=arguments.get("folder", "INBOX"),
|
|
account=acct,
|
|
limit=arguments.get("limit", 10),
|
|
max_scan=arguments.get("max_scan", 100),
|
|
)
|
|
if not result.get("success"):
|
|
return [TextContent(type="text", text=f"Spam scan failed: {result.get('error', 'unknown error')}")]
|
|
candidates = result.get("candidates") or []
|
|
if not candidates:
|
|
return [TextContent(type="text", text=f"No likely spam found in {result.get('scanned', 0)} recent email(s).")]
|
|
lines = [
|
|
f"Found {len(candidates)} likely spam candidate(s) from {result.get('scanned', 0)} recent email(s).",
|
|
"Review with the user before moving, deleting, unsubscribing, or blocking senders.\n",
|
|
]
|
|
for i, item in enumerate(candidates, 1):
|
|
account_label = item.get("account") or "default"
|
|
if item.get("account_email"):
|
|
account_label += f" <{item['account_email']}>"
|
|
lines.append(
|
|
f"{i}. **{item.get('subject') or '(no subject)'}**\n"
|
|
f" From: {item.get('from') or item.get('from_address') or '(unknown)'} ({item.get('from_address') or ''})\n"
|
|
f" Date: {item.get('date') or ''}\n"
|
|
f" UID: {item.get('uid')}\n"
|
|
f" Account: {account_label}\n"
|
|
f" Spam score: {item.get('spam_score', 0)}"
|
|
)
|
|
if item.get("spam_label"):
|
|
lines.append(f" Label: {item['spam_label']}")
|
|
if item.get("reasons"):
|
|
lines.append(" Reasons: " + "; ".join(str(r) for r in item["reasons"]))
|
|
if item.get("attachments"):
|
|
names = ", ".join(str(a.get("filename") or "") for a in item["attachments"])
|
|
lines.append(f" Attachments: {names}")
|
|
return [TextContent(type="text", text="\n".join(lines))]
|
|
|
|
elif name == "download_attachment":
|
|
uid = arguments.get("uid")
|
|
index = arguments.get("index")
|
|
folder = arguments.get("folder", "INBOX")
|
|
if uid is None or index is None:
|
|
return [TextContent(type="text", text="Error: uid and index are required")]
|
|
result = _download_attachment(uid, index, folder, account=acct)
|
|
if "error" in result:
|
|
return [TextContent(type="text", text=f"Error: {result['error']}")]
|
|
text = (
|
|
f"Attachment downloaded to: `{result['path']}`\n"
|
|
f"Filename: {result['filename']}\n"
|
|
f"Size: {result['size']} bytes\n\n"
|
|
)
|
|
content = str(result.get("content") or "").strip()
|
|
if content:
|
|
if len(content) > 12000:
|
|
content = content[:12000].rstrip() + "\n...[truncated]"
|
|
text += f"Content:\n{content}"
|
|
else:
|
|
text += "You can now read this file using the read_file tool."
|
|
return [TextContent(type="text", text=text)]
|
|
|
|
elif name == "search_emails":
|
|
q = arguments.get("query", "")
|
|
folders = arguments.get("folders") or None
|
|
max_results = arguments.get("max_results", 20)
|
|
try:
|
|
hits = _search_emails(
|
|
q,
|
|
folders=folders,
|
|
max_results=max_results,
|
|
account=acct,
|
|
date_from=arguments.get("date_from"),
|
|
date_to=arguments.get("date_to"),
|
|
)
|
|
except Exception as e:
|
|
# Text-only stdio MCP results use the explicit Error: prefix
|
|
# so the host normalizes this to exit_code=1 instead of
|
|
# treating an outage as successful search evidence.
|
|
return [TextContent(type="text", text=f"Error: Search failed: {e}")]
|
|
if not hits:
|
|
return [TextContent(type="text", text=f'No emails matched "{q}".')]
|
|
lines = [f'Found {len(hits)} email(s) matching "{q}":\n']
|
|
for i, em in enumerate(hits, 1):
|
|
lines.append(
|
|
f"{i}. **{em['subject']}**\n"
|
|
f" From: {em['from']} ({em['from_address']})\n"
|
|
f" Date: {em['date']}\n"
|
|
f" Folder: {em.get('_folder', 'INBOX')}\n"
|
|
f" UID: {em['uid']}"
|
|
)
|
|
if em.get("_account"):
|
|
account_label = em.get("_account")
|
|
if em.get("_account_email"):
|
|
account_label += f" <{em['_account_email']}>"
|
|
lines.append(f" Account: {account_label}")
|
|
if em.get("_source") == "index":
|
|
lines.append(" Source: cached index")
|
|
if em.get('to'):
|
|
lines.append(f" To: {em['to']}")
|
|
if em.get('summary'):
|
|
summary = re.sub(r"\s+", " ", str(em["summary"])).strip()
|
|
lines.append(f" Summary: {summary}")
|
|
if em.get("attachments"):
|
|
names = ", ".join(str(a.get("filename") or f"attachment-{a.get('index')}") for a in em.get("attachments") or [])
|
|
lines.append(f" Attachments: {names}")
|
|
return [TextContent(type="text", text="\n".join(lines))]
|
|
|
|
elif name == "read_email":
|
|
all_accounts = _list_accounts_raw()
|
|
if len(all_accounts) >= 2 and not acct:
|
|
result = _read_email_across_accounts(
|
|
uid=arguments.get("uid"),
|
|
message_id=arguments.get("message_id"),
|
|
folder=arguments.get("folder", "INBOX"),
|
|
)
|
|
else:
|
|
result = _read_email(
|
|
uid=arguments.get("uid"),
|
|
message_id=arguments.get("message_id"),
|
|
folder=arguments.get("folder", "INBOX"),
|
|
account=acct,
|
|
)
|
|
if "error" in result:
|
|
return [TextContent(type="text", text=f"Error: {result['error']}")]
|
|
|
|
text = (
|
|
f"**Subject:** {result['subject']}\n"
|
|
f"**From:** {result['from']} ({result['from_address']})\n"
|
|
f"**Date:** {result['date']}\n"
|
|
f"**UID:** {result['uid']}\n"
|
|
f"**Account:** {result.get('account', 'default')} ({result.get('account_email', '')})\n"
|
|
f"**Message-ID:** {result['message_id']}\n"
|
|
)
|
|
if result.get('attachments'):
|
|
text += f"\n**Attachments ({len(result['attachments'])}):**\n"
|
|
for a in result['attachments']:
|
|
size = int(a.get('size') or 0)
|
|
size_label = f"{size} bytes" if size < 1024 else f"{size / 1024:.1f}KB"
|
|
text += f" - [{a['index']}] {a['filename']} ({a['content_type']}, {size_label})\n"
|
|
text += "\n_Use `download_attachment` with the UID and index to download._\n"
|
|
text += f"\n---\n\n{result['body']}"
|
|
return [TextContent(type="text", text=text)]
|
|
|
|
elif name == "send_email":
|
|
_clear_email_list_cache()
|
|
to = arguments.get("to")
|
|
subject = arguments.get("subject")
|
|
body = arguments.get("body")
|
|
if not to or not subject or body is None:
|
|
return [TextContent(type="text", text="Error: to, subject, and body are required")]
|
|
result = _send_email(
|
|
to=to,
|
|
subject=subject,
|
|
body=body,
|
|
cc=arguments.get("cc"),
|
|
bcc=arguments.get("bcc"),
|
|
account=acct,
|
|
)
|
|
if "error" in result:
|
|
return [TextContent(type="text", text=f"Error: {result['error']}")]
|
|
if result.get("pending"):
|
|
return [TextContent(
|
|
type="text",
|
|
text=(
|
|
f"Draft staged for approval (pending id: {result.get('pending_id')}). "
|
|
"Nothing has been sent yet. Review and approve it in Odysseus before delivery."
|
|
),
|
|
)]
|
|
acct_note = f" (from {result['account']})" if result.get("account") else ""
|
|
return [TextContent(type="text", text=f"Sent email to {result['to']} with subject '{result['subject']}'{acct_note}.")]
|
|
|
|
elif name == "draft_email":
|
|
to = arguments.get("to")
|
|
subject = arguments.get("subject")
|
|
body = arguments.get("body")
|
|
if not to or not subject or body is None:
|
|
return [TextContent(type="text", text="Error: to, subject, and body are required")]
|
|
result = _create_email_draft_document(
|
|
to=to,
|
|
subject=subject,
|
|
body=body,
|
|
title=arguments.get("title"),
|
|
cc=arguments.get("cc"),
|
|
bcc=arguments.get("bcc"),
|
|
account=acct,
|
|
)
|
|
acct_note = f" from {result['account']}" if result.get("account") else ""
|
|
return [TextContent(
|
|
type="text",
|
|
text=(
|
|
f"Created Odysseus email draft [{result['title']}](#document-{result['doc_id']}) "
|
|
f"(document ID: {result['doc_id']}){acct_note}. "
|
|
"It has not been sent; open the document in Odysseus to review and send."
|
|
),
|
|
)]
|
|
|
|
elif name == "reply_to_email":
|
|
_clear_email_list_cache()
|
|
uid = arguments.get("uid")
|
|
body = arguments.get("body")
|
|
if not uid or body is None:
|
|
return [TextContent(type="text", text="Error: uid and body are required")]
|
|
result = _reply_to_email(
|
|
uid=uid,
|
|
body=body,
|
|
folder=arguments.get("folder", "INBOX"),
|
|
reply_all=bool(arguments.get("reply_all", False)),
|
|
account=acct,
|
|
)
|
|
if "error" in result:
|
|
return [TextContent(type="text", text=f"Error: {result['error']}")]
|
|
# Mark original as answered
|
|
try:
|
|
_set_flag(uid, arguments.get("folder", "INBOX"), "\\Answered", add=True, account=acct)
|
|
except Exception:
|
|
pass
|
|
return [TextContent(type="text", text=f"Replied to UID {uid}: '{result['subject']}' → {result['to']}")]
|
|
|
|
elif name == "draft_email_reply":
|
|
uid = arguments.get("uid")
|
|
body = arguments.get("body")
|
|
if not uid or body is None:
|
|
return [TextContent(type="text", text="Error: uid and body are required")]
|
|
result = _draft_reply_to_email(
|
|
uid=uid,
|
|
body=body,
|
|
folder=arguments.get("folder", "INBOX"),
|
|
reply_all=bool(arguments.get("reply_all", False)),
|
|
account=acct,
|
|
title=arguments.get("title"),
|
|
)
|
|
if "error" in result:
|
|
return [TextContent(type="text", text=f"Error: {result['error']}")]
|
|
acct_note = f" from {result['account']}" if result.get("account") else ""
|
|
return [TextContent(
|
|
type="text",
|
|
text=(
|
|
f"Created Odysseus reply draft [{result['title']}](#document-{result['doc_id']}) for UID {uid} "
|
|
f"(document ID: {result['doc_id']}){acct_note}. "
|
|
"It has not been sent; open the document in Odysseus to review and send."
|
|
),
|
|
)]
|
|
|
|
elif name == "ai_draft_email_reply":
|
|
uid = arguments.get("uid")
|
|
if not uid:
|
|
return [TextContent(type="text", text="Error: uid is required")]
|
|
result = await _ai_draft_reply_to_email(
|
|
uid=uid,
|
|
folder=arguments.get("folder", "INBOX"),
|
|
reply_all=bool(arguments.get("reply_all", False)),
|
|
account=acct,
|
|
title=arguments.get("title"),
|
|
)
|
|
if "error" in result:
|
|
return [TextContent(type="text", text=f"Error: {result['error']}")]
|
|
acct_note = f" from {result['account']}" if result.get("account") else ""
|
|
return [TextContent(
|
|
type="text",
|
|
text=(
|
|
f"Generated AI reply and created Odysseus compose draft "
|
|
f"[{result['title']}](#document-{result['doc_id']}) for UID {uid} "
|
|
f"(document ID: {result['doc_id']}){acct_note}. "
|
|
"It has not been sent; open the document in Odysseus to review and send."
|
|
),
|
|
)]
|
|
|
|
elif name == "archive_email":
|
|
_clear_email_list_cache()
|
|
uid = arguments.get("uid")
|
|
if not uid:
|
|
return [TextContent(type="text", text="Error: uid is required")]
|
|
ok = _archive_email(uid, arguments.get("folder", "INBOX"), account=acct)
|
|
return [TextContent(type="text", text=f"{'Archived' if ok else 'Failed to archive'} UID {uid}")]
|
|
|
|
elif name == "delete_email":
|
|
_clear_email_list_cache()
|
|
uid = arguments.get("uid")
|
|
if not uid:
|
|
return [TextContent(type="text", text="Error: uid is required")]
|
|
ok = _delete_email(
|
|
uid,
|
|
arguments.get("folder", "INBOX"),
|
|
permanent=bool(arguments.get("permanent", False)),
|
|
account=acct,
|
|
)
|
|
return [TextContent(type="text", text=f"{'Deleted' if ok else 'Failed to delete'} UID {uid}")]
|
|
|
|
elif name == "mark_email_read":
|
|
_clear_email_list_cache()
|
|
uid = arguments.get("uid")
|
|
if not uid:
|
|
return [TextContent(type="text", text="Error: uid is required")]
|
|
read = bool(arguments.get("read", True))
|
|
ok = _set_flag(uid, arguments.get("folder", "INBOX"), "\\Seen", add=read, account=acct)
|
|
state = "read" if read else "unread"
|
|
return [TextContent(type="text", text=f"{'Marked' if ok else 'Failed to mark'} UID {uid} as {state}")]
|
|
|
|
elif name == "manage_email_state":
|
|
_clear_email_list_cache()
|
|
action = str(arguments.get("action") or "").strip()
|
|
folder = arguments.get("folder") or ("Archive" if action == "unarchive" else "INBOX")
|
|
uid = arguments.get("uid")
|
|
if action == "list_blocked":
|
|
result = _list_blocked_senders(account=acct)
|
|
entries = result.get("blocked_senders") or []
|
|
if not entries:
|
|
return [TextContent(type="text", text="No blocked email senders.")]
|
|
lines = [f"Blocked email senders ({len(entries)}):"]
|
|
for i, entry in enumerate(entries, 1):
|
|
line = f"{i}. {entry.get('sender') or '(unknown sender)'}"
|
|
details = []
|
|
if entry.get("account"):
|
|
details.append(f"account: {entry['account']}")
|
|
if entry.get("reason"):
|
|
details.append(f"reason: {entry['reason']}")
|
|
if entry.get("created_at"):
|
|
details.append(f"blocked: {entry['created_at']}")
|
|
if details:
|
|
line += " — " + "; ".join(details)
|
|
lines.append(line)
|
|
return [TextContent(type="text", text="\n".join(lines))]
|
|
if action == "unblock_sender":
|
|
result = _unblock_sender(sender=arguments.get("sender", ""), account=acct)
|
|
if not result.get("success"):
|
|
return [TextContent(type="text", text=f"Unblock sender failed: {result.get('error', 'unknown error')}")]
|
|
return [TextContent(type="text", text=f"Unblocked sender {result.get('sender')} ({result.get('removed', 1)} rule(s) removed).")]
|
|
if not uid:
|
|
return [TextContent(type="text", text=f"Error: uid is required for {action}")]
|
|
if action == "favorite":
|
|
ok = _set_flag(uid, folder, "\\Flagged", add=True, account=acct)
|
|
return [TextContent(type="text", text=f"{'Marked' if ok else 'Failed to mark'} UID {uid} as favorite")]
|
|
if action == "unfavorite":
|
|
ok = _set_flag(uid, folder, "\\Flagged", add=False, account=acct)
|
|
return [TextContent(type="text", text=f"{'Marked' if ok else 'Failed to mark'} UID {uid} as not favorite")]
|
|
if action == "mark_read":
|
|
ok = _set_flag(uid, folder, "\\Seen", add=True, account=acct)
|
|
return [TextContent(type="text", text=f"{'Marked' if ok else 'Failed to mark'} UID {uid} as read")]
|
|
if action == "mark_unread":
|
|
ok = _set_flag(uid, folder, "\\Seen", add=False, account=acct)
|
|
return [TextContent(type="text", text=f"{'Marked' if ok else 'Failed to mark'} UID {uid} as unread")]
|
|
if action == "mark_done":
|
|
ok = _set_flag(uid, folder, "\\Answered", add=True, account=acct)
|
|
return [TextContent(type="text", text=f"{'Marked' if ok else 'Failed to mark'} UID {uid} as done")]
|
|
if action == "mark_undone":
|
|
ok = _set_flag(uid, folder, "\\Answered", add=False, account=acct)
|
|
return [TextContent(type="text", text=f"{'Marked' if ok else 'Failed to mark'} UID {uid} as undone")]
|
|
if action == "unarchive":
|
|
ok = _unarchive_email(uid, folder, account=acct)
|
|
return [TextContent(type="text", text=f"{'Unarchived' if ok else 'Failed to unarchive'} UID {uid}")]
|
|
return [TextContent(type="text", text=f"Unknown email state action: {action!r}")]
|
|
|
|
elif name == "bulk_email":
|
|
_clear_email_list_cache()
|
|
action = arguments.get("action", "")
|
|
folder = arguments.get("folder", "INBOX")
|
|
all_unread = bool(arguments.get("all_unread", False))
|
|
uids = arguments.get("uids") or []
|
|
if all_unread:
|
|
uids = _search_uids(folder, "UNSEEN", account=acct)
|
|
if not uids:
|
|
return [TextContent(type="text", text="No messages selected (pass uids or all_unread=true).")]
|
|
requested_n = len(uids)
|
|
changed_n = 0
|
|
try:
|
|
if action == "mark_read":
|
|
changed_n = _bulk_set_flag(uids, folder, "\\Seen", add=True, account=acct)
|
|
verb = "marked read"
|
|
elif action == "mark_unread":
|
|
changed_n = _bulk_set_flag(uids, folder, "\\Seen", add=False, account=acct)
|
|
verb = "marked unread"
|
|
elif action == "archive":
|
|
cfg = _load_config(acct)
|
|
changed_n = _bulk_move(uids, folder, cfg["archive_folder"], account=acct, role="archive")
|
|
verb = "archived"
|
|
elif action == "junk":
|
|
cfg = _load_config(acct)
|
|
junk_folder = cfg.get("junk_folder") or "Junk"
|
|
changed_n = _bulk_move(uids, folder, junk_folder, account=acct, role="junk")
|
|
verb = "moved to Junk"
|
|
elif action == "delete":
|
|
permanent = bool(arguments.get("permanent", False))
|
|
if permanent:
|
|
changed_n = _bulk_set_flag(uids, folder, "\\Deleted", add=True, account=acct)
|
|
verb = "permanently deleted"
|
|
else:
|
|
cfg = _load_config(acct)
|
|
changed_n = _bulk_move(uids, folder, cfg["trash_folder"], account=acct, role="trash")
|
|
verb = "moved to Trash"
|
|
else:
|
|
return [TextContent(type="text", text=f"Unknown bulk action: {action!r}. Use mark_read/mark_unread/archive/delete/junk.")]
|
|
except Exception as e:
|
|
return [TextContent(type="text", text=f"Bulk {action} failed after partial work: {e}")]
|
|
if changed_n <= 0:
|
|
return [TextContent(type="text", text=f"No matching UIDs found in {folder}; 0 of {requested_n} email(s) {verb}.")]
|
|
if requested_n and changed_n == 0:
|
|
return [TextContent(
|
|
type="text",
|
|
text=(
|
|
f"Error: no requested emails were {verb}. "
|
|
f"The {requested_n} UIDs may be stale, in another folder, or the mail server rejected the change."
|
|
),
|
|
)]
|
|
suffix = "" if changed_n == requested_n else f" ({changed_n} of {requested_n} requested UIDs matched)"
|
|
return [TextContent(type="text", text=f"Done — {changed_n} email(s) {verb}{suffix}.")]
|
|
|
|
elif name == "block_sender":
|
|
_clear_email_list_cache()
|
|
result = _block_sender(
|
|
sender=arguments.get("sender"),
|
|
uids=arguments.get("uids") or [],
|
|
folder=arguments.get("folder", "INBOX"),
|
|
account=acct,
|
|
reason=arguments.get("reason", ""),
|
|
move_existing=bool(arguments.get("move_existing", True)),
|
|
)
|
|
if not result.get("success"):
|
|
return [TextContent(type="text", text=f"Block sender failed: {result.get('error') or '; '.join(result.get('errors') or ['unknown error'])}")]
|
|
lines = []
|
|
if result.get("blocked"):
|
|
lines.append("Blocked sender(s): " + ", ".join(result["blocked"]))
|
|
if result.get("already_blocked"):
|
|
lines.append("Already blocked: " + ", ".join(result["already_blocked"]))
|
|
lines.append(f"Moved {result.get('moved_to_junk', 0)} current message(s) to Junk.")
|
|
if result.get("moved_uids"):
|
|
lines.append("Moved UIDs: " + ", ".join(result["moved_uids"][:20]))
|
|
lines.append("Future matching fixture mail will appear in Junk; real IMAP routing depends on provider-side filters or Odysseus polling.")
|
|
return [TextContent(type="text", text="\n".join(lines))]
|
|
|
|
else:
|
|
return [TextContent(type="text", text=f"Unknown tool: {name}")]
|
|
|
|
except Exception as e:
|
|
return [TextContent(type="text", text=f"Error: {e}")]
|
|
finally:
|
|
_CURRENT_OWNER.reset(owner_token)
|
|
_CURRENT_SESSION_ID.reset(session_token)
|
|
|
|
|
|
# ── Main ──
|
|
|
|
async def run():
|
|
async with stdio_server() as (read_stream, write_stream):
|
|
await server.run(
|
|
read_stream, write_stream, server.create_initialization_options()
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(run())
|