8	"golang.org/x/exp/slog"
 
10	"github.com/mjl-/bstore"
 
12	"github.com/mjl-/mox/message"
 
13	"github.com/mjl-/mox/store"
 
16// Search returns messages matching criteria specified in parameters.
 
19func (c *conn) cmdxSearch(isUID bool, tag, cmd string, p *parser) {
 
24	// We will respond with ESEARCH instead of SEARCH if "RETURN" is present or for IMAP4rev2.
 
25	var eargs map[string]bool // Options except SAVE. Nil means old-style SEARCH response.
 
26	var save bool             // For SAVE option. Kept separately for easier handling of MIN/MAX later.
 
28	// IMAP4rev2 always returns ESEARCH, even with absent RETURN.
 
29	if c.enabled[capIMAP4rev2] {
 
30		eargs = map[string]bool{}
 
33	if p.take(" RETURN (") {
 
34		eargs = map[string]bool{}
 
37			if len(eargs) > 0 || save {
 
40			if w, ok := p.takelist("MIN", "MAX", "ALL", "COUNT", "SAVE"); ok {
 
48				xsyntaxErrorf("ESEARCH result option %q not supported", w)
 
53	if eargs != nil && len(eargs) == 0 && !save {
 
57	// If UTF8=ACCEPT is enabled, we should not accept any charset. We are a bit more
 
59	if p.take(" CHARSET ") {
 
60		charset := strings.ToUpper(p.xastring())
 
61		if charset != "US-ASCII" && charset != "UTF-8" {
 
63			xusercodeErrorf("BADCHARSET", "only US-ASCII and UTF-8 supported")
 
68		searchKeys: []searchKey{*p.xsearchKey()},
 
72		sk.searchKeys = append(sk.searchKeys, *p.xsearchKey())
 
75	// Even in case of error, we ensure search result is changed.
 
77		c.searchResult = []store.UID{}
 
80	// We gather word and not-word searches from the top-level, turn them
 
81	// into a WordSearch for a more efficient search.
 
82	// todo optimize: also gather them out of AND searches.
 
83	var textWords, textNotWords, bodyWords, bodyNotWords []string
 
85	for _, xsk := range sk.searchKeys {
 
88			bodyWords = append(bodyWords, xsk.astring)
 
91			textWords = append(textWords, xsk.astring)
 
94			switch xsk.searchKey.op {
 
96				bodyNotWords = append(bodyNotWords, xsk.searchKey.astring)
 
99				textNotWords = append(textNotWords, xsk.searchKey.astring)
 
103		sk.searchKeys[n] = xsk
 
106	// We may be left with an empty but non-nil sk.searchKeys, which is important for
 
108	sk.searchKeys = sk.searchKeys[:n]
 
109	var bodySearch, textSearch *store.WordSearch
 
110	if len(bodyWords) > 0 || len(bodyNotWords) > 0 {
 
111		ws := store.PrepareWordSearch(bodyWords, bodyNotWords)
 
114	if len(textWords) > 0 || len(textNotWords) > 0 {
 
115		ws := store.PrepareWordSearch(textWords, textNotWords)
 
119	// Note: we only hold the account rlock for verifying the mailbox at the start.
 
121	runlock := c.account.RUnlock
 
122	// Note: in a defer because we replace it below.
 
127	// If we only have a MIN and/or MAX, we can stop processing as soon as we
 
128	// have those matches.
 
137	var expungeIssued bool
 
138	var maxModSeq store.ModSeq
 
141	c.xdbread(func(tx *bstore.Tx) {
 
142		c.xmailboxID(tx, c.mailboxID) // Validate.
 
146		// Normal forward search when we don't have MAX only.
 
148		if eargs == nil || max == 0 || len(eargs) != 1 {
 
149			for i, uid := range c.uids {
 
151				if match, modseq := c.searchMatch(tx, msgseq(i+1), uid, *sk, bodySearch, textSearch, &expungeIssued); match {
 
152					uids = append(uids, uid)
 
153					if modseq > maxModSeq {
 
156					if min == 1 && min+max == len(eargs) {
 
162		// And reverse search for MAX if we have only MAX or MAX combined with MIN.
 
163		if max == 1 && (len(eargs) == 1 || min+max == len(eargs)) {
 
164			for i := len(c.uids) - 1; i > lastIndex; i-- {
 
165				if match, modseq := c.searchMatch(tx, msgseq(i+1), c.uids[i], *sk, bodySearch, textSearch, &expungeIssued); match {
 
166					uids = append(uids, c.uids[i])
 
167					if modseq > maxModSeq {
 
179			c.bwritelinef("* SEARCH")
 
182		// Old-style SEARCH response. We must spell out each number. So we may be splitting
 
190			for _, v := range uids[:n] {
 
192					v = store.UID(c.xsequence(v))
 
194				s += " " + fmt.Sprintf("%d", v)
 
197			// Since we don't have the max modseq for the possibly partial uid range we're
 
198			// writing here within hand reach, we conveniently interpret the ambiguous "for all
 
200			// write. And that clients only commit this value after they have seen the tagged
 
206				modseq = fmt.Sprintf(" (MODSEQ %d)", maxModSeq.Client())
 
209			c.bwritelinef("* SEARCH%s%s", s, modseq)
 
217			c.searchResult = uids
 
219				checkUIDs(c.searchResult)
 
225			// The tag was originally a string, became an astring in IMAP4rev2, better stick to
 
227			resp := fmt.Sprintf(`* ESEARCH (TAG "%s")`, tag)
 
232			// NOTE: we are converting UIDs to msgseq in the uids slice (if needed) while
 
233			// keeping the "uids" name!
 
235				// If searchResult is hanging on to the slice, we need to work on a copy.
 
237					nuids := make([]store.UID, len(uids))
 
241				for i, uid := range uids {
 
242					uids[i] = store.UID(c.xsequence(uid))
 
247			if eargs["MIN"] && len(uids) > 0 {
 
248				resp += fmt.Sprintf(" MIN %d", uids[0])
 
250			if eargs["MAX"] && len(uids) > 0 {
 
251				resp += fmt.Sprintf(" MAX %d", uids[len(uids)-1])
 
254				resp += fmt.Sprintf(" COUNT %d", len(uids))
 
256			if eargs["ALL"] && len(uids) > 0 {
 
257				resp += fmt.Sprintf(" ALL %s", compactUIDSet(uids).String())
 
261			// Summary: send the highest modseq of the returned messages.
 
262			if sk.hasModseq() && len(uids) > 0 {
 
263				resp += fmt.Sprintf(" MODSEQ %d", maxModSeq.Client())
 
266			c.bwritelinef("%s", resp)
 
271		c.writeresultf("%s OK [EXPUNGEISSUED] done", tag)
 
289func (c *conn) searchMatch(tx *bstore.Tx, seq msgseq, uid store.UID, sk searchKey, bodySearch, textSearch *store.WordSearch, expungeIssued *bool) (bool, store.ModSeq) {
 
290	s := search{c: c, tx: tx, seq: seq, uid: uid, expungeIssued: expungeIssued, hasModseq: sk.hasModseq()}
 
294			c.xsanity(err, "closing messagereader")
 
298	return s.match(sk, bodySearch, textSearch)
 
301func (s *search) match(sk searchKey, bodySearch, textSearch *store.WordSearch) (match bool, modseq store.ModSeq) {
 
302	// Instead of littering all the cases in match0 with calls to get modseq, we do it once
 
303	// here in case of a match.
 
305		if match && s.hasModseq {
 
307				match = s.xensureMessage()
 
314	if match && bodySearch != nil {
 
315		if !s.xensurePart() {
 
320		match, err = bodySearch.MatchPart(s.c.log, s.p, false)
 
321		xcheckf(err, "search words in bodies")
 
323	if match && textSearch != nil {
 
324		if !s.xensurePart() {
 
329		match, err = textSearch.MatchPart(s.c.log, s.p, true)
 
330		xcheckf(err, "search words in headers and bodies")
 
335func (s *search) xensureMessage() bool {
 
340	q := bstore.QueryTx[store.Message](s.tx)
 
341	q.FilterNonzero(store.Message{MailboxID: s.c.mailboxID, UID: s.uid})
 
343	if err == bstore.ErrAbsent || err == nil && m.Expunged {
 
345		*s.expungeIssued = true
 
348	xcheckf(err, "get message")
 
353// ensure message, reader and part are loaded. returns whether that was
 
355func (s *search) xensurePart() bool {
 
360	if !s.xensureMessage() {
 
364	// Closed by searchMatch after all (recursive) search.match calls are finished.
 
365	s.mr = s.c.account.MessageReader(s.m)
 
367	if s.m.ParsedBuf == nil {
 
368		s.c.log.Error("missing parsed message")
 
371	p, err := s.m.LoadPart(s.mr)
 
372	xcheckf(err, "load parsed message")
 
377func (s *search) match0(sk searchKey) bool {
 
380	// Difference between sk.searchKeys nil and length 0 is important. Because we take
 
381	// out word/notword searches, the list may be empty but non-nil.
 
382	if sk.searchKeys != nil {
 
383		for _, ssk := range sk.searchKeys {
 
389	} else if sk.seqSet != nil {
 
390		return sk.seqSet.containsSeq(s.seq, c.uids, c.searchResult)
 
393	filterHeader := func(field, value string) bool {
 
394		lower := strings.ToLower(value)
 
395		h, err := s.p.Header()
 
397			c.log.Debugx("parsing message header", err, slog.Any("uid", s.uid))
 
400		for _, v := range h.Values(field) {
 
401			if strings.Contains(strings.ToLower(v), lower) {
 
408	// We handle ops by groups that need increasing details about the message.
 
414		// We do not implement the RECENT flag, so messages cannot be NEW.
 
417		// We treat all messages as non-recent, so this means all messages.
 
420		// We do not implement the RECENT flag. All messages are not recent.
 
423		return !s.match0(*sk.searchKey)
 
425		return s.match0(*sk.searchKey) || s.match0(*sk.searchKey2)
 
427		return sk.uidSet.containsUID(s.uid, c.uids, c.searchResult)
 
431	if !s.xensurePart() {
 
435	// Parsed message, basic info.
 
444		kw := strings.ToLower(sk.atom)
 
457			for _, k := range s.m.Keywords {
 
473		kw := strings.ToLower(sk.atom)
 
476			return !s.m.Forwarded
 
486			for _, k := range s.m.Keywords {
 
499	case "BEFORE", "ON", "SINCE":
 
500		skdt := sk.date.Format("2006-01-02")
 
501		rdt := s.m.Received.Format("2006-01-02")
 
510		panic("missing case")
 
512		return s.m.Size > sk.number
 
514		return s.m.Size < sk.number
 
517		return s.m.ModSeq.Client() >= *sk.clientModseq
 
521		c.log.Info("missing parsed message, not matching", slog.Any("uid", s.uid))
 
525	// Parsed message, more info.
 
528		return filterHeader("Bcc", sk.astring)
 
530		// We gathered word/notword searches from the top-level, but we can also get them
 
532		// todo optimize: handle deeper nested word/not-word searches more efficiently.
 
533		headerToo := sk.op == "TEXT"
 
534		match, err := store.PrepareWordSearch([]string{sk.astring}, nil).MatchPart(s.c.log, s.p, headerToo)
 
535		xcheckf(err, "word search")
 
538		return filterHeader("Cc", sk.astring)
 
540		return filterHeader("From", sk.astring)
 
542		return filterHeader("Subject", sk.astring)
 
544		return filterHeader("To", sk.astring)
 
547		lower := strings.ToLower(sk.astring)
 
548		h, err := s.p.Header()
 
550			c.log.Errorx("parsing header for search", err, slog.Any("uid", s.uid))
 
553		k := textproto.CanonicalMIMEHeaderKey(sk.headerField)
 
554		for _, v := range h.Values(k) {
 
555			if lower == "" || strings.Contains(strings.ToLower(v), lower) {
 
560	case "SENTBEFORE", "SENTON", "SENTSINCE":
 
561		if s.p.Envelope == nil || s.p.Envelope.Date.IsZero() {
 
564		dt := s.p.Envelope.Date.Format("2006-01-02")
 
565		skdt := sk.date.Format("2006-01-02")
 
574		panic("missing case")
 
576	panic(serverError{fmt.Errorf("missing case for search key op %q", sk.op)})