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