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
+5 -1
View File
@@ -183,7 +183,11 @@ func runImport(args []string) {
var messages [][]byte
if fe.isMbox {
messages = mailparser.SplitMbox(raw)
var splitErr error
messages, splitErr = mailparser.SplitMboxErr(raw)
if splitErr != nil {
fmt.Fprintf(os.Stderr, "warning: %s: %v\n", fe.path, splitErr)
}
if len(messages) == 0 {
continue
}
+13 -6
View File
@@ -32,6 +32,7 @@ import (
"archivmail/internal/ocr"
pop3store "archivmail/internal/pop3"
"archivmail/internal/reconciliation"
"archivmail/internal/safego"
"archivmail/internal/smtpoutconfig"
"archivmail/internal/smtpd"
"archivmail/internal/storage"
@@ -246,7 +247,7 @@ func main() {
// queries only return genuinely outstanding jobs.
// PROJ-58: skipped in batch mode — the cron job drains the backlog instead.
if !cfg.OCR.BatchMode {
go func() {
safego.Go(logger, "ocr boot-resume", func() {
ctx := context.Background()
queueCap := 1000 // matches ocr.Options.QueueSize above
processed := 0
@@ -281,7 +282,7 @@ func main() {
logger.Info("ocr boot-resume: enqueued batch",
"batch", len(pending), "total_so_far", processed)
}
}()
})
}
// User store
@@ -519,11 +520,15 @@ func main() {
if cfg.OCR.BatchMode {
backfillOCR = nil
}
go runBackfill(context.Background(), mailStore, idx, tenantWorker, logger, backfillOCR)
safego.Go(logger, "backfill", func() {
runBackfill(context.Background(), mailStore, idx, tenantWorker, logger, backfillOCR)
})
}
// Background integrity verification — runs every 5 minutes
go runIntegrityCheck(context.Background(), mailStore, logger)
safego.Go(logger, "integrity check", func() {
runIntegrityCheck(context.Background(), mailStore, logger)
})
// Start HTTP API
go func() {
@@ -538,11 +543,13 @@ func main() {
// (which would otherwise drop SMTP/IMAP connections unnecessarily).
reload := make(chan os.Signal, 1)
signal.Notify(reload, syscall.SIGHUP)
go func() {
safego.Go(logger, "sighup reload", func() {
for range reload {
safego.Run(logger, "ocr pause-window reload", func() {
reloadOCRPauseWindow(*configPath, ocrWorker, logger)
})
}
}()
})
// Graceful shutdown
quit := make(chan os.Signal, 1)
+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
+11 -2
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:
// 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)
+36 -8
View File
@@ -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
}
+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)
}
}
}
+3 -1
View File
@@ -199,7 +199,9 @@ export default function UploadPage() {
<ul className="space-y-1 mb-4 max-h-48 overflow-y-auto">
{selectedFiles.map((f, i) => (
<li
key={i}
// Name+Größe statt Index: beim Entfernen eines Eintrags
// wuerde React sonst DOM-Knoten falsch wiederverwenden.
key={`${f.name}-${f.size}-${f.lastModified}`}
className="flex items-center justify-between text-sm py-1"
>
<span className="truncate max-w-[calc(100%-6rem)]">
+46
View File
@@ -0,0 +1,46 @@
"use client";
import { useEffect } from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export default function AppError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Kein Mail-Inhalt loggen — nur Fehlertext/Digest (DSGVO).
console.error("Unerwarteter Fehler:", error.message, error.digest ?? "");
}, [error]);
return (
<div className="mx-auto flex min-h-screen max-w-lg items-center justify-center px-4">
<Card className="w-full">
<CardHeader>
<CardTitle>Es ist ein Fehler aufgetreten</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Die Seite konnte nicht dargestellt werden. Du kannst es erneut
versuchen oder zur Suche zurückkehren.
</p>
{error.digest && (
<p className="font-mono text-xs text-muted-foreground">
Fehler-ID: {error.digest}
</p>
)}
<div className="flex flex-wrap gap-2">
<Button onClick={() => reset()}>Erneut versuchen</Button>
<Button variant="outline" asChild>
<Link href="/search">Zur Suche</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
"use client";
import { useEffect } from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
// Eigene Boundary für die Mail-Detailansicht: archivierte Mails können
// defekte Header/Encodings enthalten. Ein Renderfehler darf nur diese Route
// betreffen, nicht die gesamte App.
export default function MailError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Bewusst nur die Fehlermeldung, keine Mail-Inhalte (DSGVO).
console.error("Mail-Ansicht fehlgeschlagen:", error.message, error.digest ?? "");
}, [error]);
return (
<div className="mx-auto max-w-4xl px-4 py-10">
<Card>
<CardHeader>
<CardTitle>Diese E-Mail kann nicht angezeigt werden</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Der archivierte Inhalt ist unvollständig oder fehlerhaft. Die
Originaldatei lässt sich weiterhin über die Suche als .eml
herunterladen.
</p>
{error.digest && (
<p className="font-mono text-xs text-muted-foreground">
Fehler-ID: {error.digest}
</p>
)}
<div className="flex flex-wrap gap-2">
<Button onClick={() => reset()}>Erneut versuchen</Button>
<Button variant="outline" asChild>
<Link href="/search">Zurück zur Suche</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
+10 -4
View File
@@ -28,14 +28,18 @@ import { FileText } from "lucide-react";
// ── Helpers ────────────────────────────────────────────────────────────────
function formatBytes(bytes: number): string {
if (typeof bytes !== "number" || !Number.isFinite(bytes)) return "";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function formatDate(iso: string): string {
if (!iso) return "";
try {
return new Date(iso).toLocaleString("de-DE", {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
@@ -304,9 +308,11 @@ export default function MailViewPage({
getMail(id)
.then((m) => {
setMail(m);
if (m.thread_id) {
if (m?.thread_id) {
getThread(m.thread_id).then((t) => {
if (t.total > 1) setThread(t.mails);
// Backend liefert bei leerem Thread ggf. null statt [].
const mails = Array.isArray(t?.mails) ? t.mails : [];
if (mails.length > 1) setThread(mails);
}).catch(() => {});
}
})
@@ -463,7 +469,7 @@ export default function MailViewPage({
</Card>
{/* Attachments */}
{mail.attachments && mail.attachments.length > 0 && (
{Array.isArray(mail.attachments) && mail.attachments.length > 0 && (
<Card>
<CardHeader className="pb-3">
<span className="text-sm font-medium">
+23
View File
@@ -0,0 +1,23 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export default function NotFound() {
return (
<div className="mx-auto flex min-h-screen max-w-lg items-center justify-center px-4">
<Card className="w-full">
<CardHeader>
<CardTitle>Seite nicht gefunden</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Die aufgerufene Adresse existiert nicht.
</p>
<Button asChild>
<Link href="/search">Zur Suche</Link>
</Button>
</CardContent>
</Card>
</div>
);
}
+1 -1
View File
@@ -74,7 +74,7 @@ export default function Pop3Page() {
const loadAccounts = useCallback(async () => {
try {
const data = await getPop3Accounts();
setAccounts(data);
setAccounts(Array.isArray(data) ? data : []);
} catch {
// ignore
} finally {
@@ -103,9 +103,9 @@ export function ArchivingRulesTab() {
setError("");
Promise.all([getArchivingRules(), getTenants()])
.then(([data, ts]) => {
setRules(data.rules);
setMinRetentionDays(data.min_retention_days);
setTenants(ts);
setRules(Array.isArray(data?.rules) ? data.rules : []);
setMinRetentionDays(data?.min_retention_days ?? 0);
setTenants(Array.isArray(ts) ? ts : []);
})
.catch(() => setError("Archivierungsregeln konnten nicht geladen werden"))
.finally(() => setLoading(false));
+4 -4
View File
@@ -80,9 +80,9 @@ function MailTimeseriesChart({ points }: { points: TimeseriesPoint[] }) {
})}
</div>
<div className="flex justify-between text-xs text-muted-foreground font-mono">
<span>{points[0]?.day.slice(5)}</span>
<span>{points[Math.floor(points.length / 2)]?.day.slice(5)}</span>
<span>{points[points.length - 1]?.day.slice(5)}</span>
<span>{points[0]?.day?.slice(5)}</span>
<span>{points[Math.floor(points.length / 2)]?.day?.slice(5)}</span>
<span>{points[points.length - 1]?.day?.slice(5)}</span>
</div>
</CardContent>
</Card>
@@ -436,7 +436,7 @@ export function DashboardTab({
)}
{/* Festplatten */}
{systemStats.disks.length > 0 && (
{Array.isArray(systemStats.disks) && systemStats.disks.length > 0 && (
<div className="space-y-2">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Festplatten</h3>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
@@ -135,7 +135,9 @@ export function ReconciliationCard() {
// Tages-Header aus dem ersten Quell-Eintrag ableiten (alle Quellen haben
// dieselben Tage in gleicher Reihenfolge).
const dayHeaders = data?.sources[0]?.points.map((p) => p.date) ?? [];
// Backend liefert leere Listen ggf. als null — defensiv normalisieren.
const sources = Array.isArray(data?.sources) ? data.sources : [];
const dayHeaders = sources[0]?.points?.map((p) => p.date) ?? [];
return (
<Card>
@@ -184,7 +186,7 @@ export function ReconciliationCard() {
Vollständigkeits-Report konnte nicht geladen werden: {error}
</AlertDescription>
</Alert>
) : !data || data.sources.length === 0 ? (
) : !data || sources.length === 0 ? (
<p className="text-sm text-muted-foreground">
Noch keine Reconciliation-Daten vorhanden. Der tägliche Zähl-Job
(<code className="font-mono">archivmail reconcile</code>) hat noch
@@ -213,7 +215,7 @@ export function ReconciliationCard() {
</TableRow>
</TableHeader>
<TableBody>
{data.sources.map((s) => {
{sources.map((s) => {
const isImap = s.source_type === "imap";
return (
<TableRow key={s.source_key}>
@@ -229,7 +231,7 @@ export function ReconciliationCard() {
</TableCell>
) : (
<>
{s.points.map((p) => (
{(s.points ?? []).map((p) => (
<TableCell key={p.date} className="text-right">
<DayCell
archived={p.archived_count}
@@ -121,8 +121,8 @@ export function RoutingRulesTab({ isSuperAdmin }: { isSuperAdmin: boolean }) {
];
Promise.all(loaders)
.then(([rs, ts]) => {
setRules(rs);
setTenants(ts);
setRules(Array.isArray(rs) ? rs : []);
setTenants(Array.isArray(ts) ? ts : []);
})
.catch(() => setError("Routing-Regeln konnten nicht geladen werden"))
.finally(() => setLoading(false));
+10 -5
View File
@@ -77,12 +77,17 @@ export function SecurityTab({
"SSH Root-Login": { action: "fix_ssh_root_login", label: "Auf prohibit-password setzen" },
};
// Backend kann bei leerem Ergebnis null statt [] liefern.
const checks: SecurityCheck[] = Array.isArray(securityAudit.checks)
? securityAudit.checks
: [];
return (
<div className="space-y-2">
{securityAudit.checks.map((check: SecurityCheck, i: number) => {
{checks.map((check: SecurityCheck, i: number) => {
const fix = check.status !== "ok" ? fixActions[check.name] : undefined;
return (
<Card key={i}>
<Card key={check.name || i}>
<CardContent className="p-4 flex items-start gap-3">
<span className={`mt-1 h-2.5 w-2.5 flex-shrink-0 rounded-full ${
check.status === "ok" ? "bg-green-500" :
@@ -130,9 +135,9 @@ export function SecurityTab({
{/* Summary */}
<div className="mt-4 grid grid-cols-3 gap-3 text-center text-sm">
{[
{ label: "OK", color: "bg-green-50 text-green-700", count: securityAudit.checks.filter((c: SecurityCheck) => c.status === "ok").length },
{ label: "Warnungen", color: "bg-yellow-50 text-yellow-700", count: securityAudit.checks.filter((c: SecurityCheck) => c.status === "warning").length },
{ label: "Fehler", color: "bg-red-50 text-red-700", count: securityAudit.checks.filter((c: SecurityCheck) => c.status === "error").length },
{ label: "OK", color: "bg-green-50 text-green-700", count: checks.filter((c: SecurityCheck) => c.status === "ok").length },
{ label: "Warnungen", color: "bg-yellow-50 text-yellow-700", count: checks.filter((c: SecurityCheck) => c.status === "warning").length },
{ label: "Fehler", color: "bg-red-50 text-red-700", count: checks.filter((c: SecurityCheck) => c.status === "error").length },
].map((s) => (
<div key={s.label} className={`rounded p-3 ${s.color}`}>
<p className="text-2xl font-bold">{s.count}</p>
+3 -2
View File
@@ -46,8 +46,9 @@ export function RestoreMailButton({ mailId, enabled }: RestoreMailButtonProps) {
getImapAccounts()
.then((data) => {
if (!active) return;
setAccounts(data);
if (data.length > 0) setSelected(String(data[0].id));
const list = Array.isArray(data) ? data : [];
setAccounts(list);
if (list.length > 0) setSelected(String(list[0].id));
})
.catch(() => {
// Ignore — without accounts the button stays hidden.
+1 -1
View File
@@ -162,7 +162,7 @@ export function SearchFilterBar(props: SearchFilterBarProps) {
type="button"
className="flex-1 text-left text-sm truncate"
onClick={() => onApplySavedSearch(s)}
title={Object.entries(s.query).map(([k, v]) => `${k}: ${v}`).join(", ")}
title={Object.entries(s.query ?? {}).map(([k, v]) => `${k}: ${v}`).join(", ")}
>
{s.name}
</button>
+1 -1
View File
@@ -33,7 +33,7 @@ const MATCH_FIELD_LABEL: Record<SearchMatchField, string> = {
function MatchSourceBadge({ field }: { field: SearchMatchField }) {
return (
<span className="inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground whitespace-nowrap">
{MATCH_FIELD_LABEL[field]}
{MATCH_FIELD_LABEL[field] ?? field}
</span>
);
}
+5 -1
View File
@@ -39,7 +39,11 @@ export function useAdminDashboard(active: boolean) {
setApiOnline(health.status === "fulfilled" && health.value.status === "ok");
setStorageStats(storage.status === "fulfilled" ? storage.value : null);
setSystemStats(sysStats.status === "fulfilled" ? sysStats.value : null);
setTimeseries(ts.status === "fulfilled" ? ts.value.points : []);
setTimeseries(
ts.status === "fulfilled" && Array.isArray(ts.value?.points)
? ts.value.points
: []
);
setDashRefreshed(new Date());
} finally {
setDashLoading(false);
+1 -1
View File
@@ -59,7 +59,7 @@ export function useImapAccounts(user: unknown) {
const loadAccounts = useCallback(async () => {
try {
const data = await getImapAccounts();
setAccounts(data);
setAccounts(Array.isArray(data) ? data : []);
} catch {
// ignore
} finally {
+14 -12
View File
@@ -101,24 +101,26 @@ export function useSavedSearches(params: UseSavedSearchesParams) {
}
function handleApplySavedSearch(s: SavedSearch) {
setQuery(s.query.q || "");
setFromFilter(s.query.from || "");
setToFilter(s.query.to || "");
setDateFrom(s.query.date_from || "");
setDateTo(s.query.date_to || "");
setHasAttachment(s.query.has_attachment === "true" ? true : undefined);
// query kann bei alten/defekten Datensaetzen fehlen (Backend: null).
const q = s.query ?? {};
setQuery(q.q || "");
setFromFilter(q.from || "");
setToFilter(q.to || "");
setDateFrom(q.date_from || "");
setDateTo(q.date_to || "");
setHasAttachment(q.has_attachment === "true" ? true : undefined);
setSavedListOpen(false);
// Trigger search after state updates
setTimeout(() => {
// We need to search with the saved query directly since state isn't updated yet
setSearching(true);
searchEmails({
q: s.query.q || undefined,
from: s.query.from || undefined,
to: s.query.to || undefined,
date_from: s.query.date_from || undefined,
date_to: s.query.date_to || undefined,
has_attachment: s.query.has_attachment === "true" ? true : undefined,
q: q.q || undefined,
from: q.from || undefined,
to: q.to || undefined,
date_from: q.date_from || undefined,
date_to: q.date_to || undefined,
has_attachment: q.has_attachment === "true" ? true : undefined,
page: 1,
page_size: pageSize,
})
+9 -1
View File
@@ -29,5 +29,13 @@ export async function request<T>(
if (res.status === 204) return {} as T;
return res.json();
// Leerer oder defekter Body darf nicht als unbehandelter SyntaxError
// durchschlagen — Aufrufer erwarten eine Error-Instanz.
const text = await res.text();
if (!text) return {} as T;
try {
return JSON.parse(text) as T;
} catch {
throw new Error("Ungültige Serverantwort");
}
}