Files
certain/web/src/pages/settings/Postfaecher.jsx
T
GuidoandClaude Opus 5 60b8178c57 fix: Mails vor der Einrichtung waren fuer immer ausserhalb des Suchfensters
Der Zeitstempel des letzten Abrufs wird auch dann gesetzt, wenn ein Abruf
nichts fand – und danach sucht Certain nur noch ab gestern. Wer das Postfach
einrichtet, nachdem die Mails schon da waren, findet sie nie: Der erste,
ergebnislose Abruf schiebt das Fenster an ihnen vorbei.

Jetzt gibt es "Aelteres nachholen": Es sucht die letzten 90 Tage und uebergeht
den Zeitstempel. Dass dabei nichts doppelt ankommt, sichert wie bisher die
Message-ID.

Ausserdem sagt die Meldung jetzt, AB WANN gesucht wurde. Bisher stand dort
"Stimmt der Ordner?" – die Frage war fast immer die falsche.

Und ein latenter Fehler, der im August nicht auffaellt: Das Suchdatum wurde mit
strftime("%b") gebaut, also mit dem Monatskuerzel in der Sprache des Servers.
IMAP verlangt die englischen; auf einem deutschen System haette die Suche im
Maerz, Mai, Oktober und Dezember nichts gefunden. Die Kuerzel stehen jetzt fest
im Code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 12:59:08 +02:00

357 lines
15 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 { formatDate } from "../../format";
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, tage) {
setBusy(true);
try {
const r = await api.fetchMailSource(quelle.id, tage);
// „0 neu" allein sagt nicht, ob nichts da war oder nichts passte. Und
// wenn nichts da war, ist die Frage nicht „stimmt der Ordner", sondern
// „wie weit wurde überhaupt gesucht" – deshalb steht das Datum dabei.
toast(r.gefunden === 0
? t("mailbox.fetchedNone", { seit: r.seit ? formatDate(r.seit) : "?" })
: 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>
{/* Der Zeitstempel wird auch gesetzt, wenn ein Abruf nichts fand.
Ohne diesen Weg blieben ältere Mails für immer ausserhalb des
Fensters, obwohl sie im Postfach liegen. */}
<button className="btn sm" disabled={busy} onClick={() => abrufen(q, 90)}>
{t("mailbox.fetchOlder")}
</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>
);
}