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
+1
-1
@@ -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 |
|
||||
|
||||
<!-- Add features above this line -->
|
||||
|
||||
|
||||
@@ -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,
|
||||
"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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -144,7 +144,36 @@ 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.
|
||||
|
||||
Reference in New Issue
Block a user