feat: Absender-Zuordnungen bearbeiten und loeschen

Der Loesch-Endpunkt war da, aber ohne Knopf in der Oberflaeche, und
Bearbeiten fehlte ganz – eine falsch eingetippte Zuordnung liess sich also
nicht mehr korrigieren.

Jede Zuordnung ist jetzt eine eigene Zeile mit Erkennungstext und Konto;
der Speichern-Knopf erscheint erst bei einer Aenderung. Dazu ein PUT auf
/mail/senders/{id} im Backend.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-22 20:30:24 +02:00
co-authored by Claude Opus 5
parent 615be0f0f5
commit 7b087247a5
7 changed files with 103 additions and 9 deletions
+17
View File
@@ -121,6 +121,23 @@ def create_sender(
return absender return absender
@router.put("/senders/{sender_id}", response_model=MailSenderOut)
def update_sender(
sender_id: int,
payload: MailSenderIn,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
absender = get_owned(db, MailSender, sender_id, user)
if payload.account_id is not None:
get_owned(db, Account, payload.account_id, user)
for schluessel, wert in payload.model_dump().items():
setattr(absender, schluessel, wert)
db.commit()
db.refresh(absender)
return absender
@router.delete("/senders/{sender_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/senders/{sender_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_sender(sender_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): def delete_sender(sender_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
db.delete(get_owned(db, MailSender, sender_id, user)) db.delete(get_owned(db, MailSender, sender_id, user))
+18
View File
@@ -321,3 +321,21 @@ def test_von_hand_weitergeleitete_mail_wird_am_betreff_erkannt(admin, monkeypatc
treffer = [e for e in admin.get("/api/inbox").json() if e["payee"] == "Sunrise GmbH / Yallo"] treffer = [e for e in admin.get("/api/inbox").json() if e["payee"] == "Sunrise GmbH / Yallo"]
assert treffer, "Die Werte müssen auch aus der weitergeleiteten Mail lesbar sein" assert treffer, "Die Werte müssen auch aus der weitergeleiteten Mail lesbar sein"
assert treffer[0]["amount_cents"] == -1490 assert treffer[0]["amount_cents"] == -1490
def test_zuordnung_laesst_sich_aendern_und_loeschen(admin):
konto = _konto(admin)
zweites = _konto(admin, name="Zweitkonto")
quelle = _postfach(admin)
a = admin.post(f"/api/mail/sources/{quelle['id']}/senders",
json={"from_contains": "alt", "account_id": konto["id"]}).json()
r = admin.put(f"/api/mail/senders/{a['id']}",
json={"from_contains": "neu", "account_id": zweites["id"]})
assert r.status_code == 200, r.text
assert r.json()["from_contains"] == "neu"
assert r.json()["account_id"] == zweites["id"]
assert admin.delete(f"/api/mail/senders/{a['id']}").status_code == 204
uebrig = admin.get(f"/api/mail/sources/{quelle['id']}/senders").json()
assert not [x for x in uebrig if x["id"] == a["id"]]
+1
View File
@@ -182,6 +182,7 @@ export const api = {
fetchMailSource: (id) => request(`/mail/sources/${id}/fetch`, { method: "POST" }), fetchMailSource: (id) => request(`/mail/sources/${id}/fetch`, { method: "POST" }),
listMailSenders: (id) => request(`/mail/sources/${id}/senders`), listMailSenders: (id) => request(`/mail/sources/${id}/senders`),
createMailSender: (id, body) => request(`/mail/sources/${id}/senders`, { method: "POST", body }), createMailSender: (id, body) => request(`/mail/sources/${id}/senders`, { method: "POST", body }),
updateMailSender: (id, body) => request(`/mail/senders/${id}`, { method: "PUT", body }),
deleteMailSender: (id) => request(`/mail/senders/${id}`, { method: "DELETE" }), deleteMailSender: (id) => request(`/mail/senders/${id}`, { method: "DELETE" }),
// Kategorien (Gruppen + Kategorien) // Kategorien (Gruppen + Kategorien)
+3 -1
View File
@@ -533,6 +533,8 @@
"skippedTitle": "Diese Mails wurden übergangen", "skippedTitle": "Diese Mails wurden übergangen",
"skippedHint": "Für sie passt keine Absender-Zuordnung. Trag einen Teil der Adresse oder des Betreffs unten ein, dann werden sie beim nächsten Abruf übernommen.", "skippedHint": "Für sie passt keine Absender-Zuordnung. Trag einen Teil der Adresse oder des Betreffs unten ein, dann werden sie beim nächsten Abruf übernommen.",
"fetchedNone": "Keine Mails im gewählten Ordner gefunden. Stimmt der Ordner? Beim ersten Abruf werden die letzten 14 Tage durchsucht.", "fetchedNone": "Keine Mails im gewählten Ordner gefunden. Stimmt der Ordner? Beim ersten Abruf werden die letzten 14 Tage durchsucht.",
"senderHint": "Gesucht wird in Absender, Betreff und im Anfang des Textes. Damit greift es auch bei weitergeleiteten Mails, wo im Absender deine eigene Adresse steht." "senderHint": "Gesucht wird in Absender, Betreff und im Anfang des Textes. Damit greift es auch bei weitergeleiteten Mails, wo im Absender deine eigene Adresse steht.",
"deleteSender": "Zuordnung entfernen",
"deleteSenderConfirm": "Zuordnung „{{text}}“ entfernen? Bereits erfasste Zahlungen bleiben."
} }
} }
+3 -1
View File
@@ -533,6 +533,8 @@
"skippedTitle": "These emails were skipped", "skippedTitle": "These emails were skipped",
"skippedHint": "No sender mapping matched them. Enter part of the address or the subject below and they will be picked up on the next fetch.", "skippedHint": "No sender mapping matched them. Enter part of the address or the subject below and they will be picked up on the next fetch.",
"fetchedNone": "No emails found in the selected folder. Is the folder right? The first fetch looks at the last 14 days.", "fetchedNone": "No emails found in the selected folder. Is the folder right? The first fetch looks at the last 14 days.",
"senderHint": "Searched in sender, subject and the start of the body. That way it also works for forwarded emails, where the sender is your own address." "senderHint": "Searched in sender, subject and the start of the body. That way it also works for forwarded emails, where the sender is your own address.",
"deleteSender": "Remove mapping",
"deleteSenderConfirm": "Remove mapping “{{text}}”? Payments already captured stay."
} }
} }
+55 -7
View File
@@ -108,6 +108,19 @@ export default function Postfaecher() {
try { await api.deleteMailSource(quelle.id); laden(); } catch (e) { toast(e.message, "error"); } 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) { async function absenderAnlegen(quelle, daten) {
try { try {
await api.createMailSender(quelle.id, daten); await api.createMailSender(quelle.id, daten);
@@ -132,13 +145,16 @@ export default function Postfaecher() {
? `${t("mailbox.lastError")}: ${q.last_error}` ? `${t("mailbox.lastError")}: ${q.last_error}`
: q.last_poll_at ? t("mailbox.lastPoll") : t("mailbox.neverPolled")} : q.last_poll_at ? t("mailbox.lastPoll") : t("mailbox.neverPolled")}
</div> </div>
<div className="muted small" style={{ marginTop: "var(--sp-2)" }}> <div style={{ marginTop: "var(--sp-2)" }}>
{(absender[q.id] || []).length === 0 {(absender[q.id] || []).length === 0 ? (
? t("mailbox.noSenders") <p className="muted small">{t("mailbox.noSenders")}</p>
: (absender[q.id] || []).map((a) => { ) : (
const konto = konten.find((k) => k.id === a.account_id); (absender[q.id] || []).map((a) => (
return `${a.from_contains || "*"} → ${konto ? konto.name : t("mailbox.noAccount")}`; <SenderRow key={a.id} absender={a} konten={konten} t={t}
}).join(" · ")} onSave={(d) => absenderAendern(a, d)}
onDelete={() => absenderLoeschen(a)} />
))
)}
</div> </div>
<SenderForm konten={konten} onAdd={(d) => absenderAnlegen(q, d)} t={t} /> <SenderForm konten={konten} onAdd={(d) => absenderAnlegen(q, d)} t={t} />
{uebergangen.length > 0 && ( {uebergangen.length > 0 && (
@@ -256,6 +272,38 @@ export default function Postfaecher() {
); );
} }
/** 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 geaendert =
text !== (absender.from_contains || "") ||
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={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,
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. */ /** Absender einem Konto zuordnen – ohne das weiss Certain nicht, wohin gebucht wird. */
function SenderForm({ konten, onAdd, t }) { function SenderForm({ konten, onAdd, t }) {
const [von, setVon] = useState(""); const [von, setVon] = useState("");
+6
View File
@@ -1155,3 +1155,9 @@ tr.row-active { background: var(--surface-2); }
(.btn-pair gibt seinen Knoepfen flex:1 – dort wird ein umgebrochener (.btn-pair gibt seinen Knoepfen flex:1 – dort wird ein umgebrochener
Papierkorb zum roten Balken). */ Papierkorb zum roten Balken). */
.mail-actions { display: flex; flex-wrap: wrap; gap: var(--sp-2); margin-top: var(--sp-3); } .mail-actions { display: flex; flex-wrap: wrap; gap: var(--sp-2); margin-top: var(--sp-3); }
/* Zuordnung Erkennungstext -> Konto. Gestapelt, weil die Einstellungs-
spalte schmal ist; nebeneinander bricht der Text auf ein Wort je Zeile. */
.sender-row { padding: var(--sp-2) 0; border-top: 1px dashed var(--border); }
.sender-row input, .sender-row select { width: 100%; }
.sender-row select { margin-top: 4px; }