Files
certain/web/src/pages/settings/Postfaecher.jsx
T
GuidoandClaude Opus 5 bfb6fa6b6d fix: Abruf sucht nach Datum statt nach "ungelesen"
Auf UNSEEN zu filtern klingt sparsam, ist aber eine Falle: Wer eine Mail in
seinem Mailprogramm ansieht, bevor Certain sie geholt hat, verliert sie
dauerhaft – sie ist dann fuer immer gelesen. Genau das ist passiert, als die
Mails vor dem ersten Abruf schon in Gmail geoeffnet waren: Ergebnis "0 neu",
ohne jeden Hinweis auf die Ursache.

Gesucht wird jetzt ab dem letzten Abruf mit einem Tag Ueberlappung; dass
nichts doppelt ankommt, sichert die Message-ID ab.

Die Meldung nennt ausserdem, wie viele Mails ueberhaupt geprueft wurden.
"0 neu" allein liess offen, ob nichts da war oder nichts passte.

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

279 lines
11 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";
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 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 className="muted small" style={{ marginTop: "var(--sp-2)" }}>
{(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} />
{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>
);
}
/** 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 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={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" style={{ marginTop: 4 }} disabled={!von.trim()}
onClick={() => { onAdd({ from_contains: von.trim(), account_id: konto ? Number(konto) : null }); setVon(""); }}>
<Icon name="plus" size={14} /> {t("mailbox.addSender")}
</button>
</div>
);
}