1package imapserver
2
3import (
4 "cmp"
5 "fmt"
6 "log/slog"
7 "maps"
8 "net/textproto"
9 "slices"
10 "strconv"
11 "strings"
12 "time"
13
14 "github.com/mjl-/bstore"
15
16 "github.com/mjl-/mox/message"
17 "github.com/mjl-/mox/store"
18)
19
20// If last search output was this long ago, we write an untagged inprogress
21// response. Changed during tests. ../rfc/9585:109
22var inProgressPeriod = time.Duration(10 * time.Second)
23
24// ESEARCH allows searching multiple mailboxes, referenced through mailbox filters
25// borrowed from the NOTIFY extension. Unlike the regular extended SEARCH/UID
26// SEARCH command that always returns an ESEARCH response, the ESEARCH command only
27// returns ESEARCH responses when there were matches in a mailbox.
28//
29// ../rfc/7377:159
30func (c *conn) cmdEsearch(tag, cmd string, p *parser) {
31 c.cmdxSearch(true, true, tag, cmd, p)
32}
33
34// Search returns messages matching criteria specified in parameters.
35//
36// State: Selected for SEARCH and UID SEARCH, Authenticated or selectd for ESEARCH.
37func (c *conn) cmdxSearch(isUID, isE bool, tag, cmd string, p *parser) {
38 // Command: ../rfc/9051:3716 ../rfc/7377:159 ../rfc/6237:142 ../rfc/4731:31 ../rfc/4466:354 ../rfc/3501:2723
39 // Examples: ../rfc/9051:3986 ../rfc/7377:385 ../rfc/6237:323 ../rfc/4731:153 ../rfc/3501:2975
40 // Syntax: ../rfc/9051:6918 ../rfc/7377:462 ../rfc/6237:403 ../rfc/4466:611 ../rfc/3501:4954
41
42 // We will respond with ESEARCH instead of SEARCH if "RETURN" is present or for IMAP4rev2 or for isE (ESEARCH command).
43 var eargs map[string]bool // Options except SAVE. Nil means old-style SEARCH response.
44 var save bool // For SAVE option. Kept separately for easier handling of MIN/MAX later.
45
46 if c.enabled[capIMAP4rev2] || isE {
47 eargs = map[string]bool{}
48 }
49
50 // The ESEARCH command has various ways to specify which mailboxes are to be
51 // searched. We parse and gather the request first, and evaluate them to mailboxes
52 // after parsing, when we start and have a DB transaction.
53 var mailboxSpecs []mailboxSpecifier
54
55 // ../rfc/7377:468
56 if isE && p.take(" IN (") {
57 for {
58 ms := p.xfilterMailbox(mbspecsEsearch)
59 mailboxSpecs = append(mailboxSpecs, ms)
60
61 if !p.take(" ") {
62 break
63 }
64 }
65 p.xtake(")")
66 // We are not parsing the scope-options since there aren't any defined yet. ../rfc/7377:469
67 }
68 // ../rfc/9051:6967
69 if p.take(" RETURN (") {
70 eargs = map[string]bool{}
71
72 for !p.take(")") {
73 if len(eargs) > 0 || save {
74 p.xspace()
75 }
76 if w, ok := p.takelist("MIN", "MAX", "ALL", "COUNT", "SAVE"); ok {
77 if w == "SAVE" {
78 save = true
79 } else {
80 eargs[w] = true
81 }
82 } else {
83 // ../rfc/4466:378 ../rfc/9051:3745
84 xsyntaxErrorf("ESEARCH result option %q not supported", w)
85 }
86 }
87 }
88 // ../rfc/4731:149 ../rfc/9051:3737
89 if eargs != nil && len(eargs) == 0 && !save {
90 eargs["ALL"] = true
91 }
92
93 // If UTF8=ACCEPT is enabled, we should not accept any charset. We are a bit more
94 // relaxed (reasonable?) and still allow US-ASCII and UTF-8. ../rfc/6855:198
95 if p.take(" CHARSET ") {
96 charset := strings.ToUpper(p.xastring())
97 if charset != "US-ASCII" && charset != "UTF-8" {
98 // ../rfc/3501:2771 ../rfc/9051:3836
99 xusercodeErrorf("BADCHARSET", "only US-ASCII and UTF-8 supported")
100 }
101 }
102 p.xspace()
103 sk := &searchKey{
104 searchKeys: []searchKey{*p.xsearchKey()},
105 }
106 for !p.empty() {
107 p.xspace()
108 sk.searchKeys = append(sk.searchKeys, *p.xsearchKey())
109 }
110
111 // Sequence set search program must be rejected with UIDONLY enabled. ../rfc/9586:220
112 if c.uidonly && sk.hasSequenceNumbers() {
113 xsyntaxCodeErrorf("UIDREQUIRED", "cannot search message sequence numbers in search program with uidonly enabled")
114 }
115
116 // Even in case of error, we ensure search result is changed.
117 if save {
118 c.searchResult = []store.UID{}
119 }
120
121 // We gather word and not-word searches from the top-level, turn them
122 // into a WordSearch for a more efficient search.
123 // todo optimize: also gather them out of AND searches.
124 var textWords, textNotWords, bodyWords, bodyNotWords []string
125 n := 0
126 for _, xsk := range sk.searchKeys {
127 switch xsk.op {
128 case "BODY":
129 bodyWords = append(bodyWords, xsk.astring)
130 continue
131 case "TEXT":
132 textWords = append(textWords, xsk.astring)
133 continue
134 case "NOT":
135 switch xsk.searchKey.op {
136 case "BODY":
137 bodyNotWords = append(bodyNotWords, xsk.searchKey.astring)
138 continue
139 case "TEXT":
140 textNotWords = append(textNotWords, xsk.searchKey.astring)
141 continue
142 }
143 }
144 sk.searchKeys[n] = xsk
145 n++
146 }
147 // We may be left with an empty but non-nil sk.searchKeys, which is important for
148 // matching.
149 sk.searchKeys = sk.searchKeys[:n]
150 var bodySearch, textSearch *store.WordSearch
151 if len(bodyWords) > 0 || len(bodyNotWords) > 0 {
152 ws := store.PrepareWordSearch(bodyWords, bodyNotWords)
153 bodySearch = &ws
154 }
155 if len(textWords) > 0 || len(textNotWords) > 0 {
156 ws := store.PrepareWordSearch(textWords, textNotWords)
157 textSearch = &ws
158 }
159
160 // Note: we only hold the account rlock for verifying the mailbox at the start.
161 c.account.RLock()
162 runlock := c.account.RUnlock
163 // Note: in a defer because we replace it below.
164 defer func() {
165 runlock()
166 }()
167
168 // If we only have a MIN and/or MAX, we can stop processing as soon as we
169 // have those matches.
170 var min1, max1 int
171 if eargs["MIN"] {
172 min1 = 1
173 }
174 if eargs["MAX"] {
175 max1 = 1
176 }
177
178 // We'll have one Result per mailbox we are searching. For regular (UID) SEARCH
179 // commands, we'll have just one, for the selected mailbox.
180 type Result struct {
181 Mailbox store.Mailbox
182 MaxModSeq store.ModSeq
183 UIDs []store.UID
184 }
185 var results []Result
186
187 // We periodically send an untagged OK with INPROGRESS code while searching, to let
188 // clients doing slow searches know we're still working.
189 inProgressLast := time.Now()
190 // Only respond with tag if it can't be confused as end of response code. ../rfc/9585:122
191 inProgressTag := "nil"
192 if !strings.Contains(tag, "]") {
193 inProgressTag = dquote(tag).pack(c)
194 }
195
196 c.xdbread(func(tx *bstore.Tx) {
197 // Gather mailboxes to operate on. Usually just the selected mailbox. But with the
198 // ESEARCH command, we may be searching multiple.
199 var mailboxes []store.Mailbox
200 if len(mailboxSpecs) > 0 {
201 // While gathering, we deduplicate mailboxes. ../rfc/7377:312
202 m := map[int64]store.Mailbox{}
203 for _, ms := range mailboxSpecs {
204 switch ms.Kind {
205 case mbspecSelected:
206 // ../rfc/7377:306
207 if c.state != stateSelected {
208 xsyntaxErrorf("cannot use ESEARCH with selected when state is not selected")
209 }
210
211 mb := c.xmailboxID(tx, c.mailboxID) // Validate.
212 m[mb.ID] = mb
213
214 case mbspecInboxes:
215 // Inbox and everything below. And we look at destinations and rulesets. We all
216 // mailboxes from the destinations, and all from the rulesets except when
217 // ListAllowDomain is non-empty.
218 // ../rfc/5465:822
219 q := bstore.QueryTx[store.Mailbox](tx)
220 q.FilterEqual("Expunged", false)
221 q.FilterGreaterEqual("Name", "Inbox")
222 q.SortAsc("Name")
223 for mb, err := range q.All() {
224 xcheckf(err, "list mailboxes")
225 if mb.Name != "Inbox" && !strings.HasPrefix(mb.Name, "Inbox/") {
226 break
227 }
228 m[mb.ID] = mb
229 }
230
231 conf, _ := c.account.Conf()
232 for _, dest := range conf.Destinations {
233 if dest.Mailbox != "" && dest.Mailbox != "Inbox" {
234 mb, err := c.account.MailboxFind(tx, dest.Mailbox)
235 xcheckf(err, "find mailbox from destination")
236 if mb != nil {
237 m[mb.ID] = *mb
238 }
239 }
240
241 for _, rs := range dest.Rulesets {
242 if rs.ListAllowDomain != "" || rs.Mailbox == "" {
243 continue
244 }
245
246 mb, err := c.account.MailboxFind(tx, rs.Mailbox)
247 xcheckf(err, "find mailbox from ruleset")
248 if mb != nil {
249 m[mb.ID] = *mb
250 }
251 }
252 }
253
254 case mbspecPersonal:
255 // All mailboxes in the personal namespace. Which is all mailboxes for us.
256 // ../rfc/5465:817
257 for mb, err := range bstore.QueryTx[store.Mailbox](tx).FilterEqual("Expunged", false).All() {
258 xcheckf(err, "list mailboxes")
259 m[mb.ID] = mb
260 }
261
262 case mbspecSubscribed:
263 // Mailboxes that are subscribed. Will typically be same as personal, since we
264 // subscribe to all mailboxes. But user can manage subscriptions differently.
265 // ../rfc/5465:831
266 for mb, err := range bstore.QueryTx[store.Mailbox](tx).FilterEqual("Expunged", false).All() {
267 xcheckf(err, "list mailboxes")
268 if err := tx.Get(&store.Subscription{Name: mb.Name}); err == nil {
269 m[mb.ID] = mb
270 } else if err != bstore.ErrAbsent {
271 xcheckf(err, "lookup subscription for mailbox")
272 }
273 }
274
275 case mbspecSubtree, mbspecSubtreeOne:
276 // The mailbox name itself, and children. ../rfc/5465:847
277 // SUBTREE is arbitrarily deep, SUBTREE-ONE is one level deeper than requested
278 // mailbox. The mailbox itself is included too ../rfc/7377:274
279
280 // We don't have to worry about loops. Mailboxes are not in the file system.
281 // ../rfc/7377:291
282
283 for _, name := range ms.Mailboxes {
284 name = xcheckmailboxname(name, true)
285
286 one := ms.Kind == mbspecSubtreeOne
287 var ntoken int
288 if one {
289 ntoken = len(strings.Split(name, "/")) + 1
290 }
291
292 q := bstore.QueryTx[store.Mailbox](tx)
293 q.FilterEqual("Expunged", false)
294 q.FilterGreaterEqual("Name", name)
295 q.SortAsc("Name")
296 for mb, err := range q.All() {
297 xcheckf(err, "list mailboxes")
298 if mb.Name != name && !strings.HasPrefix(mb.Name, name+"/") {
299 break
300 }
301 if !one || mb.Name == name || len(strings.Split(mb.Name, "/")) == ntoken {
302 m[mb.ID] = mb
303 }
304 }
305 }
306
307 case mbspecMailboxes:
308 // Just the specified mailboxes. ../rfc/5465:853
309 for _, name := range ms.Mailboxes {
310 name = xcheckmailboxname(name, true)
311
312 // If a mailbox doesn't exist, we don't treat it as an error. Seems reasonable
313 // giving we are searching. Messages may not exist. And likewise for the mailbox.
314 // Just results in no hits.
315 mb, err := c.account.MailboxFind(tx, name)
316 xcheckf(err, "looking up mailbox")
317 if mb != nil {
318 m[mb.ID] = *mb
319 }
320 }
321
322 default:
323 panic("missing case")
324 }
325 }
326 mailboxes = slices.Collect(maps.Values(m))
327 slices.SortFunc(mailboxes, func(a, b store.Mailbox) int {
328 return cmp.Compare(a.Name, b.Name)
329 })
330
331 // If no source mailboxes were specified (no mailboxSpecs), the selected mailbox is
332 // used below. ../rfc/7377:298
333 } else {
334 mb := c.xmailboxID(tx, c.mailboxID) // Validate.
335 mailboxes = []store.Mailbox{mb}
336 }
337
338 if save && !(len(mailboxes) == 1 && mailboxes[0].ID == c.mailboxID) {
339 // ../rfc/7377:319
340 xsyntaxErrorf("can only use SAVE on selected mailbox")
341 }
342
343 runlock()
344 runlock = func() {}
345
346 // Determine if search has a sequence set without search results. If so, we need
347 // sequence numbers for matching, and we must always go through the messages in
348 // forward order. No reverse search for MAX only.
349 needSeq := (len(mailboxes) > 1 || len(mailboxes) == 1 && mailboxes[0].ID != c.mailboxID) && sk.hasSequenceNumbers()
350
351 forward := eargs == nil || max1 == 0 || len(eargs) != 1 || needSeq
352 reverse := max1 == 1 && (len(eargs) == 1 || min1+max1 == len(eargs)) && !needSeq
353
354 // We set a worst-case "goal" of having gone through all messages in all mailboxes.
355 // Sometimes, we can be faster, when we only do a MIN and/or MAX query and we can
356 // stop early. We'll account for that as we go. For the selected mailbox, we'll
357 // only look at those the session has already seen.
358 goal := "nil"
359 var total uint32
360 for _, mb := range mailboxes {
361 if mb.ID == c.mailboxID && !c.uidonly {
362 total += c.exists
363 } else {
364 total += uint32(mb.Total + mb.Deleted)
365 }
366 }
367 if total > 0 {
368 // Goal is always non-zero. ../rfc/9585:232
369 goal = fmt.Sprintf("%d", total)
370 }
371
372 var progress uint32
373 for _, mb := range mailboxes {
374 var lastUID store.UID
375
376 result := Result{Mailbox: mb}
377
378 msgCount := uint32(mb.MailboxCounts.Total + mb.MailboxCounts.Deleted)
379 if mb.ID == c.mailboxID && !c.uidonly {
380 msgCount = c.exists
381 }
382
383 // Used for interpreting UID sets with a star, like "1:*" and "10:*". Only called
384 // for UIDs that are higher than the number, since "10:*" evaluates to "10:5" if 5
385 // is the highest UID, and UID 5-10 would all match.
386 var cachedHighestUID store.UID
387 xhighestUID := func() store.UID {
388 if cachedHighestUID > 0 {
389 return cachedHighestUID
390 }
391
392 q := bstore.QueryTx[store.Message](tx)
393 q.FilterNonzero(store.Message{MailboxID: mb.ID})
394 q.FilterEqual("Expunged", false)
395 if mb.ID == c.mailboxID {
396 q.FilterLess("UID", c.uidnext)
397 }
398 q.SortDesc("UID")
399 q.Limit(1)
400 m, err := q.Get()
401 if err == bstore.ErrAbsent {
402 xuserErrorf("cannot use * on empty mailbox")
403 }
404 xcheckf(err, "get last uid")
405 cachedHighestUID = m.UID
406 return cachedHighestUID
407 }
408
409 progressOrig := progress
410
411 if forward {
412 // We track this for non-selected mailboxes. searchMatch will look the message
413 // sequence number for this session up if we are searching the selected mailbox.
414 var seq msgseq = 1
415
416 q := bstore.QueryTx[store.Message](tx)
417 q.FilterNonzero(store.Message{MailboxID: mb.ID})
418 q.FilterEqual("Expunged", false)
419 if mb.ID == c.mailboxID {
420 q.FilterLess("UID", c.uidnext)
421 }
422 q.SortAsc("UID")
423 for m, err := range q.All() {
424 xcheckf(err, "list messages in mailbox")
425
426 // We track this for the "reverse" case, we'll stop before seeing lastUID.
427 lastUID = m.UID
428
429 if time.Since(inProgressLast) > inProgressPeriod {
430 c.xwritelinef("* OK [INPROGRESS (%s %d %s)] still searching", inProgressTag, progress, goal)
431 inProgressLast = time.Now()
432 }
433 progress++
434
435 if c.searchMatch(tx, msgCount, seq, m, *sk, bodySearch, textSearch, xhighestUID) {
436 result.UIDs = append(result.UIDs, m.UID)
437 result.MaxModSeq = max(result.MaxModSeq, m.ModSeq)
438 if min1 == 1 && min1+max1 == len(eargs) {
439 if !needSeq {
440 break
441 }
442 // We only need a MIN and a MAX, but we also need sequence numbers so we are
443 // walking through and collecting all UIDs. Correct for that, keeping only the MIN
444 // (first)
445 // and MAX (second).
446 if len(result.UIDs) == 3 {
447 result.UIDs[1] = result.UIDs[2]
448 result.UIDs = result.UIDs[:2]
449 }
450 }
451 }
452 seq++
453 }
454 }
455 // And reverse search for MAX if we have only MAX or MAX combined with MIN, and
456 // don't need sequence numbers. We just need a single match, then we stop.
457 if reverse {
458 q := bstore.QueryTx[store.Message](tx)
459 q.FilterNonzero(store.Message{MailboxID: mb.ID})
460 q.FilterEqual("Expunged", false)
461 q.FilterGreater("UID", lastUID)
462 if mb.ID == c.mailboxID {
463 q.FilterLess("UID", c.uidnext)
464 }
465 q.SortDesc("UID")
466 for m, err := range q.All() {
467 xcheckf(err, "list messages in mailbox")
468
469 if time.Since(inProgressLast) > inProgressPeriod {
470 c.xwritelinef("* OK [INPROGRESS (%s %d %s)] still searching", inProgressTag, progress, goal)
471 inProgressLast = time.Now()
472 }
473 progress++
474
475 var seq msgseq // Filled in by searchMatch for messages in selected mailbox.
476 if c.searchMatch(tx, msgCount, seq, m, *sk, bodySearch, textSearch, xhighestUID) {
477 result.UIDs = append(result.UIDs, m.UID)
478 result.MaxModSeq = max(result.MaxModSeq, m.ModSeq)
479 break
480 }
481 }
482 }
483
484 // We could have finished searching the mailbox with fewer
485 mailboxProcessed := progress - progressOrig
486 mailboxTotal := uint32(mb.MailboxCounts.Total + mb.MailboxCounts.Deleted)
487 progress += max(0, mailboxTotal-mailboxProcessed)
488
489 results = append(results, result)
490 }
491 })
492
493 if eargs == nil {
494 // We'll only have a result for the one selected mailbox.
495 result := results[0]
496
497 // Old-style SEARCH response: a single untagged line listing all matching
498 // numbers. RFC 3501 specifies one response; clients like emersion/go-imap
499 // (used by aerc) only retain the last untagged SEARCH response, so any
500 // split truncates the visible result set. See issue #389.
501 // ../rfc/3501:2728 ../rfc/3501:4833 ../rfc/9051:6809
502 var s strings.Builder
503 for _, v := range result.UIDs {
504 if !isUID {
505 v = store.UID(c.xsequence(v))
506 }
507 s.WriteString(" ")
508 s.WriteString(strconv.FormatUint(uint64(v), 10))
509 }
510
511 // MODSEQ is only attached when there were matches: RFC 7162 ties the
512 // returned modseq to "all messages being returned", so an empty result
513 // has nothing to attach it to. ../rfc/7162:1077 ../rfc/7162:1101
514 // ../rfc/7162:2323 ../rfc/7162:2557
515 var modseq string
516 if sk.hasModseq() && len(result.UIDs) > 0 {
517 modseq = fmt.Sprintf(" (MODSEQ %d)", result.MaxModSeq.Client())
518 }
519 c.xbwritelinef("* SEARCH%s%s", s.String(), modseq)
520 } else {
521 // New-style ESEARCH response syntax: ../rfc/9051:6546 ../rfc/4466:522
522
523 if save {
524 // ../rfc/9051:3784 ../rfc/5182:13
525 c.searchResult = results[0].UIDs
526 c.checkUIDs(c.searchResult, false)
527 }
528
529 // No untagged ESEARCH response if nothing was requested. ../rfc/9051:4160
530 if len(eargs) > 0 {
531 for _, result := range results {
532 // For the ESEARCH command, we must not return a response if there were no matching
533 // messages. This is unlike the later IMAP4rev2, where an ESEARCH response must be
534 // sent if there were no matches. ../rfc/7377:243 ../rfc/9051:3775
535 if isE && len(result.UIDs) == 0 {
536 continue
537 }
538
539 // The tag was originally a string, became an astring in IMAP4rev2, better stick to
540 // string. ../rfc/4466:707 ../rfc/5259:1163 ../rfc/9051:7087
541 if isE {
542 fmt.Fprintf(c.xbw, `* ESEARCH (TAG "%s" MAILBOX %s UIDVALIDITY %d)`, tag, result.Mailbox.Name, result.Mailbox.UIDValidity)
543 } else {
544 fmt.Fprintf(c.xbw, `* ESEARCH (TAG "%s")`, tag)
545 }
546 if isUID {
547 fmt.Fprintf(c.xbw, " UID")
548 }
549
550 // NOTE: we are potentially converting UIDs to msgseq, but keep the store.UID type
551 // for convenience.
552 nums := result.UIDs
553 if !isUID {
554 // If searchResult is hanging on to the slice, we need to work on a copy.
555 if save {
556 nums = slices.Clone(nums)
557 }
558 for i, uid := range nums {
559 nums[i] = store.UID(c.xsequence(uid))
560 }
561 }
562
563 // If no matches, then no MIN/MAX response. ../rfc/4731:98 ../rfc/9051:3758
564 if eargs["MIN"] && len(nums) > 0 {
565 fmt.Fprintf(c.xbw, " MIN %d", nums[0])
566 }
567 if eargs["MAX"] && len(result.UIDs) > 0 {
568 fmt.Fprintf(c.xbw, " MAX %d", nums[len(nums)-1])
569 }
570 if eargs["COUNT"] {
571 fmt.Fprintf(c.xbw, " COUNT %d", len(nums))
572 }
573 if eargs["ALL"] && len(nums) > 0 {
574 fmt.Fprintf(c.xbw, " ALL %s", compactUIDSet(nums).String())
575 }
576
577 // Interaction between ESEARCH and CONDSTORE: ../rfc/7162:1211 ../rfc/4731:273
578 // Summary: send the highest modseq of the returned messages.
579 if sk.hasModseq() && len(nums) > 0 {
580 fmt.Fprintf(c.xbw, " MODSEQ %d", result.MaxModSeq.Client())
581 }
582
583 c.xbwritelinef("")
584 }
585 }
586 }
587
588 c.ok(tag, cmd)
589}
590
591type search struct {
592 c *conn
593 tx *bstore.Tx
594 msgCount uint32 // Number of messages in mailbox (or session when selected).
595 seq msgseq // Can be 0, for other mailboxes than selected in case of MAX.
596 m store.Message
597 mr *store.MsgReader
598 p *message.Part
599 xhighestUID func() store.UID
600}
601
602func (c *conn) searchMatch(tx *bstore.Tx, msgCount uint32, seq msgseq, m store.Message, sk searchKey, bodySearch, textSearch *store.WordSearch, xhighestUID func() store.UID) bool {
603 if m.MailboxID == c.mailboxID {
604 // If session doesn't know about the message yet, don't return it.
605 if c.uidonly {
606 if m.UID >= c.uidnext {
607 return false
608 }
609 } else {
610 // Set seq for use in evaluations.
611 seq = c.sequence(m.UID)
612 if seq == 0 {
613 return false
614 }
615 }
616 }
617
618 s := search{c: c, tx: tx, msgCount: msgCount, seq: seq, m: m, xhighestUID: xhighestUID}
619 defer func() {
620 if s.mr != nil {
621 err := s.mr.Close()
622 c.xsanity(err, "closing messagereader")
623 s.mr = nil
624 }
625 }()
626 return s.match(sk, bodySearch, textSearch)
627}
628
629func (s *search) match(sk searchKey, bodySearch, textSearch *store.WordSearch) (match bool) {
630 match = s.match0(sk)
631 if match && bodySearch != nil {
632 if !s.xensurePart() {
633 match = false
634 return
635 }
636 var err error
637 match, err = bodySearch.MatchPart(s.c.log, s.p, false)
638 xcheckf(err, "search words in bodies")
639 }
640 if match && textSearch != nil {
641 if !s.xensurePart() {
642 match = false
643 return
644 }
645 var err error
646 match, err = textSearch.MatchPart(s.c.log, s.p, true)
647 xcheckf(err, "search words in headers and bodies")
648 }
649 return
650}
651
652// ensure message, reader and part are loaded. returns whether that was
653// successful.
654func (s *search) xensurePart() bool {
655 if s.mr != nil {
656 return s.p != nil
657 }
658
659 // Closed by searchMatch after all (recursive) search.match calls are finished.
660 s.mr = s.c.account.MessageReader(s.m)
661
662 if s.m.ParsedBuf == nil {
663 s.c.log.Error("missing parsed message")
664 return false
665 }
666 p, err := s.m.LoadPart(s.mr)
667 xcheckf(err, "load parsed message")
668 s.p = &p
669 return true
670}
671
672func (s *search) match0(sk searchKey) bool {
673 c := s.c
674
675 // Difference between sk.searchKeys nil and length 0 is important. Because we take
676 // out word/notword searches, the list may be empty but non-nil.
677 if sk.searchKeys != nil {
678 for _, ssk := range sk.searchKeys {
679 if !s.match0(ssk) {
680 return false
681 }
682 }
683 return true
684 } else if sk.seqSet != nil {
685 if sk.seqSet.searchResult {
686 // Interpreting search results on a mailbox that isn't selected during multisearch
687 // is likely a mistake. No mention about it in the RFC. ../rfc/7377:257
688 if s.m.MailboxID != c.mailboxID {
689 xuserErrorf("can only use search result with the selected mailbox")
690 }
691 return uidSearch(c.searchResult, s.m.UID) > 0
692 }
693 // For multisearch, we have arranged to have a seq for non-selected mailboxes too.
694 return sk.seqSet.containsSeqCount(s.seq, s.msgCount)
695 }
696
697 filterHeader := func(field, value string) bool {
698 lower := strings.ToLower(value)
699 h, err := s.p.Header()
700 if err != nil {
701 c.log.Debugx("parsing message header", err, slog.Any("uid", s.m.UID), slog.Int64("msgid", s.m.ID))
702 return false
703 }
704 for _, v := range h.Values(field) {
705 if strings.Contains(strings.ToLower(v), lower) {
706 return true
707 }
708 }
709 return false
710 }
711
712 // We handle ops by groups that need increasing details about the message.
713
714 switch sk.op {
715 case "ALL":
716 return true
717 case "NEW":
718 // We do not implement the RECENT flag, so messages cannot be NEW.
719 return false
720 case "OLD":
721 // We treat all messages as non-recent, so this means all messages.
722 return true
723 case "RECENT":
724 // We do not implement the RECENT flag. All messages are not recent.
725 return false
726 case "NOT":
727 return !s.match0(*sk.searchKey)
728 case "OR":
729 return s.match0(*sk.searchKey) || s.match0(*sk.searchKey2)
730 case "UID":
731 if sk.uidSet.searchResult && s.m.MailboxID != c.mailboxID {
732 // Interpreting search results on a mailbox that isn't selected during multisearch
733 // is likely a mistake. No mention about it in the RFC. ../rfc/7377:257
734 xuserErrorf("cannot use search result from another mailbox")
735 }
736 return sk.uidSet.xcontainsKnownUID(s.m.UID, c.searchResult, s.xhighestUID)
737 }
738
739 // Parsed part.
740 if !s.xensurePart() {
741 return false
742 }
743
744 // Parsed message, basic info.
745 switch sk.op {
746 case "ANSWERED":
747 return s.m.Answered
748 case "DELETED":
749 return s.m.Deleted
750 case "FLAGGED":
751 return s.m.Flagged
752 case "KEYWORD":
753 kw := strings.ToLower(sk.atom)
754 switch kw {
755 case "$forwarded":
756 return s.m.Forwarded
757 case "$junk":
758 return s.m.Junk
759 case "$notjunk":
760 return s.m.Notjunk
761 case "$phishing":
762 return s.m.Phishing
763 case "$mdnsent":
764 return s.m.MDNSent
765 default:
766 return slices.Contains(s.m.Keywords, kw)
767 }
768 case "SEEN":
769 return s.m.Seen
770 case "UNANSWERED":
771 return !s.m.Answered
772 case "UNDELETED":
773 return !s.m.Deleted
774 case "UNFLAGGED":
775 return !s.m.Flagged
776 case "UNKEYWORD":
777 kw := strings.ToLower(sk.atom)
778 switch kw {
779 case "$forwarded":
780 return !s.m.Forwarded
781 case "$junk":
782 return !s.m.Junk
783 case "$notjunk":
784 return !s.m.Notjunk
785 case "$phishing":
786 return !s.m.Phishing
787 case "$mdnsent":
788 return !s.m.MDNSent
789 default:
790 return !slices.Contains(s.m.Keywords, kw)
791 }
792 case "UNSEEN":
793 return !s.m.Seen
794 case "DRAFT":
795 return s.m.Draft
796 case "UNDRAFT":
797 return !s.m.Draft
798 case "BEFORE", "ON", "SINCE":
799 skdt := sk.date.Format("2006-01-02")
800 rdt := s.m.Received.Format("2006-01-02")
801 switch sk.op {
802 case "BEFORE":
803 return rdt < skdt
804 case "ON":
805 return rdt == skdt
806 case "SINCE":
807 return rdt >= skdt
808 }
809 panic("missing case")
810 case "LARGER":
811 return s.m.Size > sk.number
812 case "SMALLER":
813 return s.m.Size < sk.number
814 case "MODSEQ":
815 // ../rfc/7162:1045
816 return s.m.ModSeq.Client() >= *sk.clientModseq
817 case "SAVEDBEFORE", "SAVEDON", "SAVEDSINCE":
818 // If we don't have a savedate for this message (for messages received before we
819 // implemented this feature), we use the "internal date" (received timestamp) of
820 // the message. ../rfc/8514:237
821 rt := s.m.Received
822 if s.m.SaveDate != nil {
823 rt = *s.m.SaveDate
824 }
825
826 skdt := sk.date.Format("2006-01-02")
827 rdt := rt.Format("2006-01-02")
828 switch sk.op {
829 case "SAVEDBEFORE":
830 return rdt < skdt
831 case "SAVEDON":
832 return rdt == skdt
833 case "SAVEDSINCE":
834 return rdt >= skdt
835 }
836 panic("missing case")
837 case "SAVEDATESUPPORTED":
838 // We return whether we have a savedate for this message. We support it on all
839 // mailboxes, but we only have this metadata from the time we implemented this
840 // feature.
841 return s.m.SaveDate != nil
842 case "OLDER":
843 // ../rfc/5032:76
844 seconds := int64(time.Since(s.m.Received) / time.Second)
845 return seconds >= sk.number
846 case "YOUNGER":
847 seconds := int64(time.Since(s.m.Received) / time.Second)
848 return seconds <= sk.number
849 }
850
851 if s.p == nil {
852 c.log.Info("missing parsed message, not matching", slog.Any("uid", s.m.UID), slog.Int64("msgid", s.m.ID))
853 return false
854 }
855
856 // Parsed message, more info.
857 switch sk.op {
858 case "BCC":
859 return filterHeader("Bcc", sk.astring)
860 case "BODY", "TEXT":
861 // We gathered word/notword searches from the top-level, but we can also get them
862 // nested.
863 // todo optimize: handle deeper nested word/not-word searches more efficiently.
864 headerToo := sk.op == "TEXT"
865 match, err := store.PrepareWordSearch([]string{sk.astring}, nil).MatchPart(s.c.log, s.p, headerToo)
866 xcheckf(err, "word search")
867 return match
868 case "CC":
869 return filterHeader("Cc", sk.astring)
870 case "FROM":
871 return filterHeader("From", sk.astring)
872 case "SUBJECT":
873 return filterHeader("Subject", sk.astring)
874 case "TO":
875 return filterHeader("To", sk.astring)
876 case "HEADER":
877 // ../rfc/9051:3895
878 lower := strings.ToLower(sk.astring)
879 h, err := s.p.Header()
880 if err != nil {
881 c.log.Errorx("parsing header for search", err, slog.Any("uid", s.m.UID), slog.Int64("msgid", s.m.ID))
882 return false
883 }
884 k := textproto.CanonicalMIMEHeaderKey(sk.headerField)
885 for _, v := range h.Values(k) {
886 if lower == "" || strings.Contains(strings.ToLower(v), lower) {
887 return true
888 }
889 }
890 return false
891 case "SENTBEFORE", "SENTON", "SENTSINCE":
892 if s.p.Envelope == nil || s.p.Envelope.Date.IsZero() {
893 return false
894 }
895 dt := s.p.Envelope.Date.Format("2006-01-02")
896 skdt := sk.date.Format("2006-01-02")
897 switch sk.op {
898 case "SENTBEFORE":
899 return dt < skdt
900 case "SENTON":
901 return dt == skdt
902 case "SENTSINCE":
903 return dt > skdt
904 }
905 panic("missing case")
906 }
907 panic(serverError{fmt.Errorf("missing case for search key op %q", sk.op)})
908}
909