From 51decb705442d0528e29c3c60cea713f92cb8f6c Mon Sep 17 00:00:00 2001 From: Guido Date: Sat, 22 Aug 2026 20:14:37 +0200 Subject: [PATCH] feat: Verschluesselung waehlbar, dazu zwei Layout-Fehler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verschluesselung: Bisher gab es nur den Schalter use_ssl – also implizites TLS auf 993 oder gar nichts. STARTTLS auf 143 fehlte ganz, und das bieten viele Anbieter an. Jetzt ein Feld mit drei Werten, der Port zieht bei der Auswahl mit, und "keine" warnt, dass Zugangsdaten im Klartext gehen. Layout: Die Postfach-Karte steht in einer schmalen Spalte; der waagerechte Aufbau brach dort auf ein Wort je Zeile um und die Knoepfe lagen ueber dem Titel. Jetzt gestapelt. Die Einstellungsseite nutzte ein Raster, das die Karten zeilenweise ausrichtet – die zweite Reihe begann erst unter der hoechsten Karte der ersten, weshalb "Backup zurueckladen" allein weit unten stand. Jetzt fliessen die Karten spaltenweise, und die Spalten sind breiter: vier schmale Karten nebeneinander waren auf dem Notebook nicht lesbar. Co-Authored-By: Claude Opus 5 --- backend/app/models.py | 5 +- backend/app/schemas.py | 4 +- backend/app/services/mail_import.py | 18 +++-- .../migrations/versions/0017_mail_security.py | 35 +++++++++ web/src/finance.css | 19 +++-- web/src/locales/de.json | 8 ++- web/src/locales/en.json | 8 ++- web/src/pages/settings/Postfaecher.jsx | 71 ++++++++++++------- web/src/styles.css | 4 ++ 9 files changed, 129 insertions(+), 43 deletions(-) create mode 100644 backend/migrations/versions/0017_mail_security.py 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) => ( -
-
-
{q.name || q.host}
-
- {q.username} · {q.folder} - {q.last_error - ? ` · ${t("mailbox.lastError")}: ${q.last_error}` - : q.last_poll_at ? ` · ${t("mailbox.lastPoll")}` : ` · ${t("mailbox.neverPolled")}`} -
-
- {(absender[q.id] || []).length === 0 - ? t("mailbox.noSenders") - : (absender[q.id] || []).map((a) => { - const konto = konten.find((k) => k.id === a.account_id); - return `${a.from_contains || "*"} → ${konto ? konto.name : t("mailbox.noAccount")}`; - }).join(" · ")} -
- absenderAnlegen(q, d)} t={t} /> +
+
{q.name || q.host}
+
{q.username} · {q.folder} · {q.security.toUpperCase()}
+
+ {q.last_error + ? `${t("mailbox.lastError")}: ${q.last_error}` + : q.last_poll_at ? t("mailbox.lastPoll") : t("mailbox.neverPolled")}
-
+
+ {(absender[q.id] || []).length === 0 + ? t("mailbox.noSenders") + : (absender[q.id] || []).map((a) => { + const konto = konten.find((k) => k.id === a.account_id); + return `${a.from_contains || "*"} → ${konto ? konto.name : t("mailbox.noAccount")}`; + }).join(" · ")} +
+ absenderAnlegen(q, d)} t={t} /> +
- - +
))} @@ -162,6 +162,22 @@ export default function Postfaecher() { setzen({ host: e.target.value })} placeholder="imap.example.com" /> + + {form.security === "none" &&

{t("mailbox.secNoneWarn")}

}
); diff --git a/web/src/styles.css b/web/src/styles.css index 70f5832..60df8b6 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1146,3 +1146,7 @@ tr.row-active { background: var(--surface-2); } /* "Foto machen"/"Galerie" sind