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
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user