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
+35 -6
View File
@@ -37,10 +37,33 @@ type ParsedMail struct {
Date time.Time
Attachments []Attachment
Raw []byte
// Accumulators used while walking MIME parts. Appending to the TextBody /
// HTMLBody strings directly is O(n²) for mails with many parts, so parts
// are collected here and joined once at the end of Parse.
textBuf strings.Builder
htmlBuf strings.Builder
}
// maxMultipartDepth caps MIME nesting. Malformed or malicious mails can nest
// multipart parts arbitrarily deep; without a limit the recursion in
// parseMultipart can exhaust the goroutine stack, which is an unrecoverable
// fatal error for the whole process.
const maxMultipartDepth = 20
// Parse parses a raw RFC 2822 / MIME email and returns a ParsedMail.
func Parse(raw []byte) (*ParsedMail, error) {
//
// Input is untrusted (IMAP/POP3/SMTP/upload). A panic inside the MIME/charset
// decoding stack must never take down the importer or the whole daemon, so it
// is converted into a normal parse error here.
func Parse(raw []byte) (pmOut *ParsedMail, errOut error) {
defer func() {
if r := recover(); r != nil {
pmOut = nil
errOut = fmt.Errorf("mailparser: panic while parsing message: %v", r)
}
}()
msg, err := mail.ReadMessage(bytes.NewReader(raw))
if err != nil {
return nil, fmt.Errorf("mailparser: read message: %w", err)
@@ -182,7 +205,9 @@ func Parse(raw []byte) (*ParsedMail, error) {
boundary := params["boundary"]
// Ignore parse errors — return partial content instead of failing completely.
// Malformed/truncated multipart emails still get metadata + whatever parts parsed.
parseMultipart(pm, msg.Body, boundary) //nolint:errcheck
parseMultipart(pm, msg.Body, boundary, 0) //nolint:errcheck
pm.TextBody = pm.textBuf.String()
pm.HTMLBody = pm.htmlBuf.String()
} else {
body, _ := io.ReadAll(msg.Body)
decoded := decodeBody(body, msg.Header.Get("Content-Transfer-Encoding"))
@@ -199,7 +224,11 @@ func Parse(raw []byte) (*ParsedMail, error) {
// parseMultipart walks MIME parts and fills text, html, and attachments.
// Truncated or malformed parts are skipped — partial content is better than nothing.
func parseMultipart(pm *ParsedMail, body io.Reader, boundary string) {
// depth guards against unbounded MIME nesting (stack overflow).
func parseMultipart(pm *ParsedMail, body io.Reader, boundary string, depth int) {
if depth >= maxMultipartDepth || boundary == "" {
return
}
mr := multipart.NewReader(body, boundary)
for {
part, err := mr.NextPart()
@@ -243,15 +272,15 @@ func parseMultipart(pm *ParsedMail, body io.Reader, boundary string) {
// Nested multipart
if strings.HasPrefix(mediaType, "multipart/") {
parseMultipart(pm, bytes.NewReader(decoded), params["boundary"])
parseMultipart(pm, bytes.NewReader(decoded), params["boundary"], depth+1)
continue
}
switch {
case strings.Contains(mediaType, "text/plain"):
pm.TextBody += string(decoded)
pm.textBuf.Write(decoded)
case strings.Contains(mediaType, "text/html"):
pm.HTMLBody += string(decoded)
pm.htmlBuf.Write(decoded)
}
}
}