package mail import ( "net/http" "net/url" "strings" "time" "github.com/ultisuite/ulti-backend/internal/api/apiresponse" "github.com/ultisuite/ulti-backend/internal/api/apivalidate" "github.com/ultisuite/ulti-backend/internal/api/middleware" "github.com/ultisuite/ulti-backend/internal/api/query" ) func (h *Handler) SearchMessages(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) params, err := query.ParseList(stripNonDateListRangeKeys(r.URL.Query())) if err != nil { apivalidate.WriteQueryError(w, r, err) return } filter, verr := parseMessageSearchFilter(r) if verr != nil { apivalidate.WriteValidationError(w, r, verr) return } h.applyMailSearchScope(&filter, r) result, err := h.svc.SearchMessages(r.Context(), claims.Sub, filter, params) if err != nil { h.logger.Error("search messages", "error", err) apivalidate.WriteInternal(w, r) return } apiresponse.WriteJSON(w, http.StatusOK, result) } func parseMessageSearchFilter(r *http.Request) (MessageSearchFilter, *apivalidate.ValidationError) { q := r.URL.Query() filter := MessageSearchFilter{ Query: q.Get("q"), Sender: parseSearchSender(q), Label: q.Get("label"), AccountID: q.Get("account_id"), IncludeSpam: parseOptionalBool(q.Get("include_spam")), IncludeTrash: parseOptionalBool(q.Get("include_trash")), } if raw := q.Get("date_from"); raw != "" { t, err := time.Parse(time.RFC3339, raw) if err != nil { return filter, apivalidate.NewValidationError(apivalidate.FieldDetail{ Field: "date_from", Message: "invalid RFC3339 datetime", }) } filter.DateFrom = &t } if raw := q.Get("date_to"); raw != "" { t, err := time.Parse(time.RFC3339, raw) if err != nil { return filter, apivalidate.NewValidationError(apivalidate.FieldDetail{ Field: "date_to", Message: "invalid RFC3339 datetime", }) } filter.DateTo = &t } if raw := q.Get("has_attachment"); raw != "" { switch raw { case "true", "1": v := true filter.HasAttachments = &v case "false", "0": v := false filter.HasAttachments = &v default: return filter, apivalidate.NewValidationError(apivalidate.FieldDetail{ Field: "has_attachment", Message: "must be true or false", }) } } if filter.Query == "" && filter.Sender == "" && filter.Label == "" && filter.AccountID == "" && filter.DateFrom == nil && filter.DateTo == nil && filter.HasAttachments == nil { return filter, apivalidate.NewValidationError(apivalidate.FieldDetail{ Field: "q", Message: "at least one search filter required", }) } return filter, nil } // stripNonDateListRangeKeys removes from/to when they are sender/recipient filters, // not YYYY-MM-DD list date bounds (shared param names on /mail/search). func stripNonDateListRangeKeys(values url.Values) url.Values { out := values clone := make(url.Values, len(values)) for k, vv := range values { clone[k] = append([]string(nil), vv...) } out = clone for _, key := range []string{"from", "to"} { raw := strings.TrimSpace(out.Get(key)) if raw == "" { continue } if _, err := query.ParseDate(raw); err != nil { out.Del(key) } } return out } func parseSearchSender(q url.Values) string { if s := strings.TrimSpace(q.Get("sender")); s != "" { return s } from := strings.TrimSpace(q.Get("from")) if from == "" { return "" } if _, err := query.ParseDate(from); err != nil { return from } return "" } func parseOptionalBool(raw string) bool { switch strings.ToLower(strings.TrimSpace(raw)) { case "1", "true", "yes", "on": return true default: return false } }