Files
NexusOS/modules/mail/backend.py
T
janvanwan 42eaed647a feat: sync with upstream — v1.2.0, in-app updates, Projects, modules
Brings the public tree back in line with the development repo after several
weeks of drift caused by a stale publish include list.

New:
- In-app update path: GET /update/check compares the checkout against
  origin/main and POST /update/apply runs `ncp upgrade` detached (pull,
  rebuild, restart). The sidebar shows the version, checks on click, and
  offers an "update available" pill.
- Projects: a project workspace groups chats and RAG documents, with
  per-project instructions and document retrieval scoped to the active
  project. Replaces the standalone Documents page.
- modules/: auto-discovered feature plugins (mail, network) with their
  frontend counterparts and tests.
- Memory curation runs in-process (synapse/memory/curator.py) on the chat
  model when a conversation goes idle. The separate memory service on :8001
  is gone, along with the launcher lines that started it.

Also: the KDE theme, panel and Promethean terminal assets, the full test
suite, and VERSION 1.2.0.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-08-25 09:13:55 -05:00

284 lines
9.6 KiB
Python

"""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}