diff --git a/web/src/api.js b/web/src/api.js
index 0afe1c3..fa68f07 100644
--- a/web/src/api.js
+++ b/web/src/api.js
@@ -173,6 +173,17 @@ export const api = {
setInboxCategory: (id, categoryId) =>
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)
listCategories: () => request("/categories"),
createGroup: (body) => request("/categories/groups", { method: "POST", body }),
diff --git a/web/src/components/Icon.jsx b/web/src/components/Icon.jsx
index 0db754b..cabfa10 100644
--- a/web/src/components/Icon.jsx
+++ b/web/src/components/Icon.jsx
@@ -44,6 +44,7 @@ const PATHS = {
check: ,
inbox: <>>,
close: ,
+ mail: <>>,
edit: (
<>
diff --git a/web/src/locales/de.json b/web/src/locales/de.json
index 9061bba..376363c 100644
--- a/web/src/locales/de.json
+++ b/web/src/locales/de.json
@@ -493,5 +493,35 @@
"viaShortcut": "per Kurzbefehl",
"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."
+ },
+ "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"
}
}
diff --git a/web/src/locales/en.json b/web/src/locales/en.json
index 36f5244..d84dce4 100644
--- a/web/src/locales/en.json
+++ b/web/src/locales/en.json
@@ -493,5 +493,35 @@
"viaShortcut": "via shortcut",
"discard": "Discard",
"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"
}
}
diff --git a/web/src/pages/Settings.jsx b/web/src/pages/Settings.jsx
index 3f55726..e2a052c 100644
--- a/web/src/pages/Settings.jsx
+++ b/web/src/pages/Settings.jsx
@@ -6,6 +6,7 @@ import { useToast } from "../toast";
import { api } from "../api";
import Icon from "../components/Icon";
import PolicyCard from "./settings/PolicyCard";
+import Postfaecher from "./settings/Postfaecher";
import RestoreCard from "./settings/RestoreCard";
export default function Settings() {
@@ -161,6 +162,7 @@ export default function Settings() {
)}
+
diff --git a/web/src/pages/settings/Postfaecher.jsx b/web/src/pages/settings/Postfaecher.jsx
new file mode 100644
index 0000000..eb72486
--- /dev/null
+++ b/web/src/pages/settings/Postfaecher.jsx
@@ -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 (
+
+
+
+
{t("mailbox.title")}
+
+
{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} />
+
+
+
+
+
+
+
+
+ ))}
+
+ {form === null ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+/** 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 (
+
+ setVon(e.target.value)} />
+
+
+
+ );
+}