forked from enderofwings/NexusOS
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)
284 lines
9.6 KiB
Python
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}
|