feat: Postfaecher einrichten und Zahlungs-Mails abholen (Etappe B, Teil 2)

- Verschluesselung fuer Zugangsdaten: Ein IMAP-Passwort laesst sich nicht
  hashen, Certain muss es dem Mailserver vorlegen. Es liegt deshalb mit
  Fernet verschluesselt in der Datenbank, der Schluessel wird per HKDF aus
  session_secret abgeleitet und nirgends gespeichert. Wird das Geheimnis
  getauscht, meldet Certain das verstaendlich statt mit einem Stacktrace.
- IMAP-Abruf ueber imaplib/email (Standardbibliothek). Ordnerliste,
  Verbindungstest mit klarer Fehlermeldung, Absender-Zuordnung auf Konten,
  danach als gelesen markieren oder verschieben.
- Wiedererkennung ueber die Message-ID statt der UID – die vergibt der
  Server beim Verschieben neu.
- Nichts wird stillschweigend gebucht: Eine Mail wird zum Eingangs-Eintrag.
  Fehlt das Zielkonto oder ist ein Feld unsicher, gilt sie als unklar.
- Hintergrund-Thread beim Start, nach dem Muster des EZB-Kursabrufs: ein
  langsames Postfach darf den Dienst nie blockieren.

Getestet gegen einen IMAP-Doppelgaenger, damit die Suite ohne Netz laeuft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-22 19:58:06 +02:00
co-authored by Claude Opus 5
parent 259013eafe
commit fc6ee2f345
9 changed files with 839 additions and 0 deletions
+52
View File
@@ -540,3 +540,55 @@ class QuickBookingOut(BaseModel):
ok: bool = True
message: str
item: InboxItemOut
# --- Postfächer für den Mail-Abruf -------------------------------------------
class MailSourceIn(BaseModel):
name: str = Field(default="", max_length=80)
host: str = Field(min_length=1, max_length=255)
port: int = 993
use_ssl: bool = True
username: str = Field(min_length=1, max_length=255)
# Beim Ändern weglassen, um das gespeicherte Passwort zu behalten.
password: str | None = None
folder: str = "INBOX"
done_folder: str | None = None
mark_seen: bool = True
is_active: bool = True
poll_minutes: int = Field(default=15, ge=5, le=1440)
class MailSourceOut(BaseModel):
id: int
name: str
host: str
port: int
use_ssl: bool
username: str
folder: str
done_folder: str | None
mark_seen: bool
is_active: bool
poll_minutes: int
last_poll_at: datetime | None
last_error: str | None
# Das Passwort verlässt den Server nie – nur die Tatsache, dass eines da ist.
password_set: bool = True
model_config = ConfigDict(from_attributes=True)
class MailSenderIn(BaseModel):
name: str = Field(default="", max_length=80)
from_contains: str | None = Field(default=None, max_length=255)
subject_contains: str | None = Field(default=None, max_length=255)
account_id: int | None = None
class MailSenderOut(MailSenderIn):
id: int
source_id: int
model_config = ConfigDict(from_attributes=True)