Files
archivmail/features/PROJ-70-imap-rueckholung-self-service.md
T
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

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ß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.
  • 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.