feat: Verschluesselung waehlbar, dazu zwei Layout-Fehler

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 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-22 20:14:37 +02:00
co-authored by Claude Opus 5
parent 408b88f893
commit 51decb7054
9 changed files with 129 additions and 43 deletions
+4 -1
View File
@@ -698,7 +698,10 @@ class MailSource(Base):
name: Mapped[str] = mapped_column(String(80), nullable=False, default="") name: Mapped[str] = mapped_column(String(80), nullable=False, default="")
host: Mapped[str] = mapped_column(String(255), nullable=False) host: Mapped[str] = mapped_column(String(255), nullable=False)
port: Mapped[int] = mapped_column(Integer, nullable=False, default=993) 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) username: Mapped[str] = mapped_column(String(255), nullable=False)
# Verschlüsselt, nicht gehasht: Certain muss es dem Mailserver vorlegen. # Verschlüsselt, nicht gehasht: Certain muss es dem Mailserver vorlegen.
password_enc: Mapped[str] = mapped_column(Text, nullable=False) password_enc: Mapped[str] = mapped_column(Text, nullable=False)
+2 -2
View File
@@ -549,7 +549,7 @@ class MailSourceIn(BaseModel):
name: str = Field(default="", max_length=80) name: str = Field(default="", max_length=80)
host: str = Field(min_length=1, max_length=255) host: str = Field(min_length=1, max_length=255)
port: int = 993 port: int = 993
use_ssl: bool = True security: Literal["ssl", "starttls", "none"] = "ssl"
username: str = Field(min_length=1, max_length=255) username: str = Field(min_length=1, max_length=255)
# Beim Ändern weglassen, um das gespeicherte Passwort zu behalten. # Beim Ändern weglassen, um das gespeicherte Passwort zu behalten.
password: str | None = None password: str | None = None
@@ -565,7 +565,7 @@ class MailSourceOut(BaseModel):
name: str name: str
host: str host: str
port: int port: int
use_ssl: bool security: str
username: str username: str
folder: str folder: str
done_folder: str | None done_folder: str | None
+13 -5
View File
@@ -45,14 +45,22 @@ def _verbinden(quelle: MailSource) -> imaplib.IMAP4:
"Das gespeicherte Passwort lässt sich nicht mehr entschlüsseln. " "Das gespeicherte Passwort lässt sich nicht mehr entschlüsseln. "
"Wurde das Server-Geheimnis getauscht? Dann bitte neu eintragen." "Wurde das Server-Geheimnis getauscht? Dann bitte neu eintragen."
) )
art = (quelle.security or "ssl").lower()
try: try:
verbindung = ( if art == "ssl":
imaplib.IMAP4_SSL(quelle.host, quelle.port) # TLS ab der ersten Zeile – der Normalfall auf Port 993.
if quelle.use_ssl verbindung = imaplib.IMAP4_SSL(quelle.host, quelle.port)
else imaplib.IMAP4(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: except OSError as e:
raise MailFehler(f"Keine Verbindung zu {quelle.host}:{quelle.port} – {e}") from 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: try:
verbindung.login(quelle.username, passwort) verbindung.login(quelle.username, passwort)
except imaplib.IMAP4.error as e: except imaplib.IMAP4.error as e:
@@ -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")
+13 -6
View File
@@ -284,13 +284,20 @@
min-width: 0; min-width: 0;
} }
/* Einstellungen & Mein Konto: Karten in ein mehrspaltiges Raster (nutzt die /* Einstellungen & Mein Konto: Karten fliessen spaltenweise.
Bildschirmbreite besser als das bisherige Untereinander). */ 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 { .settings-grid {
display: grid; column-width: 380px;
gap: var(--sp-4); column-gap: var(--sp-4);
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); }
align-items: start; .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". */ /* Nutzer-Chip in der Seitenleisten-Fußzeile ist ein Link auf „Mein Konto". */
+7 -1
View File
@@ -522,6 +522,12 @@
"deleteConfirm": "Postfach „{{name}}“ entfernen? Bereits erfasste Zahlungen bleiben.", "deleteConfirm": "Postfach „{{name}}“ entfernen? Bereits erfasste Zahlungen bleiben.",
"noSenders": "Noch kein Absender zugeordnet – ohne Zuordnung wird nichts übernommen.", "noSenders": "Noch kein Absender zugeordnet – ohne Zuordnung wird nichts übernommen.",
"noAccount": "kein Konto", "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"
} }
} }
+7 -1
View File
@@ -522,6 +522,12 @@
"deleteConfirm": "Remove mailbox “{{name}}”? Payments already captured stay.", "deleteConfirm": "Remove mailbox “{{name}}”? Payments already captured stay.",
"noSenders": "No sender mapped yet – without a mapping nothing is taken over.", "noSenders": "No sender mapped yet – without a mapping nothing is taken over.",
"noAccount": "no account", "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"
} }
} }
+44 -27
View File
@@ -6,7 +6,7 @@ import { useConfirm } from "../../confirm";
import Icon from "../../components/Icon"; import Icon from "../../components/Icon";
const LEER = { 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, folder: "INBOX", done_folder: "", mark_seen: true, is_active: true, poll_minutes: 15,
}; };
@@ -116,32 +116,32 @@ export default function Postfaecher() {
<p className="muted small">{t("mailbox.intro")}</p> <p className="muted small">{t("mailbox.intro")}</p>
{quellen.map((q) => ( {quellen.map((q) => (
<div key={q.id} className="account-row" style={{ alignItems: "flex-start" }}> <div key={q.id} className="mail-source">
<div className="named-text grow"> <div><strong>{q.name || q.host}</strong></div>
<div><strong>{q.name || q.host}</strong></div> <div className="muted small">{q.username} · {q.folder} · {q.security.toUpperCase()}</div>
<div className="named-sub"> <div className="muted small">
{q.username} · {q.folder} {q.last_error
{q.last_error ? `${t("mailbox.lastError")}: ${q.last_error}`
? ` · ${t("mailbox.lastError")}: ${q.last_error}` : q.last_poll_at ? t("mailbox.lastPoll") : t("mailbox.neverPolled")}
: q.last_poll_at ? ` · ${t("mailbox.lastPoll")}` : ` · ${t("mailbox.neverPolled")}`}
</div>
<div className="named-sub">
{(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(" · ")}
</div>
<SenderForm konten={konten} onAdd={(d) => absenderAnlegen(q, d)} t={t} />
</div> </div>
<div className="row-actions"> <div className="muted small" style={{ marginTop: "var(--sp-2)" }}>
{(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(" · ")}
</div>
<SenderForm konten={konten} onAdd={(d) => absenderAnlegen(q, d)} t={t} />
<div className="btn-pair" style={{ marginTop: "var(--sp-2)" }}>
<button className="btn sm" disabled={busy} onClick={() => testen(q)}>{t("mailbox.test")}</button> <button className="btn sm" disabled={busy} onClick={() => testen(q)}>{t("mailbox.test")}</button>
<button className="btn sm primary" disabled={busy} onClick={() => abrufen(q)}>{t("mailbox.fetchNow")}</button> <button className="btn sm primary" disabled={busy} onClick={() => abrufen(q)}>{t("mailbox.fetchNow")}</button>
<button className="btn sm ghost" onClick={() => { setForm({ ...LEER, ...q, password: "" }); setOrdner(null); }}> <button className="btn sm ghost" title={t("common.edit")}
onClick={() => { setForm({ ...LEER, ...q, password: "" }); setOrdner(null); }}>
<Icon name="edit" size={15} /> <Icon name="edit" size={15} />
</button> </button>
<button className="btn sm danger" onClick={() => loeschen(q)}><Icon name="trash" size={15} /></button> <button className="btn sm danger" title={t("common.delete")}
onClick={() => loeschen(q)}><Icon name="trash" size={15} /></button>
</div> </div>
</div> </div>
))} ))}
@@ -162,6 +162,22 @@ export default function Postfaecher() {
<input value={form.host} required onChange={(e) => setzen({ host: e.target.value })} <input value={form.host} required onChange={(e) => setzen({ host: e.target.value })}
placeholder="imap.example.com" /> placeholder="imap.example.com" />
</label> </label>
<label className="field">
<span>{t("mailbox.security")}</span>
<select value={form.security}
onChange={(e) => {
// Der uebliche Port haengt an der Verschluesselung – gleich
// mitziehen, damit die haeufigste Fehlerquelle wegfaellt.
const art = e.target.value;
const port = art === "ssl" ? 993 : 143;
setzen({ security: art, port });
}}>
<option value="ssl">{t("mailbox.secSsl")}</option>
<option value="starttls">{t("mailbox.secStarttls")}</option>
<option value="none">{t("mailbox.secNone")}</option>
</select>
</label>
{form.security === "none" && <p className="muted small">{t("mailbox.secNoneWarn")}</p>}
<label className="field"> <label className="field">
<span>{t("mailbox.port")}</span> <span>{t("mailbox.port")}</span>
<input type="number" value={form.port} <input type="number" value={form.port}
@@ -216,16 +232,17 @@ function SenderForm({ konten, onAdd, t }) {
const [von, setVon] = useState(""); const [von, setVon] = useState("");
const [konto, setKonto] = useState(""); const [konto, setKonto] = useState("");
return ( return (
<div className="field-inline" style={{ marginTop: "var(--sp-2)", marginBottom: 0 }}> <div style={{ marginTop: "var(--sp-2)" }}>
<input placeholder={t("mailbox.fromPlaceholder")} value={von} <input style={{ width: "100%" }} placeholder={t("mailbox.fromPlaceholder")} value={von}
onChange={(e) => setVon(e.target.value)} /> onChange={(e) => setVon(e.target.value)} />
<select value={konto} onChange={(e) => setKonto(e.target.value)}> <select style={{ width: "100%", marginTop: 4 }} value={konto}
onChange={(e) => setKonto(e.target.value)}>
<option value="">{t("mailbox.noAccount")}</option> <option value="">{t("mailbox.noAccount")}</option>
{konten.map((k) => <option key={k.id} value={k.id}>{k.name}</option>)} {konten.map((k) => <option key={k.id} value={k.id}>{k.name}</option>)}
</select> </select>
<button type="button" className="btn sm" disabled={!von.trim()} <button type="button" className="btn sm" style={{ marginTop: 4 }} disabled={!von.trim()}
onClick={() => { onAdd({ from_contains: von.trim(), account_id: konto ? Number(konto) : null }); setVon(""); }}> onClick={() => { onAdd({ from_contains: von.trim(), account_id: konto ? Number(konto) : null }); setVon(""); }}>
<Icon name="plus" size={14} /> <Icon name="plus" size={14} /> {t("mailbox.addSender")}
</button> </button>
</div> </div>
); );
+4
View File
@@ -1146,3 +1146,7 @@ tr.row-active { background: var(--surface-2); }
/* "Foto machen"/"Galerie" sind <label> und erben sonst das margin-bottom des /* "Foto machen"/"Galerie" sind <label> und erben sonst das margin-bottom des
globalen label-Stils – dann saessen sie hoeher als der <button> "Entfernen". */ globalen label-Stils – dann saessen sie hoeher als der <button> "Entfernen". */
.foto-knoepfe .btn { cursor: pointer; margin: 0; } .foto-knoepfe .btn { cursor: pointer; margin: 0; }
/* Postfach-Eintrag: gestapelt, weil die Einstellungsspalte schmal ist. */
.mail-source { padding: var(--sp-3) 0; border-bottom: 1px solid var(--border); }
.mail-source:last-of-type { border-bottom: 0; }