From 7b087247a587ac605544a9fbc16caf9e4bc1bd1a Mon Sep 17 00:00:00 2001 From: Guido Date: Sat, 22 Aug 2026 20:30:24 +0200 Subject: [PATCH] feat: Absender-Zuordnungen bearbeiten und loeschen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/routers/mail.py | 17 +++++++ backend/tests/test_mail_import.py | 18 ++++++++ web/src/api.js | 1 + web/src/locales/de.json | 4 +- web/src/locales/en.json | 4 +- web/src/pages/settings/Postfaecher.jsx | 62 +++++++++++++++++++++++--- web/src/styles.css | 6 +++ 7 files changed, 103 insertions(+), 9 deletions(-) diff --git a/backend/app/routers/mail.py b/backend/app/routers/mail.py index 23a1c58..e6d5d26 100644 --- a/backend/app/routers/mail.py +++ b/backend/app/routers/mail.py @@ -121,6 +121,23 @@ def create_sender( 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) 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)) diff --git a/backend/tests/test_mail_import.py b/backend/tests/test_mail_import.py index 6c4ccd0..6e82903 100644 --- a/backend/tests/test_mail_import.py +++ b/backend/tests/test_mail_import.py @@ -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"] assert treffer, "Die Werte müssen auch aus der weitergeleiteten Mail lesbar sein" 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"]] diff --git a/web/src/api.js b/web/src/api.js index fa68f07..132f139 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -182,6 +182,7 @@ export const api = { fetchMailSource: (id) => request(`/mail/sources/${id}/fetch`, { method: "POST" }), listMailSenders: (id) => request(`/mail/sources/${id}/senders`), 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" }), // Kategorien (Gruppen + Kategorien) diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 6be29ff..4ffc90c 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -533,6 +533,8 @@ "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.", "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." } } diff --git a/web/src/locales/en.json b/web/src/locales/en.json index cf2d7b8..065591a 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -533,6 +533,8 @@ "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.", "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." } } diff --git a/web/src/pages/settings/Postfaecher.jsx b/web/src/pages/settings/Postfaecher.jsx index 39e4da4..0f9a990 100644 --- a/web/src/pages/settings/Postfaecher.jsx +++ b/web/src/pages/settings/Postfaecher.jsx @@ -108,6 +108,19 @@ export default function Postfaecher() { 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); @@ -132,13 +145,16 @@ export default function Postfaecher() { ? `${t("mailbox.lastError")}: ${q.last_error}` : q.last_poll_at ? t("mailbox.lastPoll") : t("mailbox.neverPolled")} -
- {(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(" · ")} +
+ {(absender[q.id] || []).length === 0 ? ( +

{t("mailbox.noSenders")}

+ ) : ( + (absender[q.id] || []).map((a) => ( + absenderAendern(a, d)} + onDelete={() => absenderLoeschen(a)} /> + )) + )}
absenderAnlegen(q, d)} t={t} /> {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 ( +
+ setText(e.target.value)} + placeholder={t("mailbox.fromPlaceholder")} /> + +
+ {geaendert && ( + + )} + +
+
+ ); +} + /** Absender einem Konto zuordnen – ohne das weiss Certain nicht, wohin gebucht wird. */ function SenderForm({ konten, onAdd, t }) { const [von, setVon] = useState(""); diff --git a/web/src/styles.css b/web/src/styles.css index d898d9b..be4ff23 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1155,3 +1155,9 @@ tr.row-active { background: var(--surface-2); } (.btn-pair gibt seinen Knoepfen flex:1 – dort wird ein umgebrochener Papierkorb zum roten Balken). */ .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; }