"""ARIAs E-Mail: senden (SMTP) und lesen (IMAP). Drei Grundsaetze, aus denen sich der Rest ergibt: 1. Die Zugangsdaten sieht ARIA nie. ARIAs Claude-Container (proxy) hat /shared eingehaengt — alles unter /shared/config kann sie lesen. Deshalb liegen Konfiguration und Passwort im Brain unter /data, das dort NICHT eingehaengt ist. Keine Schnittstelle gibt das Passwort je zurueck (nur `password_set: true`), kein Tool-Ergebnis enthaelt es, der Brain-Export laesst die Geheimnis-Datei aus. 2. Die Whitelist ist eine harte Grenze, kein Prompt-Hinweis. Geprueft wird hier im Sendepfad, fuer JEDEN Empfaenger (To, Cc, Bcc). Leere Liste = ARIA kann nirgendwohin senden. Aendern darf die Liste nur, wer den Admin-Schluessel hat (/data/email_admin.key) — den liest die Diagnostic, ARIAs Container nicht. Wichtig, weil eingehende Mails Text enthalten, den ARIA liest: eine Mail mit "schick das an X" darf nicht reichen. 3. Eingehende Mails sind Daten, keine Anweisungen. Tool-Ergebnisse mit Mail-Inhalt sind entsprechend markiert. Grenze, die man kennen sollte: ARIAs Container hat einen SSH-Schluessel auf die VM. Legt sie es gezielt darauf an, kommt sie als root an jede Datei. Dieses Modul sorgt dafuer, dass die Zugangsdaten nirgends von selbst auftauchen — es ist kein Schutz gegen einen bewussten Einbruch. """ from __future__ import annotations import hmac import html as html_mod import imaplib import json import logging import mimetypes import os import re import secrets import smtplib import ssl import threading import time from email import policy from email.message import EmailMessage from email.parser import BytesParser from email.utils import formataddr, formatdate, getaddresses, make_msgid, parseaddr from pathlib import Path from typing import Iterable, Optional logger = logging.getLogger(__name__) DATA_DIR = Path(os.environ.get("ARIA_MAIL_DATA_DIR", "/data")) CONFIG_FILE = DATA_DIR / "email.json" # Server, Ports, Whitelist, Benutzername SECRET_FILE = DATA_DIR / "email_secret.json" # nur Passwoerter — vom Export ausgenommen ADMIN_KEY_FILE = DATA_DIR / "email_admin.key" # Schreibrecht auf die Konfiguration # Wohin ARIA Anhaenge ablegt bzw. woher sie welche nehmen darf. Alles andere # (vor allem /data, wo die Passwoerter liegen) ist tabu — auch ueber Symlinks. UPLOAD_ROOT = Path(os.environ.get("ARIA_MAIL_UPLOAD_ROOT", "/shared/uploads")) ATTACH_ROOTS = [Path(p) for p in os.environ.get( "ARIA_MAIL_ATTACH_ROOTS", "/shared/uploads:/shared/projects").split(":") if p] MAX_SEND_BYTES = 25 * 1024 * 1024 # gaengiges Limit der meisten Mailserver MAX_BODY_CHARS = 20000 # so viel Mailtext bekommt ARIA pro Mail NET_TIMEOUT = 30 ENCRYPTIONS = ("ssl", "starttls", "none") DEFAULTS = { "from_name": "ARIA", "from_address": "", "username": "", "imap_host": "", "imap_port": 993, "imap_encryption": "ssl", "smtp_host": "", "smtp_port": 465, "smtp_encryption": "ssl", # Abweichender SMTP-Login (selten, z.B. Relay). Leer = wie IMAP. "smtp_username": "", "verify_tls": True, "save_sent": True, # Kopie jeder gesendeten Mail in "Gesendet" ablegen "whitelist": [], } _lock = threading.Lock() class MailError(Exception): """Fehler, dessen Text ARIA/Stefan sehen duerfen (kein Geheimnis darin).""" # ── Speicher ──────────────────────────────────────────────────────── def _read_json(path: Path) -> dict: try: if path.exists(): data = json.loads(path.read_text(encoding="utf-8")) if isinstance(data, dict): return data except Exception as exc: logger.warning("[mail] %s nicht lesbar: %s", path.name, exc) return {} def _write_private(path: Path, data: dict) -> None: """Atomar und mit 0600 — die Datei existiert nie mit offeneren Rechten.""" path.parent.mkdir(parents=True, exist_ok=True) tmp = path.with_suffix(path.suffix + ".tmp") fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) os.replace(tmp, path) def load_config() -> dict: cfg = dict(DEFAULTS) cfg.update({k: v for k, v in _read_json(CONFIG_FILE).items() if k in DEFAULTS}) cfg["whitelist"] = [str(p).strip() for p in (cfg.get("whitelist") or []) if str(p).strip()] return cfg def _secrets() -> dict: return _read_json(SECRET_FILE) def public_config() -> dict: """Konfiguration fuer die Diagnostic. Passwoerter NUR als Ja/Nein.""" cfg = load_config() sec = _secrets() cfg["password_set"] = bool(sec.get("password")) cfg["smtp_password_set"] = bool(sec.get("smtp_password")) return cfg def save_config(patch: dict) -> dict: """Konfiguration aendern. Passwort-Felder: password / smtp_password — nicht leer = neu setzen, leer/fehlt = unveraendert clear_password / clear_smtp_password — True = loeschen """ with _lock: cfg = load_config() for key in DEFAULTS: if key not in patch: continue val = patch[key] if key in ("imap_port", "smtp_port"): try: val = int(val) except (TypeError, ValueError): raise MailError(f"{key}: keine Zahl") if not 1 <= val <= 65535: raise MailError(f"{key}: {val} ist kein gueltiger Port") elif key in ("imap_encryption", "smtp_encryption"): val = str(val or "").lower() if val not in ENCRYPTIONS: raise MailError(f"{key}: erlaubt sind {', '.join(ENCRYPTIONS)}") elif key in ("verify_tls", "save_sent"): val = bool(val) elif key == "whitelist": if isinstance(val, str): val = re.split(r"[\s,;]+", val) val = [str(p).strip() for p in (val or []) if str(p).strip()] for p in val: _check_pattern(p) else: val = _no_newlines(str(val or "").strip()) cfg[key] = val _write_private(CONFIG_FILE, cfg) sec = _secrets() changed = False for field in ("password", "smtp_password"): if patch.get("clear_" + field): changed |= sec.pop(field, None) is not None elif patch.get(field): sec[field] = str(patch[field]) changed = True if changed: _write_private(SECRET_FILE, sec) # Bewusst ohne Werte geloggt. logger.info("[mail] Konfiguration gespeichert (Whitelist: %d Eintraege)", len(cfg["whitelist"])) return public_config() def admin_key() -> str: """Schreibschluessel fuer die Konfiguration. Wird beim ersten Zugriff erzeugt. Die Diagnostic liest ihn aus ./aria-data/brain/data — ARIAs Container hat diesen Pfad nicht.""" with _lock: try: if ADMIN_KEY_FILE.exists(): k = ADMIN_KEY_FILE.read_text(encoding="utf-8").strip() if k: return k except Exception: pass k = secrets.token_urlsafe(32) ADMIN_KEY_FILE.parent.mkdir(parents=True, exist_ok=True) fd = os.open(str(ADMIN_KEY_FILE), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) with os.fdopen(fd, "w") as f: f.write(k) return k def check_admin(provided: Optional[str]) -> bool: if not provided: return False return hmac.compare_digest(provided.strip(), admin_key()) def status() -> dict: """Was ARIA ueber ihr Postfach wissen darf — ohne Zugangsdaten.""" cfg = load_config() sec = _secrets() can_login = bool(cfg["username"] and sec.get("password")) return { "configured_imap": bool(cfg["imap_host"] and can_login), "configured_smtp": bool(cfg["smtp_host"] and (can_login or ( cfg["smtp_username"] and sec.get("smtp_password")))), "from_address": cfg["from_address"], "from_name": cfg["from_name"], "whitelist": list(cfg["whitelist"]), } # ── Whitelist ─────────────────────────────────────────────────────── _ADDR_RE = re.compile(r"^[^@\s<>()\[\],;:\"]+@([a-z0-9-]+(\.[a-z0-9-]+)+)$", re.IGNORECASE) _DOMAIN_RE = re.compile(r"^[a-z0-9-]+(\.[a-z0-9-]+)+$", re.IGNORECASE) def _pattern(p: str) -> tuple: """Muster zerlegen → (art, wert). Wirft bei ungueltigen Mustern. Genau EINE Schreibweise: name@domain, Platzhalter nur als ganzes Teil. stefan@hacker-net.de → ('adresse', 'stefan@hacker-net.de') *@hacker-net.de → ('domain', 'hacker-net.de') *@*.hacker-net.de → ('subdomain', 'hacker-net.de') *@* → ('alles', '') Kurzformen wie '*hacker-net.de' oder 'hacker-net.de' werden abgelehnt: '*hacker-net.de' liest sich, als passte es auch auf 'evilhacker-net.de'. In einer Sicherheitsliste soll niemand raten muessen, was ein Eintrag tut. """ q = (p or "").strip().lower() if q == "*@*": return "alles", "" if q.count("@") != 1: hint = q.lstrip("*@.") raise MailError(f"Whitelist: '{p}' — Schreibweise ist name@domain" + (f", fuer alle Adressen der Domain: *@{hint}" if _DOMAIN_RE.match(hint) else "")) local, dom = q.split("@") if local == "*": if dom.startswith("*."): if _DOMAIN_RE.match(dom[2:]): return "subdomain", dom[2:] elif _DOMAIN_RE.match(dom): return "domain", dom raise MailError(f"Whitelist: '{p}' — Domain ungueltig ('*' nur als *@domain oder *@*.domain)") if "*" in q or not _ADDR_RE.match(q): raise MailError(f"Whitelist: '{p}' ist keine gueltige Adresse") return "adresse", q def _check_pattern(p: str) -> None: """Ungueltige Muster schon beim Speichern abweisen — ein Tippfehler soll nicht still 'nichts erlaubt' oder gar 'zu viel erlaubt' bedeuten.""" _pattern(p) def address_allowed(address: str, whitelist: Iterable[str]) -> bool: """Darf an diese Adresse gesendet werden? Muster siehe _pattern(). Verglichen wird die Domain als Ganzes, nie ein Textende: '*@hacker-net.de' trifft weder 'evilhacker-net.de' noch 'mail.hacker-net.de'. """ addr = (address or "").strip().lower() m = _ADDR_RE.match(addr) if not m: return False domain = m.group(1) for raw in whitelist or (): try: kind, val = _pattern(str(raw)) except MailError: continue # kaputtes Muster erlaubt nichts if kind == "alles": return True if kind == "adresse" and addr == val: return True if kind == "domain" and domain == val: return True # Mit Punkt davor: "mail.hacker-net.de" endet auf ".hacker-net.de", # "evilhacker-net.de" nicht. if kind == "subdomain" and domain.endswith("." + val): return True return False def _parse_recipients(values) -> list: """Liste aus Strings/Listen → reine Adressen. Namen ("Max ") sind erlaubt, zaehlen aber nur ueber die Adresse.""" if not values: return [] if isinstance(values, str): values = [values] out = [] for name, addr in getaddresses([str(v) for v in values]): addr = addr.strip() if addr: out.append((name.strip(), addr)) return out def check_recipients(to, cc=None, bcc=None) -> tuple: """(erlaubt, abgelehnt) ueber ALLE Empfaenger. Ein einziger abgelehnter blockiert die ganze Mail — teilweises Senden waere schlimmer als keins.""" wl = load_config()["whitelist"] allowed, rejected = [], [] for name, addr in _parse_recipients(to) + _parse_recipients(cc) + _parse_recipients(bcc): (allowed if address_allowed(addr, wl) else rejected).append(addr) return allowed, rejected # ── Pfade ─────────────────────────────────────────────────────────── def _safe_attach_path(p: str) -> Path: """Nur Dateien unter den erlaubten Wurzeln. realpath zuerst: ein Symlink in /shared/uploads, der auf /data/email_secret.json zeigt, wuerde sonst im Brain-Container aufgeloest — und das Passwort als Anhang verschickt.""" if not p: raise MailError("Leerer Dateipfad") real = Path(os.path.realpath(p)) for root in ATTACH_ROOTS: root_real = Path(os.path.realpath(str(root))) if real == root_real or root_real in real.parents: if not real.is_file(): raise MailError(f"{p}: keine Datei") return real raise MailError(f"{p}: nur Dateien unter {', '.join(str(r) for r in ATTACH_ROOTS)} erlaubt") def _no_newlines(s: str) -> str: """Kopfzeilen ohne Zeilenumbruch — sonst liessen sich Header einschmuggeln.""" return re.sub(r"[\r\n]+", " ", s or "") def _html_to_text(html: str) -> str: t = re.sub(r"(?is)<(script|style|head)[^>]*>.*?", "", html or "") t = re.sub(r"(?i)", "\n", t) t = re.sub(r"(?i)", "\n", t) t = re.sub(r"(?s)<[^>]+>", "", t) t = html_mod.unescape(t) t = re.sub(r"[ \t]+", " ", t) return re.sub(r"\n\s*\n+", "\n\n", t).strip() # ── Nachricht bauen ───────────────────────────────────────────────── def build_message(cfg: dict, to, subject: str, text: str = "", html: str = "", cc=None, attachments: Optional[list] = None, inline_images: Optional[list] = None, in_reply_to: str = "", references: str = "") -> EmailMessage: """Baut die MIME-Struktur: multipart/mixed (nur wenn Anhaenge) multipart/alternative text/plain (immer — aus HTML erzeugt, wenn keiner da) multipart/related (nur wenn eingebettete Bilder) text/html image/* Content-ID application/... Anhaenge Bcc steht absichtlich NICHT im Header — er geht nur in die Umschlag- Empfaenger beim Senden. """ if not (text or html): raise MailError("Leere Mail: text oder html angeben") if inline_images and not html: raise MailError("Eingebettete Bilder brauchen html ()") msg = EmailMessage(policy=policy.SMTP) msg["From"] = formataddr((_no_newlines(cfg["from_name"]), cfg["from_address"])) msg["To"] = ", ".join(formataddr(r) for r in _parse_recipients(to)) if cc: msg["Cc"] = ", ".join(formataddr(r) for r in _parse_recipients(cc)) msg["Subject"] = _no_newlines(subject or "") msg["Date"] = formatdate(localtime=True) domain = (cfg["from_address"].split("@", 1)[1] if "@" in cfg["from_address"] else None) msg["Message-ID"] = make_msgid(domain=domain) if in_reply_to: msg["In-Reply-To"] = _no_newlines(in_reply_to) msg["References"] = _no_newlines((references + " " + in_reply_to).strip()) msg.set_content(text or _html_to_text(html)) if html: msg.add_alternative(html, subtype="html") if inline_images: html_part = msg.get_payload()[-1] for img in inline_images: path = _safe_attach_path(img.get("path", "")) cid = (img.get("cid") or path.name).strip("<>") ctype = mimetypes.guess_type(path.name)[0] or "application/octet-stream" maintype, subtype = ctype.split("/", 1) if maintype != "image": raise MailError(f"{path.name}: eingebettet werden nur Bilder") html_part.add_related(path.read_bytes(), maintype=maintype, subtype=subtype, cid=f"<{cid}>", filename=path.name, disposition="inline") for p in attachments or []: path = _safe_attach_path(p) ctype = mimetypes.guess_type(path.name)[0] or "application/octet-stream" maintype, subtype = ctype.split("/", 1) msg.add_attachment(path.read_bytes(), maintype=maintype, subtype=subtype, filename=path.name) return msg # ── Verbindungen ──────────────────────────────────────────────────── def _tls_context(cfg: dict) -> ssl.SSLContext: ctx = ssl.create_default_context() if not cfg.get("verify_tls", True): # Fuer eigene Server mit selbstsigniertem Zertifikat. Bewusste Wahl in # der Diagnostic — verschluesselt bleibt es trotzdem. ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE return ctx def _login_error(proto: str, exc: Exception) -> MailError: # Fehlertexte der Server enthalten nie das Passwort — trotzdem nur den # Typ + gekuerzte Meldung weitergeben. return MailError(f"{proto}: {type(exc).__name__}: {str(exc)[:200]}") def _smtp_open(cfg: dict): sec = _secrets() user = cfg["smtp_username"] or cfg["username"] pw = sec.get("smtp_password") if cfg["smtp_username"] else sec.get("password") if not (cfg["smtp_host"] and user and pw): raise MailError("SMTP ist nicht eingerichtet (Diagnostic → Einstellungen → ARIA E-Mail)") enc = cfg["smtp_encryption"] try: if enc == "ssl": s = smtplib.SMTP_SSL(cfg["smtp_host"], cfg["smtp_port"], timeout=NET_TIMEOUT, context=_tls_context(cfg)) else: s = smtplib.SMTP(cfg["smtp_host"], cfg["smtp_port"], timeout=NET_TIMEOUT) s.ehlo() if enc == "starttls": s.starttls(context=_tls_context(cfg)) s.ehlo() s.login(user, pw) return s except MailError: raise except Exception as exc: raise _login_error("SMTP", exc) def _imap_open(cfg: dict): sec = _secrets() if not (cfg["imap_host"] and cfg["username"] and sec.get("password")): raise MailError("IMAP ist nicht eingerichtet (Diagnostic → Einstellungen → ARIA E-Mail)") enc = cfg["imap_encryption"] try: if enc == "ssl": m = imaplib.IMAP4_SSL(cfg["imap_host"], cfg["imap_port"], ssl_context=_tls_context(cfg), timeout=NET_TIMEOUT) else: m = imaplib.IMAP4(cfg["imap_host"], cfg["imap_port"], timeout=NET_TIMEOUT) if enc == "starttls": m.starttls(ssl_context=_tls_context(cfg)) m.login(cfg["username"], sec["password"]) return m except MailError: raise except Exception as exc: raise _login_error("IMAP", exc) def _imap_close(m) -> None: try: m.logout() except Exception: pass def test_connection() -> dict: """Login an beiden Servern pruefen, ohne etwas zu senden.""" cfg = load_config() out = {} for proto, opener, closer in (("imap", _imap_open, _imap_close), ("smtp", _smtp_open, lambda s: s.quit())): try: conn = opener(cfg) try: closer(conn) except Exception: pass out[proto] = {"ok": True} except MailError as exc: out[proto] = {"ok": False, "error": str(exc)} return out # ── Senden ────────────────────────────────────────────────────────── def send(to, subject: str, text: str = "", html: str = "", cc=None, bcc=None, attachments: Optional[list] = None, inline_images: Optional[list] = None, reply_to_uid: str = "", reply_folder: str = "INBOX") -> dict: cfg = load_config() if not cfg["from_address"]: raise MailError("Keine Absender-Adresse eingerichtet") recipients = _parse_recipients(to) + _parse_recipients(cc) + _parse_recipients(bcc) if not recipients: raise MailError("Kein Empfaenger") if not cfg["whitelist"]: raise MailError("Die Whitelist ist leer — ich darf an niemanden senden. " "Stefan traegt erlaubte Adressen in der Diagnostic ein.") _, rejected = check_recipients(to, cc, bcc) if rejected: raise MailError("Nicht auf der Whitelist: " + ", ".join(rejected) + " — nichts gesendet. Erlaubt: " + ", ".join(cfg["whitelist"])) in_reply_to = references = "" if reply_to_uid: orig = _fetch_message(reply_folder, reply_to_uid, mark_read=False) in_reply_to = orig.get("Message-ID", "") or "" references = orig.get("References", "") or "" msg = build_message(cfg, to, subject, text=text, html=html, cc=cc, attachments=attachments, inline_images=inline_images, in_reply_to=in_reply_to, references=references) raw = msg.as_bytes() if len(raw) > MAX_SEND_BYTES: raise MailError(f"Mail ist {len(raw) // (1024 * 1024)} MB gross — Grenze " f"{MAX_SEND_BYTES // (1024 * 1024)} MB") s = _smtp_open(cfg) try: s.send_message(msg, from_addr=cfg["from_address"], to_addrs=[a for _, a in recipients]) except Exception as exc: raise MailError(f"SMTP-Versand: {type(exc).__name__}: {str(exc)[:200]}") finally: try: s.quit() except Exception: pass saved = "" if cfg.get("save_sent"): try: saved = _append_sent(cfg, raw) except Exception as exc: # Gesendet ist gesendet — die Kopie ist Komfort, kein Fehler. logger.warning("[mail] Kopie in Gesendet fehlgeschlagen: %s", exc) logger.info("[mail] gesendet an %d Empfaenger, %d Byte", len(recipients), len(raw)) return {"ok": True, "message_id": msg["Message-ID"], "recipients": [a for _, a in recipients], "bytes": len(raw), "saved_to": saved} def _find_sent_folder(m) -> str: typ, data = m.list() names = [] for line in data or []: if not line: continue s = line.decode("utf-8", "replace") if isinstance(line, bytes) else str(line) name = _parse_list_name(s) if "\\Sent" in s: return name names.append(name) for cand in ("Sent", "Gesendet", "INBOX.Sent", "Sent Items", "Gesendete Objekte", "INBOX.Gesendet"): for n in names: if n.lower() == cand.lower(): return n return "" SENT_FALLBACK = "Gesendet" def _append_sent(cfg: dict, raw: bytes) -> str: m = _imap_open(cfg) try: folder = _find_sent_folder(m) if not folder: # Kein Gesendet-Ordner (frisches Postfach, manche Hoster): anlegen. # Sonst ginge die Kopie still verloren — und Stefan saehe in der # Diagnostic nicht, was ARIA verschickt hat. typ, _ = m.create(_quote(SENT_FALLBACK)) if typ != "OK": logger.warning("[mail] Ordner %r nicht anlegbar — keine Kopie", SENT_FALLBACK) return "" folder = SENT_FALLBACK m.append(_quote(folder), "(\\Seen)", imaplib.Time2Internaldate(time.time()), raw) return folder finally: _imap_close(m) # ── Lesen ─────────────────────────────────────────────────────────── _LIST_RE = re.compile(r'\((?P[^)]*)\)\s+(?P"[^"]*"|NIL)\s+(?P.+)$') def _parse_list_name(line: str) -> str: m = _LIST_RE.match(line.strip()) if not m: return line.strip() name = m.group("name").strip() if name.startswith('"') and name.endswith('"'): name = name[1:-1].replace('\\"', '"').replace("\\\\", "\\") return name def imap_utf7_decode(name: str) -> str: """'Entw&APw-rfe' → 'Entwürfe'. IMAP kodiert Ordnernamen in modifiziertem UTF-7 (RFC 3501). Nur fuer die ANZEIGE — an den Server geht der Rohname.""" import base64 def dec(m): chunk = m.group(1) if chunk == "": return "&" b = chunk.replace(",", "/") b += "=" * (-len(b) % 4) try: return base64.b64decode(b).decode("utf-16-be") except Exception: return m.group(0) return re.sub(r"&([A-Za-z0-9+,]*)-", dec, name or "") def _quote(folder: str) -> str: return '"' + folder.replace("\\", "\\\\").replace('"', '\\"') + '"' _SENT_NAMES = {"sent", "gesendet", "inbox.sent", "sent items", "gesendete objekte", "inbox.gesendet", "gesendete elemente"} def list_folders() -> list: """[{name (roh, fuer den Server), label (lesbar), role}], Posteingang und Gesendet zuerst. role: 'inbox' | 'sent' | ''. Gesendet wird ueber das Server-Flag \\Sent erkannt, sonst ueber die ueblichen Namen.""" m = _imap_open(load_config()) try: typ, data = m.list() out = [] for line in data or []: if not line: continue s = line.decode("utf-8", "replace") if isinstance(line, bytes) else str(line) if "\\Noselect" in s: continue name = _parse_list_name(s) label = imap_utf7_decode(name) lm = _LIST_RE.match(s.strip()) delim = (lm.group("delim").strip('"') if lm and lm.group("delim") != "NIL" else "") role = ("inbox" if name.upper() == "INBOX" else "sent" if "\\Sent" in s or label.lower() in _SENT_NAMES else "") # Unterordner: Tiefe + letzter Namensteil fuer die Baumansicht. parts = label.split(delim) if delim else [label] out.append({"name": name, "label": label, "role": role, "depth": len(parts) - 1, "short": parts[-1]}) # Posteingang + Gesendet nach oben; der Rest nach vollem Pfad, damit # Unterordner direkt unter ihrem Elternordner stehen. order = {"inbox": 0, "sent": 1} out.sort(key=lambda f: (order.get(f["role"], 2), f["label"].lower())) return out finally: _imap_close(m) def _uid_ok(uid) -> str: u = str(uid or "").strip() if not u.isdigit(): raise MailError(f"Ungueltige UID: {uid!r}") return u def _select(m, folder: str, readonly: bool) -> None: typ, data = m.select(_quote(folder or "INBOX"), readonly=readonly) if typ != "OK": raise MailError(f"Ordner {folder!r} nicht gefunden") def list_messages(folder: str = "INBOX", limit: int = 20, offset: int = 0, unread_only: bool = False, search: str = "") -> dict: limit = max(1, min(int(limit or 20), 100)) offset = max(0, int(offset or 0)) m = _imap_open(load_config()) try: _select(m, folder, readonly=True) if search: m.literal = search.encode("utf-8") crit = ["CHARSET", "UTF-8"] + (["UNSEEN"] if unread_only else []) + ["TEXT"] typ, data = m.uid("SEARCH", *crit) else: typ, data = m.uid("SEARCH", "UNSEEN" if unread_only else "ALL") uids = (data[0] or b"").split() if data else [] uids = sorted((int(u) for u in uids), reverse=True) total = len(uids) page = uids[offset:offset + limit] items = [] if page: fields = "FROM TO CC SUBJECT DATE CONTENT-TYPE MESSAGE-ID" typ, data = m.uid("FETCH", ",".join(str(u) for u in page), f"(UID FLAGS RFC822.SIZE BODY.PEEK[HEADER.FIELDS ({fields})])") by_uid = {} last = None # imaplib liefert pro Mail ein Tupel (Meta, Header-Bytes) und danach # ein Bytes-Stueck mit dem Rest der Zeile. Manche Server (Dovecot je # nach Reihenfolge, Exchange) schreiben FLAGS erst DORT hin — nur im # Meta-Teil zu suchen hiesse: jede Mail "ungelesen". for part in data or []: if isinstance(part, tuple): meta = part[0].decode("utf-8", "replace") um = re.search(r"UID (\d+)", meta) if not um: last = None continue hdr = BytesParser(policy=policy.default).parsebytes(part[1], headersonly=True) sm = re.search(r"RFC822\.SIZE (\d+)", meta) ctype = str(hdr.get("Content-Type", "") or "").lower() last = int(um.group(1)) by_uid[last] = { "uid": um.group(1), "from": str(hdr.get("From", "") or ""), "to": str(hdr.get("To", "") or ""), "subject": str(hdr.get("Subject", "") or ""), "date": str(hdr.get("Date", "") or ""), "seen": False, "size": int(sm.group(1)) if sm else 0, "maybe_attachments": "multipart/mixed" in ctype, } meta_flags = re.search(r"FLAGS \(([^)]*)\)", meta) if meta_flags: by_uid[last]["seen"] = "\\Seen" in meta_flags.group(1) elif isinstance(part, bytes) and last is not None: tail_flags = re.search(r"FLAGS \(([^)]*)\)", part.decode("utf-8", "replace")) if tail_flags: by_uid[last]["seen"] = "\\Seen" in tail_flags.group(1) items = [by_uid[u] for u in page if u in by_uid] return {"folder": folder, "total": total, "offset": offset, "messages": items} finally: _imap_close(m) def _fetch_message(folder: str, uid, mark_read: bool): u = _uid_ok(uid) m = _imap_open(load_config()) try: _select(m, folder, readonly=not mark_read) typ, data = m.uid("FETCH", u, "(BODY.PEEK[])") raw = next((p[1] for p in (data or []) if isinstance(p, tuple)), None) if raw is None: raise MailError(f"Mail UID {u} in {folder!r} nicht gefunden") if mark_read: m.uid("STORE", u, "+FLAGS", "(\\Seen)") return BytesParser(policy=policy.default).parsebytes(raw) finally: _imap_close(m) def _safe_name(name: str) -> str: n = re.sub(r"[^\w.\- ]+", "_", os.path.basename(name or "")).strip(" .") return n[:120] or "anhang" def _bodies(msg) -> tuple: text_part = msg.get_body(preferencelist=("plain",)) html_part = msg.get_body(preferencelist=("html",)) text = text_part.get_content() if text_part is not None else "" html = html_part.get_content() if html_part is not None else "" return text, html def read_message(folder: str, uid, mark_read: bool = True) -> dict: """Fuer ARIA: Kopfzeilen, Text (HTML → Text, wenn es nur HTML gibt) und Anhaenge — die landen unter /shared/uploads/email/, damit ARIA sie wie jeden anderen Upload ansehen kann.""" msg = _fetch_message(folder, uid, mark_read) text, html = _bodies(msg) body = text or _html_to_text(html) truncated = len(body) > MAX_BODY_CHARS attachments = [] target = UPLOAD_ROOT / "email" / f"{_safe_name(folder)}-{_uid_ok(uid)}" for i, part in enumerate(msg.iter_attachments()): name = _safe_name(part.get_filename() or f"anhang-{i + 1}") data = part.get_payload(decode=True) or b"" target.mkdir(parents=True, exist_ok=True) path = target / name path.write_bytes(data) attachments.append({"name": name, "path": str(path), "size": len(data), "type": part.get_content_type()}) return { "uid": _uid_ok(uid), "folder": folder, "from": str(msg.get("From", "") or ""), "to": str(msg.get("To", "") or ""), "cc": str(msg.get("Cc", "") or ""), "subject": str(msg.get("Subject", "") or ""), "date": str(msg.get("Date", "") or ""), "message_id": str(msg.get("Message-ID", "") or ""), "body": body[:MAX_BODY_CHARS], "truncated": truncated, "had_html": bool(html), "attachments": attachments, } def view_message(folder: str, uid) -> dict: """Fuer den Postfach-Viewer: wie read_message, aber OHNE als gelesen zu markieren und mit dem HTML. Eingebettete Bilder (cid:) werden zu data:-URIs, damit der Viewer sie zeigen kann, ohne irgendetwas nachzuladen.""" import base64 msg = _fetch_message(folder, uid, mark_read=False) text, html = _bodies(msg) if html: for part in msg.walk(): cid = (part.get("Content-ID") or "").strip().strip("<>") if cid and part.get_content_maintype() == "image": b64 = base64.b64encode(part.get_payload(decode=True) or b"").decode("ascii") html = html.replace(f"cid:{cid}", f"data:{part.get_content_type()};base64,{b64}") attachments = [{"index": i, "name": _safe_name(p.get_filename() or f"anhang-{i + 1}"), "type": p.get_content_type(), "size": len(p.get_payload(decode=True) or b"")} for i, p in enumerate(msg.iter_attachments())] return { "uid": _uid_ok(uid), "folder": folder, "from": str(msg.get("From", "") or ""), "to": str(msg.get("To", "") or ""), "cc": str(msg.get("Cc", "") or ""), "subject": str(msg.get("Subject", "") or ""), "date": str(msg.get("Date", "") or ""), "text": text, "html": html, "attachments": attachments, } def get_attachment(folder: str, uid, index: int) -> tuple: msg = _fetch_message(folder, uid, mark_read=False) for i, part in enumerate(msg.iter_attachments()): if i == int(index): return (_safe_name(part.get_filename() or f"anhang-{i + 1}"), part.get_content_type(), part.get_payload(decode=True) or b"") raise MailError("Anhang nicht gefunden") # ── Ordner + Verschieben ──────────────────────────────────────────── def imap_utf7_encode(name: str) -> str: """'Entwürfe' → 'Entw&APw-rfe' (Gegenstueck zu imap_utf7_decode).""" import base64 out, buf = [], [] def flush(): if buf: b = base64.b64encode("".join(buf).encode("utf-16-be")).decode("ascii") out.append("&" + b.rstrip("=").replace("/", ",") + "-") buf.clear() for ch in name: if 0x20 <= ord(ch) <= 0x7E: flush() out.append("&-" if ch == "&" else ch) else: buf.append(ch) flush() return "".join(out) def _delimiter(m) -> str: typ, data = m.list('""', '""') for line in data or []: s = line.decode("utf-8", "replace") if isinstance(line, bytes) else str(line) lm = _LIST_RE.match(s.strip()) if lm and lm.group("delim") != "NIL": return lm.group("delim").strip('"') return "/" def _resolve_folder(folder: str) -> str: """Ordner per Rohname ODER lesbarem Namen finden (ARIA sagt 'Entwürfe', der Server kennt 'Entw&APw-rfe'). Gross/klein egal.""" want = (folder or "").strip() if not want: raise MailError("Kein Ordner angegeben") folders = list_folders() for f in folders: if f["name"] == want or f["label"] == want: return f["name"] for f in folders: if want.lower() in (f["name"].lower(), f["label"].lower()): return f["name"] raise MailError(f"Ordner {folder!r} gibt es nicht. Vorhanden: " + ", ".join(f["label"] for f in folders)) def create_folder(name: str, parent: str = "") -> dict: """Ordner anlegen, optional als Unterordner. Der Name darf das Trennzeichen des Servers nicht enthalten — sonst entstuende still eine ganze Kette von Ordnern statt des einen gewuenschten.""" name = (name or "").strip() if not name: raise MailError("Kein Ordnername") parent_raw = _resolve_folder(parent) if parent else "" m = _imap_open(load_config()) try: delim = _delimiter(m) if delim and delim in name: raise MailError(f"Ordnername darf {delim!r} nicht enthalten — fuer Unterordner 'parent' angeben") raw = (parent_raw + delim if parent_raw else "") + imap_utf7_encode(name) typ, data = m.create(_quote(raw)) if typ != "OK": msg = (data[0].decode("utf-8", "replace") if data and isinstance(data[0], bytes) else str(data)) raise MailError(f"Anlegen abgelehnt: {msg[:200]}") m.subscribe(_quote(raw)) # sonst zeigen manche Mailprogramme ihn nicht return {"name": raw, "label": imap_utf7_decode(raw)} finally: _imap_close(m) def move_message(folder: str, uid, target: str) -> dict: """Mail in einen anderen Ordner verschieben. Mit MOVE (RFC 6851) in einem Schritt. Ohne: kopieren, als geloescht markieren und NUR diese Mail entfernen (UID EXPUNGE, braucht UIDPLUS). Ein nacktes EXPUNGE wuerde alles mitnehmen, was im Ordner sonst noch als geloescht markiert ist — dann bleibt die Mail lieber markiert liegen. """ u = _uid_ok(uid) src = _resolve_folder(folder or "INBOX") dst = _resolve_folder(target) if src == dst: raise MailError("Quelle und Ziel sind derselbe Ordner") m = _imap_open(load_config()) try: _select(m, src, readonly=False) caps = {c.upper() for c in (m.capabilities or ())} if "MOVE" in caps: typ, data = m.uid("MOVE", u, _quote(dst)) if typ != "OK": raise MailError(f"Verschieben abgelehnt: {data!r}"[:200]) return {"ok": True, "from": imap_utf7_decode(src), "to": imap_utf7_decode(dst), "how": "move"} typ, data = m.uid("COPY", u, _quote(dst)) if typ != "OK": raise MailError(f"Kopieren abgelehnt: {data!r}"[:200]) m.uid("STORE", u, "+FLAGS", "(\\Deleted)") if "UIDPLUS" in caps: m.uid("EXPUNGE", u) how = "copy+expunge" else: how = "copy (Original als geloescht markiert)" return {"ok": True, "from": imap_utf7_decode(src), "to": imap_utf7_decode(dst), "how": how} finally: _imap_close(m)