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)})