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:
co-authored by
Claude Sonnet 5
parent
7aa63e30ca
commit
da79a56b3e
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user