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>
252 lines
8.3 KiB
Go
252 lines
8.3 KiB
Go
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,
|
|
})
|
|
}
|