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
+53
View File
@@ -684,3 +684,56 @@ class InboxItem(Base):
__table_args__ = (
UniqueConstraint("owner_id", "source", "external_id", name="uq_inbox_owner_source_external"),
)
class MailSource(Base):
"""Ein Postfach, aus dem Certain Zahlungs-Mails abholt."""
__tablename__ = "mail_sources"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
owner_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(80), nullable=False, default="")
host: Mapped[str] = mapped_column(String(255), nullable=False)
port: Mapped[int] = mapped_column(Integer, nullable=False, default=993)
use_ssl: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
username: Mapped[str] = mapped_column(String(255), nullable=False)
# Verschlüsselt, nicht gehasht: Certain muss es dem Mailserver vorlegen.
password_enc: Mapped[str] = mapped_column(Text, nullable=False)
folder: Mapped[str] = mapped_column(String(255), nullable=False, default="INBOX")
# Nach dem Auswerten: dorthin verschieben (leer = nicht verschieben).
done_folder: Mapped[str | None] = mapped_column(String(255), nullable=True)
mark_seen: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
poll_minutes: Mapped[int] = mapped_column(Integer, nullable=False, default=15)
last_poll_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
class MailSender(Base):
"""Welcher Absender auf welches Konto bucht.
Ohne Zuordnung weiss Certain nicht, wohin eine erkannte Zahlung gehört –
dieselbe Mailadresse kann je nach Konto etwas anderes bedeuten.
"""
__tablename__ = "mail_senders"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
source_id: Mapped[int] = mapped_column(
ForeignKey("mail_sources.id", ondelete="CASCADE"), nullable=False, index=True
)
owner_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(80), nullable=False, default="")
# Teilstrings, gegen Absender und Betreff geprüft. Leer = jede Mail.
from_contains: Mapped[str | None] = mapped_column(String(255), nullable=True)
subject_contains: Mapped[str | None] = mapped_column(String(255), nullable=True)
account_id: Mapped[int | None] = mapped_column(
ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)