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:
@@ -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 }),
|
||||
|
||||
@@ -44,6 +44,7 @@ const PATHS = {
|
||||
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" /></>,
|
||||
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: (
|
||||
<>
|
||||
<path d="M12 20h9" />
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Postfaecher />
|
||||
<RestoreCard />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user