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 || user == 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 || user == 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, }) }