feat(PROJ-70): Self-Service IMAP-Rückholung (Backend)

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 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-07-07 00:36:44 +02:00
co-authored by Claude Sonnet 5
parent 7aa63e30ca
commit da79a56b3e
7 changed files with 379 additions and 5 deletions
+9 -4
View File
@@ -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,
})
}
+251
View File
@@ -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,
})
}
+6
View File
@@ -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))