feat: ARIA kann E-Mails lesen und senden

IMAP/SMTP mit SSL/TLS, STARTTLS oder unverschluesselt. Senden mit Text, HTML,
Anhaengen und eingebetteten Bildern (cid:), Antworten mit In-Reply-To. Lesen,
Suchen, Ordner anlegen (auch Unterordner), Mails verschieben.
Tools: email_status/send/list/read/folders/folder_create/move.

Whitelist als harte Grenze im Sendepfad, nicht im Prompt: alles, was nicht
drinsteht, ist gesperrt, leer = nirgendwohin. Jeder Empfaenger zaehlt (An, Cc,
Bcc), ein fremder blockiert die ganze Mail. Genau eine Schreibweise:
name@domain, '*' nur als *@domain, *@*.domain oder *@*. '*hacker-net.de' liest
sich, als passte es auf 'evilhacker-net.de' — solche Kurzformen lehnt das
Speichern mit Korrekturvorschlag ab. Verglichen wird die Domain als Ganzes.

Zugangsdaten sieht ARIA nie:
- liegen im Brain unter /data. NICHT in /shared — das ist in ARIAs Container
  eingehaengt, /data nicht.
- keine Schnittstelle gibt das Passwort zurueck, nur password_set.
- Brain-Export laesst email_secret.json + email_admin.key aus (GNU- und
  busybox-tar geprueft, der Diagnostic-Container ist Alpine).
- Anhangspfade nach realpath geprueft: ein Symlink in /shared/uploads auf die
  Geheimnis-Datei wuerde sonst im Brain aufgeloest und verschickt.
- Konfiguration nur mit Admin-Schluessel (X-Aria-Admin, liegt in /data). Die
  Diagnostic haengt ihn an, weist aber Anfragen aus ARIAs Container ab —
  sonst koennte eine eingehende Mail sie per curl die Whitelist oeffnen lassen.
Grenze: ARIAs Container hat SSH auf die VM. Gegen einen gezielten Einbruch
schuetzt das nicht, nur dagegen, dass Zugangsdaten von selbst auftauchen.

Eingehende Mails sind fuer ARIA als Daten markiert, nicht als Anweisungen.

Diagnostic: Einstellungen → Integrationen → ARIA E-Mail, plus
"📬 Postfach oeffnen" — ARIAs ganzes Postfach in eigenem Fenster, Unterordner
eingerueckt, Posteingang + Gesendet oben. Nur lesend, markiert nichts als
gelesen. Mail-HTML in einem Rahmen ohne Skripte und ohne Nachladen von aussen
(kein Tracking-Pixel); eingebettete Bilder kommen als data: mit. Jede
gesendete Mail landet als Kopie in "Gesendet" — fehlt der Ordner, wird er
angelegt, statt die Kopie still zu verlieren.

Tests: 65 Einheitstests (Whitelist inkl. evilhacker-net.de, MIME-Aufbau,
Header-Injection, Symlink-Ausbruch, Geheimnis nie in Ausgaben) + 50 gegen einen
echten Mailserver (GreenMail): TLS-Login, Senden mit Anhang + Bild, Flags,
Suche mit Umlaut, Gesendet-Kopie, In-Reply-To, Ordner mit Umlaut, MOVE und
der Fallback ohne MOVE (der keine fremden geloeschten Mails mitreisst).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 18:14:31 +02:00
co-authored by Claude Opus 5.5
parent 6d978d72b3
commit d684bbb5f0
7 changed files with 1705 additions and 2 deletions
+81 -1
View File
@@ -23,7 +23,7 @@ from typing import List, Optional
import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, BackgroundTasks, Request, UploadFile, File
from fastapi import FastAPI, HTTPException, BackgroundTasks, Request, UploadFile, File, Header
from fastapi.responses import Response
from pydantic import BaseModel, Field
@@ -40,6 +40,7 @@ import oauth as oauth_mod
import seed_rules as seed_rules_mod
import safeguard as safeguard_mod
import context_model as context_model_mod
import aria_mail
import projects as projects_mod
import project_vms as project_vms_mod
@@ -1379,6 +1380,85 @@ def context_model_back(body: ContextModelResetBody):
return out
# ─── E-Mail (aria_mail.py) ──────────────────────────────────────────
# Konfiguration lesen/aendern NUR mit dem Admin-Schluessel (Header
# X-Aria-Admin). Den reicht die Diagnostic durch; ARIAs Container kann ihn
# nicht lesen (liegt in /data, das dort nicht eingehaengt ist). Ohne diesen
# Schutz koennte eine eingehende Mail ARIA dazu bringen, per curl die
# Whitelist zu oeffnen.
# Das Postfach LESEN braucht keinen Schluessel — das kann ARIA ohnehin ueber
# ihre Tools, und der Viewer nutzt dieselben Wege.
def _mail_admin(key: Optional[str]) -> None:
if not aria_mail.check_admin(key):
raise HTTPException(403, "Nur ueber die Diagnostic")
def _mail_call(fn, *a, **kw):
try:
return fn(*a, **kw)
except aria_mail.MailError as exc:
raise HTTPException(400, str(exc))
# Schluessel beim Start anlegen. Die Diagnostic liest ihn aus der Datei; gaebe
# es ihn erst beim ersten Admin-Zugriff, haette sie fuer genau diesen Zugriff
# keinen.
try:
aria_mail.admin_key()
except Exception as _exc: # /data fehlt (lokaler Test) — kein Startabbruch
logger.warning("[mail] Admin-Schluessel nicht anlegbar: %s", _exc)
@app.get("/email/status")
def email_status():
return aria_mail.status()
@app.get("/email/config")
def email_config_get(x_aria_admin: Optional[str] = Header(None)):
_mail_admin(x_aria_admin)
return aria_mail.public_config()
@app.post("/email/config")
def email_config_set(body: dict, x_aria_admin: Optional[str] = Header(None)):
_mail_admin(x_aria_admin)
return _mail_call(aria_mail.save_config, body or {})
@app.post("/email/test")
def email_test(x_aria_admin: Optional[str] = Header(None)):
_mail_admin(x_aria_admin)
return aria_mail.test_connection()
@app.get("/email/folders")
def email_folders():
return {"folders": _mail_call(aria_mail.list_folders)}
@app.get("/email/messages")
def email_messages(folder: str = "INBOX", limit: int = 50, offset: int = 0,
unread_only: bool = False, search: str = ""):
return _mail_call(aria_mail.list_messages, folder, limit, offset, unread_only, search)
@app.get("/email/message")
def email_message(folder: str, uid: str):
"""Fuer den Viewer — markiert NICHT als gelesen (sonst wuesste ARIA
nicht mehr, was neu ist, nur weil Stefan reingeschaut hat)."""
return _mail_call(aria_mail.view_message, folder, uid)
@app.get("/email/attachment")
def email_attachment(folder: str, uid: str, index: int):
from urllib.parse import quote as _q
name, ctype, data = _mail_call(aria_mail.get_attachment, folder, uid, index)
return Response(content=data, media_type=ctype or "application/octet-stream",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{_q(name)}"})
# ─── Call-Metrics (Token / Quota-Monitoring) ────────────────────────
@app.get("/metrics/calls")