fix: Crash-Robustheit + Performance in Backend und Frontend härten

Backend: recover() in allen langlebigen Goroutinen (neues internal/safego-
Paket), MIME-Multipart-Tiefenlimit gegen Stack-Overflow, IMAP-Zeilenlängen-
und FETCH-Result-Limits gegen OOM, MBOX-Buffer-Aliasing-Bug (Datenkorruption
beim Import), ungeprüfte Type Assertions abgesichert, Data Race im
API-Key-Rate-Limiter behoben, SMTP-Session-Panic führt jetzt zu 451-Retry
statt Prozessabsturz. Performance: O(n²)-String-Concat in Mailparser und
IMAP-Parser durch strings.Builder ersetzt.

Frontend: Error Boundaries für Root und Mail-Detailansicht ergänzt (gab es
vorher nicht), zahlreiche Guards gegen nil-Slices aus dem Backend-JSON die
sonst .map()/.length-Crashes/White-Screens auslösten, defekte JSON-Antworten
in api/core.ts abgefangen, zwei React-Key-Bugs bei löschbaren Listen
korrigiert.

Verifiziert auf 192.168.1.132: Build und Tests der geänderten Pakete
fehlerfrei, keine Regressionen gegenüber vorbestehendem Stand.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019j28kGcaJAhBnrYX34hGdt
This commit is contained in:
sysops
2026-08-05 13:39:00 +02:00
co-authored by Claude Sonnet 5
parent 0fa4fad49d
commit 798cb2817c
35 changed files with 502 additions and 112 deletions
+4 -1
View File
@@ -9,6 +9,7 @@ import (
"archivmail/internal/auth"
imapstore "archivmail/internal/imap"
"archivmail/internal/safego"
"archivmail/internal/userstore"
)
@@ -211,7 +212,9 @@ func (s *Server) handleStartImport(w http.ResponseWriter, r *http.Request) {
return
}
go s.imapImporter.Run(context.Background(), id)
safego.Go(s.logger, "imap import", func() {
s.imapImporter.Run(context.Background(), id)
})
// Return current account state (status will switch to "running" shortly)
acc.Status = "running"
+5 -3
View File
@@ -8,6 +8,7 @@ import (
"archivmail/internal/audit"
"archivmail/internal/mailer"
"archivmail/internal/safego"
"archivmail/internal/tokenstore"
"archivmail/internal/userstore"
)
@@ -55,11 +56,12 @@ func (s *Server) handleCreateInvite(w http.ResponseWriter, r *http.Request) {
tenantName = t.Name
}
}
go func() {
email := body.Email
safego.Go(s.logger, "send invite mail", func() {
html := mailer.InviteHTML(s.fqdn, token, tenantName)
txt := mailer.InviteText(s.fqdn, token, tenantName)
_ = s.mailer.Send(body.Email, "Einladung zu archivmail", html, txt)
}()
_ = s.mailer.Send(email, "Einladung zu archivmail", html, txt)
})
}
if s.audlog != nil {
+9 -7
View File
@@ -7,6 +7,7 @@ import (
"archivmail/internal/audit"
"archivmail/internal/mailer"
"archivmail/internal/safego"
"archivmail/internal/tokenstore"
"archivmail/internal/userstore"
)
@@ -79,11 +80,12 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
if err != nil {
// SEC: token already consumed — response is identical to success to prevent
// enumeration of whether the email address already existed.
go func() {
email := body.Email
safego.Go(s.logger, "send already-registered mail", func() {
html := mailer.AlreadyRegisteredHTML(s.fqdn)
txt := mailer.AlreadyRegisteredText(s.fqdn)
_ = s.mailer.Send(body.Email, "archivmail Registrierungsversuch", html, txt)
}()
_ = s.mailer.Send(email, "archivmail Registrierungsversuch", html, txt)
})
writeJSON(w, http.StatusOK, map[string]string{"message": signupMsg})
return
}
@@ -100,11 +102,11 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
}
// Send verification email.
go func() {
safego.Go(s.logger, "send verify mail", func() {
html := mailer.VerifyEmailHTML(s.fqdn, token, u.Username)
txt := mailer.VerifyEmailText(s.fqdn, token, u.Username)
_ = s.mailer.Send(u.Email, "archivmail E-Mail bestätigen", html, txt)
}()
})
if s.audlog != nil {
s.audlog.Log(audit.Entry{
@@ -193,11 +195,11 @@ func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) {
return
}
go func() {
safego.Go(s.logger, "send password-reset mail", func() {
html := mailer.ResetPasswordHTML(s.fqdn, token, u.Username)
txt := mailer.ResetPasswordText(s.fqdn, token, u.Username)
_ = s.mailer.Send(u.Email, "archivmail Passwort zurücksetzen", html, txt)
}()
})
if s.audlog != nil {
s.audlog.Log(audit.Entry{
+4 -1
View File
@@ -9,6 +9,7 @@ import (
"archivmail/internal/auth"
pop3store "archivmail/internal/pop3"
"archivmail/internal/safego"
"archivmail/internal/userstore"
)
@@ -212,7 +213,9 @@ func (s *Server) handleStartPop3Import(w http.ResponseWriter, r *http.Request) {
return
}
go s.pop3Importer.Run(context.Background(), id)
safego.Go(s.logger, "pop3 import", func() {
s.pop3Importer.Run(context.Background(), id)
})
// Return current account state (status will switch to "running" shortly)
acc.Status = "running"
+15 -3
View File
@@ -9,6 +9,7 @@ import (
"sync"
"archivmail/internal/index"
"archivmail/internal/safego"
"archivmail/pkg/mailparser"
)
@@ -94,7 +95,12 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
var allMessages [][]byte
for _, e := range entries {
if e.isMbox {
msgs := mailparser.SplitMbox(e.data)
msgs, err := mailparser.SplitMboxErr(e.data)
if err != nil {
// Partial result: import what was parsed, but make the
// truncation visible instead of losing mails silently.
s.logger.Warn("upload: mbox scan incomplete", "messages", len(msgs), "err", err)
}
allMessages = append(allMessages, msgs...)
} else {
allMessages = append(allMessages, e.data)
@@ -113,7 +119,9 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
tenantID := tenantFromCtx(r.Context())
// Run import in background
go s.runUploadJob(job, allMessages, tenantID)
safego.Go(s.logger, "upload import job", func() {
s.runUploadJob(job, allMessages, tenantID)
})
writeJSON(w, http.StatusAccepted, map[string]string{"job_id": jobID})
}
@@ -126,7 +134,11 @@ func (s *Server) handleUploadProgress(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "job not found")
return
}
job := val.(*UploadJob)
job, ok := val.(*UploadJob)
if !ok || job == nil {
writeError(w, http.StatusNotFound, "job not found")
return
}
writeJSON(w, http.StatusOK, job.snapshot())
}