Wichtig für PROJ-32-Duplikatserkennung. Bereits erfüllt durch 1:1-Weitergabe der rohen RFC-2822-Bytes an AppendToMailbox() ohne Reparse. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
8.7 KiB
id, title, status, created
| id | title | status | created |
|---|---|---|---|
| PROJ-70 | User-Self-Service IMAP-Rückholung (Archiv-Mail zurück ins Postfach) | Deployed | 2026-07-07 |
Problem
User können archivierte Mails nicht selbst wieder in ihr eigenes IMAP-Postfach
zurückholen. Aktuell existiert nur die Import-Richtung (Postfach → Archiv,
internal/imap/). Der eingebettete IMAP-Server des Archivs (internal/imapserver/)
ist bewusst read-only (server.go:342, server.go:609 — blockt APPEND/COPY/
MOVE/STORE) und muss read-only bleiben.
Lösung
Neue Rückholung schreibt NICHT ins Archiv, sondern liest eine archivierte Mail
lesend aus internal/storage und schreibt eine Kopie per IMAP APPEND in das
externe, echte Postfach des Users (Gmail/eigener Server), unter Nutzung der
bereits hinterlegten IMAP-Account-Credentials (internal/imap/, imap_accounts
Tabelle).
Vorgaben
- Archiv bleibt read-only — kein Schreibzugriff auf
internal/imapserver, ausschließlichstorage.Store.Load()lesend. - Standardmäßig deaktiviert. Rückholung ist pro User ein Opt-in-Feature: Schieberegler in den User-Einstellungen, den der User selbst aktivieren muss.
- Passwort-Bestätigung beim Aktivieren. Nach Umlegen des Schiebereglers muss der User sein aktuelles Login-Passwort erneut eingeben, bevor die Rückholung-Funktion freigeschaltet wird (analog sensiblen Aktionen wie Passwort-Änderung).
- Kein Admin-Override. Nur der Account-Owner selbst darf seine eigenen Mails zurückholen — kein Admin/domain_admin-Zugriff auf fremde Postfächer.
- Zielordner: fix
INBOX(kein Ordner-Picker in v1).
Tenant-/Ownership-Isolation (kritisch, siehe PROJ-61-Muster)
Zwei unabhängige Checks in EINER gemeinsamen Hilfsfunktion (nicht getrennt in Mail- und IMAP-Handler dupliziert):
- Gehört die angeforderte Mail-ID (
email_refs) dem anfragenden User (user_id/tenant_idAbgleich)? - Gehört das Ziel-IMAP-Konto (
imap_accounts.owner) demselben User/Tenant?
Acceptance Criteria
- Neues User-Setting
imap_restore_enabled(defaultfalse), umschaltbar per Schieberegler (z.B. in/imapoder Profil-Einstellungen). - Aktivierung erfordert erneute Passwort-Eingabe (bcrypt-Vergleich gegen hinterlegtes Passwort), sonst bleibt Regler aus/wird zurückgesetzt.
- Solange
imap_restore_enabled = false: Rückholung-Button/Endpoint nicht sichtbar bzw. Backend lehnt Anfrage ab (403), auch bei direktem API-Call. - Neuer Endpoint (z.B.
POST /api/mail/{id}/restore) mit den zwei Ownership-Checks aus obigem Abschnitt. - Neue Funktion in
internal/imap/(z.B.append.go) für IMAPAPPEND, Wiederverwendung vonConnect()/GetPassword()ausclient.go/store.go. - Fehlerfall abgefangen: Ziel-IMAP-Konto erlaubt kein Schreiben
(
NO [CANNOT]/NO [PERMISSIONDENIED]) → klare Fehlermeldung im Frontend, kein 500 mit leerem Body. - Jeder Rückhol-Vorgang wird im Audit-Log erfasst (neuer Entry-Typ
restore/retrieve), analog Export-Zugriff. - Archiv selbst bleibt unverändert nach Rückholung (keine Löschung, keine Änderung an SHA-256/Retention/Löschsperre) — reine Kopie.
- Frontend: Button in
/mail/[id](Detailansicht), nur sichtbar wennimap_restore_enabled = trueund mindestens ein IMAP-Konto hinterlegt. - Tenant-Isolation-Test: User A kann Mail von User B nicht in sein (A's) Postfach zurückholen, auch nicht bei manipulierter Mail-ID im Request.
- Message-ID bleibt erhalten. Die zurückgeholte Mail muss exakt
dieselbe
Message-ID(und alle anderen Header) wie die archivierte Mail behalten — wichtig für Duplikatserkennung (PROJ-32) und damit die Mail im Postfach als "dieselbe" erkennbar bleibt, nicht als neue Mail mit neuer ID. Umgesetzt durch 1:1-Weitergabe der rohen RFC-2822-Bytes ausstorage.Load()anAppendToMailbox()— kein Reparse/Neubau der Mail vor dem APPEND.mailparser.Parse()wird nur für den Ownership-Check (From/To/CC-Abgleich) genutzt,rawselbst bleibt unangetastet (verifiziert:internal/api/restore_handlers.go:155-182).
Non-Goals
- Kein Ordner-Picker für Zielordner (v1: fix INBOX).
- Kein Admin-Zugriff/Support-Override auf fremde Rückholungen.
- Keine Änderung am read-only-Verhalten des eingebetteten IMAP-Archivservers.
Offene Punkte
- Credentials-Reichweite: Ob das gespeicherte IMAP-Passwort auch Schreibrechte beim Provider hat, ist nicht vom Datenmodell garantiert — nur als Fehlerfall behandelbar, nicht vorab prüfbar ohne Testschreibversuch.
Implementation Notes
Backend (Commit da79a56):
internal/userstore/userstore.go: Spalteimap_restore_enabled BOOLEAN NOT NULL DEFAULT falsevia idempotenterinitSchema-Migration, plusGetRestoreEnabled/SetRestoreEnabled.internal/api/restore_handlers.go(neu):PATCH /api/auth/imap-restore— Body{ "enabled": bool, "current_password": string }, Aktivierung erfordert bcrypt-Gegenprobe des aktuellen Passworts (LDAP-Accounts abgelehnt, da kein lokales Passwort). Antwort:{ "ok": true, "imap_restore_enabled": bool }.POST /api/mails/{id}/restore— Body{ "account_id": number }, hinterrequireMailAccess(schließt superadmin/domain_admin explizit aus, SEC-29-Muster). Lehnt mit 403 ab, wennimap_restore_enabled=false. Führt beide Ownership-Checks (Mail- und Account-Zugehörigkeit) inrestoreAccessAllowed()zusammen (PROJ-61-Muster). Antwort bei Erfolg:{ "ok": true, "mailbox": "INBOX", "account": <id> }. Fehlerfälle: 400 (ungültige ID/fehlender account_id), 403 (Opt-in aus oder Zugriff verweigert), 404 (Mail/Account nicht gefunden), 422 (Zielpostfach lehnt APPEND ab —ErrAppendRejected, Klartext-Fehlermeldung), 502 (Verbindung zum Zielpostfach fehlgeschlagen).
internal/imap/append.go(neu):AppendToMailbox(host, port, tlsMode, username, password, mailbox, raw []byte) error, nutzt bestehendenConnect()/Conn-Typ, 2-Minuten-Deadline für den gesamten Connect+Login+Append-Vorgang.classifyAppendErrerkennt serverseitige Ablehnungen (CANNOT/PERMISSIONDENIED/ NOPERM/OVERQUOTA/READ-ONLY) und wrapped sie umErrAppendRejected.internal/audit/audit.go: neuerEventRestore = "restore", geloggt bei jedem Versuch (Erfolg und Fehlschlag) inkl. Mail-ID.- Kein Schreibzugriff auf
internal/imapserver(Archiv-Server) — nur lesendesstorage.Load()plus schreibender Client gegen das externe Postfach. - Nicht lokal kompilierbar/testbar (kein Go-Toolchain lokal) — Verifikation von
go vet/Kompilierung steht auf Test-Server (132) noch aus.
Frontend (Commit 4b3a4ef):
src/hooks/useImapRestore.ts(neu): State-Hook für den Opt-in-Schalter — AN löst Passwort-Dialog aus, AUS deaktiviert direkt ohne Passwort; Regler springt bei Fehler (falsches Passwort, LDAP-Account) auf AUS zurück.src/components/settings/ImapRestoreSection.tsx(neu): shadcnSwitch+Dialogmit Passwortfeld, Loading/Error/Success-States. Eingebunden insrc/app/settings/page.tsx,onChangedtriggertuseAuth().refresh().src/components/mail/RestoreMailButton.tsx(neu): Button "Zurück ins Postfach" insrc/app/mail/[id]/page.tsx— nur sichtbar beiimap_restore_enabled=trueund ≥1 IMAP-Konto; bei mehreren KontenSelectzur Zielwahl; zeigt die 422-Klartextfehlermeldung vom Server per shadcnAlert(kein Toast-System im Repo vorhanden).src/lib/api/users.ts:MeResponse.imap_restore_enabled?: boolean, neuesetImapRestore().src/lib/api/mail.ts:RestoreResult-Interface +restoreMail()(eigenerfetch, um daserror-JSON-Feld alsError.messagedurchzureichen).npx tsc --noEmit -p .lief sauber durch (Exit 0).
QA Test Results
Nur Smoke-Tests, kein vollständiger E2E-Test:
- 132: Backend/Frontend kompilieren, Dienste laufen,
imap_restore_enabled- Spalte vorhanden,PATCH /api/auth/imap-restoreundPOST /api/mails/{id}/restoreliefern 401 (Auth erforderlich) statt 404 — Endpoints korrekt registriert. - 131: identische Smoke-Test-Ergebnisse.
- Nicht getestet (bewusst, keine Produktiv-/Test-Zugangsdaten angefasst): echter Login-Flow, Passwort-Bestätigungs-Dialog, Restore-Button- Sichtbarkeit, 422-Fehlermeldung bei Postfach-Ablehnung, Tenant-Isolation-Test (User A → Postfach von User B). Empfehlung: mit dediziertem Testuser nachholen (QA Engineer oder integration-tester-Skill).
Deployment
- 2026-07-07, 192.168.1.132 (Test): 2x
update.sh(Self-Update-Timing, siehe PROJ-67/68), Migration lief, Diensteactive, Health-Check grün. - 2026-07-07, 192.168.1.131 (Produktiv): identisch, 2x
update.sh, Migration lief, Diensteactive, Health-Check grün. Kein Login mit Produktiv-Zugangsdaten durchgeführt.