about summary refs log tree commit diff stats
path: root/worker/maildir/search.go
diff options
context:
space:
mode:
Diffstat (limited to 'worker/maildir/search.go')
-rw-r--r--worker/maildir/search.go245
1 files changed, 245 insertions, 0 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
+}