feat(PROJ-30): Xapian → Manticore Search Migration

- internal/index/manticore.go: ManticoreTenantManager + manticoreIndex (RT-Indizes, CGO-frei)
- internal/index/index.go: TenantIndexer Interface (Xapian + Manticore)
- internal/index/tenant_worker.go: mgr-Typ auf TenantIndexer Interface
- internal/api/server.go: idxMgr auf TenantIndexer Interface
- config/config.go: IndexConfig.ManticoreDSN Feld
- cmd/archivmail/cmd_reindex.go: reindex Subkommando
- cmd/archivmail/main.go: Manticore-Branch + reindex Case
- go.mod: github.com/go-sql-driver/mysql v1.8.1
- update.sh: Manticore auto-install, CGO_ENABLED=0, config.yml migration, auto-reindex

fix(IMAP): TCP-Deadline-Wrapper für steckengebliebene Imports
fix(auth): Email-Claim in JWT für User-Isolation
fix(search): User-Isolation via sess.Email (fail-safe)
fix(ui): Admin-Login Auth-Cache, Logout-Redirect, IMAP-Polling-Resilienz

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-04-03 21:19:36 +02:00
parent e90d588e30
commit a93a843506
19 changed files with 742 additions and 65 deletions
+29 -5
View File
@@ -171,6 +171,18 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
labelMap, _ = s.labels.GetLabelsForEmails(r.Context(), emailIDs)
}
// SEC: For user role, restrict results to mails the user is involved in
// (From, To, or CC). Email comes from the JWT session — no DB lookup needed.
// If email is missing for a user-role session, block all results (fail-safe).
var userEmailFilter string
if sess.Role == userstore.RoleUser {
userEmailFilter = strings.ToLower(sess.Email)
if userEmailFilter == "" {
writeJSON(w, http.StatusOK, map[string]interface{}{"total": 0, "hits": []interface{}{}})
return
}
}
enriched := make([]enrichedHit, 0, len(result.Hits))
for _, h := range result.Hits {
eh := enrichedHit{ID: h.ID, Score: h.Score}
@@ -186,6 +198,14 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
eh.Date = pm.Date.UTC().Format(time.RFC3339)
}
eh.HasAttachments = len(pm.Attachments) > 0
// User isolation: skip mails the user is not involved in.
if userEmailFilter != "" && !mailBelongsToUser(pm, userEmailFilter) {
continue
}
} else if userEmailFilter != "" {
// If mail can't be parsed, deny access to user role.
continue
}
}
if labelMap != nil {
@@ -233,8 +253,7 @@ func (s *Server) handleGetMail(w http.ResponseWriter, r *http.Request) {
// user and auditor: only own mails; domain_auditor: all tenant mails (no filter)
if sess.Role == userstore.RoleUser || sess.Role == userstore.RoleAuditor {
u, err := s.users.GetByUsername(sess.Username)
if err != nil || !mailBelongsToUser(pm, u.Email) {
if sess.Email == "" || !mailBelongsToUser(pm, sess.Email) {
writeError(w, http.StatusForbidden, "access denied")
return
}
@@ -399,16 +418,21 @@ func (s *Server) handleGetRaw(w http.ResponseWriter, r *http.Request) {
w.Write(raw)
}
// mailBelongsToUser checks if the user's email appears in To or CC.
// mailBelongsToUser checks if the user's email appears in From, To, or CC.
// Users can access mails they sent as well as mails they received.
// From may contain a display name ("Name <addr>"), so Contains is used.
func mailBelongsToUser(pm *mailparser.ParsedMail, userEmail string) bool {
email := strings.ToLower(userEmail)
if strings.Contains(strings.ToLower(pm.From), email) {
return true
}
for _, to := range pm.To {
if strings.ToLower(to) == email {
if strings.Contains(strings.ToLower(to), email) {
return true
}
}
for _, cc := range pm.CC {
if strings.ToLower(cc) == email {
if strings.Contains(strings.ToLower(cc), email) {
return true
}
}