docs(PROJ-70): Feature-Spec-Datei nachtragen (versehentlich nicht committed)
Datei war durch .git/info/exclude (features/) von "git add -u" ausgenommen, INDEX-Eintrag existierte bereits, die eigentliche Spec-Datei fehlte im Commit. Enthält jetzt auch die Implementation Notes zum Backend. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
da79a56b3e
commit
c00bf27fea
@@ -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": <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.
|
||||||
|
- **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._
|
||||||
Reference in New Issue
Block a user