package imapserver import ( "cmp" "fmt" "log/slog" "maps" "net/textproto" "slices" "strings" "time" "github.com/mjl-/bstore" "github.com/mjl-/mox/message" "github.com/mjl-/mox/store" ) // If last search output was this long ago, we write an untagged inprogress // response. Changed during tests. ../rfc/9585:109 var inProgressPeriod = time.Duration(10 * time.Second) // ESEARCH allows searching multiple mailboxes, referenced through mailbox filters // borrowed from the NOTIFY extension. Unlike the regular extended SEARCH/UID // SEARCH command that always returns an ESEARCH response, the ESEARCH command only // returns ESEARCH responses when there were matches in a mailbox. // // ../rfc/7377:159 func (c *conn) cmdEsearch(tag, cmd string, p *parser) { c.cmdxSearch(true, true, tag, cmd, p) } // Search returns messages matching criteria specified in parameters. // // State: Selected for SEARCH and UID SEARCH, Authenticated or selectd for ESEARCH. func (c *conn) cmdxSearch(isUID, isE bool, tag, cmd string, p *parser) { // Command: ../rfc/9051:3716 ../rfc/7377:159 ../rfc/6237:142 ../rfc/4731:31 ../rfc/4466:354 ../rfc/3501:2723 // Examples: ../rfc/9051:3986 ../rfc/7377:385 ../rfc/6237:323 ../rfc/4731:153 ../rfc/3501:2975 // Syntax: ../rfc/9051:6918 ../rfc/7377:462 ../rfc/6237:403 ../rfc/4466:611 ../rfc/3501:4954 // We will respond with ESEARCH instead of SEARCH if "RETURN" is present or for IMAP4rev2 or for isE (ESEARCH command). var eargs map[string]bool // Options except SAVE. Nil means old-style SEARCH response. var save bool // For SAVE option. Kept separately for easier handling of MIN/MAX later. if c.enabled[capIMAP4rev2] || isE { eargs = map[string]bool{} } // The ESEARCH command has various ways to specify which mailboxes are to be // searched. We parse and gather the request first, and evaluate them to mailboxes // after parsing, when we start and have a DB transaction. var mailboxSpecs []mailboxSpecifier // ../rfc/7377:468 if isE && p.take(" IN (") { for { ms := p.xfilterMailbox(mbspecsEsearch) mailboxSpecs = append(mailboxSpecs, ms) if !p.take(" ") { break } } p.xtake(")") // We are not parsing the scope-options since there aren't any defined yet. ../rfc/7377:469 } // ../rfc/9051:6967 if p.take(" RETURN (") { eargs = map[string]bool{} for !p.take(")") { if len(eargs) > 0 || save { p.xspace() } if w, ok := p.takelist("MIN", "MAX", "ALL", "COUNT", "SAVE"); ok { if w == "SAVE" { save = true } else { eargs[w] = true } } else { // ../rfc/4466:378 ../rfc/9051:3745 xsyntaxErrorf("ESEARCH result option %q not supported", w) } } } // ../rfc/4731:149 ../rfc/9051:3737 if eargs != nil && len(eargs) == 0 && !save { eargs["ALL"] = true } // If UTF8=ACCEPT is enabled, we should not accept any charset. We are a bit more // relaxed (reasonable?) and still allow US-ASCII and UTF-8. ../rfc/6855:198 if p.take(" CHARSET ") { charset := strings.ToUpper(p.xastring()) if charset != "US-ASCII" && charset != "UTF-8" { // ../rfc/3501:2771 ../rfc/9051:3836 xusercodeErrorf("BADCHARSET", "only US-ASCII and UTF-8 supported") } } p.xspace() sk := &searchKey{ searchKeys: []searchKey{*p.xsearchKey()}, } for !p.empty() { p.xspace() sk.searchKeys = append(sk.searchKeys, *p.xsearchKey()) } // Sequence set search program must be rejected with UIDONLY enabled. ../rfc/9586:220 if c.uidonly && sk.hasSequenceNumbers() { xsyntaxCodeErrorf("UIDREQUIRED", "cannot search message sequence numbers in search program with uidonly enabled") } // Even in case of error, we ensure search result is changed. if save { c.searchResult = []store.UID{} } // We gather word and not-word searches from the top-level, turn them // into a WordSearch for a more efficient search. // todo optimize: also gather them out of AND searches. var textWords, textNotWords, bodyWords, bodyNotWords []string n := 0 for _, xsk := range sk.searchKeys { switch xsk.op { case "BODY": bodyWords = append(bodyWords, xsk.astring) continue case "TEXT": textWords = append(textWords, xsk.astring) continue case "NOT": switch xsk.searchKey.op { case "BODY": bodyNotWords = append(bodyNotWords, xsk.searchKey.astring) continue case "TEXT": textNotWords = append(textNotWords, xsk.searchKey.astring) continue } } sk.searchKeys[n] = xsk n++ } // We may be left with an empty but non-nil sk.searchKeys, which is important for // matching. sk.searchKeys = sk.searchKeys[:n] var bodySearch, textSearch *store.WordSearch if len(bodyWords) > 0 || len(bodyNotWords) > 0 { ws := store.PrepareWordSearch(bodyWords, bodyNotWords) bodySearch = &ws } if len(textWords) > 0 || len(textNotWords) > 0 { ws := store.PrepareWordSearch(textWords, textNotWords) textSearch = &ws } // Note: we only hold the account rlock for verifying the mailbox at the start. c.account.RLock() runlock := c.account.RUnlock // Note: in a defer because we replace it below. defer func() { runlock() }() // If we only have a MIN and/or MAX, we can stop processing as soon as we // have those matches. var min1, max1 int if eargs["MIN"] { min1 = 1 } if eargs["MAX"] { max1 = 1 } // We'll have one Result per mailbox we are searching. For regular (UID) SEARCH // commands, we'll have just one, for the selected mailbox. type Result struct { Mailbox store.Mailbox MaxModSeq store.ModSeq UIDs []store.UID } var results []Result // We periodically send an untagged OK with INPROGRESS code while searching, to let // clients doing slow searches know we're still working. inProgressLast := time.Now() // Only respond with tag if it can't be confused as end of response code. ../rfc/9585:122 inProgressTag := "nil" if !strings.Contains(tag, "]") { inProgressTag = dquote(tag).pack(c) } c.xdbread(func(tx *bstore.Tx) { // Gather mailboxes to operate on. Usually just the selected mailbox. But with the // ESEARCH command, we may be searching multiple. var mailboxes []store.Mailbox if len(mailboxSpecs) > 0 { // While gathering, we deduplicate mailboxes. ../rfc/7377:312 m := map[int64]store.Mailbox{} for _, ms := range mailboxSpecs { switch ms.Kind { case mbspecSelected: // ../rfc/7377:306 if c.state != stateSelected { xsyntaxErrorf("cannot use ESEARCH with selected when state is not selected") } mb := c.xmailboxID(tx, c.mailboxID) // Validate. m[mb.ID] = mb case mbspecInboxes: // Inbox and everything below. And we look at destinations and rulesets. We all // mailboxes from the destinations, and all from the rulesets except when // ListAllowDomain is non-empty. // ../rfc/5465:822 q := bstore.QueryTx[store.Mailbox](tx) q.FilterEqual("Expunged", false) q.FilterGreaterEqual("Name", "Inbox") q.SortAsc("Name") for mb, err := range q.All() { xcheckf(err, "list mailboxes") if mb.Name != "Inbox" && !strings.HasPrefix(mb.Name, "Inbox/") { break } m[mb.ID] = mb } conf, _ := c.account.Conf() for _, dest := range conf.Destinations { if dest.Mailbox != "" && dest.Mailbox != "Inbox" { mb, err := c.account.MailboxFind(tx, dest.Mailbox) xcheckf(err, "find mailbox from destination") if mb != nil { m[mb.ID] = *mb } } for _, rs := range dest.Rulesets { if rs.ListAllowDomain != "" || rs.Mailbox == "" { continue } mb, err := c.account.MailboxFind(tx, rs.Mailbox) xcheckf(err, "find mailbox from ruleset") if mb != nil { m[mb.ID] = *mb } } } case mbspecPersonal: // All mailboxes in the personal namespace. Which is all mailboxes for us. // ../rfc/5465:817 for mb, err := range bstore.QueryTx[store.Mailbox](tx).FilterEqual("Expunged", false).All() { xcheckf(err, "list mailboxes") m[mb.ID] = mb } case mbspecSubscribed: // Mailboxes that are subscribed. Will typically be same as personal, since we // subscribe to all mailboxes. But user can manage subscriptions differently. // ../rfc/5465:831 for mb, err := range bstore.QueryTx[store.Mailbox](tx).FilterEqual("Expunged", false).All() { xcheckf(err, "list mailboxes") if err := tx.Get(&store.Subscription{Name: mb.Name}); err == nil { m[mb.ID] = mb } else if err != bstore.ErrAbsent { xcheckf(err, "lookup subscription for mailbox") } } case mbspecSubtree, mbspecSubtreeOne: // The mailbox name itself, and children. ../rfc/5465:847 // SUBTREE is arbitrarily deep, SUBTREE-ONE is one level deeper than requested // mailbox. The mailbox itself is included too ../rfc/7377:274 // We don't have to worry about loops. Mailboxes are not in the file system. // ../rfc/7377:291 for _, name := range ms.Mailboxes { name = xcheckmailboxname(name, true) one := ms.Kind == mbspecSubtreeOne var ntoken int if one { ntoken = len(strings.Split(name, "/")) + 1 } q := bstore.QueryTx[store.Mailbox](tx) q.FilterEqual("Expunged", false) q.FilterGreaterEqual("Name", name) q.SortAsc("Name") for mb, err := range q.All() { xcheckf(err, "list mailboxes") if mb.Name != name && !strings.HasPrefix(mb.Name, name+"/") { break } if !one || mb.Name == name || len(strings.Split(mb.Name, "/")) == ntoken { m[mb.ID] = mb } } } case mbspecMailboxes: // Just the specified mailboxes. ../rfc/5465:853 for _, name := range ms.Mailboxes { name = xcheckmailboxname(name, true) // If a mailbox doesn't exist, we don't treat it as an error. Seems reasonable // giving we are searching. Messages may not exist. And likewise for the mailbox. // Just results in no hits. mb, err := c.account.MailboxFind(tx, name) xcheckf(err, "looking up mailbox") if mb != nil { m[mb.ID] = *mb } } default: panic("missing case") } } mailboxes = slices.Collect(maps.Values(m)) slices.SortFunc(mailboxes, func(a, b store.Mailbox) int { return cmp.Compare(a.Name, b.Name) }) // If no source mailboxes were specified (no mailboxSpecs), the selected mailbox is // used below. ../rfc/7377:298 } else { mb := c.xmailboxID(tx, c.mailboxID) // Validate. mailboxes = []store.Mailbox{mb} } if save && !(len(mailboxes) == 1 && mailboxes[0].ID == c.mailboxID) { // ../rfc/7377:319 xsyntaxErrorf("can only use SAVE on selected mailbox") } runlock() runlock = func() {} // Determine if search has a sequence set without search results. If so, we need // sequence numbers for matching, and we must always go through the messages in // forward order. No reverse search for MAX only. needSeq := (len(mailboxes) > 1 || len(mailboxes) == 1 && mailboxes[0].ID != c.mailboxID) && sk.hasSequenceNumbers() forward := eargs == nil || max1 == 0 || len(eargs) != 1 || needSeq reverse := max1 == 1 && (len(eargs) == 1 || min1+max1 == len(eargs)) && !needSeq // We set a worst-case "goal" of having gone through all messages in all mailboxes. // Sometimes, we can be faster, when we only do a MIN and/or MAX query and we can // stop early. We'll account for that as we go. For the selected mailbox, we'll // only look at those the session has already seen. goal := "nil" var total uint32 for _, mb := range mailboxes { if mb.ID == c.mailboxID && !c.uidonly { total += c.exists } else { total += uint32(mb.Total + mb.Deleted) } } if total > 0 { // Goal is always non-zero. ../rfc/9585:232 goal = fmt.Sprintf("%d", total) } var progress uint32 for _, mb := range mailboxes { var lastUID store.UID result := Result{Mailbox: mb} msgCount := uint32(mb.MailboxCounts.Total + mb.MailboxCounts.Deleted) if mb.ID == c.mailboxID && !c.uidonly { msgCount = c.exists } // Used for interpreting UID sets with a star, like "1:*" and "10:*". Only called // for UIDs that are higher than the number, since "10:*" evaluates to "10:5" if 5 // is the highest UID, and UID 5-10 would all match. var cachedHighestUID store.UID xhighestUID := func() store.UID { if cachedHighestUID > 0 { return cachedHighestUID } q := bstore.QueryTx[store.Message](tx) q.FilterNonzero(store.Message{MailboxID: mb.ID}) q.FilterEqual("Expunged", false) if mb.ID == c.mailboxID { q.FilterLess("UID", c.uidnext) } q.SortDesc("UID") q.Limit(1) m, err := q.Get() if err == bstore.ErrAbsent { xuserErrorf("cannot use * on empty mailbox") } xcheckf(err, "get last uid") cachedHighestUID = m.UID return cachedHighestUID } progressOrig := progress if forward { // We track this for non-selected mailboxes. searchMatch will look the message // sequence number for this session up if we are searching the selected mailbox. var seq msgseq = 1 q := bstore.QueryTx[store.Message](tx) q.FilterNonzero(store.Message{MailboxID: mb.ID}) q.FilterEqual("Expunged", false) if mb.ID == c.mailboxID { q.FilterLess("UID", c.uidnext) } q.SortAsc("UID") for m, err := range q.All() { xcheckf(err, "list messages in mailbox") // We track this for the "reverse" case, we'll stop before seeing lastUID. lastUID = m.UID if time.Since(inProgressLast) > inProgressPeriod { c.xwritelinef("* OK [INPROGRESS (%s %d %s)] still searching", inProgressTag, progress, goal) inProgressLast = time.Now() } progress++ if c.searchMatch(tx, msgCount, seq, m, *sk, bodySearch, textSearch, xhighestUID) { result.UIDs = append(result.UIDs, m.UID) result.MaxModSeq = max(result.MaxModSeq, m.ModSeq) if min1 == 1 && min1+max1 == len(eargs) { if !needSeq { break } // We only need a MIN and a MAX, but we also need sequence numbers so we are // walking through and collecting all UIDs. Correct for that, keeping only the MIN // (first) // and MAX (second). if len(result.UIDs) == 3 { result.UIDs[1] = result.UIDs[2] result.UIDs = result.UIDs[:2] } } } seq++ } } // And reverse search for MAX if we have only MAX or MAX combined with MIN, and // don't need sequence numbers. We just need a single match, then we stop. if reverse { q := bstore.QueryTx[store.Message](tx) q.FilterNonzero(store.Message{MailboxID: mb.ID}) q.FilterEqual("Expunged", false) q.FilterGreater("UID", lastUID) if mb.ID == c.mailboxID { q.FilterLess("UID", c.uidnext) } q.SortDesc("UID") for m, err := range q.All() { xcheckf(err, "list messages in mailbox") if time.Since(inProgressLast) > inProgressPeriod { c.xwritelinef("* OK [INPROGRESS (%s %d %s)] still searching", inProgressTag, progress, goal) inProgressLast = time.Now() } progress++ var seq msgseq // Filled in by searchMatch for messages in selected mailbox. if c.searchMatch(tx, msgCount, seq, m, *sk, bodySearch, textSearch, xhighestUID) { result.UIDs = append(result.UIDs, m.UID) result.MaxModSeq = max(result.MaxModSeq, m.ModSeq) break } } } // We could have finished searching the mailbox with fewer mailboxProcessed := progress - progressOrig mailboxTotal := uint32(mb.MailboxCounts.Total + mb.MailboxCounts.Deleted) progress += max(0, mailboxTotal-mailboxProcessed) results = append(results, result) } }) if eargs == nil { // We'll only have a result for the one selected mailbox. result := results[0] // In IMAP4rev1, an untagged SEARCH response is required. ../rfc/3501:2728 if len(result.UIDs) == 0 { c.xbwritelinef("* SEARCH") } // Old-style SEARCH response. We must spell out each number. So we may be splitting // into multiple responses. ../rfc/9051:6809 ../rfc/3501:4833 for len(result.UIDs) > 0 { n := min(100, len(result.UIDs)) s := "" for _, v := range result.UIDs[:n] { if !isUID { v = store.UID(c.xsequence(v)) } s += " " + fmt.Sprintf("%d", v) } // Since we don't have the max modseq for the possibly partial uid range we're // writing here within hand reach, we conveniently interpret the ambiguous "for all // messages being returned" in ../rfc/7162:1107 as meaning over all lines that we // write. And that clients only commit this value after they have seen the tagged // end of the command. Appears to be recommended behaviour, ../rfc/7162:2323. // ../rfc/7162:1077 ../rfc/7162:1101 var modseq string if sk.hasModseq() { // ../rfc/7162:2557 modseq = fmt.Sprintf(" (MODSEQ %d)", result.MaxModSeq.Client()) } c.xbwritelinef("* SEARCH%s%s", s, modseq) result.UIDs = result.UIDs[n:] } } else { // New-style ESEARCH response syntax: ../rfc/9051:6546 ../rfc/4466:522 if save { // ../rfc/9051:3784 ../rfc/5182:13 c.searchResult = results[0].UIDs c.checkUIDs(c.searchResult, false) } // No untagged ESEARCH response if nothing was requested. ../rfc/9051:4160 if len(eargs) > 0 { for _, result := range results { // For the ESEARCH command, we must not return a response if there were no matching // messages. This is unlike the later IMAP4rev2, where an ESEARCH response must be // sent if there were no matches. ../rfc/7377:243 ../rfc/9051:3775 if isE && len(result.UIDs) == 0 { continue } // The tag was originally a string, became an astring in IMAP4rev2, better stick to // string. ../rfc/4466:707 ../rfc/5259:1163 ../rfc/9051:7087 if isE { fmt.Fprintf(c.xbw, `* ESEARCH (TAG "%s" MAILBOX %s UIDVALIDITY %d)`, tag, result.Mailbox.Name, result.Mailbox.UIDValidity) } else { fmt.Fprintf(c.xbw, `* ESEARCH (TAG "%s")`, tag) } if isUID { fmt.Fprintf(c.xbw, " UID") } // NOTE: we are potentially converting UIDs to msgseq, but keep the store.UID type // for convenience. nums := result.UIDs if !isUID { // If searchResult is hanging on to the slice, we need to work on a copy. if save { nums = slices.Clone(nums) } for i, uid := range nums { nums[i] = store.UID(c.xsequence(uid)) } } // If no matches, then no MIN/MAX response. ../rfc/4731:98 ../rfc/9051:3758 if eargs["MIN"] && len(nums) > 0 { fmt.Fprintf(c.xbw, " MIN %d", nums[0]) } if eargs["MAX"] && len(result.UIDs) > 0 { fmt.Fprintf(c.xbw, " MAX %d", nums[len(nums)-1]) } if eargs["COUNT"] { fmt.Fprintf(c.xbw, " COUNT %d", len(nums)) } if eargs["ALL"] && len(nums) > 0 { fmt.Fprintf(c.xbw, " ALL %s", compactUIDSet(nums).String()) } // Interaction between ESEARCH and CONDSTORE: ../rfc/7162:1211 ../rfc/4731:273 // Summary: send the highest modseq of the returned messages. if sk.hasModseq() && len(nums) > 0 { fmt.Fprintf(c.xbw, " MODSEQ %d", result.MaxModSeq.Client()) } c.xbwritelinef("") } } } c.ok(tag, cmd) } type search struct { c *conn tx *bstore.Tx msgCount uint32 // Number of messages in mailbox (or session when selected). seq msgseq // Can be 0, for other mailboxes than selected in case of MAX. m store.Message mr *store.MsgReader p *message.Part xhighestUID func() store.UID } func (c *conn) searchMatch(tx *bstore.Tx, msgCount uint32, seq msgseq, m store.Message, sk searchKey, bodySearch, textSearch *store.WordSearch, xhighestUID func() store.UID) bool { if m.MailboxID == c.mailboxID { // If session doesn't know about the message yet, don't return it. if c.uidonly { if m.UID >= c.uidnext { return false } } else { // Set seq for use in evaluations. seq = c.sequence(m.UID) if seq == 0 { return false } } } s := search{c: c, tx: tx, msgCount: msgCount, seq: seq, m: m, xhighestUID: xhighestUID} defer func() { if s.mr != nil { err := s.mr.Close() c.xsanity(err, "closing messagereader") s.mr = nil } }() return s.match(sk, bodySearch, textSearch) } func (s *search) match(sk searchKey, bodySearch, textSearch *store.WordSearch) (match bool) { match = s.match0(sk) if match && bodySearch != nil { if !s.xensurePart() { match = false return } var err error match, err = bodySearch.MatchPart(s.c.log, s.p, false) xcheckf(err, "search words in bodies") } if match && textSearch != nil { if !s.xensurePart() { match = false return } var err error match, err = textSearch.MatchPart(s.c.log, s.p, true) xcheckf(err, "search words in headers and bodies") } return } // ensure message, reader and part are loaded. returns whether that was // successful. func (s *search) xensurePart() bool { if s.mr != nil { return s.p != nil } // Closed by searchMatch after all (recursive) search.match calls are finished. s.mr = s.c.account.MessageReader(s.m) if s.m.ParsedBuf == nil { s.c.log.Error("missing parsed message") return false } p, err := s.m.LoadPart(s.mr) xcheckf(err, "load parsed message") s.p = &p return true } func (s *search) match0(sk searchKey) bool { c := s.c // Difference between sk.searchKeys nil and length 0 is important. Because we take // out word/notword searches, the list may be empty but non-nil. if sk.searchKeys != nil { for _, ssk := range sk.searchKeys { if !s.match0(ssk) { return false } } return true } else if sk.seqSet != nil { if sk.seqSet.searchResult { // Interpreting search results on a mailbox that isn't selected during multisearch // is likely a mistake. No mention about it in the RFC. ../rfc/7377:257 if s.m.MailboxID != c.mailboxID { xuserErrorf("can only use search result with the selected mailbox") } return uidSearch(c.searchResult, s.m.UID) > 0 } // For multisearch, we have arranged to have a seq for non-selected mailboxes too. return sk.seqSet.containsSeqCount(s.seq, s.msgCount) } filterHeader := func(field, value string) bool { lower := strings.ToLower(value) h, err := s.p.Header() if err != nil { c.log.Debugx("parsing message header", err, slog.Any("uid", s.m.UID), slog.Int64("msgid", s.m.ID)) return false } for _, v := range h.Values(field) { if strings.Contains(strings.ToLower(v), lower) { return true } } return false } // We handle ops by groups that need increasing details about the message. switch sk.op { case "ALL": return true case "NEW": // We do not implement the RECENT flag, so messages cannot be NEW. return false case "OLD": // We treat all messages as non-recent, so this means all messages. return true case "RECENT": // We do not implement the RECENT flag. All messages are not recent. return false case "NOT": return !s.match0(*sk.searchKey) case "OR": return s.match0(*sk.searchKey) || s.match0(*sk.searchKey2) case "UID": if sk.uidSet.searchResult && s.m.MailboxID != c.mailboxID { // Interpreting search results on a mailbox that isn't selected during multisearch // is likely a mistake. No mention about it in the RFC. ../rfc/7377:257 xuserErrorf("cannot use search result from another mailbox") } return sk.uidSet.xcontainsKnownUID(s.m.UID, c.searchResult, s.xhighestUID) } // Parsed part. if !s.xensurePart() { return false } // Parsed message, basic info. switch sk.op { case "ANSWERED": return s.m.Answered case "DELETED": return s.m.Deleted case "FLAGGED": return s.m.Flagged case "KEYWORD": kw := strings.ToLower(sk.atom) switch kw { case "$forwarded": return s.m.Forwarded case "$junk": return s.m.Junk case "$notjunk": return s.m.Notjunk case "$phishing": return s.m.Phishing case "$mdnsent": return s.m.MDNSent default: return slices.Contains(s.m.Keywords, kw) } case "SEEN": return s.m.Seen case "UNANSWERED": return !s.m.Answered case "UNDELETED": return !s.m.Deleted case "UNFLAGGED": return !s.m.Flagged case "UNKEYWORD": kw := strings.ToLower(sk.atom) switch kw { case "$forwarded": return !s.m.Forwarded case "$junk": return !s.m.Junk case "$notjunk": return !s.m.Notjunk case "$phishing": return !s.m.Phishing case "$mdnsent": return !s.m.MDNSent default: return !slices.Contains(s.m.Keywords, kw) } case "UNSEEN": return !s.m.Seen case "DRAFT": return s.m.Draft case "UNDRAFT": return !s.m.Draft case "BEFORE", "ON", "SINCE": skdt := sk.date.Format("2006-01-02") rdt := s.m.Received.Format("2006-01-02") switch sk.op { case "BEFORE": return rdt < skdt case "ON": return rdt == skdt case "SINCE": return rdt >= skdt } panic("missing case") case "LARGER": return s.m.Size > sk.number case "SMALLER": return s.m.Size < sk.number case "MODSEQ": // ../rfc/7162:1045 return s.m.ModSeq.Client() >= *sk.clientModseq case "SAVEDBEFORE", "SAVEDON", "SAVEDSINCE": // If we don't have a savedate for this message (for messages received before we // implemented this feature), we use the "internal date" (received timestamp) of // the message. ../rfc/8514:237 rt := s.m.Received if s.m.SaveDate != nil { rt = *s.m.SaveDate } skdt := sk.date.Format("2006-01-02") rdt := rt.Format("2006-01-02") switch sk.op { case "SAVEDBEFORE": return rdt < skdt case "SAVEDON": return rdt == skdt case "SAVEDSINCE": return rdt >= skdt } panic("missing case") case "SAVEDATESUPPORTED": // We return whether we have a savedate for this message. We support it on all // mailboxes, but we only have this metadata from the time we implemented this // feature. return s.m.SaveDate != nil case "OLDER": // ../rfc/5032:76 seconds := int64(time.Since(s.m.Received) / time.Second) return seconds >= sk.number case "YOUNGER": seconds := int64(time.Since(s.m.Received) / time.Second) return seconds <= sk.number } if s.p == nil { c.log.Info("missing parsed message, not matching", slog.Any("uid", s.m.UID), slog.Int64("msgid", s.m.ID)) return false } // Parsed message, more info. switch sk.op { case "BCC": return filterHeader("Bcc", sk.astring) case "BODY", "TEXT": // We gathered word/notword searches from the top-level, but we can also get them // nested. // todo optimize: handle deeper nested word/not-word searches more efficiently. headerToo := sk.op == "TEXT" match, err := store.PrepareWordSearch([]string{sk.astring}, nil).MatchPart(s.c.log, s.p, headerToo) xcheckf(err, "word search") return match case "CC": return filterHeader("Cc", sk.astring) case "FROM": return filterHeader("From", sk.astring) case "SUBJECT": return filterHeader("Subject", sk.astring) case "TO": return filterHeader("To", sk.astring) case "HEADER": // ../rfc/9051:3895 lower := strings.ToLower(sk.astring) h, err := s.p.Header() if err != nil { c.log.Errorx("parsing header for search", err, slog.Any("uid", s.m.UID), slog.Int64("msgid", s.m.ID)) return false } k := textproto.CanonicalMIMEHeaderKey(sk.headerField) for _, v := range h.Values(k) { if lower == "" || strings.Contains(strings.ToLower(v), lower) { return true } } return false case "SENTBEFORE", "SENTON", "SENTSINCE": if s.p.Envelope == nil || s.p.Envelope.Date.IsZero() { return false } dt := s.p.Envelope.Date.Format("2006-01-02") skdt := sk.date.Format("2006-01-02") switch sk.op { case "SENTBEFORE": return dt < skdt case "SENTON": return dt == skdt case "SENTSINCE": return dt > skdt } panic("missing case") } panic(serverError{fmt.Errorf("missing case for search key op %q", sk.op)}) }