Files
archivmail/features/PROJ-70-imap-rueckholung-self-service.md
sysopsandClaude Sonnet 5 e221fcf63f docs(PROJ-70): Message-ID-Erhalt als Acceptance Criterion festhalten
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>
2026-07-07 01:42:02 +02:00

165 lines
8.7 KiB
Markdown

---
id: PROJ-70
title: User-Self-Service IMAP-Rückholung (Archiv-Mail zurück ins Postfach)
status: Deployed
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.
- [x] **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
aus `storage.Load()` an `AppendToMailbox()` — kein Reparse/Neubau der
Mail vor dem APPEND. `mailparser.Parse()` wird nur für den
Ownership-Check (From/To/CC-Abgleich) genutzt, `raw` selbst 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`: 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": <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 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.
- 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): shadcn `Switch` +
`Dialog` mit Passwortfeld, Loading/Error/Success-States. Eingebunden in
`src/app/settings/page.tsx`, `onChanged` triggert `useAuth().refresh()`.
- `src/components/mail/RestoreMailButton.tsx` (neu): Button "Zurück ins
Postfach" in `src/app/mail/[id]/page.tsx` — nur sichtbar bei
`imap_restore_enabled=true` und ≥1 IMAP-Konto; bei mehreren Konten `Select`
zur Zielwahl; zeigt die 422-Klartextfehlermeldung vom Server per shadcn
`Alert` (kein Toast-System im Repo vorhanden).
- `src/lib/api/users.ts`: `MeResponse.imap_restore_enabled?: boolean`, neue
`setImapRestore()`.
- `src/lib/api/mail.ts`: `RestoreResult`-Interface + `restoreMail()` (eigener
`fetch`, um das `error`-JSON-Feld als `Error.message` durchzureichen).
- `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-restore` und
`POST /api/mails/{id}/restore` liefern 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, Dienste `active`, Health-Check grün.
- 2026-07-07, 192.168.1.131 (Produktiv): identisch, 2x `update.sh`, Migration
lief, Dienste `active`, Health-Check grün. Kein Login mit
Produktiv-Zugangsdaten durchgeführt.