9 "github.com/mjl-/bstore"
11 "github.com/mjl-/mox/message"
12 "github.com/mjl-/mox/store"
15// Search returns messages matching criteria specified in parameters.
18func (c *conn) cmdxSearch(isUID bool, tag, cmd string, p *parser) {
23 // We will respond with ESEARCH instead of SEARCH if "RETURN" is present or for IMAP4rev2.
24 var eargs map[string]bool // Options except SAVE. Nil means old-style SEARCH response.
25 var save bool // For SAVE option. Kept separately for easier handling of MIN/MAX later.
27 // IMAP4rev2 always returns ESEARCH, even with absent RETURN.
28 if c.enabled[capIMAP4rev2] {
29 eargs = map[string]bool{}
32 if p.take(" RETURN (") {
33 eargs = map[string]bool{}
36 if len(eargs) > 0 || save {
39 if w, ok := p.takelist("MIN", "MAX", "ALL", "COUNT", "SAVE"); ok {
47 xsyntaxErrorf("ESEARCH result option %q not supported", w)
52 if eargs != nil && len(eargs) == 0 && !save {
56 // If UTF8=ACCEPT is enabled, we should not accept any charset. We are a bit more
58 if p.take(" CHARSET ") {
59 charset := strings.ToUpper(p.xastring())
60 if charset != "US-ASCII" && charset != "UTF-8" {
62 xusercodeErrorf("BADCHARSET", "only US-ASCII and UTF-8 supported")
67 searchKeys: []searchKey{*p.xsearchKey()},
71 sk.searchKeys = append(sk.searchKeys, *p.xsearchKey())
74 // Even in case of error, we ensure search result is changed.
76 c.searchResult = []store.UID{}
79 // We gather word and not-word searches from the top-level, turn them
80 // into a WordSearch for a more efficient search.
81 // todo optimize: also gather them out of AND searches.
82 var textWords, textNotWords, bodyWords, bodyNotWords []string
84 for _, xsk := range sk.searchKeys {
87 bodyWords = append(bodyWords, xsk.astring)
90 textWords = append(textWords, xsk.astring)
93 switch xsk.searchKey.op {
95 bodyNotWords = append(bodyNotWords, xsk.searchKey.astring)
98 textNotWords = append(textNotWords, xsk.searchKey.astring)
102 sk.searchKeys[n] = xsk
105 // We may be left with an empty but non-nil sk.searchKeys, which is important for
107 sk.searchKeys = sk.searchKeys[:n]
108 var bodySearch, textSearch *store.WordSearch
109 if len(bodyWords) > 0 || len(bodyNotWords) > 0 {
110 ws := store.PrepareWordSearch(bodyWords, bodyNotWords)
113 if len(textWords) > 0 || len(textNotWords) > 0 {
114 ws := store.PrepareWordSearch(textWords, textNotWords)
118 // Note: we only hold the account rlock for verifying the mailbox at the start.
120 runlock := c.account.RUnlock
121 // Note: in a defer because we replace it below.
126 // If we only have a MIN and/or MAX, we can stop processing as soon as we
127 // have those matches.
136 var expungeIssued bool
137 var maxModSeq store.ModSeq
140 c.xdbread(func(tx *bstore.Tx) {
141 c.xmailboxID(tx, c.mailboxID) // Validate.
145 // Normal forward search when we don't have MAX only.
147 if eargs == nil || max == 0 || len(eargs) != 1 {
148 for i, uid := range c.uids {
150 if match, modseq := c.searchMatch(tx, msgseq(i+1), uid, *sk, bodySearch, textSearch, &expungeIssued); match {
151 uids = append(uids, uid)
152 if modseq > maxModSeq {
155 if min == 1 && min+max == len(eargs) {
161 // And reverse search for MAX if we have only MAX or MAX combined with MIN.
162 if max == 1 && (len(eargs) == 1 || min+max == len(eargs)) {
163 for i := len(c.uids) - 1; i > lastIndex; i-- {
164 if match, modseq := c.searchMatch(tx, msgseq(i+1), c.uids[i], *sk, bodySearch, textSearch, &expungeIssued); match {
165 uids = append(uids, c.uids[i])
166 if modseq > maxModSeq {
178 c.bwritelinef("* SEARCH")
181 // Old-style SEARCH response. We must spell out each number. So we may be splitting
189 for _, v := range uids[:n] {
191 v = store.UID(c.xsequence(v))
193 s += " " + fmt.Sprintf("%d", v)
196 // Since we don't have the max modseq for the possibly partial uid range we're
197 // writing here within hand reach, we conveniently interpret the ambiguous "for all
199 // write. And that clients only commit this value after they have seen the tagged
205 modseq = fmt.Sprintf(" (MODSEQ %d)", maxModSeq.Client())
208 c.bwritelinef("* SEARCH%s%s", s, modseq)
216 c.searchResult = uids
218 checkUIDs(c.searchResult)
224 // The tag was originally a string, became an astring in IMAP4rev2, better stick to
226 resp := fmt.Sprintf(`* ESEARCH (TAG "%s")`, tag)
231 // NOTE: we are converting UIDs to msgseq in the uids slice (if needed) while
232 // keeping the "uids" name!
234 // If searchResult is hanging on to the slice, we need to work on a copy.
236 nuids := make([]store.UID, len(uids))
240 for i, uid := range uids {
241 uids[i] = store.UID(c.xsequence(uid))
246 if eargs["MIN"] && len(uids) > 0 {
247 resp += fmt.Sprintf(" MIN %d", uids[0])
249 if eargs["MAX"] && len(uids) > 0 {
250 resp += fmt.Sprintf(" MAX %d", uids[len(uids)-1])
253 resp += fmt.Sprintf(" COUNT %d", len(uids))
255 if eargs["ALL"] && len(uids) > 0 {
256 resp += fmt.Sprintf(" ALL %s", compactUIDSet(uids).String())
260 // Summary: send the highest modseq of the returned messages.
261 if sk.hasModseq() && len(uids) > 0 {
262 resp += fmt.Sprintf(" MODSEQ %d", maxModSeq.Client())
265 c.bwritelinef("%s", resp)
270 c.writeresultf("%s OK [EXPUNGEISSUED] done", tag)
288func (c *conn) searchMatch(tx *bstore.Tx, seq msgseq, uid store.UID, sk searchKey, bodySearch, textSearch *store.WordSearch, expungeIssued *bool) (bool, store.ModSeq) {
289 s := search{c: c, tx: tx, seq: seq, uid: uid, expungeIssued: expungeIssued, hasModseq: sk.hasModseq()}
293 c.xsanity(err, "closing messagereader")
297 return s.match(sk, bodySearch, textSearch)
300func (s *search) match(sk searchKey, bodySearch, textSearch *store.WordSearch) (match bool, modseq store.ModSeq) {
301 // Instead of littering all the cases in match0 with calls to get modseq, we do it once
302 // here in case of a match.
304 if match && s.hasModseq {
306 match = s.xensureMessage()
313 if match && bodySearch != nil {
314 if !s.xensurePart() {
319 match, err = bodySearch.MatchPart(s.c.log, s.p, false)
320 xcheckf(err, "search words in bodies")
322 if match && textSearch != nil {
323 if !s.xensurePart() {
328 match, err = textSearch.MatchPart(s.c.log, s.p, true)
329 xcheckf(err, "search words in headers and bodies")
334func (s *search) xensureMessage() bool {
339 q := bstore.QueryTx[store.Message](s.tx)
340 q.FilterNonzero(store.Message{MailboxID: s.c.mailboxID, UID: s.uid})
342 if err == bstore.ErrAbsent || err == nil && m.Expunged {
344 *s.expungeIssued = true
347 xcheckf(err, "get message")
352// ensure message, reader and part are loaded. returns whether that was
354func (s *search) xensurePart() bool {
359 if !s.xensureMessage() {
363 // Closed by searchMatch after all (recursive) search.match calls are finished.
364 s.mr = s.c.account.MessageReader(s.m)
366 if s.m.ParsedBuf == nil {
367 s.c.log.Error("missing parsed message")
370 p, err := s.m.LoadPart(s.mr)
371 xcheckf(err, "load parsed message")
376func (s *search) match0(sk searchKey) bool {
379 // Difference between sk.searchKeys nil and length 0 is important. Because we take
380 // out word/notword searches, the list may be empty but non-nil.
381 if sk.searchKeys != nil {
382 for _, ssk := range sk.searchKeys {
388 } else if sk.seqSet != nil {
389 return sk.seqSet.containsSeq(s.seq, c.uids, c.searchResult)
392 filterHeader := func(field, value string) bool {
393 lower := strings.ToLower(value)
394 h, err := s.p.Header()
396 c.log.Debugx("parsing message header", err, slog.Any("uid", s.uid))
399 for _, v := range h.Values(field) {
400 if strings.Contains(strings.ToLower(v), lower) {
407 // We handle ops by groups that need increasing details about the message.
413 // We do not implement the RECENT flag, so messages cannot be NEW.
416 // We treat all messages as non-recent, so this means all messages.
419 // We do not implement the RECENT flag. All messages are not recent.
422 return !s.match0(*sk.searchKey)
424 return s.match0(*sk.searchKey) || s.match0(*sk.searchKey2)
426 return sk.uidSet.containsUID(s.uid, c.uids, c.searchResult)
430 if !s.xensurePart() {
434 // Parsed message, basic info.
443 kw := strings.ToLower(sk.atom)
456 for _, k := range s.m.Keywords {
472 kw := strings.ToLower(sk.atom)
475 return !s.m.Forwarded
485 for _, k := range s.m.Keywords {
498 case "BEFORE", "ON", "SINCE":
499 skdt := sk.date.Format("2006-01-02")
500 rdt := s.m.Received.Format("2006-01-02")
509 panic("missing case")
511 return s.m.Size > sk.number
513 return s.m.Size < sk.number
516 return s.m.ModSeq.Client() >= *sk.clientModseq
520 c.log.Info("missing parsed message, not matching", slog.Any("uid", s.uid))
524 // Parsed message, more info.
527 return filterHeader("Bcc", sk.astring)
529 // We gathered word/notword searches from the top-level, but we can also get them
531 // todo optimize: handle deeper nested word/not-word searches more efficiently.
532 headerToo := sk.op == "TEXT"
533 match, err := store.PrepareWordSearch([]string{sk.astring}, nil).MatchPart(s.c.log, s.p, headerToo)
534 xcheckf(err, "word search")
537 return filterHeader("Cc", sk.astring)
539 return filterHeader("From", sk.astring)
541 return filterHeader("Subject", sk.astring)
543 return filterHeader("To", sk.astring)
546 lower := strings.ToLower(sk.astring)
547 h, err := s.p.Header()
549 c.log.Errorx("parsing header for search", err, slog.Any("uid", s.uid))
552 k := textproto.CanonicalMIMEHeaderKey(sk.headerField)
553 for _, v := range h.Values(k) {
554 if lower == "" || strings.Contains(strings.ToLower(v), lower) {
559 case "SENTBEFORE", "SENTON", "SENTSINCE":
560 if s.p.Envelope == nil || s.p.Envelope.Date.IsZero() {
563 dt := s.p.Envelope.Date.Format("2006-01-02")
564 skdt := sk.date.Format("2006-01-02")
573 panic("missing case")
575 panic(serverError{fmt.Errorf("missing case for search key op %q", sk.op)})