"""Email backend for the NexusOS mail client. IMAP read via imap-tools, SMTP send via the stdlib. Defaults target iCloud (imap.mail.me.com / smtp.mail.me.com); with a custom-domain iCloud account the LOGIN is the primary Apple ID + an app-specific password, and the custom-domain address is the From alias. Multiple accounts are supported (e.g. two iCloud aliases sharing the same server config) — each gets its own id, credentials, and cached IMAP connection. Credentials live in a gitignored file under runtime/ — NEVER the settings table, which bin/sync.py dumps to git. Password is write-only over the API. """ from __future__ import annotations import json import os import smtplib import ssl import threading import uuid from email.message import EmailMessage from typing import Any, Callable, Dict, List, Optional from synapse.nexus_config import RUNTIME_DIR _ACCOUNT_FILE = RUNTIME_DIR / "mail_accounts.json" # Both imaplib.IMAP4_SSL and smtplib.SMTP.starttls() fall back to # ssl._create_stdlib_context() when handed no context, and that one sets # verify_mode=CERT_NONE with check_hostname=False -- encrypted, but to nobody in # particular. Anything positioned to intercept the connection can present its # own certificate and collect the app-specific password on login. The default # context verifies the chain and the hostname. _TLS = ssl.create_default_context() _DEFAULTS: Dict[str, Any] = { "label": "", # optional nickname shown in the account list "imap_host": "imap.mail.me.com", "imap_port": 993, "smtp_host": "smtp.mail.me.com", "smtp_port": 587, "username": "", # primary Apple ID address (login), not the alias "password": "", # app-specific password "from_addr": "", # e.g. nexus@enderofwings.com "from_name": "", } def _new_id() -> str: return uuid.uuid4().hex[:12] def _write_accounts(accounts: List[Dict[str, Any]]) -> None: _ACCOUNT_FILE.parent.mkdir(parents=True, exist_ok=True) # Create the file 0600 and rename it into place, rather than write-then-chmod: # that left it at the umask (usually world-readable) for the length of the # write, and a crash partway through truncated the real account file. The # temp file inherits the mode through os.replace. tmp = _ACCOUNT_FILE.with_name(_ACCOUNT_FILE.name + ".tmp") fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) with os.fdopen(fd, "w") as fh: json.dump({"accounts": accounts}, fh, indent=2) os.replace(tmp, _ACCOUNT_FILE) def load_accounts() -> List[Dict[str, Any]]: try: raw = json.loads(_ACCOUNT_FILE.read_text()) except Exception: return [] if isinstance(raw, dict) and "accounts" in raw: accounts = raw["accounts"] elif isinstance(raw, dict) and raw.get("username"): # Legacy single-account file (pre-multi-account) — migrate in place. accounts = [{**_DEFAULTS, **raw, "id": _new_id()}] _write_accounts(accounts) else: accounts = [] return [{**_DEFAULTS, **a} for a in accounts] def get_account(account_id: str) -> Dict[str, Any]: for a in load_accounts(): if a["id"] == account_id: return a raise KeyError(account_id) def save_account(account_id: Optional[str], update: Dict[str, Any]) -> Dict[str, Any]: """Create (account_id=None) or merge-update an account. A blank/missing password on update keeps the stored one.""" accounts = load_accounts() if account_id is None: data = dict(_DEFAULTS) data["id"] = _new_id() for k in _DEFAULTS: if k in update: data[k] = update[k] accounts.append(data) else: for a in accounts: if a["id"] != account_id: continue for k in _DEFAULTS: if k == "password": if update.get("password"): a["password"] = update["password"] elif k in update: a[k] = update[k] data = a break else: raise KeyError(account_id) _write_accounts(accounts) _reset_conn(data["id"]) # creds/host may have changed return public_account(data) def delete_account(account_id: str) -> None: accounts = [a for a in load_accounts() if a["id"] != account_id] _write_accounts(accounts) _reset_conn(account_id) def public_account(a: Dict[str, Any]) -> Dict[str, Any]: """Account config for the UI — password replaced by a has_password flag.""" configured = _is_configured(a) data = dict(a) pw = data.pop("password", "") data["has_password"] = bool(pw) data["configured"] = configured return data def public_accounts() -> List[Dict[str, Any]]: return [public_account(a) for a in load_accounts()] def _is_configured(a: Dict[str, Any]) -> bool: return bool(a.get("username") and a.get("password") and a.get("imap_host")) def is_configured(account_id: str) -> bool: try: return _is_configured(get_account(account_id)) except KeyError: return False # --- IMAP ---------------------------------------------------------------------- # One authenticated connection per account, reused across requests — a fresh # login per click is the ~1-2s iCloud handshake, and that was the visible lag. # imap-tools/imaplib is single-command-at-a-time, so a lock serialises the # thread-pooled endpoints. _conns: Dict[str, Any] = {} _conn_lock = threading.Lock() def _new_mailbox(a: Dict[str, Any]): from imap_tools import MailBox return MailBox(a["imap_host"], a["imap_port"], ssl_context=_TLS).login(a["username"], a["password"]) def _reset_conn(account_id: str) -> None: with _conn_lock: conn = _conns.pop(account_id, None) try: if conn is not None: conn.logout() except Exception: pass def _run(account_id: str, fn: Callable): """Run fn(mailbox) on the account's shared connection, rebuilding it once if the connection was dropped (iCloud closes idle sockets).""" a = get_account(account_id) with _conn_lock: for attempt in (1, 2): try: conn = _conns.get(account_id) if conn is None: conn = _conns[account_id] = _new_mailbox(a) return fn(conn) except Exception: conn = _conns.pop(account_id, None) try: if conn is not None: conn.logout() except Exception: pass if attempt == 2: raise def test_connection(account_id: str) -> Dict[str, Any]: _reset_conn(account_id) # force a fresh login so the test reflects the saved creds try: return {"ok": True, "folders": len(_run(account_id, lambda mb: mb.folder.list()))} except Exception as e: return {"ok": False, "error": str(e)} def list_folders(account_id: str) -> List[str]: return _run(account_id, lambda mb: [f.name for f in mb.folder.list()]) def _summary(msg) -> Dict[str, Any]: return { "uid": msg.uid, "subject": msg.subject, "from": msg.from_, "to": list(msg.to), "date": msg.date.isoformat() if msg.date else None, "seen": "\\Seen" in msg.flags, "preview": (msg.text or msg.html or "")[:160].strip(), } def list_messages(account_id: str, folder: str = "INBOX", limit: int = 30, offset: int = 0) -> List[Dict[str, Any]]: def op(mb): mb.folder.set(folder) # Newest first; over-fetch by offset then slice (fine at personal scale). msgs = list(mb.fetch(reverse=True, limit=offset + limit, headers_only=True, bulk=True, mark_seen=False)) return [_summary(m) for m in msgs[offset:offset + limit]] return _run(account_id, op) def get_message(account_id: str, folder: str, uid: str) -> Optional[Dict[str, Any]]: from imap_tools import AND def op(mb): mb.folder.set(folder) for msg in mb.fetch(AND(uid=uid), mark_seen=True, bulk=True): return { "uid": msg.uid, "subject": msg.subject, "from": msg.from_, "to": list(msg.to), "cc": list(msg.cc), "date": msg.date.isoformat() if msg.date else None, "html": msg.html, "text": msg.text, "attachments": [{"name": a.filename, "size": a.size} for a in msg.attachments], } return None return _run(account_id, op) def set_seen(account_id: str, folder: str, uid: str, seen: bool = True) -> None: def op(mb): mb.folder.set(folder) mb.flag(uid, "\\Seen", seen) _run(account_id, op) def delete_message(account_id: str, folder: str, uid: str) -> None: def op(mb): mb.folder.set(folder) mb.delete(uid) _run(account_id, op) # --- SMTP ---------------------------------------------------------------------- def send_message(account_id: str, to: str, subject: str, body: str, cc: str = "", from_addr: str = "") -> Dict[str, Any]: a = get_account(account_id) sender = from_addr or a["from_addr"] or a["username"] msg = EmailMessage() msg["From"] = f'{a["from_name"]} <{sender}>' if a["from_name"] else sender msg["To"] = to if cc: msg["Cc"] = cc msg["Subject"] = subject msg.set_content(body) recipients = [r.strip() for r in (to + "," + cc).split(",") if r.strip()] with smtplib.SMTP(a["smtp_host"], a["smtp_port"]) as s: s.starttls(context=_TLS) s.login(a["username"], a["password"]) s.send_message(msg, from_addr=sender, to_addrs=recipients) return {"sent": True, "to": recipients}