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:
@@ -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