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:
co-authored by
Claude Sonnet 5
parent
0fa4fad49d
commit
798cb2817c
+36
-8
@@ -3,12 +3,30 @@ package mailparser
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"strings"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
var (
|
||||
mboxSeparator = []byte("From ")
|
||||
mboxHeaderTag = []byte("From: ")
|
||||
mboxQuoted = []byte(">From ")
|
||||
)
|
||||
|
||||
// SplitMbox splits a raw mbox file into individual RFC 2822 message bytes.
|
||||
// Each message starts with a "From " separator line which is skipped.
|
||||
//
|
||||
// Scan errors (e.g. a single line larger than the 10 MB scanner limit) are
|
||||
// ignored here for backwards compatibility — use SplitMboxErr to detect a
|
||||
// truncated result instead of silently importing partial data.
|
||||
func SplitMbox(data []byte) [][]byte {
|
||||
messages, _ := SplitMboxErr(data)
|
||||
return messages
|
||||
}
|
||||
|
||||
// SplitMboxErr behaves like SplitMbox but also reports scanner failures.
|
||||
// On error the already-collected messages are still returned so the caller can
|
||||
// decide between aborting and importing a partial file.
|
||||
func SplitMboxErr(data []byte) ([][]byte, error) {
|
||||
var messages [][]byte
|
||||
var current bytes.Buffer
|
||||
|
||||
@@ -17,11 +35,16 @@ func SplitMbox(data []byte) [][]byte {
|
||||
|
||||
inMessage := false
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
// Bytes() avoids a string allocation per line — mbox files routinely
|
||||
// have millions of lines.
|
||||
line := scanner.Bytes()
|
||||
// mbox separator: line starts with "From " but not "From:" header
|
||||
if strings.HasPrefix(line, "From ") && !strings.HasPrefix(line, "From: ") {
|
||||
if bytes.HasPrefix(line, mboxSeparator) && !bytes.HasPrefix(line, mboxHeaderTag) {
|
||||
if inMessage && current.Len() > 0 {
|
||||
messages = append(messages, bytes.TrimSpace(current.Bytes()))
|
||||
// Copy: current.Bytes() aliases the buffer, which is reused
|
||||
// after Reset() and would corrupt already-collected messages.
|
||||
msg := bytes.TrimSpace(current.Bytes())
|
||||
messages = append(messages, append([]byte(nil), msg...))
|
||||
current.Reset()
|
||||
}
|
||||
inMessage = true
|
||||
@@ -29,15 +52,20 @@ func SplitMbox(data []byte) [][]byte {
|
||||
}
|
||||
if inMessage {
|
||||
// unescape ">From " lines (mbox quoting)
|
||||
if strings.HasPrefix(line, ">From ") {
|
||||
if bytes.HasPrefix(line, mboxQuoted) {
|
||||
line = line[1:]
|
||||
}
|
||||
current.WriteString(line)
|
||||
current.Write(line)
|
||||
current.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
if inMessage && current.Len() > 0 {
|
||||
messages = append(messages, bytes.TrimSpace(current.Bytes()))
|
||||
msg := bytes.TrimSpace(current.Bytes())
|
||||
messages = append(messages, append([]byte(nil), msg...))
|
||||
}
|
||||
return messages
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return messages, fmt.Errorf("mailparser: mbox scan aborted after %d messages: %w", len(messages), err)
|
||||
}
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user