fix: Crash-Robustheit + Performance in Backend und Frontend härten

Backend: recover() in allen langlebigen Goroutinen (neues internal/safego-
Paket), MIME-Multipart-Tiefenlimit gegen Stack-Overflow, IMAP-Zeilenlängen-
und FETCH-Result-Limits gegen OOM, MBOX-Buffer-Aliasing-Bug (Datenkorruption
beim Import), ungeprüfte Type Assertions abgesichert, Data Race im
API-Key-Rate-Limiter behoben, SMTP-Session-Panic führt jetzt zu 451-Retry
statt Prozessabsturz. Performance: O(n²)-String-Concat in Mailparser und
IMAP-Parser durch strings.Builder ersetzt.

Frontend: Error Boundaries für Root und Mail-Detailansicht ergänzt (gab es
vorher nicht), zahlreiche Guards gegen nil-Slices aus dem Backend-JSON die
sonst .map()/.length-Crashes/White-Screens auslösten, defekte JSON-Antworten
in api/core.ts abgefangen, zwei React-Key-Bugs bei löschbaren Listen
korrigiert.

Verifiziert auf 192.168.1.132: Build und Tests der geänderten Pakete
fehlerfrei, keine Regressionen gegenüber vorbestehendem Stand.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019j28kGcaJAhBnrYX34hGdt
This commit is contained in:
sysops
2026-08-05 13:39:00 +02:00
co-authored by Claude Sonnet 5
parent 0fa4fad49d
commit 798cb2817c
35 changed files with 502 additions and 112 deletions
+4 -1
View File
@@ -9,6 +9,7 @@ import (
"archivmail/internal/auth"
imapstore "archivmail/internal/imap"
"archivmail/internal/safego"
"archivmail/internal/userstore"
)
@@ -211,7 +212,9 @@ func (s *Server) handleStartImport(w http.ResponseWriter, r *http.Request) {
return
}
go s.imapImporter.Run(context.Background(), id)
safego.Go(s.logger, "imap import", func() {
s.imapImporter.Run(context.Background(), id)
})
// Return current account state (status will switch to "running" shortly)
acc.Status = "running"
+5 -3
View File
@@ -8,6 +8,7 @@ import (
"archivmail/internal/audit"
"archivmail/internal/mailer"
"archivmail/internal/safego"
"archivmail/internal/tokenstore"
"archivmail/internal/userstore"
)
@@ -55,11 +56,12 @@ func (s *Server) handleCreateInvite(w http.ResponseWriter, r *http.Request) {
tenantName = t.Name
}
}
go func() {
email := body.Email
safego.Go(s.logger, "send invite mail", func() {
html := mailer.InviteHTML(s.fqdn, token, tenantName)
txt := mailer.InviteText(s.fqdn, token, tenantName)
_ = s.mailer.Send(body.Email, "Einladung zu archivmail", html, txt)
}()
_ = s.mailer.Send(email, "Einladung zu archivmail", html, txt)
})
}
if s.audlog != nil {
+9 -7
View File
@@ -7,6 +7,7 @@ import (
"archivmail/internal/audit"
"archivmail/internal/mailer"
"archivmail/internal/safego"
"archivmail/internal/tokenstore"
"archivmail/internal/userstore"
)
@@ -79,11 +80,12 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
if err != nil {
// SEC: token already consumed — response is identical to success to prevent
// enumeration of whether the email address already existed.
go func() {
email := body.Email
safego.Go(s.logger, "send already-registered mail", func() {
html := mailer.AlreadyRegisteredHTML(s.fqdn)
txt := mailer.AlreadyRegisteredText(s.fqdn)
_ = s.mailer.Send(body.Email, "archivmail Registrierungsversuch", html, txt)
}()
_ = s.mailer.Send(email, "archivmail Registrierungsversuch", html, txt)
})
writeJSON(w, http.StatusOK, map[string]string{"message": signupMsg})
return
}
@@ -100,11 +102,11 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
}
// Send verification email.
go func() {
safego.Go(s.logger, "send verify mail", func() {
html := mailer.VerifyEmailHTML(s.fqdn, token, u.Username)
txt := mailer.VerifyEmailText(s.fqdn, token, u.Username)
_ = s.mailer.Send(u.Email, "archivmail E-Mail bestätigen", html, txt)
}()
})
if s.audlog != nil {
s.audlog.Log(audit.Entry{
@@ -193,11 +195,11 @@ func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) {
return
}
go func() {
safego.Go(s.logger, "send password-reset mail", func() {
html := mailer.ResetPasswordHTML(s.fqdn, token, u.Username)
txt := mailer.ResetPasswordText(s.fqdn, token, u.Username)
_ = s.mailer.Send(u.Email, "archivmail Passwort zurücksetzen", html, txt)
}()
})
if s.audlog != nil {
s.audlog.Log(audit.Entry{
+4 -1
View File
@@ -9,6 +9,7 @@ import (
"archivmail/internal/auth"
pop3store "archivmail/internal/pop3"
"archivmail/internal/safego"
"archivmail/internal/userstore"
)
@@ -212,7 +213,9 @@ func (s *Server) handleStartPop3Import(w http.ResponseWriter, r *http.Request) {
return
}
go s.pop3Importer.Run(context.Background(), id)
safego.Go(s.logger, "pop3 import", func() {
s.pop3Importer.Run(context.Background(), id)
})
// Return current account state (status will switch to "running" shortly)
acc.Status = "running"
+15 -3
View File
@@ -9,6 +9,7 @@ import (
"sync"
"archivmail/internal/index"
"archivmail/internal/safego"
"archivmail/pkg/mailparser"
)
@@ -94,7 +95,12 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
var allMessages [][]byte
for _, e := range entries {
if e.isMbox {
msgs := mailparser.SplitMbox(e.data)
msgs, err := mailparser.SplitMboxErr(e.data)
if err != nil {
// Partial result: import what was parsed, but make the
// truncation visible instead of losing mails silently.
s.logger.Warn("upload: mbox scan incomplete", "messages", len(msgs), "err", err)
}
allMessages = append(allMessages, msgs...)
} else {
allMessages = append(allMessages, e.data)
@@ -113,7 +119,9 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
tenantID := tenantFromCtx(r.Context())
// Run import in background
go s.runUploadJob(job, allMessages, tenantID)
safego.Go(s.logger, "upload import job", func() {
s.runUploadJob(job, allMessages, tenantID)
})
writeJSON(w, http.StatusAccepted, map[string]string{"job_id": jobID})
}
@@ -126,7 +134,11 @@ func (s *Server) handleUploadProgress(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "job not found")
return
}
job := val.(*UploadJob)
job, ok := val.(*UploadJob)
if !ok || job == nil {
writeError(w, http.StatusNotFound, "job not found")
return
}
writeJSON(w, http.StatusOK, job.snapshot())
}
+9 -1
View File
@@ -52,6 +52,7 @@ type APIKeyRow struct {
// tokenBucket implements a simple per-key token-bucket rate limiter.
type tokenBucket struct {
mu sync.Mutex // guards tokens/lastCheck — concurrent requests share one bucket
tokens float64
limit float64
lastCheck time.Time
@@ -128,7 +129,14 @@ func (m *APIKeyMiddleware) allow(keyID int64, limitPerMin int) bool {
limit: limit,
lastCheck: now,
})
bucket := val.(*tokenBucket)
bucket, ok := val.(*tokenBucket)
if !ok || bucket == nil {
// Should never happen — fail closed rather than panic.
return false
}
bucket.mu.Lock()
defer bucket.mu.Unlock()
elapsed := now.Sub(bucket.lastCheck).Seconds()
bucket.lastCheck = now
+12 -3
View File
@@ -10,6 +10,7 @@ import (
"time"
"archivmail/internal/audit"
"archivmail/internal/safego"
imapv2 "github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapclient"
@@ -107,7 +108,9 @@ func (s *Scheduler) TriggerSync(ctx context.Context, accountID int64) error {
s.running[accountID] = true
s.mu.Unlock()
go s.runSyncWithRetry(context.Background(), accountID)
safego.Go(s.logger, "imap manual sync", func() {
s.runSyncWithRetry(context.Background(), accountID)
})
return nil
}
@@ -121,7 +124,10 @@ func (s *Scheduler) loop(ctx context.Context) {
case <-ctx.Done():
return
case <-ticker.C:
s.checkAccounts(ctx)
// A panic in one tick must not stop the scheduler permanently.
safego.Run(s.logger, "imap scheduler tick", func() {
s.checkAccounts(ctx)
})
}
}
}
@@ -166,7 +172,10 @@ func (s *Scheduler) checkAccounts(ctx context.Context) {
s.logger.Info("imap scheduler: starting scheduled sync",
"account_id", acc.ID, "name", acc.Name)
go s.runSyncWithRetry(context.Background(), acc.ID)
accID := acc.ID
safego.Go(s.logger, "imap scheduled sync", func() {
s.runSyncWithRetry(context.Background(), accID)
})
}
}
}
+67 -21
View File
@@ -10,6 +10,7 @@ import (
"bufio"
"context"
"crypto/tls"
"errors"
"fmt"
"log/slog"
"net"
@@ -31,6 +32,9 @@ const (
maxConnsPerUser = 5
readBufferSize = 8192
maxLineLength = 65536
// maxFetchResults caps how many messages a single sequence set may expand
// to, bounding per-request memory for "FETCH 1:*" on huge mailboxes.
maxFetchResults = 50000
)
// tenantIMAPModeGetter is satisfied by tenantstore.Store.
@@ -199,6 +203,7 @@ func (s *Server) handleConnection(conn net.Conn) {
defer conn.Close()
remoteAddr := conn.RemoteAddr().String()
s.logger.Debug("imapserver: new connection", "remote", remoteAddr)
sess := &session{
@@ -209,6 +214,19 @@ func (s *Server) handleConnection(conn net.Conn) {
state: stateNotAuth,
}
// A panic while parsing a malformed/hostile client command must only kill
// this connection, never the whole archivmail process. The per-user
// connection counter is released here as well so it cannot leak.
defer func() {
if r := recover(); r != nil {
if sess.username != "" {
s.releaseConn(sess.username)
}
s.logger.Error("imapserver: recovered from panic in connection handler",
"remote", remoteAddr, "panic", fmt.Sprintf("%v", r))
}
}()
// Send greeting — use FQDN if configured (RFC 3501 §7.1)
fqdn := s.cfg.FQDN
if fqdn == "" {
@@ -282,12 +300,26 @@ type mailEntry struct {
HasAttach bool
}
// readLine reads a single CRLF-terminated command line. The length is capped at
// maxLineLength: a client that never sends a newline would otherwise make the
// buffer grow without bound until the process is killed by the OOM killer.
func (sess *session) readLine() (string, error) {
line, err := sess.reader.ReadString('\n')
if err != nil {
var buf []byte
for {
chunk, err := sess.reader.ReadSlice('\n')
if len(buf)+len(chunk) > maxLineLength {
return "", fmt.Errorf("imapserver: command line exceeds %d bytes", maxLineLength)
}
buf = append(buf, chunk...)
if err == nil {
break
}
if errors.Is(err, bufio.ErrBufferFull) {
continue
}
return "", err
}
return strings.TrimRight(line, "\r\n"), nil
return strings.TrimRight(string(buf), "\r\n"), nil
}
func (sess *session) writeResponse(line string) {
@@ -949,26 +981,29 @@ func parseFetchItems(items string) []string {
items = strings.TrimSuffix(items, ")")
var result []string
current := ""
// strings.Builder instead of `current += string(ch)`: the latter reallocates
// and copies on every rune, i.e. O(n²) for a command line that may be up to
// maxLineLength bytes long.
var current strings.Builder
bracketDepth := 0
for _, ch := range items {
if ch == '[' {
bracketDepth++
current += string(ch)
current.WriteRune(ch)
} else if ch == ']' {
bracketDepth--
current += string(ch)
current.WriteRune(ch)
} else if ch == ' ' && bracketDepth == 0 {
if current != "" {
result = append(result, current)
current = ""
if current.Len() > 0 {
result = append(result, current.String())
current.Reset()
}
} else {
current += string(ch)
current.WriteRune(ch)
}
}
if current != "" {
result = append(result, current)
if current.Len() > 0 {
result = append(result, current.String())
}
return result
@@ -976,15 +1011,22 @@ func parseFetchItems(items string) []string {
// parseSequenceSet parses an IMAP sequence set (e.g. "1:*", "1,3:5", "1")
// and returns the expanded list of numbers.
//
// The expansion is capped at maxFetchResults: "FETCH 1:*" on a mailbox with
// millions of mails would otherwise allocate a huge slice plus dedup map per
// request — several concurrent clients could exhaust the machine's memory.
func parseSequenceSet(set string, maxVal uint32) []uint32 {
if maxVal == 0 {
return nil
}
var result []uint32
result := make([]uint32, 0, 64)
seen := make(map[uint32]bool)
for _, part := range strings.Split(set, ",") {
if len(result) >= maxFetchResults {
break
}
part = strings.TrimSpace(part)
if part == "" {
continue
@@ -998,6 +1040,9 @@ func parseSequenceSet(set string, maxVal uint32) []uint32 {
start, end = end, start
}
for i := start; i <= end; i++ {
if len(result) >= maxFetchResults {
break
}
if !seen[i] {
result = append(result, i)
seen[i] = true
@@ -1151,23 +1196,24 @@ func stripQuotes(s string) string {
func splitIMAPArgs(s string) []string {
var parts []string
current := ""
// See parseFetchItems: strings.Builder avoids O(n²) rune-wise concatenation.
var current strings.Builder
inQuote := false
for _, ch := range s {
if ch == '"' {
inQuote = !inQuote
current += string(ch)
current.WriteRune(ch)
} else if ch == ' ' && !inQuote {
if current != "" {
parts = append(parts, current)
current = ""
if current.Len() > 0 {
parts = append(parts, current.String())
current.Reset()
}
} else {
current += string(ch)
current.WriteRune(ch)
}
}
if current != "" {
parts = append(parts, current)
if current.Len() > 0 {
parts = append(parts, current.String())
}
return parts
}
+14
View File
@@ -1,6 +1,7 @@
package index
import (
"fmt"
"log/slog"
"sync"
)
@@ -83,7 +84,20 @@ func (w *TenantIndexWorker) QueueLen() int {
}
func (w *TenantIndexWorker) indexDoc(doc MailDocument) {
// A panic while indexing a single document must not kill the long-running
// worker goroutine (and with it the whole process).
defer func() {
if r := recover(); r != nil {
w.logger.Error("tenant index worker: recovered from panic",
"id", doc.ID, "tenant_id", doc.TenantID, "panic", fmt.Sprintf("%v", r))
}
}()
idx := w.mgr.ForTenant(doc.TenantID)
if idx == nil {
w.logger.Error("tenant index worker: no indexer for tenant", "id", doc.ID, "tenant_id", doc.TenantID)
return
}
if err := idx.IndexSync(doc); err != nil {
w.logger.Error("tenant index worker: index failed", "id", doc.ID, "tenant_id", doc.TenantID, "err", err)
}
+18 -6
View File
@@ -1,6 +1,7 @@
package index
import (
"fmt"
"log/slog"
"sync"
)
@@ -53,9 +54,7 @@ func (w *IndexWorker) Start() {
// Channel closed, drain complete
return
}
if err := w.idx.IndexSync(doc); err != nil {
w.logger.Error("index worker: index failed", "id", doc.ID, "err", err)
}
w.indexDoc(doc, "")
case <-w.done:
// Drain remaining items in the queue before exiting
for {
@@ -64,9 +63,7 @@ func (w *IndexWorker) Start() {
if !ok {
return
}
if err := w.idx.IndexSync(doc); err != nil {
w.logger.Error("index worker: index failed (drain)", "id", doc.ID, "err", err)
}
w.indexDoc(doc, " (drain)")
default:
return
}
@@ -76,6 +73,21 @@ func (w *IndexWorker) Start() {
}()
}
// indexDoc indexes a single document. A panic in the backend must not kill the
// long-running worker goroutine (and with it the whole process).
func (w *IndexWorker) indexDoc(doc MailDocument, phase string) {
defer func() {
if r := recover(); r != nil {
w.logger.Error("index worker: recovered from panic"+phase,
"id", doc.ID, "panic", fmt.Sprintf("%v", r))
}
}()
if err := w.idx.IndexSync(doc); err != nil {
w.logger.Error("index worker: index failed"+phase, "id", doc.ID, "err", err)
}
}
// Stop signals the worker to drain remaining items and stop. It blocks until
// the worker goroutine has exited.
func (w *IndexWorker) Stop() {
+14
View File
@@ -3,6 +3,7 @@ package ocr
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"sync"
@@ -199,6 +200,19 @@ func (w *Worker) run(ctx context.Context, id int) {
}
func (w *Worker) process(ctx context.Context, job Job) {
// A panic while processing a single mail (malformed attachment, external
// tool output, index backend) must not kill the worker goroutine — the
// whole process would go down with it.
defer func() {
if r := recover(); r != nil {
w.logger.Error("ocr worker: recovered from panic",
"mail_id", job.MailID, "panic", fmt.Sprintf("%v", r))
if w.store != nil {
_ = w.store.SetOCRResult(ctx, job.MailID, "failed", 0)
}
}
}()
// PROJ-44: The canonical source of truth for a mail's tenant assignment
// is emails.tenant_id in PostgreSQL — never the submitter's context.
// Re-imports via IMAP/POP3 scheduler may submit the same mail with a
+32
View File
@@ -0,0 +1,32 @@
// Package safego starts background goroutines that cannot take the process
// down. A panic inside a detached goroutine is fatal for the whole binary in
// Go — there is no per-goroutine boundary like net/http provides for request
// handlers. Every "fire and forget" goroutine in archivmail (mail sending,
// importer runs, boot-resume jobs) therefore goes through Go/Run.
package safego
import (
"fmt"
"log/slog"
)
// Run executes fn synchronously and recovers from a panic, logging it with the
// given name. It returns true when fn completed without panicking.
func Run(logger *slog.Logger, name string, fn func()) (ok bool) {
defer func() {
if r := recover(); r != nil {
ok = false
if logger != nil {
logger.Error("recovered from panic in background task",
"task", name, "panic", fmt.Sprintf("%v", r))
}
}
}()
fn()
return true
}
// Go runs fn in a new goroutine, recovering and logging any panic.
func Go(logger *slog.Logger, name string, fn func()) {
go Run(logger, name, fn) //nolint:errcheck // result is irrelevant for detached tasks
}
+17 -1
View File
@@ -365,7 +365,23 @@ func (s *session) Rcpt(to string, _ *smtp.RcptOptions) error {
return nil
}
func (s *session) Data(r io.Reader) error {
func (s *session) Data(r io.Reader) (err error) {
// go-smtp does not guard session handlers: a panic while processing a
// malformed mail would take the whole archivmail process down. Convert it
// into a temporary failure so the sending MTA retries.
defer func() {
if rec := recover(); rec != nil {
s.daemon.stats.Rejected.Add(1)
s.daemon.logger.Error("SMTP: recovered from panic while processing message",
"from", s.from, "ip", s.remoteIP, "panic", fmt.Sprintf("%v", rec))
err = &smtp.SMTPError{
Code: 451,
EnhancedCode: smtp.EnhancedCode{4, 3, 0},
Message: "Temporary processing failure, please retry",
}
}
}()
var buf bytes.Buffer
if _, err := io.Copy(&buf, r); err != nil {
s.daemon.stats.Rejected.Add(1)