From b437cef2ab7d318b24b455861f594cd2370b2133 Mon Sep 17 00:00:00 2001 From: Drew DeVault Date: Fri, 23 Oct 2020 11:45:00 -0400 Subject: [PATCH] Implement broader search functionality --- plugins/base/imap.go | 27 +------ plugins/base/search.go | 162 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+), 26 deletions(-) create mode 100644 plugins/base/search.go diff --git a/plugins/base/imap.go b/plugins/base/imap.go index 6c3f33f..3492e7e 100644 --- a/plugins/base/imap.go +++ b/plugins/base/imap.go @@ -399,37 +399,12 @@ func listMessages(conn *imapclient.Client, mbox *MailboxStatus, page, messagesPe return msgs, nil } -func searchCriteriaHeader(k, v string) *imap.SearchCriteria { - return &imap.SearchCriteria{ - Header: map[string][]string{ - k: []string{v}, - }, - } -} - -func searchCriteriaOr(criteria ...*imap.SearchCriteria) *imap.SearchCriteria { - or := criteria[0] - for _, c := range criteria[1:] { - or = &imap.SearchCriteria{ - Or: [][2]*imap.SearchCriteria{{or, c}}, - } - } - return or -} - func searchMessages(conn *imapclient.Client, mboxName, query string, page, messagesPerPage int) (msgs []IMAPMessage, total int, err error) { if err := ensureMailboxSelected(conn, mboxName); err != nil { return nil, 0, err } - // TODO: full-text search on demand (can be slow) - //criteria := &imap.SearchCriteria{Text: []string{query}} - criteria := searchCriteriaOr( - searchCriteriaHeader("From", query), - searchCriteriaHeader("To", query), - searchCriteriaHeader("Cc", query), - searchCriteriaHeader("Subject", query), - ) + criteria := PrepareSearch(query) nums, err := conn.Search(criteria) if err != nil { return nil, 0, fmt.Errorf("UID SEARCH failed: %v", err) diff --git a/plugins/base/search.go b/plugins/base/search.go new file mode 100644 index 0000000..4d8cef8 --- /dev/null +++ b/plugins/base/search.go @@ -0,0 +1,162 @@ +package alpsbase + +import ( + "bufio" + "bytes" + "net/textproto" + "strings" + + "github.com/emersion/go-imap" +) + +func searchCriteriaHeader(k, v string) *imap.SearchCriteria { + return &imap.SearchCriteria{ + Header: map[string][]string{ + k: []string{v}, + }, + } +} + +func searchCriteriaOr(criteria ...*imap.SearchCriteria) *imap.SearchCriteria { + if criteria[0] == nil { + criteria = criteria[1:] + } + or := criteria[0] + for _, c := range criteria[1:] { + or = &imap.SearchCriteria{ + Or: [][2]*imap.SearchCriteria{{or, c}}, + } + } + return or +} + +func searchCriteriaAnd(criteria ...*imap.SearchCriteria) *imap.SearchCriteria { + if criteria[0] == nil { + criteria = criteria[1:] + } + and := criteria[0] + for _, c := range criteria[1:] { + // TODO: Maybe pitch the AND and OR functions to go-imap upstream + if c.Header != nil { + if and.Header == nil { + and.Header = make(textproto.MIMEHeader) + } + + for key, value := range c.Header { + if _, ok := and.Header[key]; !ok { + and.Header[key] = nil + } + and.Header[key] = append(and.Header[key], value...) + } + } + and.Body = append(and.Body, c.Body...) + and.Text = append(and.Text, c.Text...) + and.WithFlags = append(and.WithFlags, c.WithFlags...) + and.WithoutFlags = append(and.WithoutFlags, c.WithoutFlags...) + // TODO: Merge more things + } + return and +} + +// Splits search up into the longest string of non-functional parts and +// functional parts +// +// Input: hello world foo:bar baz trains:"are cool" +// Output: ["hello world", "foo:bar", "baz", "trains:are cool"] +func splitSearchTokens(buf []byte, eof bool) (int, []byte, error) { + if len(buf) == 0 { + return 0, nil, nil + } + + if buf[0] == ' ' { + return 1, nil, nil + } + + colon := bytes.IndexByte(buf, byte(':')) + if colon == -1 && eof { + return len(buf), buf, nil + } else if colon == -1 { + return 0, nil, nil + } else { + space := bytes.LastIndexByte(buf[:colon], byte(' ')) + if space != -1 { + return space, buf[:space], nil + } + + var ( + terminator int + quoted bool + ) + if colon + 1 < len(buf) && buf[colon+1] == byte('"') { + terminator = bytes.IndexByte(buf[colon+2:], byte('"')) + terminator += colon + 3 + quoted = true + } else { + terminator = bytes.IndexByte(buf[colon:], byte(' ')) + terminator += colon + } + + if terminator == -1 { + return 0, nil, nil + } else if terminator == -1 && eof { + terminator = len(buf) + } + + if quoted { + trimmed := append(buf[:colon+1], buf[colon+2:terminator-1]...) + return terminator, trimmed, nil + } + + return terminator, buf[:terminator], nil + } +} + +// TODO: Document search functionality somewhere +func PrepareSearch(terms string) *imap.SearchCriteria { + // XXX: If Migadu's IMAP servers can learn a better Full-Text Search then + // we can probably start matching on the message bodies by default (gated + // behind some kind of flag, perhaps) + var criteria *imap.SearchCriteria + + scanner := bufio.NewScanner(strings.NewReader(terms)) + scanner.Split(splitSearchTokens) + + for scanner.Scan() { + term := scanner.Text() + if !strings.ContainsRune(term, ':') { + criteria = searchCriteriaAnd( + criteria, + searchCriteriaOr( + searchCriteriaHeader("From", term), + searchCriteriaHeader("To", term), + searchCriteriaHeader("Cc", term), + searchCriteriaHeader("Subject", term), + ), + ) + } else { + parts := strings.SplitN(term, ":", 2) + key, value := parts[0], parts[1] + switch strings.ToLower(key) { + case "from": + criteria = searchCriteriaAnd( + criteria, searchCriteriaHeader("From", value)) + case "to": + criteria = searchCriteriaAnd( + criteria, searchCriteriaHeader("To", value)) + case "cc": + criteria = searchCriteriaAnd( + criteria, searchCriteriaHeader("Cc", value)) + case "subject": + criteria = searchCriteriaAnd( + criteria, searchCriteriaHeader("Subject", value)) + case "body": + criteria = searchCriteriaAnd( + criteria, &imap.SearchCriteria{Body: []string{value}}) + default: + continue + } + } + } + + return criteria +}