feat: Postfach-Formular in den Einstellungen

Server, Port, Benutzer und Passwort eintragen, Verbindung testen, Ordner
danach aus der echten Liste des Postfachs waehlen. Je Absender wird ein
Zielkonto zugeordnet – ohne Zuordnung uebernimmt Certain nichts, denn
dieselbe Adresse kann je nach Konto etwas anderes bedeuten.

Der Hinweis am Passwortfeld sagt, was passiert, wenn das Server-Geheimnis
getauscht wird, und empfiehlt ein App-Passwort statt des echten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-22 20:00:06 +02:00
co-authored by Claude Opus 5
parent fc6ee2f345
commit 8e41cce9a2
6 changed files with 306 additions and 0 deletions
+232
View File
@@ -0,0 +1,232 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../../api";
import { useToast } from "../../toast";
import { useConfirm } from "../../confirm";
import Icon from "../../components/Icon";
const LEER = {
name: "", host: "", port: 993, use_ssl: true, username: "", password: "",
folder: "INBOX", done_folder: "", mark_seen: true, is_active: true, poll_minutes: 15,
};
/**
* Postfächer für den Mail-Abruf.
*
* Raiffeisen und Revolut melden Überweisungen per E-Mail. Certain holt sie ab
* und macht daraus Vorschläge – gebucht wird nur, was der Nutzer abnickt.
*
* Kartenzahlungen im Laden melden die Banken NICHT per Mail, nur per Push in
* ihrer App. Dafür gibt es die Schnellerfassung per Kurzbefehl unter
* „Mein Konto".
*/
export default function Postfaecher() {
const { t } = useTranslation();
const toast = useToast();
const confirm = useConfirm();
const [quellen, setQuellen] = useState([]);
const [konten, setKonten] = useState([]);
const [form, setForm] = useState(null); // null = Formular zu
const [ordner, setOrdner] = useState(null); // Ergebnis des Verbindungstests
const [absender, setAbsender] = useState({});
const [busy, setBusy] = useState(false);
async function laden() {
try {
const [q, k] = await Promise.all([api.listMailSources(), api.listAccounts()]);
setQuellen(q);
setKonten(k);
const je = {};
for (const s of q) je[s.id] = await api.listMailSenders(s.id);
setAbsender(je);
} catch (e) {
toast(e.message, "error");
}
}
useEffect(() => { laden(); }, []);
const setzen = (teil) => setForm((f) => ({ ...f, ...teil }));
async function speichern(e) {
e.preventDefault();
setBusy(true);
try {
const koerper = { ...form };
if (form.id && !koerper.password) delete koerper.password;
if (form.id) await api.updateMailSource(form.id, koerper);
else await api.createMailSource(koerper);
setForm(null);
setOrdner(null);
laden();
} catch (e) {
toast(e.message, "error");
} finally {
setBusy(false);
}
}
async function testen(quelle) {
setBusy(true);
try {
const r = await api.testMailSource(quelle.id);
setOrdner(r.folders);
toast(t("mailbox.testOk", { n: r.count }));
} catch (e) {
toast(e.message, "error");
} finally {
setBusy(false);
}
}
async function abrufen(quelle) {
setBusy(true);
try {
const r = await api.fetchMailSource(quelle.id);
toast(t("mailbox.fetched", { neu: r.neu, unklar: r.unklar }));
laden();
} catch (e) {
toast(e.message, "error");
} finally {
setBusy(false);
}
}
async function loeschen(quelle) {
if (!(await confirm({
title: t("mailbox.delete"), message: t("mailbox.deleteConfirm", { name: quelle.name || quelle.host }),
danger: true, confirmLabel: t("common.delete"),
}))) return;
try { await api.deleteMailSource(quelle.id); laden(); } catch (e) { toast(e.message, "error"); }
}
async function absenderAnlegen(quelle, daten) {
try {
await api.createMailSender(quelle.id, daten);
laden();
} catch (e) { toast(e.message, "error"); }
}
return (
<div className="card">
<div className="card-head">
<Icon name="mail" size={18} />
<h2>{t("mailbox.title")}</h2>
</div>
<p className="muted small">{t("mailbox.intro")}</p>
{quellen.map((q) => (
<div key={q.id} className="account-row" style={{ alignItems: "flex-start" }}>
<div className="named-text grow">
<div><strong>{q.name || q.host}</strong></div>
<div className="named-sub">
{q.username} · {q.folder}
{q.last_error
? ` · ${t("mailbox.lastError")}: ${q.last_error}`
: 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 className="row-actions">
<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 ghost" onClick={() => { setForm({ ...LEER, ...q, password: "" }); setOrdner(null); }}>
<Icon name="edit" size={15} />
</button>
<button className="btn sm danger" onClick={() => loeschen(q)}><Icon name="trash" size={15} /></button>
</div>
</div>
))}
{form === null ? (
<button className="btn" style={{ marginTop: "var(--sp-3)" }} onClick={() => setForm({ ...LEER })}>
<Icon name="plus" size={15} /> {t("mailbox.add")}
</button>
) : (
<form onSubmit={speichern} style={{ marginTop: "var(--sp-3)" }}>
<label className="field">
<span>{t("mailbox.name")}</span>
<input value={form.name} onChange={(e) => setzen({ name: e.target.value })}
placeholder={t("mailbox.namePlaceholder")} />
</label>
<label className="field">
<span>{t("mailbox.host")}</span>
<input value={form.host} required onChange={(e) => setzen({ host: e.target.value })}
placeholder="imap.example.com" />
</label>
<label className="field">
<span>{t("mailbox.port")}</span>
<input type="number" value={form.port}
onChange={(e) => setzen({ port: Number(e.target.value) })} />
</label>
<label className="field">
<span>{t("mailbox.username")}</span>
<input value={form.username} required autoComplete="off"
onChange={(e) => setzen({ username: e.target.value })} />
</label>
<label className="field">
<span>{form.id ? t("mailbox.passwordKeep") : t("mailbox.password")}</span>
<input type="password" value={form.password} autoComplete="new-password"
onChange={(e) => setzen({ password: e.target.value })} />
</label>
<p className="muted small">{t("mailbox.passwordHint")}</p>
<label className="field">
<span>{t("mailbox.folder")}</span>
{ordner ? (
<select value={form.folder} onChange={(e) => setzen({ folder: e.target.value })}>
{ordner.map((o) => <option key={o} value={o}>{o}</option>)}
</select>
) : (
<input value={form.folder} onChange={(e) => setzen({ folder: e.target.value })} />
)}
</label>
<p className="muted small">{t("mailbox.folderHint")}</p>
<label className="field">
<span>{t("mailbox.doneFolder")}</span>
<input value={form.done_folder || ""} placeholder={t("mailbox.doneFolderPlaceholder")}
onChange={(e) => setzen({ done_folder: e.target.value })} />
</label>
<label className="field">
<span>{t("mailbox.pollMinutes")}</span>
<input type="number" min="5" max="1440" value={form.poll_minutes}
onChange={(e) => setzen({ poll_minutes: Number(e.target.value) })} />
</label>
<div className="btn-pair">
<button className="btn primary" disabled={busy}>{t("common.save")}</button>
<button type="button" className="btn ghost" onClick={() => { setForm(null); setOrdner(null); }}>
{t("common.cancel")}
</button>
</div>
</form>
)}
</div>
);
}
/** Absender einem Konto zuordnen – ohne das weiss Certain nicht, wohin gebucht wird. */
function SenderForm({ konten, onAdd, t }) {
const [von, setVon] = useState("");
const [konto, setKonto] = useState("");
return (
<div className="field-inline" style={{ marginTop: "var(--sp-2)", marginBottom: 0 }}>
<input placeholder={t("mailbox.fromPlaceholder")} value={von}
onChange={(e) => setVon(e.target.value)} />
<select value={konto} onChange={(e) => setKonto(e.target.value)}>
<option value="">{t("mailbox.noAccount")}</option>
{konten.map((k) => <option key={k.id} value={k.id}>{k.name}</option>)}
</select>
<button type="button" className="btn sm" disabled={!von.trim()}
onClick={() => { onAdd({ from_contains: von.trim(), account_id: konto ? Number(konto) : null }); setVon(""); }}>
<Icon name="plus" size={14} />
</button>
</div>
);
}