From da79a56b3ee3c8edec24855d52cdf8f2b17e67f2 Mon Sep 17 00:00:00 2001 From: sysops Date: Tue, 7 Jul 2026 00:35:57 +0200 Subject: [PATCH] =?UTF-8?q?feat(PROJ-70):=20Self-Service=20IMAP-R=C3=BCckh?= =?UTF-8?q?olung=20(Backend)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neuer Opt-in-Endpunkt, mit dem User archivierte Mails per IMAP APPEND zurück in ihr eigenes externes Postfach (INBOX) kopieren können. Das Archiv selbst bleibt read-only (nur storage.Load(), kein Schreibzugriff auf internal/imapserver). - PATCH /api/auth/imap-restore: Opt-in-Flag umschalten, Aktivierung erfordert Passwort-Reverifikation. - POST /api/mails/{id}/restore: lädt Mail lesend, prüft Mail- und Account-Ownership in restoreAccessAllowed() (PROJ-61-Muster), APPEND via neuer internal/imap/append.go, kein Admin-Override. - Audit-Log-Eintrag (EventRestore) pro Versuch, erfolgreich und fehlgeschlagen. - imap_restore_enabled-Spalte (default false) via idempotenter initSchema-Migration. Co-Authored-By: Claude Sonnet 5 --- features/INDEX.md | 2 +- internal/api/auth_handlers.go | 13 +- internal/api/restore_handlers.go | 251 +++++++++++++++++++++++++++++++ internal/api/server.go | 6 + internal/audit/audit.go | 4 + internal/imap/append.go | 79 ++++++++++ internal/userstore/userstore.go | 29 ++++ 7 files changed, 379 insertions(+), 5 deletions(-) create mode 100644 internal/api/restore_handlers.go create mode 100644 internal/imap/append.go diff --git a/features/INDEX.md b/features/INDEX.md index e42099c..30b8718 100644 --- a/features/INDEX.md +++ b/features/INDEX.md @@ -85,7 +85,7 @@ | PROJ-67 | Manticore Search Upgrade 25.0.0 → 27.1.5 + Auto-Upgrade-Pfad | Deployed | [PROJ-67](PROJ-67-manticore-upgrade-25-zu-27.md) | 2026-07-05 | | PROJ-68 | sudo-Provisionierung für Admin-Dienststeuerung fehlte komplett | Deployed | [PROJ-68](PROJ-68-sudo-provisionierung-dienststeuerung.md) | 2026-07-05 | | PROJ-69 | Admin-Dashboard Tab-Gruppierung (2-Ebenen-Navigation) | Planned | [PROJ-69](PROJ-69-admin-tabs-gruppierung.md) | 2026-07-06 | -| PROJ-70 | User-Self-Service IMAP-Rückholung (Archiv-Mail zurück ins Postfach) | Planned | [PROJ-70](PROJ-70-imap-rueckholung-self-service.md) | 2026-07-07 | +| PROJ-70 | User-Self-Service IMAP-Rückholung (Archiv-Mail zurück ins Postfach) | In Progress | [PROJ-70](PROJ-70-imap-rueckholung-self-service.md) | 2026-07-07 | diff --git a/internal/api/auth_handlers.go b/internal/api/auth_handlers.go index 2329233..a6452d3 100644 --- a/internal/api/auth_handlers.go +++ b/internal/api/auth_handlers.go @@ -108,11 +108,16 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) { return } + // PROJ-70: expose the restore opt-in flag so the frontend can show/hide the + // "Zurück ins Postfach"-Button. Best effort — a lookup error defaults to false. + restoreEnabled, _ := s.users.GetRestoreEnabled(r.Context(), user.ID) + writeJSON(w, http.StatusOK, map[string]interface{}{ - "username": user.Username, - "email": user.Email, - "role": user.Role, - "list_page_size": user.ListPageSize, + "username": user.Username, + "email": user.Email, + "role": user.Role, + "list_page_size": user.ListPageSize, + "imap_restore_enabled": restoreEnabled, }) } diff --git a/internal/api/restore_handlers.go b/internal/api/restore_handlers.go new file mode 100644 index 0000000..6259925 --- /dev/null +++ b/internal/api/restore_handlers.go @@ -0,0 +1,251 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strconv" + + "archivmail/internal/audit" + "archivmail/internal/auth" + imapstore "archivmail/internal/imap" + "archivmail/internal/userstore" + "archivmail/pkg/mailparser" + "golang.org/x/crypto/bcrypt" +) + +// ── PROJ-70: Self-Service IMAP-Rückholung ───────────────────────────────── + +// handleSetRestoreEnabled toggles the per-user opt-in flag for the self-service +// IMAP restore feature. Enabling requires re-entering the current login password +// (bcrypt-verified), mirroring the sensitive-action pattern used for password +// changes. Disabling requires no confirmation. +// PATCH /api/auth/imap-restore +// Body: { "enabled": bool, "current_password": string } +func (s *Server) handleSetRestoreEnabled(w http.ResponseWriter, r *http.Request) { + sess := sessionFromCtx(r.Context()) + if sess == nil || sess.UserID == 0 { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + + var req struct { + Enabled bool `json:"enabled"` + CurrentPassword string `json:"current_password"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + user, err := s.users.GetByUsername(sess.Username) + if err != nil { + s.logger.Error("restore_toggle: user not found", "err", err, "username", sess.Username) + writeError(w, http.StatusInternalServerError, "user not found") + return + } + + // Enabling is the sensitive direction — require password re-verification. + if req.Enabled { + if user.Source == "ldap" { + writeError(w, http.StatusBadRequest, "password confirmation is not available for LDAP accounts") + return + } + hash, err := s.users.GetPasswordHash(r.Context(), user.ID) + if err != nil { + s.logger.Error("restore_toggle: get hash failed", "err", err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.CurrentPassword)); err != nil { + writeError(w, http.StatusForbidden, "current password is incorrect") + return + } + } + + if err := s.users.SetRestoreEnabled(r.Context(), user.ID, req.Enabled); err != nil { + s.logger.Error("restore_toggle: persist failed", "err", err) + writeError(w, http.StatusInternalServerError, "failed to update setting") + return + } + + detail := "imap_restore_disabled" + if req.Enabled { + detail = "imap_restore_enabled" + } + s.audlog.Log(audit.Entry{ + EventType: audit.EventUserMgmt, + Username: sess.Username, + TenantID: sess.TenantID, + IPAddress: s.remoteIP(r), + Success: true, + Detail: detail, + }) + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "ok": true, + "imap_restore_enabled": req.Enabled, + }) +} + +// handleRestoreMail copies an archived mail back into the user's own external +// IMAP mailbox (target: INBOX). It never modifies the archive — the mail is +// loaded read-only and appended to the remote mailbox via internal/imap. +// POST /api/mails/{id}/restore +// Body: { "account_id": number } +func (s *Server) handleRestoreMail(w http.ResponseWriter, r *http.Request) { + if s.imapStore == nil { + writeError(w, http.StatusServiceUnavailable, "IMAP not configured") + return + } + + sess := sessionFromCtx(r.Context()) + if sess == nil || sess.UserID == 0 { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + + id := r.PathValue("id") + // SEC: validate mail ID format to prevent path traversal. + if !isValidMailID(id) { + writeError(w, http.StatusBadRequest, "invalid mail id") + return + } + + var req struct { + AccountID int64 `json:"account_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.AccountID <= 0 { + writeError(w, http.StatusBadRequest, "account_id is required") + return + } + + // Opt-in gate: reject unless the user has explicitly enabled restore. + enabled, err := s.users.GetRestoreEnabled(r.Context(), sess.UserID) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to read user setting") + return + } + if !enabled { + writeError(w, http.StatusForbidden, "IMAP restore is not enabled for your account") + return + } + + user, err := s.users.GetByUsername(sess.Username) + if err != nil { + writeError(w, http.StatusInternalServerError, "user lookup failed") + return + } + + // Load the target IMAP account and run BOTH ownership checks in one place + // (PROJ-70 / PROJ-61 pattern): mail must belong to the user AND the target + // account must belong to the same user/tenant. + acc, err := s.imapStore.Get(r.Context(), req.AccountID) + if err != nil { + writeError(w, http.StatusNotFound, "IMAP account not found") + return + } + + // Read-only load of the archived mail — never mutates the archive. + raw, err := s.store.Load(id) + if err != nil { + s.logRestore(r, sess, id, false, "mail not found") + writeError(w, http.StatusNotFound, "mail not found") + return + } + pm, err := mailparser.Parse(raw) + if err != nil { + s.logRestore(r, sess, id, false, "parse error") + writeError(w, http.StatusInternalServerError, "failed to parse mail") + return + } + + if !s.restoreAccessAllowed(r.Context(), sess, user, id, pm, acc) { + s.logRestore(r, sess, id, false, "access denied") + writeError(w, http.StatusForbidden, "access denied") + return + } + + password, err := s.imapStore.GetPassword(r.Context(), acc.ID) + if err != nil { + s.logRestore(r, sess, id, false, "credential error") + writeError(w, http.StatusInternalServerError, "failed to read account credentials") + return + } + + // Append into the fixed target mailbox (INBOX, no folder picker in v1). + err = imapstore.AppendToMailbox(acc.Host, acc.Port, acc.TLS, acc.Username, password, "INBOX", raw) + if err != nil { + s.logRestore(r, sess, id, false, err.Error()) + if errors.Is(err, imapstore.ErrAppendRejected) { + // The server accepted login but refused the write (read-only mailbox, + // no insert permission, quota exceeded, ...). Surface a clear 4xx. + writeError(w, http.StatusUnprocessableEntity, + "das Zielpostfach hat die Rückholung abgelehnt: "+err.Error()) + return + } + s.logger.Error("restore: append failed", "err", err, "account", acc.ID, "mail", id) + writeError(w, http.StatusBadGateway, + "Verbindung zum Zielpostfach fehlgeschlagen: "+err.Error()) + return + } + + s.logRestore(r, sess, id, true, "restored to INBOX of account "+strconv.FormatInt(acc.ID, 10)) + writeJSON(w, http.StatusOK, map[string]interface{}{ + "ok": true, + "mailbox": "INBOX", + "account": acc.ID, + }) +} + +// restoreAccessAllowed performs the two independent ownership checks required by +// PROJ-70 in a single place so mail-side and account-side scope cannot drift +// apart (the PROJ-61 failure mode): +// 1. the archived mail belongs to the requesting user (via From/To/CC match) +// and, for tenant users, to the caller's tenant, and +// 2. the target IMAP account belongs to the same user and tenant. +// +// Deliberately no admin/domain_admin override — only the owner may restore their +// own mail into their own mailbox (spec: "Kein Admin-Override"). +func (s *Server) restoreAccessAllowed(ctx context.Context, sess *auth.Session, user *userstore.User, mailID string, pm *mailparser.ParsedMail, acc *imapstore.Account) bool { + // Check 2 (account ownership): the account must belong to the caller and, + // for tenant users, to the caller's tenant. + if acc.Owner != sess.Username { + return false + } + if !tenantAccessAllowed(sess, acc.TenantID) { + return false + } + + // Check 1 (mail ownership): tenant isolation first — a tenant user may only + // touch mails assigned to their own tenant. + if sess.TenantID != nil { + mailTenant, err := s.store.GetTenantForMail(ctx, mailID) + if err != nil || mailTenant == nil || *mailTenant != *sess.TenantID { + return false + } + } + // The mail must actually involve the user (From/To/CC). + if !mailBelongsToUser(pm, user.Email) { + return false + } + return true +} + +// logRestore writes an audit entry for a restore attempt (success or failure). +func (s *Server) logRestore(r *http.Request, sess *auth.Session, mailID string, success bool, detail string) { + s.audlog.Log(audit.Entry{ + EventType: audit.EventRestore, + Username: sess.Username, + TenantID: sess.TenantID, + IPAddress: s.remoteIP(r), + MailID: mailID, + Success: success, + Detail: detail, + }) +} diff --git a/internal/api/server.go b/internal/api/server.go index d026f66..f5219a3 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -315,6 +315,12 @@ func (s *Server) routes() { s.mux.HandleFunc("GET /api/imap/{id}/progress", s.auth(s.handleImapProgress)) s.mux.HandleFunc("POST /api/imap/{id}/sync", s.auth(s.handleSyncNow)) + // PROJ-70: Self-Service IMAP-Rückholung. Toggle requires re-auth; restore is + // gated on the opt-in flag and enforces mail+account ownership. requireMailAccess + // keeps admins/superadmins out (they must not read/restore mail content). + s.mux.HandleFunc("PATCH /api/auth/imap-restore", s.auth(s.handleSetRestoreEnabled)) + s.mux.HandleFunc("POST /api/mails/{id}/restore", s.auth(s.requireMailAccess(s.handleRestoreMail))) + // POP3 routes (accessible to all authenticated users) s.mux.HandleFunc("GET /api/pop3", s.auth(s.handleListPop3)) s.mux.HandleFunc("POST /api/pop3", s.auth(s.handleCreatePop3)) diff --git a/internal/audit/audit.go b/internal/audit/audit.go index c9a8c04..784dfc7 100644 --- a/internal/audit/audit.go +++ b/internal/audit/audit.go @@ -23,6 +23,10 @@ const ( EventExport = "export" EventUserMgmt = "user_mgmt" EventOCRDownload = "mail:ocr_download" // PROJ-44: extracted OCR text downloaded + // EventRestore (PROJ-70): a user restored an archived mail back into their own + // external IMAP mailbox via the self-service restore feature. Logged on every + // attempt (success and failure) for GoBD/forensic traceability. + EventRestore = "restore" EventDSGVORequest = "dsgvo_request" // PROJ-50: DSGVO Löschersuchen erfasst/bearbeitet // EventReconciliationAnomaly (PROJ-52): a source's newly-archived count for a // day dropped significantly below its trailing 7-day average, or the IMAP diff --git a/internal/imap/append.go b/internal/imap/append.go new file mode 100644 index 0000000..9a9d663 --- /dev/null +++ b/internal/imap/append.go @@ -0,0 +1,79 @@ +package imap + +import ( + "errors" + "fmt" + "strings" + "time" +) + +// appendTimeout bounds the whole connect+login+append round-trip so a stalled +// or malicious remote server cannot block the request goroutine indefinitely. +const appendTimeout = 2 * time.Minute + +// ErrAppendRejected signals that the remote IMAP server accepted the connection +// and login but refused the APPEND itself — e.g. read-only mailbox, missing +// insert permission or an exceeded quota (NO [CANNOT] / NO [PERMISSIONDENIED] / +// NO [OVERQUOTA]). Callers should surface this to the user as a 4xx with a clear +// message rather than a generic 500 (PROJ-70). +var ErrAppendRejected = errors.New("imap: append rejected by remote server") + +// AppendToMailbox connects to the given IMAP account, logs in and appends the +// raw RFC-2822 message into the target mailbox (v1: always "INBOX"). It is used +// by the self-service restore feature (PROJ-70) and never touches the archive's +// own read-only IMAP server — it only writes to the user's external mailbox. +// +// Distinguishable failure classes: +// - connect/login errors are returned wrapped as-is, +// - a server-side APPEND rejection is returned wrapped around ErrAppendRejected +// so the API layer can map it to a 4xx with the server's own message. +func AppendToMailbox(host string, port int, tlsMode, username, password, mailbox string, raw []byte) error { + if mailbox == "" { + mailbox = "INBOX" + } + + c, err := Connect(host, port, tlsMode) + if err != nil { + return fmt.Errorf("imap append: connect: %w", err) + } + defer c.Close() + + // Bound the entire operation with a single deadline on the raw connection. + _ = c.raw.SetDeadline(time.Now().Add(appendTimeout)) + defer c.ClearDeadline() + + if err := c.Login(username, password).Wait(); err != nil { + return fmt.Errorf("imap append: login: %w", err) + } + defer func() { _ = c.Logout().Wait() }() + + appendCmd := c.Append(mailbox, int64(len(raw)), nil) + if _, err := appendCmd.Write(raw); err != nil { + return classifyAppendErr(err) + } + if err := appendCmd.Close(); err != nil { + return classifyAppendErr(err) + } + if _, err := appendCmd.Wait(); err != nil { + return classifyAppendErr(err) + } + return nil +} + +// classifyAppendErr wraps server-side rejections (permission/quota/read-only) +// around ErrAppendRejected so the API layer can respond with a 4xx instead of a +// generic 500. Detection is based on the well-known IMAP response codes that a +// server returns in a tagged NO response (RFC 3501/5530), matched in the error +// text since go-imap surfaces them there. +func classifyAppendErr(err error) error { + if err == nil { + return nil + } + upper := strings.ToUpper(err.Error()) + for _, code := range []string{"CANNOT", "PERMISSIONDENIED", "NOPERM", "OVERQUOTA", "READ-ONLY", "READONLY", "ALERT"} { + if strings.Contains(upper, code) { + return fmt.Errorf("%w: %s", ErrAppendRejected, err.Error()) + } + } + return fmt.Errorf("imap append: %w", err) +} diff --git a/internal/userstore/userstore.go b/internal/userstore/userstore.go index 70b80a3..0ba726b 100644 --- a/internal/userstore/userstore.go +++ b/internal/userstore/userstore.go @@ -144,9 +144,38 @@ func (s *Store) initSchema(ctx context.Context) error { _, err = s.pool.Exec(ctx, ` ALTER TABLE users ADD COLUMN IF NOT EXISTS tokens_valid_after TIMESTAMPTZ; `) + if err != nil { + return err + } + // PROJ-70: Opt-in-Flag für die Self-Service-IMAP-Rückholung (Archiv-Mail zurück + // ins eigene Postfach). Default false — der Nutzer muss die Funktion selbst per + // Schieberegler mit Passwort-Bestätigung freischalten. + _, err = s.pool.Exec(ctx, ` + ALTER TABLE users ADD COLUMN IF NOT EXISTS imap_restore_enabled BOOLEAN NOT NULL DEFAULT false; + `) return err } +// GetRestoreEnabled reports whether the user has opted in to the self-service +// IMAP restore feature (PROJ-70). +func (s *Store) GetRestoreEnabled(ctx context.Context, userID int64) (bool, error) { + var enabled bool + err := s.pool.QueryRow(ctx, `SELECT imap_restore_enabled FROM users WHERE id = $1`, userID).Scan(&enabled) + if err != nil { + return false, fmt.Errorf("userstore: get restore enabled: %w", err) + } + return enabled, nil +} + +// SetRestoreEnabled toggles the self-service IMAP restore opt-in flag (PROJ-70). +func (s *Store) SetRestoreEnabled(ctx context.Context, userID int64, enabled bool) error { + _, err := s.pool.Exec(ctx, `UPDATE users SET imap_restore_enabled = $1 WHERE id = $2`, enabled, userID) + if err != nil { + return fmt.Errorf("userstore: set restore enabled: %w", err) + } + return nil +} + // Close closes the underlying connection pool. func (s *Store) Close() error { s.pool.Close()