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>
951 lines
38 KiB
Python
951 lines
38 KiB
Python
"""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 <m@x.de>") 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)[^>]*>.*?</\1>", "", html or "")
|
|
t = re.sub(r"(?i)<br\s*/?>", "\n", t)
|
|
t = re.sub(r"(?i)</(p|div|tr|h[1-6]|li)>", "\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 <cid>
|
|
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 (<img src=\"cid:...\">)")
|
|
|
|
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<flags>[^)]*)\)\s+(?P<delim>"[^"]*"|NIL)\s+(?P<name>.+)$')
|
|
|
|
|
|
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)
|