diff options
Diffstat (limited to 'worker')
-rw-r--r-- | worker/imap/list.go | 6 | ||||
-rw-r--r-- | worker/imap/open.go | 23 | ||||
-rw-r--r-- | worker/imap/search.go | 115 | ||||
-rw-r--r-- | worker/jmap/cache/cache.go | 33 | ||||
-rw-r--r-- | worker/jmap/cache/folder_contents.go | 17 | ||||
-rw-r--r-- | worker/jmap/directories.go | 39 | ||||
-rw-r--r-- | worker/jmap/push.go | 4 | ||||
-rw-r--r-- | worker/jmap/search.go | 137 | ||||
-rw-r--r-- | worker/lib/search.go | 188 | ||||
-rw-r--r-- | worker/maildir/search.go | 286 | ||||
-rw-r--r-- | worker/maildir/worker.go | 26 | ||||
-rw-r--r-- | worker/mbox/worker.go | 10 | ||||
-rw-r--r-- | worker/notmuch/search.go | 143 | ||||
-rw-r--r-- | worker/notmuch/worker.go | 20 | ||||
-rw-r--r-- | worker/types/messages.go | 18 | ||||
-rw-r--r-- | worker/types/search.go | 66 |
16 files changed, 414 insertions, 717 deletions
diff --git a/worker/imap/list.go b/worker/imap/list.go index fc1d7e94..5a97d5e2 100644 --- a/worker/imap/list.go +++ b/worker/imap/list.go @@ -115,11 +115,7 @@ func (imapw *IMAPWorker) handleSearchDirectory(msg *types.SearchDirectory) { } imapw.worker.Tracef("Executing search") - criteria, err := parseSearch(msg.Argv) - if err != nil { - emitError(err) - return - } + criteria := translateSearch(msg.Criteria) if msg.Context.Err() != nil { imapw.worker.PostMessage(&types.Cancelled{ diff --git a/worker/imap/open.go b/worker/imap/open.go index 693d93a9..355709a7 100644 --- a/worker/imap/open.go +++ b/worker/imap/open.go @@ -39,17 +39,11 @@ func (imapw *IMAPWorker) handleFetchDirectoryContents( } imapw.worker.Tracef("Fetching UID list") - searchCriteria, err := parseSearch(msg.FilterCriteria) - if err != nil { - imapw.worker.PostMessage(&types.Error{ - Message: types.RespondTo(msg), - Error: err, - }, nil) - return - } + searchCriteria := translateSearch(msg.Filter) sortCriteria := translateSortCriterions(msg.SortCriteria) hasSortCriteria := len(sortCriteria) > 0 + var err error var uids []uint32 // If the server supports the SORT extension, do the sorting server side @@ -87,7 +81,7 @@ func (imapw *IMAPWorker) handleFetchDirectoryContents( return } imapw.worker.Tracef("Found %d UIDs", len(uids)) - if len(msg.FilterCriteria) == 1 { + if msg.Filter == nil { // Only initialize if we are not filtering imapw.seqMap.Initialize(uids) } @@ -134,14 +128,7 @@ func (imapw *IMAPWorker) handleDirectoryThreaded( } imapw.worker.Tracef("Fetching threaded UID list") - searchCriteria, err := parseSearch(msg.FilterCriteria) - if err != nil { - imapw.worker.PostMessage(&types.Error{ - Message: types.RespondTo(msg), - Error: err, - }, nil) - return - } + searchCriteria := translateSearch(msg.Filter) threads, err := imapw.client.thread.UidThread(imapw.threadAlgorithm, searchCriteria) if err != nil { @@ -154,7 +141,7 @@ func (imapw *IMAPWorker) handleDirectoryThreaded( aercThreads, count := convertThreads(threads, nil) sort.Sort(types.ByUID(aercThreads)) imapw.worker.Tracef("Found %d threaded messages", count) - if len(msg.FilterCriteria) == 1 { + if msg.Filter == nil { // Only initialize if we are not filtering var uids []uint32 for i := len(aercThreads) - 1; i >= 0; i-- { diff --git a/worker/imap/search.go b/worker/imap/search.go index e9238190..0305a80f 100644 --- a/worker/imap/search.go +++ b/worker/imap/search.go @@ -1,95 +1,50 @@ package imap import ( - "errors" - "strings" - "github.com/emersion/go-imap" - "git.sr.ht/~rjarry/aerc/lib/parse" - "git.sr.ht/~rjarry/aerc/log" - "git.sr.ht/~sircmpwn/getopt" + "git.sr.ht/~rjarry/aerc/worker/types" + "git.sr.ht/~rjarry/go-opt" ) -func parseSearch(args []string) (*imap.SearchCriteria, error) { +func translateSearch(c *types.SearchCriteria) *imap.SearchCriteria { criteria := imap.NewSearchCriteria() - if len(args) == 0 { - return criteria, nil + if c == nil { + return criteria } + criteria.WithFlags = translateFlags(c.WithFlags) + criteria.WithoutFlags = translateFlags(c.WithoutFlags) - opts, optind, err := getopt.Getopts(args, "rubax:X:t:H:f:c:d:") - if err != nil { - return nil, err + if !c.StartDate.IsZero() { + criteria.SentSince = c.StartDate } - body := false - text := false - for _, opt := range opts { - switch opt.Option { - case 'r': - criteria.WithFlags = append(criteria.WithFlags, imap.SeenFlag) - case 'u': - criteria.WithoutFlags = append(criteria.WithoutFlags, imap.SeenFlag) - case 'x': - if f, err := getParsedFlag(opt.Value); err == nil { - criteria.WithFlags = append(criteria.WithFlags, f) - } - case 'X': - if f, err := getParsedFlag(opt.Value); err == nil { - criteria.WithoutFlags = append(criteria.WithoutFlags, f) - } - case 'H': - if strings.Contains(opt.Value, ": ") { - HeaderValue := strings.SplitN(opt.Value, ": ", 2) - criteria.Header.Add(HeaderValue[0], HeaderValue[1]) - } else { - log.Errorf("Header is not given properly, must be given in format `Header: Value`") - continue - } - case 'f': - criteria.Header.Add("From", opt.Value) - case 't': - criteria.Header.Add("To", opt.Value) - case 'c': - criteria.Header.Add("Cc", opt.Value) - case 'b': - body = true - case 'a': - text = true - case 'd': - start, end, err := parse.DateRange(opt.Value) - if err != nil { - log.Errorf("failed to parse start date: %v", err) - continue - } - if !start.IsZero() { - criteria.SentSince = start - } - if !end.IsZero() { - criteria.SentBefore = end - } - } + if !c.StartDate.IsZero() { + criteria.SentBefore = c.EndDate } - switch { - case text: - criteria.Text = args[optind:] - case body: - criteria.Body = args[optind:] - default: - for _, arg := range args[optind:] { - criteria.Header.Add("Subject", arg) - } + for k, v := range c.Headers { + criteria.Header[k] = v } - return criteria, nil -} - -func getParsedFlag(name string) (string, error) { - switch strings.ToLower(name) { - case "seen": - return imap.SeenFlag, nil - case "flagged": - return imap.FlaggedFlag, nil - case "answered": - return imap.AnsweredFlag, nil + for _, f := range c.From { + criteria.Header.Add("From", f) + } + for _, t := range c.To { + criteria.Header.Add("To", t) + } + for _, c := range c.Cc { + criteria.Header.Add("Cc", c) + } + terms := opt.LexArgs(c.Terms) + if terms.Count() > 0 { + switch { + case c.SearchAll: + criteria.Text = terms.Args() + case c.SearchBody: + criteria.Body = terms.Args() + default: + for _, term := range terms.Args() { + criteria.Header.Add("Subject", term) + } + } } - return imap.FlaggedFlag, errors.New("Flag not suppored") + return criteria } diff --git a/worker/jmap/cache/cache.go b/worker/jmap/cache/cache.go index 249ed0e9..6d815177 100644 --- a/worker/jmap/cache/cache.go +++ b/worker/jmap/cache/cache.go @@ -4,10 +4,12 @@ import ( "errors" "os" "path" + "strings" "git.sr.ht/~rjarry/aerc/lib/xdg" "git.sr.ht/~rjarry/aerc/log" "github.com/syndtr/goleveldb/leveldb" + "github.com/syndtr/goleveldb/leveldb/util" ) type JMAPCache struct { @@ -74,3 +76,34 @@ func (c *JMAPCache) delete(key string) error { } panic("jmap cache with no backend") } + +func (c *JMAPCache) purge(prefix string) error { + switch { + case c.file != nil: + txn, err := c.file.OpenTransaction() + if err != nil { + return err + } + iter := txn.NewIterator(util.BytesPrefix([]byte(prefix)), nil) + for iter.Next() { + err = txn.Delete(iter.Key(), nil) + if err != nil { + break + } + } + iter.Release() + if err != nil { + txn.Discard() + return err + } + return txn.Commit() + case c.mem != nil: + for key := range c.mem { + if strings.HasPrefix(key, prefix) { + delete(c.mem, key) + } + } + return nil + } + panic("jmap cache with no backend") +} diff --git a/worker/jmap/cache/folder_contents.go b/worker/jmap/cache/folder_contents.go index 6c6a7d80..46a36607 100644 --- a/worker/jmap/cache/folder_contents.go +++ b/worker/jmap/cache/folder_contents.go @@ -3,26 +3,32 @@ package cache import ( "reflect" + "git.sr.ht/~rjarry/aerc/log" + "git.sr.ht/~rjarry/aerc/worker/types" "git.sr.ht/~rockorager/go-jmap" - "git.sr.ht/~rockorager/go-jmap/mail/email" ) type FolderContents struct { MailboxID jmap.ID QueryState string - Filter *email.FilterCondition - Sort []*email.SortComparator + Filter *types.SearchCriteria + Sort []*types.SortCriterion MessageIDs []jmap.ID } func (c *JMAPCache) GetFolderContents(mailboxId jmap.ID) (*FolderContents, error) { - buf, err := c.get(folderContentsKey(mailboxId)) + key := folderContentsKey(mailboxId) + buf, err := c.get(key) if err != nil { return nil, err } m := new(FolderContents) err = unmarshal(buf, m) if err != nil { + log.Debugf("cache format has changed, purging foldercontents") + if e := c.purge("foldercontents/"); e != nil { + log.Errorf("foldercontents cache purge: %s", e) + } return nil, err } return m, nil @@ -45,7 +51,7 @@ func folderContentsKey(mailboxId jmap.ID) string { } func (f *FolderContents) NeedsRefresh( - filter *email.FilterCondition, sort []*email.SortComparator, + filter *types.SearchCriteria, sort []*types.SortCriterion, ) bool { if f.QueryState == "" || f.Filter == nil || len(f.Sort) != len(sort) { return true @@ -56,6 +62,5 @@ func (f *FolderContents) NeedsRefresh( return true } } - return !reflect.DeepEqual(filter, f.Filter) } diff --git a/worker/jmap/directories.go b/worker/jmap/directories.go index 0fa3d898..bc47e691 100644 --- a/worker/jmap/directories.go +++ b/worker/jmap/directories.go @@ -126,25 +126,16 @@ func (w *JMAPWorker) handleFetchDirectoryContents(msg *types.FetchDirectoryConte if err != nil { contents = &cache.FolderContents{ MailboxID: w.selectedMbox, - Filter: &email.FilterCondition{}, } } - filter, err := parseSearch(msg.FilterCriteria) - if err != nil { - return err - } - filter.InMailbox = w.selectedMbox - - sort := translateSort(msg.SortCriteria) - - if contents.NeedsRefresh(filter, sort) { + if contents.NeedsRefresh(msg.Filter, msg.SortCriteria) { var req jmap.Request req.Invoke(&email.Query{ Account: w.accountId, - Filter: filter, - Sort: sort, + Filter: w.translateSearch(w.selectedMbox, msg.Filter), + Sort: translateSort(msg.SortCriteria), }) resp, err := w.Do(&req) if err != nil { @@ -154,8 +145,8 @@ func (w *JMAPWorker) handleFetchDirectoryContents(msg *types.FetchDirectoryConte for _, inv := range resp.Responses { switch r := inv.Args.(type) { case *email.QueryResponse: - contents.Sort = sort - contents.Filter = filter + contents.Sort = msg.SortCriteria + contents.Filter = msg.Filter contents.QueryState = r.QueryState contents.MessageIDs = r.IDs canCalculateChanges = r.CanCalculateChanges @@ -193,27 +184,9 @@ func (w *JMAPWorker) handleFetchDirectoryContents(msg *types.FetchDirectoryConte func (w *JMAPWorker) handleSearchDirectory(msg *types.SearchDirectory) error { var req jmap.Request - filter, err := parseSearch(msg.Argv) - if err != nil { - return err - } - if w.selectedMbox == "" { - // all mail virtual folder: display all but trash and spam - var mboxes []jmap.ID - if id, ok := w.roles[mailbox.RoleJunk]; ok { - mboxes = append(mboxes, id) - } - if id, ok := w.roles[mailbox.RoleTrash]; ok { - mboxes = append(mboxes, id) - } - filter.InMailboxOtherThan = mboxes - } else { - filter.InMailbox = w.selectedMbox - } - req.Invoke(&email.Query{ Account: w.accountId, - Filter: filter, + Filter: w.translateSearch(w.selectedMbox, msg.Criteria), }) resp, err := w.Do(&req) diff --git a/worker/jmap/push.go b/worker/jmap/push.go index 320fee4f..34a90ca4 100644 --- a/worker/jmap/push.go +++ b/worker/jmap/push.go @@ -127,8 +127,8 @@ func (w *JMAPWorker) refresh(newState jmap.TypeState) error { } callID = req.Invoke(&email.QueryChanges{ Account: w.accountId, - Filter: contents.Filter, - Sort: contents.Sort, + Filter: w.translateSearch(id, contents.Filter), + Sort: translateSort(contents.Sort), SinceQueryState: contents.QueryState, }) queryChangesCalls[callID] = id diff --git a/worker/jmap/search.go b/worker/jmap/search.go index 17b5ca11..0101ce9b 100644 --- a/worker/jmap/search.go +++ b/worker/jmap/search.go @@ -1,63 +1,98 @@ package jmap import ( - "strings" - - "git.sr.ht/~rjarry/aerc/lib/parse" - "git.sr.ht/~rjarry/aerc/log" + "git.sr.ht/~rjarry/aerc/worker/types" + "git.sr.ht/~rockorager/go-jmap" "git.sr.ht/~rockorager/go-jmap/mail/email" - "git.sr.ht/~sircmpwn/getopt" + "git.sr.ht/~rockorager/go-jmap/mail/mailbox" ) -func parseSearch(args []string) (*email.FilterCondition, error) { - f := new(email.FilterCondition) - if len(args) == 0 { - return f, nil - } - - opts, optind, err := getopt.Getopts(args, "rubax:X:t:H:f:c:d:") - if err != nil { - return nil, err - } - body := false - text := false - for _, opt := range opts { - switch opt.Option { - case 'r': - f.HasKeyword = "$seen" - case 'u': - f.NotKeyword = "$seen" - case 'f': - f.From = opt.Value - case 't': - f.To = opt.Value - case 'c': - f.Cc = opt.Value - case 'b': - body = true - case 'a': - text = true - case 'd': - start, end, err := parse.DateRange(opt.Value) - if err != nil { - log.Errorf("failed to parse start date: %v", err) - continue - } - if !start.IsZero() { - f.After = &start - } - if !end.IsZero() { - f.Before = &end - } +func (w *JMAPWorker) translateSearch( + mbox jmap.ID, criteria *types.SearchCriteria, +) email.Filter { + cond := new(email.FilterCondition) + + if mbox == "" { + // all mail virtual folder: display all but trash and spam + var mboxes []jmap.ID + if id, ok := w.roles[mailbox.RoleJunk]; ok { + mboxes = append(mboxes, id) + } + if id, ok := w.roles[mailbox.RoleTrash]; ok { + mboxes = append(mboxes, id) } + cond.InMailboxOtherThan = mboxes + } else { + cond.InMailbox = mbox + } + if criteria == nil { + return cond + } + + // dates + if !criteria.StartDate.IsZero() { + cond.After = &criteria.StartDate } + if !criteria.EndDate.IsZero() { + cond.Before = &criteria.EndDate + } + + // general search terms switch { - case text: - f.Text = strings.Join(args[optind:], " ") - case body: - f.Body = strings.Join(args[optind:], " ") + case criteria.SearchAll: + cond.Text = criteria.Terms + case criteria.SearchBody: + cond.Body = criteria.Terms default: - f.Subject = strings.Join(args[optind:], " ") + cond.Subject = criteria.Terms + } + + filter := &email.FilterOperator{Operator: jmap.OperatorAND} + filter.Conditions = append(filter.Conditions, cond) + + // keywords/flags + for kw := range flagsToKeywords(criteria.WithFlags) { + filter.Conditions = append(filter.Conditions, + &email.FilterCondition{HasKeyword: kw}) + } + for kw := range flagsToKeywords(criteria.WithoutFlags) { + filter.Conditions = append(filter.Conditions, + &email.FilterCondition{NotKeyword: kw}) } - return f, nil + + // recipients + addrs := &email.FilterOperator{ + Operator: jmap.OperatorOR, + } + for _, from := range criteria.From { + addrs.Conditions = append(addrs.Conditions, + &email.FilterCondition{From: from}) + } + for _, to := range criteria.To { + addrs.Conditions = append(addrs.Conditions, + &email.FilterCondition{To: to}) + } + for _, cc := range criteria.Cc { + addrs.Conditions = append(addrs.Conditions, + &email.FilterCondition{Cc: cc}) + } + if len(addrs.Conditions) > 0 { + filter.Conditions = append(filter.Conditions, addrs) + } + + // specific headers + headers := &email.FilterOperator{ + Operator: jmap.OperatorAND, + } + for h, values := range criteria.Headers { + for _, v := range values { + headers.Conditions = append(headers.Conditions, + &email.FilterCondition{Header: []string{h, v}}) + } + } + if len(headers.Conditions) > 0 { + filter.Conditions = append(filter.Conditions, headers) + } + + return filter } diff --git a/worker/lib/search.go b/worker/lib/search.go index cd372aae..a3604430 100644 --- a/worker/lib/search.go +++ b/worker/lib/search.go @@ -2,111 +2,23 @@ package lib import ( "io" - "net/textproto" "strings" - "time" "unicode" - "git.sr.ht/~sircmpwn/getopt" - - "git.sr.ht/~rjarry/aerc/lib/parse" + "git.sr.ht/~rjarry/aerc/lib" "git.sr.ht/~rjarry/aerc/lib/rfc822" "git.sr.ht/~rjarry/aerc/log" "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/types" + "git.sr.ht/~rjarry/go-opt" ) -type searchCriteria struct { - Header textproto.MIMEHeader - Body []string - Text []string - - WithFlags models.Flags - WithoutFlags models.Flags - - startDate, endDate time.Time -} - -func GetSearchCriteria(args []string) (*searchCriteria, error) { - criteria := &searchCriteria{Header: make(textproto.MIMEHeader)} - - opts, optind, err := getopt.Getopts(args, "rux:X:bat:H:f:c:d:") - if err != nil { - return nil, err - } - body := false - text := false - for _, opt := range opts { - switch opt.Option { - case 'r': - criteria.WithFlags |= models.SeenFlag - case 'u': - criteria.WithoutFlags |= models.SeenFlag - case 'x': - criteria.WithFlags |= getParsedFlag(opt.Value) - case 'X': - criteria.WithoutFlags |= getParsedFlag(opt.Value) - case 'H': - if strings.Contains(opt.Value, ": ") { - HeaderValue := strings.SplitN(opt.Value, ": ", 2) - criteria.Header.Add(HeaderValue[0], HeaderValue[1]) - } else { - log.Errorf("Header is not given properly, must be given in format `Header: Value`") - continue - } - case 'f': - criteria.Header.Add("From", opt.Value) - case 't': - criteria.Header.Add("To", opt.Value) - case 'c': - criteria.Header.Add("Cc", opt.Value) - case 'b': - body = true - case 'd': - start, end, err := parse.DateRange(opt.Value) - if err != nil { - log.Errorf("failed to parse start date: %v", err) - continue - } - if !start.IsZero() { - criteria.startDate = start - } - if !end.IsZero() { - criteria.endDate = end - } - } - } - switch { - case text: - criteria.Text = args[optind:] - case body: - criteria.Body = args[optind:] - default: - for _, arg := range args[optind:] { - criteria.Header.Add("Subject", arg) - } - } - return criteria, nil -} - -func getParsedFlag(name string) models.Flags { - var f models.Flags - switch strings.ToLower(name) { - case "seen": - f = models.SeenFlag - case "answered": - f = models.AnsweredFlag - case "flagged": - f = models.FlaggedFlag - } - return f -} - -func Search(messages []rfc822.RawMessage, criteria *searchCriteria) ([]uint32, error) { - requiredParts := getRequiredParts(criteria) +func Search(messages []rfc822.RawMessage, criteria *types.SearchCriteria) ([]uint32, error) { + requiredParts := GetRequiredParts(criteria) matchedUids := []uint32{} for _, m := range messages { - success, err := searchMessage(m, criteria, requiredParts) + success, err := SearchMessage(m, criteria, requiredParts) if err != nil { return nil, err } else if success { @@ -119,17 +31,19 @@ func Search(messages []rfc822.RawMessage, criteria *searchCriteria) ([]uint32, e // searchMessage executes the search criteria for the given RawMessage, // returns true if search succeeded -func searchMessage(message rfc822.RawMessage, criteria *searchCriteria, +func SearchMessage(message rfc822.RawMessage, criteria *types.SearchCriteria, parts MsgParts, ) (bool, error) { + if criteria == nil { + return true, nil + } // setup parts of the message to use in the search // this is so that we try to minimise reading unnecessary parts var ( - flags models.Flags - header *models.MessageInfo - body string - all string - err error + flags models.Flags + info *models.MessageInfo + text string + err error ) if parts&FLAGS > 0 { @@ -138,26 +52,34 @@ func searchMessage(message rfc822.RawMessage, criteria *searchCriteria, return false, err } } - if parts&HEADER > 0 || parts&DATE > 0 { - header, err = rfc822.MessageInfo(message) + if parts&HEADER > 0 || parts&DATE > 0 || (parts&(BODY|ALL)) == 0 { + info, err = rfc822.MessageInfo(message) if err != nil { return false, err } } - if parts&BODY > 0 { - // TODO: select body properly; this is just an 'all' clone + switch { + case parts&BODY > 0: + path := lib.FindFirstNonMultipart(info.BodyStructure, nil) reader, err := message.NewReader() if err != nil { return false, err } defer reader.Close() - bytes, err := io.ReadAll(reader) + msg, err := rfc822.ReadMessage(reader) if err != nil { return false, err } - body = string(bytes) - } - if parts&ALL > 0 { + part, err := rfc822.FetchEntityPartReader(msg, path) + if err != nil { + return false, err + } + bytes, err := io.ReadAll(part) + if err != nil { + return false, err + } + text = string(bytes) + case parts&ALL > 0: reader, err := message.NewReader() if err != nil { return false, err @@ -167,14 +89,16 @@ func searchMessage(message rfc822.RawMessage, criteria *searchCriteria, if err != nil { return false, err } - all = string(bytes) + text = string(bytes) + default: + text = info.Envelope.Subject } // now search through the criteria // implicit AND at the moment so fail fast - if criteria.Header != nil { - for k, v := range criteria.Header { - headerValue := header.RFC822Headers.Get(k) + if criteria.Headers != nil { + for k, v := range criteria.Headers { + headerValue := info.RFC822Headers.Get(k) for _, text := range v { if !containsSmartCase(headerValue, text) { return false, nil @@ -182,18 +106,11 @@ func searchMessage(message rfc822.RawMessage, criteria *searchCriteria, } } } - if criteria.Body != nil { - for _, searchTerm := range criteria.Body { - if !containsSmartCase(body, searchTerm) { - return false, nil - } - } - } - if criteria.Text != nil { - for _, searchTerm := range criteria.Text { - if !containsSmartCase(all, searchTerm) { - return false, nil - } + + args := opt.LexArgs(criteria.Terms) + for _, searchTerm := range args.Args() { + if !containsSmartCase(text, searchTerm) { + return false, nil } } if criteria.WithFlags != 0 { @@ -207,16 +124,16 @@ func searchMessage(message rfc822.RawMessage, criteria *searchCriteria, } } if parts&DATE > 0 { - if date, err := header.RFC822Headers.Date(); err != nil { + if date, err := info.RFC822Headers.Date(); err != nil { log.Errorf("Failed to get date from header: %v", err) } else { - if !criteria.startDate.IsZero() { - if date.Before(criteria.startDate) { + if !criteria.StartDate.IsZero() { + if date.Before(criteria.StartDate) { return false, nil } } - if !criteria.endDate.IsZero() { - if date.After(criteria.endDate) { + if !criteria.EndDate.IsZero() { + if date.After(criteria.EndDate) { return false, nil } } @@ -257,18 +174,21 @@ const ( // Returns a bitmask of the parts of the message required to be loaded for the // given criteria -func getRequiredParts(criteria *searchCriteria) MsgParts { +func GetRequiredParts(criteria *types.SearchCriteria) MsgParts { required := NONE - if len(criteria.Header) > 0 { + if criteria == nil { + return required + } + if len(criteria.Headers) > 0 { required |= HEADER } - if !criteria.startDate.IsZero() || !criteria.endDate.IsZero() { + if !criteria.StartDate.IsZero() || !criteria.EndDate.IsZero() { required |= DATE } - if criteria.Body != nil && len(criteria.Body) > 0 { + if criteria.SearchBody { required |= BODY } - if criteria.Text != nil && len(criteria.Text) > 0 { + if criteria.SearchAll { required |= ALL } if criteria.WithFlags != 0 { diff --git a/worker/maildir/search.go b/worker/maildir/search.go index c1ea4de2..cf954753 100644 --- a/worker/maildir/search.go +++ b/worker/maildir/search.go @@ -2,114 +2,17 @@ package maildir import ( "context" - "io" - "net/textproto" "runtime" - "strings" "sync" - "time" - "unicode" - "github.com/emersion/go-maildir" - - "git.sr.ht/~sircmpwn/getopt" - - "git.sr.ht/~rjarry/aerc/lib" - "git.sr.ht/~rjarry/aerc/lib/parse" "git.sr.ht/~rjarry/aerc/log" - "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/lib" + "git.sr.ht/~rjarry/aerc/worker/types" ) -type searchCriteria struct { - Header textproto.MIMEHeader - Body []string - Text []string - - WithFlags []maildir.Flag - WithoutFlags []maildir.Flag - - startDate, endDate time.Time -} - -func parseSearch(args []string) (*searchCriteria, error) { - criteria := &searchCriteria{Header: make(textproto.MIMEHeader)} - - opts, optind, err := getopt.Getopts(args, "rux:X:bat:H:f:c:d:") - if err != nil { - return nil, err - } - body := false - text := false - for _, opt := range opts { - switch opt.Option { - case 'r': - criteria.WithFlags = append(criteria.WithFlags, maildir.FlagSeen) - case 'u': - criteria.WithoutFlags = append(criteria.WithoutFlags, maildir.FlagSeen) - case 'x': - criteria.WithFlags = append(criteria.WithFlags, getParsedFlag(opt.Value)) - case 'X': - criteria.WithoutFlags = append(criteria.WithoutFlags, getParsedFlag(opt.Value)) - case 'H': - if strings.Contains(opt.Value, ": ") { - HeaderValue := strings.SplitN(opt.Value, ": ", 2) - criteria.Header.Add(HeaderValue[0], HeaderValue[1]) - } else { - log.Errorf("Header is not given properly, must be given in format `Header: Value`") - continue - } - case 'f': - criteria.Header.Add("From", opt.Value) - case 't': - criteria.Header.Add("To", opt.Value) - case 'c': - criteria.Header.Add("Cc", opt.Value) - case 'b': - body = true - case 'a': - text = true - case 'd': - start, end, err := parse.DateRange(opt.Value) - if err != nil { - log.Errorf("failed to parse start date: %v", err) - continue - } - if !start.IsZero() { - criteria.startDate = start - } - if !end.IsZero() { - criteria.endDate = end - } - } - } - switch { - case text: - criteria.Text = args[optind:] - case body: - criteria.Body = args[optind:] - default: - for _, arg := range args[optind:] { - criteria.Header.Add("Subject", arg) - } - } - return criteria, nil -} - -func getParsedFlag(name string) maildir.Flag { - var f maildir.Flag - switch strings.ToLower(name) { - case "seen": - f = maildir.FlagSeen - case "answered": - f = maildir.FlagReplied - case "flagged": - f = maildir.FlagFlagged - } - return f -} +func (w *Worker) search(ctx context.Context, criteria *types.SearchCriteria) ([]uint32, error) { + requiredParts := lib.GetRequiredParts(criteria) -func (w *Worker) search(ctx context.Context, criteria *searchCriteria) ([]uint32, error) { - requiredParts := getRequiredParts(criteria) w.worker.Debugf("Required parts bitmask for search: %b", requiredParts) keys, err := w.c.UIDs(*w.selected) @@ -152,187 +55,12 @@ func (w *Worker) search(ctx context.Context, criteria *searchCriteria) ([]uint32 } // Execute the search criteria for the given key, returns true if search succeeded -func (w *Worker) searchKey(key uint32, criteria *searchCriteria, - parts MsgParts, +func (w *Worker) searchKey(key uint32, criteria *types.SearchCriteria, + parts lib.MsgParts, ) (bool, error) { message, err := w.c.Message(*w.selected, key) if err != nil { return false, err } - - // setup parts of the message to use in the search - // this is so that we try to minimise reading unnecessary parts - var ( - flags []maildir.Flag - header *models.MessageInfo - body string - all string - ) - - if parts&FLAGS > 0 { - flags, err = message.Flags() - if err != nil { - return false, err - } - } - if parts&HEADER > 0 || parts&DATE > 0 { - header, err = message.MessageInfo() - if err != nil { - return false, err - } - } - if parts&BODY > 0 { - // TODO: select which part to search, maybe look for text/plain - mi, err := message.MessageInfo() - if err != nil { - return false, err - } - path := lib.FindFirstNonMultipart(mi.BodyStructure, nil) - reader, err := message.NewBodyPartReader(path) - if err != nil { - return false, err - } - bytes, err := io.ReadAll(reader) - if err != nil { - return false, err - } - body = string(bytes) - } - if parts&ALL > 0 { - reader, err := message.NewReader() - if err != nil { - return false, err - } - defer reader.Close() - bytes, err := io.ReadAll(reader) - if err != nil { - return false, err - } - all = string(bytes) - } - - // now search through the criteria - // implicit AND at the moment so fail fast - if criteria.Header != nil { - for k, v := range criteria.Header { - headerValue := header.RFC822Headers.Get(k) - for _, text := range v { - if !containsSmartCase(headerValue, text) { - return false, nil - } - } - } - } - if criteria.Body != nil { - for _, searchTerm := range criteria.Body { - if !containsSmartCase(body, searchTerm) { - return false, nil - } - } - } - if criteria.Text != nil { - for _, searchTerm := range criteria.Text { - if !containsSmartCase(all, searchTerm) { - return false, nil - } - } - } - if criteria.WithFlags != nil { - for _, searchFlag := range criteria.WithFlags { - if !containsFlag(flags, searchFlag) { - return false, nil - } - } - } - if criteria.WithoutFlags != nil { - for _, searchFlag := range criteria.WithoutFlags { - if containsFlag(flags, searchFlag) { - return false, nil - } - } - } - if parts&DATE > 0 { - if date, err := header.RFC822Headers.Date(); err != nil { - w.worker.Errorf("Failed to get date from header: %v", err) - } else { - if !criteria.startDate.IsZero() { - if date.Before(criteria.startDate) { - return false, nil - } - } - if !criteria.endDate.IsZero() { - if date.After(criteria.endDate) { - return false, nil - } - } - } - } - return true, nil -} - -// Returns true if searchFlag appears in flags -func containsFlag(flags []maildir.Flag, searchFlag maildir.Flag) bool { - match := false - for _, flag := range flags { - if searchFlag == flag { - match = true - } - } - return match -} - -// Smarter version of strings.Contains for searching. -// Is case-insensitive unless substr contains an upper case character -func containsSmartCase(s string, substr string) bool { - if hasUpper(substr) { - return strings.Contains(s, substr) - } - return strings.Contains(strings.ToLower(s), strings.ToLower(substr)) -} - -func hasUpper(s string) bool { - for _, r := range s { - if unicode.IsUpper(r) { - return true - } - } - return false -} - -// The parts of a message, kind of -type MsgParts int - -const NONE MsgParts = 0 -const ( - FLAGS MsgParts = 1 << iota - HEADER - DATE - BODY - ALL -) - -// Returns a bitmask of the parts of the message required to be loaded for the -// given criteria -func getRequiredParts(criteria *searchCriteria) MsgParts { - required := NONE - if len(criteria.Header) > 0 { - required |= HEADER - } - if !criteria.startDate.IsZero() || !criteria.endDate.IsZero() { - required |= DATE - } - if criteria.Body != nil && len(criteria.Body) > 0 { - required |= BODY - } - if criteria.Text != nil && len(criteria.Text) > 0 { - required |= ALL - } - if criteria.WithFlags != nil && len(criteria.WithFlags) > 0 { - required |= FLAGS - } - if criteria.WithoutFlags != nil && len(criteria.WithoutFlags) > 0 { - required |= FLAGS - } - - return required + return lib.SearchMessage(message, criteria, parts) } diff --git a/worker/maildir/worker.go b/worker/maildir/worker.go index 0be6137f..f843d002 100644 --- a/worker/maildir/worker.go +++ b/worker/maildir/worker.go @@ -461,13 +461,8 @@ func (w *Worker) handleFetchDirectoryContents( uids []uint32 err error ) - // FilterCriteria always contains "filter" as first item - if len(msg.FilterCriteria) > 1 { - filter, err := parseSearch(msg.FilterCriteria) - if err != nil { - return err - } - uids, err = w.search(msg.Context, filter) + if msg.Filter != nil { + uids, err = w.search(msg.Context, msg.Filter) if err != nil { return err } @@ -546,12 +541,8 @@ func (w *Worker) handleFetchDirectoryThreaded( uids []uint32 err error ) - if len(msg.FilterCriteria) > 1 { - filter, err := parseSearch(msg.FilterCriteria) - if err != nil { - return err - } - uids, err = w.search(msg.Context, filter) + if msg.Filter != nil { + uids, err = w.search(msg.Context, msg.Filter) if err != nil { return err } @@ -871,13 +862,8 @@ func (w *Worker) handleAppendMessage(msg *types.AppendMessage) error { } func (w *Worker) handleSearchDirectory(msg *types.SearchDirectory) error { - w.worker.Debugf("Searching directory %v with args: %v", *w.selected, msg.Argv) - criteria, err := parseSearch(msg.Argv) - if err != nil { - return err - } - w.worker.Tracef("Searching with parsed criteria: %#v", criteria) - uids, err := w.search(msg.Context, criteria) + w.worker.Tracef("Searching with criteria: %#v", msg.Criteria) + uids, err := w.search(msg.Context, msg.Criteria) if err != nil { return err } diff --git a/worker/mbox/worker.go b/worker/mbox/worker.go index 160dfa55..5de3e30d 100644 --- a/worker/mbox/worker.go +++ b/worker/mbox/worker.go @@ -119,7 +119,7 @@ func (w *mboxWorker) handleMessage(msg types.WorkerMessage) error { w.worker.Debugf("%s opened", msg.Directory) case *types.FetchDirectoryContents: - uids, err := filterUids(w.folder, w.folder.Uids(), msg.FilterCriteria) + uids, err := filterUids(w.folder, w.folder.Uids(), msg.Filter) if err != nil { reterr = err break @@ -339,7 +339,7 @@ func (w *mboxWorker) handleMessage(msg types.WorkerMessage) error { &types.Done{Message: types.RespondTo(msg)}, nil) case *types.SearchDirectory: - uids, err := filterUids(w.folder, w.folder.Uids(), msg.Argv) + uids, err := filterUids(w.folder, w.folder.Uids(), msg.Criteria) if err != nil { reterr = err break @@ -405,11 +405,7 @@ func (w *mboxWorker) PathSeparator() string { return "/" } -func filterUids(folder *container, uids []uint32, args []string) ([]uint32, error) { - criteria, err := lib.GetSearchCriteria(args) - if err != nil { - return nil, err - } +func filterUids(folder *container, uids []uint32, criteria *types.SearchCriteria) ([]uint32, error) { log.Debugf("Search with parsed criteria: %#v", criteria) m := make([]rfc822.RawMessage, 0, len(uids)) for _, uid := range uids { diff --git a/worker/notmuch/search.go b/worker/notmuch/search.go index e1428b45..b3592d41 100644 --- a/worker/notmuch/search.go +++ b/worker/notmuch/search.go @@ -4,18 +4,18 @@ package notmuch import ( - "strconv" - "strings" + "fmt" - "git.sr.ht/~rjarry/aerc/log" - "git.sr.ht/~sircmpwn/getopt" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/types" + "git.sr.ht/~rjarry/go-opt" ) type queryBuilder struct { s string } -func (q *queryBuilder) add(s string) { +func (q *queryBuilder) and(s string) { if len(s) == 0 { return } @@ -25,62 +25,93 @@ func (q *queryBuilder) add(s string) { q.s += "(" + s + ")" } -func translate(args []string) (string, error) { - if len(args) == 0 { - return "", nil - } - var qb queryBuilder - opts, optind, err := getopt.Getopts(args, "rux:X:bat:H:f:c:d:") - if err != nil { - // if error occurs here, don't fail - log.Errorf("getopts failed: %v", err) - return strings.Join(args[1:], ""), nil - } - body := false - for _, opt := range opts { - switch opt.Option { - case 'r': - qb.add("not tag:unread") - case 'u': - qb.add("tag:unread") - case 'x': - qb.add(getParsedFlag(opt.Value)) - case 'X': - qb.add("not " + getParsedFlag(opt.Value)) - case 'H': - // TODO - case 'f': - qb.add("from:" + opt.Value) - case 't': - qb.add("to:" + opt.Value) - case 'c': - qb.add("cc:" + opt.Value) - case 'a': - // TODO - case 'b': - body = true - case 'd': - qb.add("date:" + strconv.Quote(opt.Value)) +func (q *queryBuilder) or(s string) { + if len(s) == 0 { + return + } + if len(q.s) != 0 { + q.s += " or " + } + q.s += "(" + s + ")" +} + +func translate(crit *types.SearchCriteria) string { + if crit == nil { + return "" + } + var base queryBuilder + + // recipients + var from queryBuilder + for _, f := range crit.From { + from.or("from:" + opt.QuoteArg(f)) + } + if from.s != "" { + base.and(from.s) + } + + var to queryBuilder + for _, t := range crit.To { + to.or("to:" + opt.QuoteArg(t)) + } + if to.s != "" { + base.and(to.s) + } + + var cc queryBuilder + for _, c := range crit.Cc { + cc.or("cc:" + opt.QuoteArg(c)) + } + if cc.s != "" { + base.and(cc.s) + } + + // flags + for _, f := range []models.Flags{models.SeenFlag, models.AnsweredFlag, models.FlaggedFlag} { + if crit.WithFlags.Has(f) { + base.and(getParsedFlag(f, false)) + } + if crit.WithoutFlags.Has(f) { + base.and(getParsedFlag(f, true)) } } + + // dates switch { - case body: - qb.add("body:" + strconv.Quote(strings.Join(args[optind:], " "))) - default: - qb.add(strings.Join(args[optind:], " ")) + case !crit.StartDate.IsZero() && !crit.EndDate.IsZero(): + base.and(fmt.Sprintf("date:@%d..@%d", + crit.StartDate.Unix(), crit.EndDate.Unix())) + case !crit.StartDate.IsZero(): + base.and(fmt.Sprintf("date:@%d..", crit.StartDate.Unix())) + case !crit.EndDate.IsZero(): + base.and(fmt.Sprintf("date:..@%d", crit.EndDate.Unix())) + } + + // other terms + if crit.Terms != "" { + if crit.SearchBody { + base.and("body:" + opt.QuoteArg(crit.Terms)) + } else { + base.and(crit.Terms) + } } - return qb.s, nil + + return base.s } -func getParsedFlag(name string) string { - switch strings.ToLower(name) { - case "answered": - return "tag:replied" - case "seen": - return "(not tag:unread)" - case "flagged": - return "tag:flagged" - default: - return name +func getParsedFlag(flag models.Flags, inverse bool) string { + name := "" + switch flag { + case models.AnsweredFlag: + name = "tag:replied" + case models.SeenFlag: + name = "tag:unread" + inverse = !inverse + case models.FlaggedFlag: + name = "tag:flagged" + } + if inverse { + name = "not " + name } + return name } diff --git a/worker/notmuch/worker.go b/worker/notmuch/worker.go index 2f601528..486312a1 100644 --- a/worker/notmuch/worker.go +++ b/worker/notmuch/worker.go @@ -531,13 +531,7 @@ func (w *worker) handleFlagMessages(msg *types.FlagMessages) error { } func (w *worker) handleSearchDirectory(msg *types.SearchDirectory) error { - // the first item is the command (:search) - log.Debugf("search args: %v", msg.Argv) - s, err := translate(msg.Argv) - if err != nil { - log.Debugf("ERROR: %v", err) - return err - } + s := translate(msg.Criteria) // we only want to search in the current query, so merge the two together search := w.query if s != "" { @@ -605,11 +599,7 @@ func (w *worker) emitDirectoryContents(parent types.WorkerMessage) error { query := w.query ctx := context.Background() if msg, ok := parent.(*types.FetchDirectoryContents); ok { - log.Debugf("filter input: '%v'", msg.FilterCriteria) - s, err := translate(msg.FilterCriteria) - if err != nil { - return err - } + s := translate(msg.Filter) if s != "" { query = fmt.Sprintf("(%v) and (%v)", query, s) log.Debugf("filter query: '%s'", query) @@ -637,11 +627,7 @@ func (w *worker) emitDirectoryThreaded(parent types.WorkerMessage) error { ctx := context.Background() threadContext := false if msg, ok := parent.(*types.FetchDirectoryThreaded); ok { - log.Debugf("filter input: '%v'", msg.FilterCriteria) - s, err := translate(msg.FilterCriteria) - if err != nil { - return err - } + s := translate(msg.Filter) if s != "" { query = fmt.Sprintf("(%v) and (%v)", query, s) log.Debugf("filter query: '%s'", query) diff --git a/worker/types/messages.go b/worker/types/messages.go index 7cab9a7a..35310b98 100644 --- a/worker/types/messages.go +++ b/worker/types/messages.go @@ -105,23 +105,23 @@ type OpenDirectory struct { type FetchDirectoryContents struct { Message - Context context.Context - SortCriteria []*SortCriterion - FilterCriteria []string + Context context.Context + SortCriteria []*SortCriterion + Filter *SearchCriteria } type FetchDirectoryThreaded struct { Message - Context context.Context - SortCriteria []*SortCriterion - FilterCriteria []string - ThreadContext bool + Context context.Context + SortCriteria []*SortCriterion + Filter *SearchCriteria + ThreadContext bool } type SearchDirectory struct { Message - Context context.Context - Argv []string + Context context.Context + Criteria *SearchCriteria } type DirectoryThreaded struct { diff --git a/worker/types/search.go b/worker/types/search.go new file mode 100644 index 00000000..b1960cad --- /dev/null +++ b/worker/types/search.go @@ -0,0 +1,66 @@ +package types + +import ( + "net/textproto" + "strings" + "time" + + "git.sr.ht/~rjarry/aerc/models" +) + +type SearchCriteria struct { + WithFlags models.Flags + WithoutFlags models.Flags + From []string + To []string + Cc []string + Headers textproto.MIMEHeader + StartDate time.Time + EndDate time.Time + SearchBody bool + SearchAll bool + Terms string +} + +func (c *SearchCriteria) Combine(other *SearchCriteria) *SearchCriteria { + if c == nil { + return other + } + headers := make(textproto.MIMEHeader) + for k, v := range c.Headers { + headers[k] = v + } + for k, v := range other.Headers { + headers[k] = v + } + start := c.StartDate + if !other.StartDate.IsZero() { + start = other.StartDate + } + end := c.EndDate + if !other.EndDate.IsZero() { + end = other.EndDate + } + from := make([]string, len(c.From)+len(other.From)) + copy(from[:len(c.From)], c.From) + copy(from[len(c.From):], other.From) + to := make([]string, len(c.To)+len(other.To)) + copy(to[:len(c.To)], c.To) + copy(to[len(c.To):], other.To) + cc := make([]string, len(c.Cc)+len(other.Cc)) + copy(cc[:len(c.Cc)], c.Cc) + copy(cc[len(c.Cc):], other.Cc) + return &SearchCriteria{ + WithFlags: c.WithFlags | other.WithFlags, + WithoutFlags: c.WithoutFlags | other.WithoutFlags, + From: from, + To: to, + Cc: cc, + Headers: headers, + StartDate: start, + EndDate: end, + SearchBody: c.SearchBody || other.SearchBody, + SearchAll: c.SearchAll || other.SearchAll, + Terms: strings.Join([]string{c.Terms, other.Terms}, " "), + } +} |