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
+11
View File
@@ -173,6 +173,17 @@ export const api = {
setInboxCategory: (id, categoryId) => setInboxCategory: (id, categoryId) =>
request(`/inbox/${id}/category/${categoryId}`, { method: "POST" }), request(`/inbox/${id}/category/${categoryId}`, { method: "POST" }),
// Postfächer für den Mail-Abruf
listMailSources: () => request("/mail/sources"),
createMailSource: (body) => request("/mail/sources", { method: "POST", body }),
updateMailSource: (id, body) => request(`/mail/sources/${id}`, { method: "PUT", body }),
deleteMailSource: (id) => request(`/mail/sources/${id}`, { method: "DELETE" }),
testMailSource: (id) => request(`/mail/sources/${id}/test`, { method: "POST" }),
fetchMailSource: (id) => request(`/mail/sources/${id}/fetch`, { method: "POST" }),
listMailSenders: (id) => request(`/mail/sources/${id}/senders`),
createMailSender: (id, body) => request(`/mail/sources/${id}/senders`, { method: "POST", body }),
deleteMailSender: (id) => request(`/mail/senders/${id}`, { method: "DELETE" }),
// Kategorien (Gruppen + Kategorien) // Kategorien (Gruppen + Kategorien)
listCategories: () => request("/categories"), listCategories: () => request("/categories"),
createGroup: (body) => request("/categories/groups", { method: "POST", body }), createGroup: (body) => request("/categories/groups", { method: "POST", body }),
+1
View File
@@ -44,6 +44,7 @@ const PATHS = {
check: <path d="M20 6 9 17l-5-5" />, check: <path d="M20 6 9 17l-5-5" />,
inbox: <><path d="M22 12h-6l-2 3h-4l-2-3H2" /><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" /></>, inbox: <><path d="M22 12h-6l-2 3h-4l-2-3H2" /><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" /></>,
close: <path d="M18 6 6 18M6 6l12 12" />, close: <path d="M18 6 6 18M6 6l12 12" />,
mail: <><rect x="2" y="4" width="20" height="16" rx="2" /><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" /></>,
edit: ( edit: (
<> <>
<path d="M12 20h9" /> <path d="M12 20h9" />
+30
View File
@@ -493,5 +493,35 @@
"viaShortcut": "per Kurzbefehl", "viaShortcut": "per Kurzbefehl",
"discard": "Verwerfen", "discard": "Verwerfen",
"discardConfirm": "Diese Erfassung verwerfen? Die Buchung wird gelöscht. Der Eintrag bleibt als verworfen vermerkt, damit der Kontoauszug die Zahlung später trotzdem erkennt." "discardConfirm": "Diese Erfassung verwerfen? Die Buchung wird gelöscht. Der Eintrag bleibt als verworfen vermerkt, damit der Kontoauszug die Zahlung später trotzdem erkennt."
},
"mailbox": {
"title": "Postfach für Zahlungs-Mails",
"intro": "Raiffeisen und Revolut melden Überweisungen per E-Mail. Certain holt sie ab und legt sie als Vorschlag in den Eingang – gebucht wird nur, was du abnickst. Kartenzahlungen im Laden melden die Banken nicht per Mail; dafür gibt es die Schnellerfassung per Kurzbefehl unter „Mein Konto“.",
"add": "Postfach hinzufügen",
"name": "Bezeichnung",
"namePlaceholder": "z. B. Belege",
"host": "IMAP-Server",
"port": "Port",
"username": "Benutzername",
"password": "Passwort",
"passwordKeep": "Passwort (leer lassen = unverändert)",
"passwordHint": "Wird verschlüsselt gespeichert. Der Schlüssel hängt am Server-Geheimnis: Wird es getauscht, musst du das Passwort neu eintragen. Nimm nach Möglichkeit ein App-Passwort statt deines echten.",
"folder": "Ordner",
"folderHint": "Nach einem Verbindungstest kannst du hier aus den echten Ordnern wählen.",
"doneFolder": "Nach dem Auswerten verschieben nach",
"doneFolderPlaceholder": "leer = nicht verschieben",
"pollMinutes": "Abrufen alle … Minuten",
"test": "Verbindung testen",
"testOk": "Verbindung steht – {{n}} Ordner gefunden.",
"fetchNow": "Jetzt abrufen",
"fetched": "{{neu}} neu, davon {{unklar}} unklar.",
"lastPoll": "zuletzt abgerufen",
"neverPolled": "noch nie abgerufen",
"lastError": "Fehler",
"delete": "Postfach entfernen",
"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"
} }
} }
+30
View File
@@ -493,5 +493,35 @@
"viaShortcut": "via shortcut", "viaShortcut": "via shortcut",
"discard": "Discard", "discard": "Discard",
"discardConfirm": "Discard this capture? The booking is deleted. The entry stays marked as discarded so the statement still recognises the payment later." "discardConfirm": "Discard this capture? The booking is deleted. The entry stays marked as discarded so the statement still recognises the payment later."
},
"mailbox": {
"title": "Mailbox for payment emails",
"intro": "Raiffeisen and Revolut send an email for transfers. Certain fetches them and puts them into the inbox as suggestions – only what you approve gets booked. Card payments in a shop are not emailed by the banks; use the shortcut capture under “My account” for those.",
"add": "Add mailbox",
"name": "Label",
"namePlaceholder": "e.g. Receipts",
"host": "IMAP server",
"port": "Port",
"username": "Username",
"password": "Password",
"passwordKeep": "Password (leave empty to keep)",
"passwordHint": "Stored encrypted. The key derives from the server secret: if that is replaced, you must enter the password again. Prefer an app password over your real one.",
"folder": "Folder",
"folderHint": "After a connection test you can pick from the real folders here.",
"doneFolder": "Move to after processing",
"doneFolderPlaceholder": "empty = do not move",
"pollMinutes": "Fetch every … minutes",
"test": "Test connection",
"testOk": "Connection works – {{n}} folders found.",
"fetchNow": "Fetch now",
"fetched": "{{neu}} new, {{unklar}} unclear.",
"lastPoll": "last fetched",
"neverPolled": "never fetched",
"lastError": "Error",
"delete": "Remove mailbox",
"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"
} }
} }
+2
View File
@@ -6,6 +6,7 @@ import { useToast } from "../toast";
import { api } from "../api"; import { api } from "../api";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import PolicyCard from "./settings/PolicyCard"; import PolicyCard from "./settings/PolicyCard";
import Postfaecher from "./settings/Postfaecher";
import RestoreCard from "./settings/RestoreCard"; import RestoreCard from "./settings/RestoreCard";
export default function Settings() { export default function Settings() {
@@ -161,6 +162,7 @@ export default function Settings() {
)} )}
</div> </div>
<Postfaecher />
<RestoreCard /> <RestoreCard />
</div> </div>
</div> </div>
+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>
);
}