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>
279 lines
11 KiB
React
279 lines
11 KiB
React
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>
|
||
);
|
||
}
|