Files
certain/web/src/pages/settings/Postfaecher.jsx
T
GuidoandClaude Opus 5 899241f991 feat: Absender-Zuordnung kann nach Waehrung unterscheiden
Revolut verschickt fuer eine Zahlung in Euro und eine in Franken dieselbe
Mail vom selben Absender. Ohne die Waehrung als Kriterium landete alles auf
demselben Konto.

Die Erkennung liest die Waehrung ohnehin aus; sie wird jetzt VOR der
Zuordnung ausgewertet und ist ein optionales Filterkriterium. Zuordnungen
mit Waehrung werden zuerst geprueft – wer neben ein allgemeines "revolut"
ein genaues "revolut + EUR" stellt, meint das genaue, unabhaengig von der
Anlagereihenfolge.

Uebergangene Mails melden ausserdem die erkannte Waehrung mit, damit beim
Einrichten sichtbar ist, welcher Wert zu filtern waere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 20:48:55 +02:00

348 lines
14 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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";
import Modal from "../../components/Modal";
// Reicht für den Alltag; wer eine andere braucht, sagt Bescheid.
const WAEHRUNGEN = ["CHF", "EUR", "USD", "GBP"];
const LEER = {
name: "", host: "", port: 993, security: "ssl", 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);
// Was beim letzten Abruf durchgefallen ist – beim Einrichten die einzige
// Möglichkeit zu sehen, was in den Absender-Filter gehört.
const [uebergangen, setUebergangen] = useState([]);
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);
// „0 neu" allein sagt nicht, ob nichts da war oder nichts passte.
toast(r.gefunden === 0
? t("mailbox.fetchedNone")
: t("mailbox.fetched", { gefunden: r.gefunden, neu: r.neu, unklar: r.unklar }));
setUebergangen(r.ohne_zuordnung || []);
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 absenderAendern(a, daten) {
try { await api.updateMailSender(a.id, daten); laden(); } catch (e) { toast(e.message, "error"); }
}
async function absenderLoeschen(a) {
if (!(await confirm({
title: t("mailbox.deleteSender"),
message: t("mailbox.deleteSenderConfirm", { text: a.from_contains || "*" }),
danger: true, confirmLabel: t("common.delete"),
}))) return;
try { await api.deleteMailSender(a.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="mail-source">
<div><strong>{q.name || q.host}</strong></div>
<div className="muted small">{q.username} · {q.folder} · {q.security.toUpperCase()}</div>
<div className="muted small">
{q.last_error
? `${t("mailbox.lastError")}: ${q.last_error}`
: q.last_poll_at ? t("mailbox.lastPoll") : t("mailbox.neverPolled")}
</div>
<div style={{ marginTop: "var(--sp-2)" }}>
{(absender[q.id] || []).length === 0 ? (
<p className="muted small">{t("mailbox.noSenders")}</p>
) : (
(absender[q.id] || []).map((a) => (
<SenderRow key={a.id} absender={a} konten={konten} t={t}
onSave={(d) => absenderAendern(a, d)}
onDelete={() => absenderLoeschen(a)} />
))
)}
</div>
<SenderForm konten={konten} onAdd={(d) => absenderAnlegen(q, d)} t={t} />
{uebergangen.length > 0 && (
<div className="alert" style={{ display: "block", marginTop: "var(--sp-2)" }}>
<strong className="small">{t("mailbox.skippedTitle")}</strong>
<p className="muted small">{t("mailbox.skippedHint")}</p>
<ul className="muted small" style={{ paddingLeft: "1.2em", margin: 0 }}>
{uebergangen.map((m, i) => (
<li key={i} className="selectable">{m.from} — {m.subject}</li>
))}
</ul>
</div>
)}
<div className="mail-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" title={t("common.edit")}
onClick={() => { setForm({ ...LEER, ...q, password: "" }); setOrdner(null); }}>
<Icon name="edit" size={15} />
</button>
<button className="btn sm danger" title={t("common.delete")}
onClick={() => loeschen(q)}><Icon name="trash" size={15} /></button>
</div>
</div>
))}
<button className="btn" style={{ marginTop: "var(--sp-3)" }} onClick={() => setForm({ ...LEER })}>
<Icon name="plus" size={15} /> {t("mailbox.add")}
</button>
{form !== null && (
<Modal
title={form.id ? t("mailbox.edit") : t("mailbox.add")}
onClose={() => { setForm(null); setOrdner(null); }}
footer={
<>
<button type="button" className="btn ghost"
onClick={() => { setForm(null); setOrdner(null); }}>
{t("common.cancel")}
</button>
<button type="submit" form="postfach-form" className="btn primary" disabled={busy}>
{t("common.save")}
</button>
</>
}
>
<form id="postfach-form" onSubmit={speichern}>
<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.security")}</span>
<select value={form.security}
onChange={(e) => {
// Der uebliche Port haengt an der Verschluesselung – gleich
// mitziehen, damit die haeufigste Fehlerquelle wegfaellt.
const art = e.target.value;
const port = art === "ssl" ? 993 : 143;
setzen({ security: art, port });
}}>
<option value="ssl">{t("mailbox.secSsl")}</option>
<option value="starttls">{t("mailbox.secStarttls")}</option>
<option value="none">{t("mailbox.secNone")}</option>
</select>
</label>
{form.security === "none" && <p className="muted small">{t("mailbox.secNoneWarn")}</p>}
<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>
</form>
</Modal>
)}
</div>
);
}
/** Eine bestehende Zuordnung: Text und Konto änderbar, dazu löschbar. */
function SenderRow({ absender, konten, t, onSave, onDelete }) {
const [text, setText] = useState(absender.from_contains || "");
const [konto, setKonto] = useState(absender.account_id ? String(absender.account_id) : "");
const [waehrung, setWaehrung] = useState(absender.currency || "");
const geaendert =
text !== (absender.from_contains || "") ||
waehrung !== (absender.currency || "") ||
konto !== (absender.account_id ? String(absender.account_id) : "");
return (
<div className="sender-row">
<input value={text} onChange={(e) => setText(e.target.value)}
placeholder={t("mailbox.fromPlaceholder")} />
<select value={waehrung} onChange={(e) => setWaehrung(e.target.value)}>
<option value="">{t("mailbox.anyCurrency")}</option>
{WAEHRUNGEN.map((w) => <option key={w} value={w}>{w}</option>)}
</select>
<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>
<div className="mail-actions" style={{ marginTop: 4 }}>
{geaendert && (
<button type="button" className="btn sm primary"
onClick={() => onSave({ from_contains: text.trim() || null,
currency: waehrung || null,
account_id: konto ? Number(konto) : null })}>
{t("common.save")}
</button>
)}
<button type="button" className="btn sm danger" onClick={onDelete}>
<Icon name="trash" size={14} /> {t("common.delete")}
</button>
</div>
</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("");
const [waehrung, setWaehrung] = useState("");
return (
<div style={{ marginTop: "var(--sp-2)" }}>
<input style={{ width: "100%" }} placeholder={t("mailbox.fromPlaceholder")} value={von}
onChange={(e) => setVon(e.target.value)} />
<select style={{ width: "100%", marginTop: 4 }} value={waehrung}
onChange={(e) => setWaehrung(e.target.value)}>
<option value="">{t("mailbox.anyCurrency")}</option>
{WAEHRUNGEN.map((w) => <option key={w} value={w}>{w}</option>)}
</select>
<select style={{ width: "100%", marginTop: 4 }} 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>
<p className="muted small" style={{ margin: "4px 0 0" }}>{t("mailbox.senderHint")}</p>
<button type="button" className="btn sm" style={{ marginTop: 4 }} disabled={!von.trim()}
onClick={() => {
onAdd({ from_contains: von.trim(), currency: waehrung || null,
account_id: konto ? Number(konto) : null });
setVon("");
}}>
<Icon name="plus" size={14} /> {t("mailbox.addSender")}
</button>
</div>
);
}