aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--plugins/base/imap.go27
-rw-r--r--plugins/base/search.go162
2 files changed, 163 insertions, 26 deletions
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
+}