diff --git a/features/PROJ-70-imap-rueckholung-self-service.md b/features/PROJ-70-imap-rueckholung-self-service.md new file mode 100644 index 0000000..4d3f736 --- /dev/null +++ b/features/PROJ-70-imap-rueckholung-self-service.md @@ -0,0 +1,124 @@ +--- +id: PROJ-70 +title: User-Self-Service IMAP-Rückholung (Archiv-Mail zurück ins Postfach) +status: Planned +created: 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ßlich `storage.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): + +1. Gehört die angeforderte Mail-ID (`email_refs`) dem anfragenden User + (`user_id`/`tenant_id` Abgleich)? +2. Gehört das Ziel-IMAP-Konto (`imap_accounts.owner`) demselben User/Tenant? + +## Acceptance Criteria + +- [ ] Neues User-Setting `imap_restore_enabled` (default `false`), umschaltbar + per Schieberegler (z.B. in `/imap` oder 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 IMAP `APPEND`, + Wiederverwendung von `Connect()`/`GetPassword()` aus `client.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 wenn + `imap_restore_enabled = true` und 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. + +## 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`: Spalte `imap_restore_enabled BOOLEAN NOT NULL + DEFAULT false` via idempotenter `initSchema`-Migration, plus `GetRestoreEnabled`/ + `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 }`, hinter + `requireMailAccess` (schließt superadmin/domain_admin explizit aus, SEC-29-Muster). + Lehnt mit 403 ab, wenn `imap_restore_enabled=false`. Führt beide Ownership-Checks + (Mail- und Account-Zugehörigkeit) in `restoreAccessAllowed()` zusammen (PROJ-61-Muster). + Antwort bei Erfolg: `{ "ok": true, "mailbox": "INBOX", "account": }`. + 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 bestehenden `Connect()`/`Conn`-Typ, + 2-Minuten-Deadline für den gesamten Connect+Login+Append-Vorgang. + `classifyAppendErr` erkennt serverseitige Ablehnungen (CANNOT/PERMISSIONDENIED/ + NOPERM/OVERQUOTA/READ-ONLY) und wrapped sie um `ErrAppendRejected`. +- `internal/audit/audit.go`: neuer `EventRestore = "restore"`, geloggt bei jedem + Versuch (Erfolg und Fehlschlag) inkl. Mail-ID. +- Kein Schreibzugriff auf `internal/imapserver` (Archiv-Server) — nur lesendes + `storage.Load()` plus schreibender Client gegen das externe Postfach. +- **Offen (Frontend, noch nicht umgesetzt):** Schieberegler + Passwort-Dialog für + Opt-in, Restore-Button in `/mail/[id]`, Auswahl des Ziel-IMAP-Kontos falls + mehrere hinterlegt sind. `GET /api/auth/me` liefert bereits `imap_restore_enabled` + im Response (siehe `auth_handlers.go`), Frontend kann das für Sichtbarkeit nutzen. +- Nicht lokal kompilierbar/testbar (kein Go-Toolchain lokal) — Verifikation von + `go vet`/Kompilierung steht auf Test-Server (132) noch aus. + +## QA Test Results +_wird nach Umsetzung ergänzt._ + +## Deployment +_wird nach Abschluss ergänzt._