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
+1 -1
View File
@@ -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 -->
+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))
+4
View File
@@ -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
+79
View File
@@ -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)
}
+29
View File
@@ -144,9 +144,38 @@ 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.
func (s *Store) Close() error {
s.pool.Close()