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
+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)
}