2019-09-14 19:05:20 +02:00
|
|
|
package maildir
|
|
|
|
|
|
|
|
import (
|
2022-08-17 16:19:45 +02:00
|
|
|
"io"
|
2019-09-14 19:05:20 +02:00
|
|
|
"net/textproto"
|
|
|
|
"strings"
|
|
|
|
"unicode"
|
|
|
|
|
|
|
|
"github.com/emersion/go-maildir"
|
|
|
|
|
|
|
|
"git.sr.ht/~sircmpwn/getopt"
|
|
|
|
|
2021-11-05 10:19:46 +01:00
|
|
|
"git.sr.ht/~rjarry/aerc/lib"
|
2022-07-19 22:31:51 +02:00
|
|
|
"git.sr.ht/~rjarry/aerc/logging"
|
2021-11-05 10:19:46 +01:00
|
|
|
"git.sr.ht/~rjarry/aerc/models"
|
2019-09-14 19:05:20 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
2020-07-24 10:36:19 +02:00
|
|
|
opts, optind, err := getopt.Getopts(args, "rux:X:bat:H:f:c:")
|
2019-09-14 19:05:20 +02:00
|
|
|
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)
|
2020-07-24 10:36:19 +02:00
|
|
|
case 'x':
|
|
|
|
criteria.WithFlags = append(criteria.WithFlags, getParsedFlag(opt.Value))
|
|
|
|
case 'X':
|
|
|
|
criteria.WithoutFlags = append(criteria.WithoutFlags, getParsedFlag(opt.Value))
|
2019-09-14 19:05:20 +02:00
|
|
|
case 'H':
|
|
|
|
// TODO
|
|
|
|
case 'f':
|
|
|
|
criteria.Header.Add("From", opt.Value)
|
2019-09-20 18:26:17 +02:00
|
|
|
case 't':
|
|
|
|
criteria.Header.Add("To", opt.Value)
|
|
|
|
case 'c':
|
|
|
|
criteria.Header.Add("Cc", opt.Value)
|
2019-09-14 19:05:20 +02:00
|
|
|
case 'b':
|
|
|
|
body = true
|
2019-09-20 18:26:17 +02:00
|
|
|
case 'a':
|
2019-09-14 19:05:20 +02:00
|
|
|
text = true
|
|
|
|
}
|
|
|
|
}
|
2022-07-31 14:32:48 +02:00
|
|
|
switch {
|
|
|
|
case text:
|
2019-09-14 19:05:20 +02:00
|
|
|
criteria.Text = args[optind:]
|
2022-07-31 14:32:48 +02:00
|
|
|
case body:
|
2019-09-14 19:05:20 +02:00
|
|
|
criteria.Body = args[optind:]
|
2022-07-31 14:32:48 +02:00
|
|
|
default:
|
2019-09-14 19:05:20 +02:00
|
|
|
for _, arg := range args[optind:] {
|
|
|
|
criteria.Header.Add("Subject", arg)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return criteria, nil
|
|
|
|
}
|
|
|
|
|
2020-07-24 10:36:19 +02:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2019-09-14 19:05:20 +02:00
|
|
|
func (w *Worker) search(criteria *searchCriteria) ([]uint32, error) {
|
|
|
|
requiredParts := getRequiredParts(criteria)
|
2022-07-19 22:31:51 +02:00
|
|
|
logging.Infof("Required parts bitmask for search: %b", requiredParts)
|
2019-09-14 19:05:20 +02:00
|
|
|
|
|
|
|
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 {
|
2020-02-26 21:02:58 +01:00
|
|
|
// don't return early so that we can still get some results
|
2022-07-19 22:31:51 +02:00
|
|
|
logging.Errorf("Failed to search key %d: %v", key, err)
|
2019-09-14 19:05:20 +02:00
|
|
|
} 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,
|
2022-07-31 22:16:40 +02:00
|
|
|
parts MsgParts,
|
|
|
|
) (bool, error) {
|
2019-09-14 19:05:20 +02:00
|
|
|
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
|
2020-06-19 17:58:08 +02:00
|
|
|
mi, err := message.MessageInfo()
|
|
|
|
if err != nil {
|
|
|
|
return false, err
|
|
|
|
}
|
|
|
|
path := lib.FindFirstNonMultipart(mi.BodyStructure, nil)
|
|
|
|
reader, err := message.NewBodyPartReader(path)
|
2019-09-14 19:05:20 +02:00
|
|
|
if err != nil {
|
|
|
|
return false, err
|
|
|
|
}
|
2022-08-17 16:19:45 +02:00
|
|
|
bytes, err := io.ReadAll(reader)
|
2019-09-14 19:05:20 +02:00
|
|
|
if err != nil {
|
|
|
|
return false, err
|
|
|
|
}
|
|
|
|
body = string(bytes)
|
|
|
|
}
|
|
|
|
if parts&ALL > 0 {
|
|
|
|
reader, err := message.NewReader()
|
|
|
|
if err != nil {
|
|
|
|
return false, err
|
|
|
|
}
|
2022-01-19 19:10:08 +01:00
|
|
|
defer reader.Close()
|
2022-08-17 16:19:45 +02:00
|
|
|
bytes, err := io.ReadAll(reader)
|
2019-09-14 19:05:20 +02:00
|
|
|
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
|
|
|
|
}
|