about summary refs log tree commit diff stats
diff options
context:
space:
mode:
authorJeffas <dev@jeffas.io>2019-09-14 18:05:20 +0100
committerDrew DeVault <sir@cmpwn.com>2019-09-16 12:40:26 -0400
commitc97d0d6320de996f00e5562c487059950423c151 (patch)
tree0dad188733eaed1b4a2d972cbb2ff5ddd61df3d4
parentedfab1b201c86a14d825ca02a82f5c7ec3eb5911 (diff)
downloadaerc-c97d0d6320de996f00e5562c487059950423c151.tar.gz
Add basic searching to the maildir backend
Basic searching is supported with the following:
- read messages
- unread messages
- from addresses
- text in body
- text in subject
- text in all

The implementation loops through all messages in the selected directory.
It tries to be smart by detecting which parts of each message the search
query needs to use and only loads these from the filesystem.
-rw-r--r--worker/maildir/search.go245
-rw-r--r--worker/maildir/worker.go16
2 files changed, 260 insertions, 1 deletions
diff --git a/worker/maildir/search.go b/worker/maildir/search.go
new file mode 100644
index 0000000..f8130ac
--- /dev/null
+++ b/worker/maildir/search.go
@@ -0,0 +1,245 @@
+package maildir
+
+import (
+	"io/ioutil"
+	"net/textproto"
+	"strings"
+	"unicode"
+
+	"github.com/emersion/go-maildir"
+
+	"git.sr.ht/~sircmpwn/getopt"
+
+	"git.sr.ht/~sircmpwn/aerc/models"
+)
+
+type searchCriteria struct {
+	Header textproto.MIMEHeader
+	Body   []string
+	Text   []string
+
+	WithFlags    []maildir.Flag
+	WithoutFlags []maildir.Flag
+}
+
+func newSearchCriteria() *searchCriteria {
+	return &searchCriteria{Header: make(textproto.MIMEHeader)}
+}
+
+func parseSearch(args []string) (*searchCriteria, error) {
+	criteria := newSearchCriteria()
+
+	opts, optind, err := getopt.Getopts(args, "rubtH:f:")
+	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 'H':
+			// TODO
+		case 'f':
+			criteria.Header.Add("From", opt.Value)
+		case 'b':
+			body = true
+		case 't':
+			text = true
+		}
+	}
+	if text {
+		criteria.Text = args[optind:]
+	} else if body {
+		criteria.Body = args[optind:]
+	} else {
+		for _, arg := range args[optind:] {
+			criteria.Header.Add("Subject", arg)
+		}
+	}
+	return criteria, nil
+}
+
+func (w *Worker) search(criteria *searchCriteria) ([]uint32, error) {
+	requiredParts := getRequiredParts(criteria)
+	w.worker.Logger.Printf("Required parts bitmask for search: %b", requiredParts)
+
+	keys, err := w.c.UIDs(*w.selected)
+	if err != nil {
+		return nil, err
+	}
+
+	matchedUids := []uint32{}
+	for _, key := range keys {
+		success, err := w.searchKey(key, criteria, requiredParts)
+		if err != nil {
+			return nil, err
+		} else if success {
+			matchedUids = append(matchedUids, key)
+		}
+	}
+
+	return matchedUids, nil
+}
+
+// Execute the search criteria for the given key, returns true if search succeeded
+func (w *Worker) searchKey(key uint32, criteria *searchCriteria,
+	parts 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 {
+		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
+		reader, err := message.NewBodyPartReader([]int{1})
+		if err != nil {
+			return false, err
+		}
+		bytes, err := ioutil.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
+		}
+		bytes, err := ioutil.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
+			}
+		}
+	}
+	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
+	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.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
+}
diff --git a/worker/maildir/worker.go b/worker/maildir/worker.go
index 533bb7c..3e59da6 100644
--- a/worker/maildir/worker.go
+++ b/worker/maildir/worker.go
@@ -407,5 +407,19 @@ func (w *Worker) handleAppendMessage(msg *types.AppendMessage) error {
 }
 
 func (w *Worker) handleSearchDirectory(msg *types.SearchDirectory) error {
-	return errUnsupported
+	w.worker.Logger.Printf("Searching directory %v with args: %v", *w.selected, msg.Argv)
+	criteria, err := parseSearch(msg.Argv)
+	if err != nil {
+		return err
+	}
+	w.worker.Logger.Printf("Searching with parsed criteria: %#v", criteria)
+	uids, err := w.search(criteria)
+	if err != nil {
+		return err
+	}
+	w.worker.PostMessage(&types.SearchResults{
+		Message: types.RespondTo(msg),
+		Uids:    uids,
+	}, nil)
+	return nil
 }