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 ? ( + + ) : ( +
+ + + + + +

{t("mailbox.passwordHint")}

+ +

{t("mailbox.folderHint")}

+ + +
+ + +
+
+ )} +
+ ); +} + +/** 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)} /> + + +
+ ); +}