Files
archivmail/internal/api/search_handlers.go
T
sysops a93a843506 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>
2026-04-03 21:19:36 +02:00

454 lines
13 KiB
Go

package api
import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/archivmail/internal/audit"
"github.com/archivmail/internal/index"
"github.com/archivmail/internal/userstore"
"github.com/archivmail/pkg/mailparser"
)
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
fromFilter := r.URL.Query().Get("from")
toFilter := r.URL.Query().Get("to")
dateFromStr := r.URL.Query().Get("date_from")
dateToStr := r.URL.Query().Get("date_to")
sortParam := r.URL.Query().Get("sort") // "relevance", "date_asc", "date_desc"
hasAttachStr := r.URL.Query().Get("has_attachment") // "true" or "false"
labelIDStr := r.URL.Query().Get("label_id") // PROJ-9: filter by label
pageStr := r.URL.Query().Get("page")
pageSizeStr := r.URL.Query().Get("page_size")
page, _ := strconv.Atoi(pageStr)
pageSize, _ := strconv.Atoi(pageSizeStr)
if pageSize <= 0 {
pageSize = 25
}
req := index.SearchRequest{
Query: q,
Sort: sortParam,
PageSize: pageSize,
Page: page,
}
// PROJ-9: Parse label_id filter.
if labelIDStr != "" {
if lid, err := strconv.ParseInt(labelIDStr, 10, 64); err == nil && lid > 0 {
req.LabelID = &lid
}
}
if hasAttachStr == "true" {
v := true
req.HasAttachment = &v
} else if hasAttachStr == "false" {
v := false
req.HasAttachment = &v
}
// Domain search: @domain.de matches both From AND To fields.
// A value starting with '@' triggers OR-search across XF and XT prefixes.
if strings.HasPrefix(fromFilter, "@") || strings.HasPrefix(toFilter, "@") {
domain := fromFilter
if domain == "" {
domain = toFilter
}
req.OwnEmail = domain
} else {
req.From = fromFilter
req.To = toFilter
}
if dateFromStr != "" {
if t, err := time.Parse(time.RFC3339, dateFromStr); err == nil {
req.DateFrom = &t
} else if t, err := time.Parse(time.DateOnly, dateFromStr); err == nil {
req.DateFrom = &t
}
}
if dateToStr != "" {
if t, err := time.Parse(time.RFC3339, dateToStr); err == nil {
req.DateTo = &t
} else if t, err := time.Parse(time.DateOnly, dateToStr); err == nil {
// end of day for date_to
t = t.Add(24*time.Hour - time.Second)
req.DateTo = &t
}
}
// PROJ-21 Phase 4: Use per-tenant index when available; fall back to
// global index + post-filter when the tenant index manager is not wired.
tenantID := tenantFromCtx(r.Context())
searchIdx := s.idx
usedTenantIndex := false
if s.idxMgr != nil && tenantID != nil {
searchIdx = s.idxMgr.ForTenant(tenantID)
usedTenantIndex = true
}
result, err := searchIdx.Search(req)
if err != nil {
writeError(w, http.StatusInternalServerError, "search failed")
return
}
// Fallback tenant isolation: post-filter when we used the global index
// but the user belongs to a tenant. This is the legacy path; the per-tenant
// index path above makes this unnecessary.
if tenantID != nil && !usedTenantIndex && len(result.Hits) > 0 {
allowedIDs, idErr := s.store.GetAllIDsByTenant(r.Context(), tenantID)
if idErr == nil {
allowed := make(map[string]struct{}, len(allowedIDs))
for _, id := range allowedIDs {
allowed[id] = struct{}{}
}
filtered := result.Hits[:0]
for _, h := range result.Hits {
if _, ok := allowed[h.ID]; ok {
filtered = append(filtered, h)
}
}
result.Hits = filtered
result.Total = len(filtered)
}
}
// PROJ-9: Post-filter by label_id when the label store is available.
if req.LabelID != nil && s.labels != nil && len(result.Hits) > 0 {
labelEmailIDs, lErr := s.labels.GetEmailIDsByLabel(r.Context(), *req.LabelID)
if lErr == nil {
allowed := make(map[string]struct{}, len(labelEmailIDs))
for _, id := range labelEmailIDs {
allowed[id] = struct{}{}
}
filtered := result.Hits[:0]
for _, h := range result.Hits {
if _, ok := allowed[h.ID]; ok {
filtered = append(filtered, h)
}
}
result.Hits = filtered
result.Total = len(filtered)
}
}
sess := sessionFromCtx(r.Context())
s.audlog.Log(audit.Entry{
EventType: audit.EventSearch,
Username: sess.Username,
IPAddress: s.remoteIP(r),
Query: q,
Success: true,
})
// Enrich hits with metadata (from, subject, date, size, attachments).
type enrichedHit struct {
ID string `json:"id"`
Score float64 `json:"score"`
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
Subject string `json:"subject,omitempty"`
Date string `json:"date,omitempty"`
Size int64 `json:"size,omitempty"`
HasAttachments bool `json:"has_attachments"`
LabelIDs []int64 `json:"label_ids,omitempty"` // PROJ-9
}
// PROJ-9: Batch-load label IDs for all hits.
var labelMap map[string][]int64
if s.labels != nil && len(result.Hits) > 0 {
emailIDs := make([]string, len(result.Hits))
for i, h := range result.Hits {
emailIDs[i] = h.ID
}
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}
if raw, err := s.store.Load(h.ID); err == nil {
eh.Size = int64(len(raw))
if pm, err := mailparser.Parse(raw); err == nil {
eh.From = pm.From
if len(pm.To) > 0 {
eh.To = strings.Join(pm.To, ", ")
}
eh.Subject = pm.Subject
if !pm.Date.IsZero() {
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 {
eh.LabelIDs = labelMap[h.ID]
}
enriched = append(enriched, eh)
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"total": result.Total,
"hits": enriched,
})
}
func (s *Server) handleGetMail(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
// SEC-22: Validate mail ID format to prevent path traversal.
if !isValidMailID(id) {
writeError(w, http.StatusBadRequest, "invalid mail id")
return
}
raw, err := s.store.Load(id)
if err != nil {
writeError(w, http.StatusNotFound, "mail not found")
return
}
pm, err := mailparser.Parse(raw)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to parse mail")
return
}
sess := sessionFromCtx(r.Context())
// Tenant isolation: domain_admin sees only own tenant's mail
if sess.TenantID != nil {
mailTenant, _ := s.store.GetTenantForMail(r.Context(), id)
if mailTenant == nil || *mailTenant != *sess.TenantID {
writeError(w, http.StatusForbidden, "access denied")
return
}
}
// user and auditor: only own mails; domain_auditor: all tenant mails (no filter)
if sess.Role == userstore.RoleUser || sess.Role == userstore.RoleAuditor {
if sess.Email == "" || !mailBelongsToUser(pm, sess.Email) {
writeError(w, http.StatusForbidden, "access denied")
return
}
}
type attachMeta struct {
Index int `json:"index"`
Filename string `json:"filename"`
ContentType string `json:"content_type"`
Size int `json:"size"`
}
attachments := make([]attachMeta, len(pm.Attachments))
for i, a := range pm.Attachments {
attachments[i] = attachMeta{
Index: i,
Filename: a.Filename,
ContentType: a.ContentType,
Size: a.Size,
}
}
var dateStr string
if !pm.Date.IsZero() {
dateStr = pm.Date.UTC().Format(time.RFC3339)
}
// Verify status
vs, _ := s.store.GetVerifyStatus(r.Context(), id)
var verifyOK interface{} = nil
var verifiedAt interface{} = nil
if vs.VerifyOK != nil {
verifyOK = *vs.VerifyOK
}
if vs.VerifiedAt != nil {
verifiedAt = vs.VerifiedAt.UTC().Format(time.RFC3339)
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"id": id,
"from": pm.From,
"to": strings.Join(pm.To, ", "),
"cc": strings.Join(pm.CC, ", "),
"subject": pm.Subject,
"date": dateStr,
"size": len(raw),
"body_html": pm.HTMLBody,
"body_plain": pm.TextBody,
"raw_headers": extractRawHeaders(raw),
"attachments": attachments,
"verify_ok": verifyOK,
"verified_at": verifiedAt,
})
}
func (s *Server) handleGetAttachment(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
// SEC-22: Validate mail ID format to prevent path traversal.
if !isValidMailID(id) {
writeError(w, http.StatusBadRequest, "invalid mail id")
return
}
indexStr := r.PathValue("index")
idx, err := strconv.Atoi(indexStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid attachment index")
return
}
raw, err := s.store.Load(id)
if err != nil {
writeError(w, http.StatusNotFound, "mail not found")
return
}
pm, err := mailparser.Parse(raw)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to parse mail")
return
}
sess := sessionFromCtx(r.Context())
// Tenant isolation
if sess.TenantID != nil {
mailTenant, _ := s.store.GetTenantForMail(r.Context(), id)
if mailTenant == nil || *mailTenant != *sess.TenantID {
writeError(w, http.StatusForbidden, "access denied")
return
}
}
// 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) {
writeError(w, http.StatusForbidden, "access denied")
return
}
}
if idx < 0 || idx >= len(pm.Attachments) {
writeError(w, http.StatusNotFound, "attachment not found")
return
}
a := pm.Attachments[idx]
filename := sanitizeFilename(a.Filename)
if filename == "" {
filename = fmt.Sprintf("attachment-%d", idx)
}
w.Header().Set("Content-Type", a.ContentType)
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
w.Header().Set("Content-Length", strconv.Itoa(len(a.Data)))
w.WriteHeader(http.StatusOK)
w.Write(a.Data)
}
func (s *Server) handleGetRaw(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
// SEC-22: Validate mail ID format to prevent path traversal.
if !isValidMailID(id) {
writeError(w, http.StatusBadRequest, "invalid mail id")
return
}
raw, err := s.store.Load(id)
if err != nil {
writeError(w, http.StatusNotFound, "mail not found")
return
}
sess := sessionFromCtx(r.Context())
// Tenant isolation
if sess.TenantID != nil {
mailTenant, _ := s.store.GetTenantForMail(r.Context(), id)
if mailTenant == nil || *mailTenant != *sess.TenantID {
writeError(w, http.StatusForbidden, "access denied")
return
}
}
// SEC-28/29: User and Auditor: only own mails. Parse failure must NOT grant access.
if sess.Role == userstore.RoleUser || sess.Role == userstore.RoleAuditor {
pm, err := mailparser.Parse(raw)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to parse mail")
return
}
u, err := s.users.GetByUsername(sess.Username)
if err != nil || !mailBelongsToUser(pm, u.Email) {
writeError(w, http.StatusForbidden, "access denied")
return
}
}
w.Header().Set("Content-Type", "message/rfc822")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.eml"`, id[:16]))
w.Header().Set("Content-Length", strconv.Itoa(len(raw)))
w.WriteHeader(http.StatusOK)
w.Write(raw)
}
// 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.Contains(strings.ToLower(to), email) {
return true
}
}
for _, cc := range pm.CC {
if strings.Contains(strings.ToLower(cc), email) {
return true
}
}
return false
}
// extractRawHeaders returns the header section of a raw RFC 2822 email.
func extractRawHeaders(raw []byte) string {
for i := 0; i < len(raw)-3; i++ {
if raw[i] == '\r' && raw[i+1] == '\n' && raw[i+2] == '\r' && raw[i+3] == '\n' {
return string(raw[:i])
}
if raw[i] == '\n' && raw[i+1] == '\n' {
return string(raw[:i])
}
}
return string(raw)
}