package mail import ( "context" "encoding/json" "fmt" "strings" "time" "github.com/ultisuite/ulti-backend/internal/api/query" "github.com/ultisuite/ulti-backend/internal/mail/imap" ) type MessageSearchFilter struct { Query string Sender string DateFrom *time.Time DateTo *time.Time HasAttachments *bool Label string AccountID string ScopedAccountIDs []string IncludeSpam bool IncludeTrash bool } type MessageSearchResult struct { Messages []map[string]any `json:"messages"` Pagination query.PaginationMeta `json:"pagination,omitempty"` } func (s *Service) SearchMessages(ctx context.Context, externalID string, filter MessageSearchFilter, params query.ListParams) (MessageSearchResult, error) { base := ` FROM messages m JOIN mail_accounts ma ON m.account_id = ma.id WHERE ma.user_id = (SELECT id FROM users WHERE external_id = $1) ` args := []any{externalID} argIdx := 2 base, args, argIdx = appendMessageAccountScope(base, args, argIdx, filter.AccountID, filter.ScopedAccountIDs) if filter.Sender != "" { base += fmt.Sprintf(" AND m.from_addr::text ILIKE '%%' || $%d || '%%'", argIdx) args = append(args, filter.Sender) argIdx++ } if filter.DateFrom != nil { base += fmt.Sprintf(" AND m.date >= $%d", argIdx) args = append(args, *filter.DateFrom) argIdx++ } if filter.DateTo != nil { base += fmt.Sprintf(" AND m.date <= $%d", argIdx) args = append(args, *filter.DateTo) argIdx++ } if filter.HasAttachments != nil { base += fmt.Sprintf(" AND m.has_attachments = $%d", argIdx) args = append(args, *filter.HasAttachments) argIdx++ } if filter.Label != "" { base += fmt.Sprintf(" AND $%d = ANY(m.labels)", argIdx) args = append(args, filter.Label) argIdx++ } if q := strings.TrimSpace(filter.Query); q != "" { tsQuery := toMailTSQuery(q) base += fmt.Sprintf(" AND m.search_vector @@ to_tsquery('simple', $%d)", argIdx) args = append(args, tsQuery) argIdx++ } excludeSpam, excludeTrash := HiddenMailboxExclusion("", filter.Label, filter.IncludeSpam, filter.IncludeTrash) base, args, argIdx = AppendHiddenMailboxExclusion(base, args, argIdx, excludeSpam, excludeTrash) var total int64 if err := s.db.QueryRow(ctx, "SELECT COUNT(*) "+base, args...).Scan(&total); err != nil { return MessageSearchResult{}, err } listQuery := ` SELECT m.id, m.message_id, m.thread_id, m.subject, m.from_addr, m.to_addrs, m.date, m.snippet, m.flags, m.labels, m.has_attachments ` + base + fmt.Sprintf(" ORDER BY m.date DESC LIMIT $%d OFFSET $%d", argIdx, argIdx+1) args = append(args, params.Limit(), params.Offset()) rows, err := s.db.Query(ctx, listQuery, args...) if err != nil { return MessageSearchResult{}, err } defer rows.Close() messages := make([]map[string]any, 0) for rows.Next() { var id, messageID, subject, snippet string var threadID *string var fromAddr, toAddrs []byte var date any var flags, labels []string var hasAttachments bool if err := rows.Scan(&id, &messageID, &threadID, &subject, &fromAddr, &toAddrs, &date, &snippet, &flags, &labels, &hasAttachments); err != nil { return MessageSearchResult{}, err } entry := map[string]any{ "id": id, "message_id": messageID, "subject": subject, "from": json.RawMessage(fromAddr), "to": json.RawMessage(toAddrs), "date": date, "snippet": imap.RepairSnippet(snippet), "flags": flags, "labels": labels, "has_attachments": hasAttachments, } if threadID != nil { entry["thread_id"] = *threadID } messages = append(messages, entry) } if err := rows.Err(); err != nil { return MessageSearchResult{}, err } return MessageSearchResult{ Messages: messages, Pagination: params.Meta(&total), }, nil } func toMailTSQuery(input string) string { words := strings.Fields(input) for i, w := range words { words[i] = w + ":*" } return strings.Join(words, " & ") }