mox/imapserver/search.go
Mechiel Lukkien 507ca73b96
imapserver: implement UIDONLY extension, RFC 9586
Once clients enable this extension, commands can no longer refer to "message
sequence numbers" (MSNs), but can only refer to messages with UIDs. This means
both sides no longer have to carefully keep their sequence numbers in sync
(error-prone), and don't have to keep track of a mapping of sequence numbers to
UIDs (saves resources).

With UIDONLY enabled, all FETCH responses are replaced with UIDFETCH response.
2025-04-11 11:45:49 +02:00

917 lines
27 KiB
Go

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