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) } 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 } } 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 role: only own mailbox if sess.Role == userstore.RoleUser { u, err := s.users.GetByUsername(sess.Username) if err != nil || !mailBelongsToUser(pm, u.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 } } if sess.Role == userstore.RoleUser { 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: Access check for user role — parse failure must NOT grant access. if sess.Role == userstore.RoleUser { 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 To or CC. func mailBelongsToUser(pm *mailparser.ParsedMail, userEmail string) bool { email := strings.ToLower(userEmail) for _, to := range pm.To { if strings.ToLower(to) == email { return true } } for _, cc := range pm.CC { if 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) }