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
@@ -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
|
||||
}
|
||||
|
||||
+14
-7
@@ -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 {
|
||||
reloadOCRPauseWindow(*configPath, ocrWorker, logger)
|
||||
safego.Run(logger, "ocr pause-window reload", func() {
|
||||
reloadOCRPauseWindow(*configPath, ocrWorker, logger)
|
||||
})
|
||||
}
|
||||
}()
|
||||
})
|
||||
|
||||
// Graceful shutdown
|
||||
quit := make(chan os.Signal, 1)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user