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
|
var messages [][]byte
|
||||||
if fe.isMbox {
|
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 {
|
if len(messages) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-6
@@ -32,6 +32,7 @@ import (
|
|||||||
"archivmail/internal/ocr"
|
"archivmail/internal/ocr"
|
||||||
pop3store "archivmail/internal/pop3"
|
pop3store "archivmail/internal/pop3"
|
||||||
"archivmail/internal/reconciliation"
|
"archivmail/internal/reconciliation"
|
||||||
|
"archivmail/internal/safego"
|
||||||
"archivmail/internal/smtpoutconfig"
|
"archivmail/internal/smtpoutconfig"
|
||||||
"archivmail/internal/smtpd"
|
"archivmail/internal/smtpd"
|
||||||
"archivmail/internal/storage"
|
"archivmail/internal/storage"
|
||||||
@@ -246,7 +247,7 @@ func main() {
|
|||||||
// queries only return genuinely outstanding jobs.
|
// queries only return genuinely outstanding jobs.
|
||||||
// PROJ-58: skipped in batch mode — the cron job drains the backlog instead.
|
// PROJ-58: skipped in batch mode — the cron job drains the backlog instead.
|
||||||
if !cfg.OCR.BatchMode {
|
if !cfg.OCR.BatchMode {
|
||||||
go func() {
|
safego.Go(logger, "ocr boot-resume", func() {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
queueCap := 1000 // matches ocr.Options.QueueSize above
|
queueCap := 1000 // matches ocr.Options.QueueSize above
|
||||||
processed := 0
|
processed := 0
|
||||||
@@ -281,7 +282,7 @@ func main() {
|
|||||||
logger.Info("ocr boot-resume: enqueued batch",
|
logger.Info("ocr boot-resume: enqueued batch",
|
||||||
"batch", len(pending), "total_so_far", processed)
|
"batch", len(pending), "total_so_far", processed)
|
||||||
}
|
}
|
||||||
}()
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// User store
|
// User store
|
||||||
@@ -519,11 +520,15 @@ func main() {
|
|||||||
if cfg.OCR.BatchMode {
|
if cfg.OCR.BatchMode {
|
||||||
backfillOCR = nil
|
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
|
// 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
|
// Start HTTP API
|
||||||
go func() {
|
go func() {
|
||||||
@@ -538,11 +543,13 @@ func main() {
|
|||||||
// (which would otherwise drop SMTP/IMAP connections unnecessarily).
|
// (which would otherwise drop SMTP/IMAP connections unnecessarily).
|
||||||
reload := make(chan os.Signal, 1)
|
reload := make(chan os.Signal, 1)
|
||||||
signal.Notify(reload, syscall.SIGHUP)
|
signal.Notify(reload, syscall.SIGHUP)
|
||||||
go func() {
|
safego.Go(logger, "sighup reload", func() {
|
||||||
for range reload {
|
for range reload {
|
||||||
|
safego.Run(logger, "ocr pause-window reload", func() {
|
||||||
reloadOCRPauseWindow(*configPath, ocrWorker, logger)
|
reloadOCRPauseWindow(*configPath, ocrWorker, logger)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}()
|
})
|
||||||
|
|
||||||
// Graceful shutdown
|
// Graceful shutdown
|
||||||
quit := make(chan os.Signal, 1)
|
quit := make(chan os.Signal, 1)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"archivmail/internal/auth"
|
"archivmail/internal/auth"
|
||||||
imapstore "archivmail/internal/imap"
|
imapstore "archivmail/internal/imap"
|
||||||
|
"archivmail/internal/safego"
|
||||||
"archivmail/internal/userstore"
|
"archivmail/internal/userstore"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -211,7 +212,9 @@ func (s *Server) handleStartImport(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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)
|
// Return current account state (status will switch to "running" shortly)
|
||||||
acc.Status = "running"
|
acc.Status = "running"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"archivmail/internal/audit"
|
"archivmail/internal/audit"
|
||||||
"archivmail/internal/mailer"
|
"archivmail/internal/mailer"
|
||||||
|
"archivmail/internal/safego"
|
||||||
"archivmail/internal/tokenstore"
|
"archivmail/internal/tokenstore"
|
||||||
"archivmail/internal/userstore"
|
"archivmail/internal/userstore"
|
||||||
)
|
)
|
||||||
@@ -55,11 +56,12 @@ func (s *Server) handleCreateInvite(w http.ResponseWriter, r *http.Request) {
|
|||||||
tenantName = t.Name
|
tenantName = t.Name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
go func() {
|
email := body.Email
|
||||||
|
safego.Go(s.logger, "send invite mail", func() {
|
||||||
html := mailer.InviteHTML(s.fqdn, token, tenantName)
|
html := mailer.InviteHTML(s.fqdn, token, tenantName)
|
||||||
txt := mailer.InviteText(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 {
|
if s.audlog != nil {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"archivmail/internal/audit"
|
"archivmail/internal/audit"
|
||||||
"archivmail/internal/mailer"
|
"archivmail/internal/mailer"
|
||||||
|
"archivmail/internal/safego"
|
||||||
"archivmail/internal/tokenstore"
|
"archivmail/internal/tokenstore"
|
||||||
"archivmail/internal/userstore"
|
"archivmail/internal/userstore"
|
||||||
)
|
)
|
||||||
@@ -79,11 +80,12 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
// SEC: token already consumed — response is identical to success to prevent
|
// SEC: token already consumed — response is identical to success to prevent
|
||||||
// enumeration of whether the email address already existed.
|
// 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)
|
html := mailer.AlreadyRegisteredHTML(s.fqdn)
|
||||||
txt := mailer.AlreadyRegisteredText(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})
|
writeJSON(w, http.StatusOK, map[string]string{"message": signupMsg})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -100,11 +102,11 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Send verification email.
|
// Send verification email.
|
||||||
go func() {
|
safego.Go(s.logger, "send verify mail", func() {
|
||||||
html := mailer.VerifyEmailHTML(s.fqdn, token, u.Username)
|
html := mailer.VerifyEmailHTML(s.fqdn, token, u.Username)
|
||||||
txt := mailer.VerifyEmailText(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)
|
_ = s.mailer.Send(u.Email, "archivmail – E-Mail bestätigen", html, txt)
|
||||||
}()
|
})
|
||||||
|
|
||||||
if s.audlog != nil {
|
if s.audlog != nil {
|
||||||
s.audlog.Log(audit.Entry{
|
s.audlog.Log(audit.Entry{
|
||||||
@@ -193,11 +195,11 @@ func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
go func() {
|
safego.Go(s.logger, "send password-reset mail", func() {
|
||||||
html := mailer.ResetPasswordHTML(s.fqdn, token, u.Username)
|
html := mailer.ResetPasswordHTML(s.fqdn, token, u.Username)
|
||||||
txt := mailer.ResetPasswordText(s.fqdn, token, u.Username)
|
txt := mailer.ResetPasswordText(s.fqdn, token, u.Username)
|
||||||
_ = s.mailer.Send(u.Email, "archivmail – Passwort zurücksetzen", html, txt)
|
_ = s.mailer.Send(u.Email, "archivmail – Passwort zurücksetzen", html, txt)
|
||||||
}()
|
})
|
||||||
|
|
||||||
if s.audlog != nil {
|
if s.audlog != nil {
|
||||||
s.audlog.Log(audit.Entry{
|
s.audlog.Log(audit.Entry{
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"archivmail/internal/auth"
|
"archivmail/internal/auth"
|
||||||
pop3store "archivmail/internal/pop3"
|
pop3store "archivmail/internal/pop3"
|
||||||
|
"archivmail/internal/safego"
|
||||||
"archivmail/internal/userstore"
|
"archivmail/internal/userstore"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -212,7 +213,9 @@ func (s *Server) handleStartPop3Import(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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)
|
// Return current account state (status will switch to "running" shortly)
|
||||||
acc.Status = "running"
|
acc.Status = "running"
|
||||||
|
|||||||
+15
-3
@@ -9,6 +9,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"archivmail/internal/index"
|
"archivmail/internal/index"
|
||||||
|
"archivmail/internal/safego"
|
||||||
"archivmail/pkg/mailparser"
|
"archivmail/pkg/mailparser"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -94,7 +95,12 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
|||||||
var allMessages [][]byte
|
var allMessages [][]byte
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if e.isMbox {
|
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...)
|
allMessages = append(allMessages, msgs...)
|
||||||
} else {
|
} else {
|
||||||
allMessages = append(allMessages, e.data)
|
allMessages = append(allMessages, e.data)
|
||||||
@@ -113,7 +119,9 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
|||||||
tenantID := tenantFromCtx(r.Context())
|
tenantID := tenantFromCtx(r.Context())
|
||||||
|
|
||||||
// Run import in background
|
// 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})
|
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")
|
writeError(w, http.StatusNotFound, "job not found")
|
||||||
return
|
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())
|
writeJSON(w, http.StatusOK, job.snapshot())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ type APIKeyRow struct {
|
|||||||
|
|
||||||
// tokenBucket implements a simple per-key token-bucket rate limiter.
|
// tokenBucket implements a simple per-key token-bucket rate limiter.
|
||||||
type tokenBucket struct {
|
type tokenBucket struct {
|
||||||
|
mu sync.Mutex // guards tokens/lastCheck — concurrent requests share one bucket
|
||||||
tokens float64
|
tokens float64
|
||||||
limit float64
|
limit float64
|
||||||
lastCheck time.Time
|
lastCheck time.Time
|
||||||
@@ -128,7 +129,14 @@ func (m *APIKeyMiddleware) allow(keyID int64, limitPerMin int) bool {
|
|||||||
limit: limit,
|
limit: limit,
|
||||||
lastCheck: now,
|
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()
|
elapsed := now.Sub(bucket.lastCheck).Seconds()
|
||||||
bucket.lastCheck = now
|
bucket.lastCheck = now
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"archivmail/internal/audit"
|
"archivmail/internal/audit"
|
||||||
|
"archivmail/internal/safego"
|
||||||
|
|
||||||
imapv2 "github.com/emersion/go-imap/v2"
|
imapv2 "github.com/emersion/go-imap/v2"
|
||||||
"github.com/emersion/go-imap/v2/imapclient"
|
"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.running[accountID] = true
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
go s.runSyncWithRetry(context.Background(), accountID)
|
safego.Go(s.logger, "imap manual sync", func() {
|
||||||
|
s.runSyncWithRetry(context.Background(), accountID)
|
||||||
|
})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +124,10 @@ func (s *Scheduler) loop(ctx context.Context) {
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
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)
|
s.checkAccounts(ctx)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -166,7 +172,10 @@ func (s *Scheduler) checkAccounts(ctx context.Context) {
|
|||||||
|
|
||||||
s.logger.Info("imap scheduler: starting scheduled sync",
|
s.logger.Info("imap scheduler: starting scheduled sync",
|
||||||
"account_id", acc.ID, "name", acc.Name)
|
"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"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
@@ -31,6 +32,9 @@ const (
|
|||||||
maxConnsPerUser = 5
|
maxConnsPerUser = 5
|
||||||
readBufferSize = 8192
|
readBufferSize = 8192
|
||||||
maxLineLength = 65536
|
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.
|
// tenantIMAPModeGetter is satisfied by tenantstore.Store.
|
||||||
@@ -199,6 +203,7 @@ func (s *Server) handleConnection(conn net.Conn) {
|
|||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
remoteAddr := conn.RemoteAddr().String()
|
remoteAddr := conn.RemoteAddr().String()
|
||||||
|
|
||||||
s.logger.Debug("imapserver: new connection", "remote", remoteAddr)
|
s.logger.Debug("imapserver: new connection", "remote", remoteAddr)
|
||||||
|
|
||||||
sess := &session{
|
sess := &session{
|
||||||
@@ -209,6 +214,19 @@ func (s *Server) handleConnection(conn net.Conn) {
|
|||||||
state: stateNotAuth,
|
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)
|
// Send greeting — use FQDN if configured (RFC 3501 §7.1)
|
||||||
fqdn := s.cfg.FQDN
|
fqdn := s.cfg.FQDN
|
||||||
if fqdn == "" {
|
if fqdn == "" {
|
||||||
@@ -282,12 +300,26 @@ type mailEntry struct {
|
|||||||
HasAttach bool
|
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) {
|
func (sess *session) readLine() (string, error) {
|
||||||
line, err := sess.reader.ReadString('\n')
|
var buf []byte
|
||||||
if err != nil {
|
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 "", err
|
||||||
}
|
}
|
||||||
return strings.TrimRight(line, "\r\n"), nil
|
return strings.TrimRight(string(buf), "\r\n"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sess *session) writeResponse(line string) {
|
func (sess *session) writeResponse(line string) {
|
||||||
@@ -949,26 +981,29 @@ func parseFetchItems(items string) []string {
|
|||||||
items = strings.TrimSuffix(items, ")")
|
items = strings.TrimSuffix(items, ")")
|
||||||
|
|
||||||
var result []string
|
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
|
bracketDepth := 0
|
||||||
for _, ch := range items {
|
for _, ch := range items {
|
||||||
if ch == '[' {
|
if ch == '[' {
|
||||||
bracketDepth++
|
bracketDepth++
|
||||||
current += string(ch)
|
current.WriteRune(ch)
|
||||||
} else if ch == ']' {
|
} else if ch == ']' {
|
||||||
bracketDepth--
|
bracketDepth--
|
||||||
current += string(ch)
|
current.WriteRune(ch)
|
||||||
} else if ch == ' ' && bracketDepth == 0 {
|
} else if ch == ' ' && bracketDepth == 0 {
|
||||||
if current != "" {
|
if current.Len() > 0 {
|
||||||
result = append(result, current)
|
result = append(result, current.String())
|
||||||
current = ""
|
current.Reset()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
current += string(ch)
|
current.WriteRune(ch)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if current != "" {
|
if current.Len() > 0 {
|
||||||
result = append(result, current)
|
result = append(result, current.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
@@ -976,15 +1011,22 @@ func parseFetchItems(items string) []string {
|
|||||||
|
|
||||||
// parseSequenceSet parses an IMAP sequence set (e.g. "1:*", "1,3:5", "1")
|
// parseSequenceSet parses an IMAP sequence set (e.g. "1:*", "1,3:5", "1")
|
||||||
// and returns the expanded list of numbers.
|
// 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 {
|
func parseSequenceSet(set string, maxVal uint32) []uint32 {
|
||||||
if maxVal == 0 {
|
if maxVal == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var result []uint32
|
result := make([]uint32, 0, 64)
|
||||||
seen := make(map[uint32]bool)
|
seen := make(map[uint32]bool)
|
||||||
|
|
||||||
for _, part := range strings.Split(set, ",") {
|
for _, part := range strings.Split(set, ",") {
|
||||||
|
if len(result) >= maxFetchResults {
|
||||||
|
break
|
||||||
|
}
|
||||||
part = strings.TrimSpace(part)
|
part = strings.TrimSpace(part)
|
||||||
if part == "" {
|
if part == "" {
|
||||||
continue
|
continue
|
||||||
@@ -998,6 +1040,9 @@ func parseSequenceSet(set string, maxVal uint32) []uint32 {
|
|||||||
start, end = end, start
|
start, end = end, start
|
||||||
}
|
}
|
||||||
for i := start; i <= end; i++ {
|
for i := start; i <= end; i++ {
|
||||||
|
if len(result) >= maxFetchResults {
|
||||||
|
break
|
||||||
|
}
|
||||||
if !seen[i] {
|
if !seen[i] {
|
||||||
result = append(result, i)
|
result = append(result, i)
|
||||||
seen[i] = true
|
seen[i] = true
|
||||||
@@ -1151,23 +1196,24 @@ func stripQuotes(s string) string {
|
|||||||
|
|
||||||
func splitIMAPArgs(s string) []string {
|
func splitIMAPArgs(s string) []string {
|
||||||
var parts []string
|
var parts []string
|
||||||
current := ""
|
// See parseFetchItems: strings.Builder avoids O(n²) rune-wise concatenation.
|
||||||
|
var current strings.Builder
|
||||||
inQuote := false
|
inQuote := false
|
||||||
for _, ch := range s {
|
for _, ch := range s {
|
||||||
if ch == '"' {
|
if ch == '"' {
|
||||||
inQuote = !inQuote
|
inQuote = !inQuote
|
||||||
current += string(ch)
|
current.WriteRune(ch)
|
||||||
} else if ch == ' ' && !inQuote {
|
} else if ch == ' ' && !inQuote {
|
||||||
if current != "" {
|
if current.Len() > 0 {
|
||||||
parts = append(parts, current)
|
parts = append(parts, current.String())
|
||||||
current = ""
|
current.Reset()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
current += string(ch)
|
current.WriteRune(ch)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if current != "" {
|
if current.Len() > 0 {
|
||||||
parts = append(parts, current)
|
parts = append(parts, current.String())
|
||||||
}
|
}
|
||||||
return parts
|
return parts
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package index
|
package index
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
@@ -83,7 +84,20 @@ func (w *TenantIndexWorker) QueueLen() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (w *TenantIndexWorker) indexDoc(doc MailDocument) {
|
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)
|
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 {
|
if err := idx.IndexSync(doc); err != nil {
|
||||||
w.logger.Error("tenant index worker: index failed", "id", doc.ID, "tenant_id", doc.TenantID, "err", err)
|
w.logger.Error("tenant index worker: index failed", "id", doc.ID, "tenant_id", doc.TenantID, "err", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package index
|
package index
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
@@ -53,9 +54,7 @@ func (w *IndexWorker) Start() {
|
|||||||
// Channel closed, drain complete
|
// Channel closed, drain complete
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := w.idx.IndexSync(doc); err != nil {
|
w.indexDoc(doc, "")
|
||||||
w.logger.Error("index worker: index failed", "id", doc.ID, "err", err)
|
|
||||||
}
|
|
||||||
case <-w.done:
|
case <-w.done:
|
||||||
// Drain remaining items in the queue before exiting
|
// Drain remaining items in the queue before exiting
|
||||||
for {
|
for {
|
||||||
@@ -64,9 +63,7 @@ func (w *IndexWorker) Start() {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := w.idx.IndexSync(doc); err != nil {
|
w.indexDoc(doc, " (drain)")
|
||||||
w.logger.Error("index worker: index failed (drain)", "id", doc.ID, "err", err)
|
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
return
|
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
|
// Stop signals the worker to drain remaining items and stop. It blocks until
|
||||||
// the worker goroutine has exited.
|
// the worker goroutine has exited.
|
||||||
func (w *IndexWorker) Stop() {
|
func (w *IndexWorker) Stop() {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package ocr
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -199,6 +200,19 @@ func (w *Worker) run(ctx context.Context, id int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (w *Worker) process(ctx context.Context, job Job) {
|
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
|
// PROJ-44: The canonical source of truth for a mail's tenant assignment
|
||||||
// is emails.tenant_id in PostgreSQL — never the submitter's context.
|
// is emails.tenant_id in PostgreSQL — never the submitter's context.
|
||||||
// Re-imports via IMAP/POP3 scheduler may submit the same mail with a
|
// 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
|
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
|
var buf bytes.Buffer
|
||||||
if _, err := io.Copy(&buf, r); err != nil {
|
if _, err := io.Copy(&buf, r); err != nil {
|
||||||
s.daemon.stats.Rejected.Add(1)
|
s.daemon.stats.Rejected.Add(1)
|
||||||
|
|||||||
+36
-8
@@ -3,12 +3,30 @@ package mailparser
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"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.
|
// SplitMbox splits a raw mbox file into individual RFC 2822 message bytes.
|
||||||
// Each message starts with a "From " separator line which is skipped.
|
// 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 {
|
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 messages [][]byte
|
||||||
var current bytes.Buffer
|
var current bytes.Buffer
|
||||||
|
|
||||||
@@ -17,11 +35,16 @@ func SplitMbox(data []byte) [][]byte {
|
|||||||
|
|
||||||
inMessage := false
|
inMessage := false
|
||||||
for scanner.Scan() {
|
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
|
// 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 {
|
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()
|
current.Reset()
|
||||||
}
|
}
|
||||||
inMessage = true
|
inMessage = true
|
||||||
@@ -29,15 +52,20 @@ func SplitMbox(data []byte) [][]byte {
|
|||||||
}
|
}
|
||||||
if inMessage {
|
if inMessage {
|
||||||
// unescape ">From " lines (mbox quoting)
|
// unescape ">From " lines (mbox quoting)
|
||||||
if strings.HasPrefix(line, ">From ") {
|
if bytes.HasPrefix(line, mboxQuoted) {
|
||||||
line = line[1:]
|
line = line[1:]
|
||||||
}
|
}
|
||||||
current.WriteString(line)
|
current.Write(line)
|
||||||
current.WriteByte('\n')
|
current.WriteByte('\n')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if inMessage && current.Len() > 0 {
|
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
|
Date time.Time
|
||||||
Attachments []Attachment
|
Attachments []Attachment
|
||||||
Raw []byte
|
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.
|
// 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))
|
msg, err := mail.ReadMessage(bytes.NewReader(raw))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("mailparser: read message: %w", err)
|
return nil, fmt.Errorf("mailparser: read message: %w", err)
|
||||||
@@ -182,7 +205,9 @@ func Parse(raw []byte) (*ParsedMail, error) {
|
|||||||
boundary := params["boundary"]
|
boundary := params["boundary"]
|
||||||
// Ignore parse errors — return partial content instead of failing completely.
|
// Ignore parse errors — return partial content instead of failing completely.
|
||||||
// Malformed/truncated multipart emails still get metadata + whatever parts parsed.
|
// 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 {
|
} else {
|
||||||
body, _ := io.ReadAll(msg.Body)
|
body, _ := io.ReadAll(msg.Body)
|
||||||
decoded := decodeBody(body, msg.Header.Get("Content-Transfer-Encoding"))
|
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.
|
// parseMultipart walks MIME parts and fills text, html, and attachments.
|
||||||
// Truncated or malformed parts are skipped — partial content is better than nothing.
|
// 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)
|
mr := multipart.NewReader(body, boundary)
|
||||||
for {
|
for {
|
||||||
part, err := mr.NextPart()
|
part, err := mr.NextPart()
|
||||||
@@ -243,15 +272,15 @@ func parseMultipart(pm *ParsedMail, body io.Reader, boundary string) {
|
|||||||
|
|
||||||
// Nested multipart
|
// Nested multipart
|
||||||
if strings.HasPrefix(mediaType, "multipart/") {
|
if strings.HasPrefix(mediaType, "multipart/") {
|
||||||
parseMultipart(pm, bytes.NewReader(decoded), params["boundary"])
|
parseMultipart(pm, bytes.NewReader(decoded), params["boundary"], depth+1)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case strings.Contains(mediaType, "text/plain"):
|
case strings.Contains(mediaType, "text/plain"):
|
||||||
pm.TextBody += string(decoded)
|
pm.textBuf.Write(decoded)
|
||||||
case strings.Contains(mediaType, "text/html"):
|
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">
|
<ul className="space-y-1 mb-4 max-h-48 overflow-y-auto">
|
||||||
{selectedFiles.map((f, i) => (
|
{selectedFiles.map((f, i) => (
|
||||||
<li
|
<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"
|
className="flex items-center justify-between text-sm py-1"
|
||||||
>
|
>
|
||||||
<span className="truncate max-w-[calc(100%-6rem)]">
|
<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 ────────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
function formatBytes(bytes: number): string {
|
||||||
|
if (typeof bytes !== "number" || !Number.isFinite(bytes)) return "–";
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(iso: string): string {
|
function formatDate(iso: string): string {
|
||||||
|
if (!iso) return "–";
|
||||||
try {
|
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",
|
day: "2-digit",
|
||||||
month: "2-digit",
|
month: "2-digit",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
@@ -304,9 +308,11 @@ export default function MailViewPage({
|
|||||||
getMail(id)
|
getMail(id)
|
||||||
.then((m) => {
|
.then((m) => {
|
||||||
setMail(m);
|
setMail(m);
|
||||||
if (m.thread_id) {
|
if (m?.thread_id) {
|
||||||
getThread(m.thread_id).then((t) => {
|
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(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -463,7 +469,7 @@ export default function MailViewPage({
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Attachments */}
|
{/* Attachments */}
|
||||||
{mail.attachments && mail.attachments.length > 0 && (
|
{Array.isArray(mail.attachments) && mail.attachments.length > 0 && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<span className="text-sm font-medium">
|
<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 () => {
|
const loadAccounts = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await getPop3Accounts();
|
const data = await getPop3Accounts();
|
||||||
setAccounts(data);
|
setAccounts(Array.isArray(data) ? data : []);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -103,9 +103,9 @@ export function ArchivingRulesTab() {
|
|||||||
setError("");
|
setError("");
|
||||||
Promise.all([getArchivingRules(), getTenants()])
|
Promise.all([getArchivingRules(), getTenants()])
|
||||||
.then(([data, ts]) => {
|
.then(([data, ts]) => {
|
||||||
setRules(data.rules);
|
setRules(Array.isArray(data?.rules) ? data.rules : []);
|
||||||
setMinRetentionDays(data.min_retention_days);
|
setMinRetentionDays(data?.min_retention_days ?? 0);
|
||||||
setTenants(ts);
|
setTenants(Array.isArray(ts) ? ts : []);
|
||||||
})
|
})
|
||||||
.catch(() => setError("Archivierungsregeln konnten nicht geladen werden"))
|
.catch(() => setError("Archivierungsregeln konnten nicht geladen werden"))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
|
|||||||
@@ -80,9 +80,9 @@ function MailTimeseriesChart({ points }: { points: TimeseriesPoint[] }) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-xs text-muted-foreground font-mono">
|
<div className="flex justify-between text-xs text-muted-foreground font-mono">
|
||||||
<span>{points[0]?.day.slice(5)}</span>
|
<span>{points[0]?.day?.slice(5)}</span>
|
||||||
<span>{points[Math.floor(points.length / 2)]?.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[points.length - 1]?.day?.slice(5)}</span>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -436,7 +436,7 @@ export function DashboardTab({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Festplatten */}
|
{/* Festplatten */}
|
||||||
{systemStats.disks.length > 0 && (
|
{Array.isArray(systemStats.disks) && systemStats.disks.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Festplatten</h3>
|
<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">
|
<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
|
// Tages-Header aus dem ersten Quell-Eintrag ableiten (alle Quellen haben
|
||||||
// dieselben Tage in gleicher Reihenfolge).
|
// 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 (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -184,7 +186,7 @@ export function ReconciliationCard() {
|
|||||||
Vollständigkeits-Report konnte nicht geladen werden: {error}
|
Vollständigkeits-Report konnte nicht geladen werden: {error}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : !data || data.sources.length === 0 ? (
|
) : !data || sources.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Noch keine Reconciliation-Daten vorhanden. Der tägliche Zähl-Job
|
Noch keine Reconciliation-Daten vorhanden. Der tägliche Zähl-Job
|
||||||
(<code className="font-mono">archivmail reconcile</code>) hat noch
|
(<code className="font-mono">archivmail reconcile</code>) hat noch
|
||||||
@@ -213,7 +215,7 @@ export function ReconciliationCard() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{data.sources.map((s) => {
|
{sources.map((s) => {
|
||||||
const isImap = s.source_type === "imap";
|
const isImap = s.source_type === "imap";
|
||||||
return (
|
return (
|
||||||
<TableRow key={s.source_key}>
|
<TableRow key={s.source_key}>
|
||||||
@@ -229,7 +231,7 @@ export function ReconciliationCard() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{s.points.map((p) => (
|
{(s.points ?? []).map((p) => (
|
||||||
<TableCell key={p.date} className="text-right">
|
<TableCell key={p.date} className="text-right">
|
||||||
<DayCell
|
<DayCell
|
||||||
archived={p.archived_count}
|
archived={p.archived_count}
|
||||||
|
|||||||
@@ -121,8 +121,8 @@ export function RoutingRulesTab({ isSuperAdmin }: { isSuperAdmin: boolean }) {
|
|||||||
];
|
];
|
||||||
Promise.all(loaders)
|
Promise.all(loaders)
|
||||||
.then(([rs, ts]) => {
|
.then(([rs, ts]) => {
|
||||||
setRules(rs);
|
setRules(Array.isArray(rs) ? rs : []);
|
||||||
setTenants(ts);
|
setTenants(Array.isArray(ts) ? ts : []);
|
||||||
})
|
})
|
||||||
.catch(() => setError("Routing-Regeln konnten nicht geladen werden"))
|
.catch(() => setError("Routing-Regeln konnten nicht geladen werden"))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
|
|||||||
@@ -77,12 +77,17 @@ export function SecurityTab({
|
|||||||
"SSH Root-Login": { action: "fix_ssh_root_login", label: "Auf prohibit-password setzen" },
|
"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 (
|
return (
|
||||||
<div className="space-y-2">
|
<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;
|
const fix = check.status !== "ok" ? fixActions[check.name] : undefined;
|
||||||
return (
|
return (
|
||||||
<Card key={i}>
|
<Card key={check.name || i}>
|
||||||
<CardContent className="p-4 flex items-start gap-3">
|
<CardContent className="p-4 flex items-start gap-3">
|
||||||
<span className={`mt-1 h-2.5 w-2.5 flex-shrink-0 rounded-full ${
|
<span className={`mt-1 h-2.5 w-2.5 flex-shrink-0 rounded-full ${
|
||||||
check.status === "ok" ? "bg-green-500" :
|
check.status === "ok" ? "bg-green-500" :
|
||||||
@@ -130,9 +135,9 @@ export function SecurityTab({
|
|||||||
{/* Summary */}
|
{/* Summary */}
|
||||||
<div className="mt-4 grid grid-cols-3 gap-3 text-center text-sm">
|
<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: "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: securityAudit.checks.filter((c: SecurityCheck) => c.status === "warning").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: securityAudit.checks.filter((c: SecurityCheck) => c.status === "error").length },
|
{ label: "Fehler", color: "bg-red-50 text-red-700", count: checks.filter((c: SecurityCheck) => c.status === "error").length },
|
||||||
].map((s) => (
|
].map((s) => (
|
||||||
<div key={s.label} className={`rounded p-3 ${s.color}`}>
|
<div key={s.label} className={`rounded p-3 ${s.color}`}>
|
||||||
<p className="text-2xl font-bold">{s.count}</p>
|
<p className="text-2xl font-bold">{s.count}</p>
|
||||||
|
|||||||
@@ -46,8 +46,9 @@ export function RestoreMailButton({ mailId, enabled }: RestoreMailButtonProps) {
|
|||||||
getImapAccounts()
|
getImapAccounts()
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
setAccounts(data);
|
const list = Array.isArray(data) ? data : [];
|
||||||
if (data.length > 0) setSelected(String(data[0].id));
|
setAccounts(list);
|
||||||
|
if (list.length > 0) setSelected(String(list[0].id));
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// Ignore — without accounts the button stays hidden.
|
// Ignore — without accounts the button stays hidden.
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ export function SearchFilterBar(props: SearchFilterBarProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
className="flex-1 text-left text-sm truncate"
|
className="flex-1 text-left text-sm truncate"
|
||||||
onClick={() => onApplySavedSearch(s)}
|
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}
|
{s.name}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const MATCH_FIELD_LABEL: Record<SearchMatchField, string> = {
|
|||||||
function MatchSourceBadge({ field }: { field: SearchMatchField }) {
|
function MatchSourceBadge({ field }: { field: SearchMatchField }) {
|
||||||
return (
|
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">
|
<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>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,11 @@ export function useAdminDashboard(active: boolean) {
|
|||||||
setApiOnline(health.status === "fulfilled" && health.value.status === "ok");
|
setApiOnline(health.status === "fulfilled" && health.value.status === "ok");
|
||||||
setStorageStats(storage.status === "fulfilled" ? storage.value : null);
|
setStorageStats(storage.status === "fulfilled" ? storage.value : null);
|
||||||
setSystemStats(sysStats.status === "fulfilled" ? sysStats.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());
|
setDashRefreshed(new Date());
|
||||||
} finally {
|
} finally {
|
||||||
setDashLoading(false);
|
setDashLoading(false);
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export function useImapAccounts(user: unknown) {
|
|||||||
const loadAccounts = useCallback(async () => {
|
const loadAccounts = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await getImapAccounts();
|
const data = await getImapAccounts();
|
||||||
setAccounts(data);
|
setAccounts(Array.isArray(data) ? data : []);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -101,24 +101,26 @@ export function useSavedSearches(params: UseSavedSearchesParams) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleApplySavedSearch(s: SavedSearch) {
|
function handleApplySavedSearch(s: SavedSearch) {
|
||||||
setQuery(s.query.q || "");
|
// query kann bei alten/defekten Datensaetzen fehlen (Backend: null).
|
||||||
setFromFilter(s.query.from || "");
|
const q = s.query ?? {};
|
||||||
setToFilter(s.query.to || "");
|
setQuery(q.q || "");
|
||||||
setDateFrom(s.query.date_from || "");
|
setFromFilter(q.from || "");
|
||||||
setDateTo(s.query.date_to || "");
|
setToFilter(q.to || "");
|
||||||
setHasAttachment(s.query.has_attachment === "true" ? true : undefined);
|
setDateFrom(q.date_from || "");
|
||||||
|
setDateTo(q.date_to || "");
|
||||||
|
setHasAttachment(q.has_attachment === "true" ? true : undefined);
|
||||||
setSavedListOpen(false);
|
setSavedListOpen(false);
|
||||||
// Trigger search after state updates
|
// Trigger search after state updates
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// We need to search with the saved query directly since state isn't updated yet
|
// We need to search with the saved query directly since state isn't updated yet
|
||||||
setSearching(true);
|
setSearching(true);
|
||||||
searchEmails({
|
searchEmails({
|
||||||
q: s.query.q || undefined,
|
q: q.q || undefined,
|
||||||
from: s.query.from || undefined,
|
from: q.from || undefined,
|
||||||
to: s.query.to || undefined,
|
to: q.to || undefined,
|
||||||
date_from: s.query.date_from || undefined,
|
date_from: q.date_from || undefined,
|
||||||
date_to: s.query.date_to || undefined,
|
date_to: q.date_to || undefined,
|
||||||
has_attachment: s.query.has_attachment === "true" ? true : undefined,
|
has_attachment: q.has_attachment === "true" ? true : undefined,
|
||||||
page: 1,
|
page: 1,
|
||||||
page_size: pageSize,
|
page_size: pageSize,
|
||||||
})
|
})
|
||||||
|
|||||||
+9
-1
@@ -29,5 +29,13 @@ export async function request<T>(
|
|||||||
|
|
||||||
if (res.status === 204) return {} as 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