diff --git a/backend/app/models.py b/backend/app/models.py index f921b9c..0e0fb19 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -698,7 +698,10 @@ class MailSource(Base): 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) + # "ssl" = TLS ab der ersten Zeile (Port 993, der Normalfall), + # "starttls" = im Klartext verbinden und dann verschlüsseln (Port 143), + # "none" = unverschlüsselt (nur sinnvoll innerhalb desselben Rechners). + security: Mapped[str] = mapped_column(String(16), nullable=False, default="ssl") 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) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 79b885a..32beeca 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -549,7 +549,7 @@ 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 + security: Literal["ssl", "starttls", "none"] = "ssl" username: str = Field(min_length=1, max_length=255) # Beim Ändern weglassen, um das gespeicherte Passwort zu behalten. password: str | None = None @@ -565,7 +565,7 @@ class MailSourceOut(BaseModel): name: str host: str port: int - use_ssl: bool + security: str username: str folder: str done_folder: str | None diff --git a/backend/app/services/mail_import.py b/backend/app/services/mail_import.py index ffdd332..2b95a02 100644 --- a/backend/app/services/mail_import.py +++ b/backend/app/services/mail_import.py @@ -45,14 +45,22 @@ def _verbinden(quelle: MailSource) -> imaplib.IMAP4: "Das gespeicherte Passwort lässt sich nicht mehr entschlüsseln. " "Wurde das Server-Geheimnis getauscht? Dann bitte neu eintragen." ) + art = (quelle.security or "ssl").lower() try: - verbindung = ( - imaplib.IMAP4_SSL(quelle.host, quelle.port) - if quelle.use_ssl - else imaplib.IMAP4(quelle.host, quelle.port) - ) + if art == "ssl": + # TLS ab der ersten Zeile – der Normalfall auf Port 993. + verbindung = imaplib.IMAP4_SSL(quelle.host, quelle.port) + else: + verbindung = imaplib.IMAP4(quelle.host, quelle.port) + if art == "starttls": + # Erst im Klartext verbinden, dann verschlüsseln (Port 143). + verbindung.starttls() except OSError as e: raise MailFehler(f"Keine Verbindung zu {quelle.host}:{quelle.port} – {e}") from e + except imaplib.IMAP4.error as e: + raise MailFehler( + f"Verschlüsselung fehlgeschlagen ({art}). Passt die Angabe zum Port {quelle.port}? – {e}" + ) from e try: verbindung.login(quelle.username, passwort) except imaplib.IMAP4.error as e: diff --git a/backend/migrations/versions/0017_mail_security.py b/backend/migrations/versions/0017_mail_security.py new file mode 100644 index 0000000..8a612e9 --- /dev/null +++ b/backend/migrations/versions/0017_mail_security.py @@ -0,0 +1,35 @@ +"""Verschluesselungsart des Postfachs waehlbar machen. + +Bisher gab es nur den Schalter ``use_ssl`` – also implizites TLS auf Port 993 +oder gar nichts. STARTTLS auf Port 143 fehlte, und genau das bieten viele +Anbieter an. Statt eines zweiten Schalters gibt es jetzt ein Feld mit drei +klaren Werten. + +Revision ID: 0017 +Revises: 0016 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "0017" +down_revision = "0016" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("mail_sources", + sa.Column("security", sa.String(length=16), nullable=False, server_default="ssl")) + # Bestehende Postfaecher behalten ihre Einstellung. + op.execute("UPDATE mail_sources SET security = CASE WHEN use_ssl THEN 'ssl' ELSE 'none' END") + with op.batch_alter_table("mail_sources") as batch: + batch.drop_column("use_ssl") + + +def downgrade() -> None: + op.add_column("mail_sources", + sa.Column("use_ssl", sa.Boolean(), nullable=False, server_default=sa.true())) + op.execute("UPDATE mail_sources SET use_ssl = (security = 'ssl')") + with op.batch_alter_table("mail_sources") as batch: + batch.drop_column("security") diff --git a/web/src/finance.css b/web/src/finance.css index ab2132a..f66f352 100644 --- a/web/src/finance.css +++ b/web/src/finance.css @@ -284,13 +284,20 @@ min-width: 0; } -/* Einstellungen & Mein Konto: Karten in ein mehrspaltiges Raster (nutzt die - Bildschirmbreite besser als das bisherige Untereinander). */ +/* Einstellungen & Mein Konto: Karten fliessen spaltenweise. + Ein Raster richtet die Karten zeilenweise aus – die zweite Reihe beginnt erst + unter der HOECHSTEN Karte der ersten, was bei sehr unterschiedlich hohen + Karten ein grosses Loch hinterlaesst. Der Spaltenfluss stapelt stattdessen + weiter, sobald oben Platz frei wird. Die Spalten sind bewusst breit: vier + schmale Karten nebeneinander waren auf dem Notebook nicht mehr lesbar. */ .settings-grid { - display: grid; - gap: var(--sp-4); - grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); - align-items: start; + column-width: 380px; + column-gap: var(--sp-4); +} +.settings-grid > * { + break-inside: avoid; + margin-bottom: var(--sp-4); + width: 100%; } /* Nutzer-Chip in der Seitenleisten-Fußzeile ist ein Link auf „Mein Konto". */ diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 7d5db18..2327b8c 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -522,6 +522,12 @@ "deleteConfirm": "Postfach „{{name}}“ entfernen? Bereits erfasste Zahlungen bleiben.", "noSenders": "Noch kein Absender zugeordnet – ohne Zuordnung wird nichts übernommen.", "noAccount": "kein Konto", - "fromPlaceholder": "Absender enthält, z. B. raiffeisen.ch" + "fromPlaceholder": "Absender enthält, z. B. raiffeisen.ch", + "security": "Verschlüsselung", + "secSsl": "SSL/TLS (üblich, Port 993)", + "secStarttls": "STARTTLS (Port 143)", + "secNone": "keine", + "secNoneWarn": "Ohne Verschlüsselung gehen Benutzername und Passwort im Klartext über das Netz. Nur sinnvoll, wenn der Mailserver auf demselben Rechner läuft.", + "addSender": "Absender zuordnen" } } diff --git a/web/src/locales/en.json b/web/src/locales/en.json index cb31344..a478473 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -522,6 +522,12 @@ "deleteConfirm": "Remove mailbox “{{name}}”? Payments already captured stay.", "noSenders": "No sender mapped yet – without a mapping nothing is taken over.", "noAccount": "no account", - "fromPlaceholder": "Sender contains, e.g. raiffeisen.ch" + "fromPlaceholder": "Sender contains, e.g. raiffeisen.ch", + "security": "Encryption", + "secSsl": "SSL/TLS (usual, port 993)", + "secStarttls": "STARTTLS (port 143)", + "secNone": "none", + "secNoneWarn": "Without encryption the username and password travel in clear text. Only sensible when the mail server runs on the same machine.", + "addSender": "Map sender" } } diff --git a/web/src/pages/settings/Postfaecher.jsx b/web/src/pages/settings/Postfaecher.jsx index eb72486..3c7f03e 100644 --- a/web/src/pages/settings/Postfaecher.jsx +++ b/web/src/pages/settings/Postfaecher.jsx @@ -6,7 +6,7 @@ import { useConfirm } from "../../confirm"; import Icon from "../../components/Icon"; const LEER = { - name: "", host: "", port: 993, use_ssl: true, username: "", password: "", + name: "", host: "", port: 993, security: "ssl", username: "", password: "", folder: "INBOX", done_folder: "", mark_seen: true, is_active: true, poll_minutes: 15, }; @@ -116,32 +116,32 @@ export default function Postfaecher() {
{t("mailbox.intro")}
{quellen.map((q) => ( -{t("mailbox.secNoneWarn")}
}