package maildir
import (
"context"
"io"
"net/textproto"
"runtime"
"strings"
"sync"
"time"
"unicode"
"github.com/emersion/go-maildir"
"git.sr.ht/~sircmpwn/getopt"
"git.sr.ht/~rjarry/aerc/lib"
"git.sr.ht/~rjarry/aerc/log"
"git.sr.ht/~rjarry/aerc/models"
wlib "git.sr.ht/~rjarry/aerc/worker/lib"
)
type searchCriteria struct {
Header textproto.MIMEHeader
Body []string
Text []string
WithFlags []maildir.Flag
WithoutFlags []maildir.Flag
startDate, endDate time.Time
}
func parseSearch(args []string) (*searchCriteria, error) {
criteria := &searchCriteria{Header: make(textproto.MIMEHeader)}
opts, optind, err := getopt.Getopts(args, "rux:X:bat:H:f:c:d:")
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 'x':
criteria.WithFlags = append(criteria.WithFlags, getParsedFlag(opt.Value))
case 'X':
criteria.WithoutFlags = append(criteria.WithoutFlags, getParsedFlag(opt.Value))
case 'H':
if strings.Contains(opt.Value, ": ") {
HeaderValue := strings.SplitN(opt.Value, ": ", 2)
criteria.Header.Add(HeaderValue[0], HeaderValue[1])
} else {
log.Errorf("Header is not given properly, must be given in format `Header: Value`")
continue
}
case 'f':
criteria.Header.Add("From", opt.Value)
case 't':
criteria.Header.Add("To", opt.Value)
case 'c':
criteria.Header.Add("Cc", opt.Value)
case 'b':
body = true
case 'a':
text = true
case 'd':
start, end, err := wlib.ParseDateRange(opt.Value)
if err != nil {
log.Errorf("failed to parse start date: %v", err)
continue
}
if !start.IsZero() {
criteria.startDate = start
}
if !end.IsZero() {
criteria.endDate = end
}
}
}
switch {
case text:
criteria.Text = args[optind:]
case body:
criteria.Body = args[optind:]
default:
for _, arg := range args[optind:] {
criteria.Header.Add("Subject", arg)
}
}
return criteria, nil
}
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
}
func (w *Worker) search(ctx context.Context, criteria *searchCriteria) ([]uint32, error) {
requiredParts := getRequiredParts(criteria)
w.worker.Debugf("Required parts bitmask for search: %b", requiredParts)
keys, err := w.c.UIDs(*w.selected)
if err != nil {
return nil, err
}
matchedUids := []uint32{}
mu := sync.Mutex{}
wg := sync.WaitGroup{}
// Hard limit at 2x CPU cores
max := runtime.NumCPU() * 2
limit := make(chan struct{}, max)
for _, key := range keys {
select {
case <-ctx.Done():
return nil, context.Canceled
default:
limit <- struct{}{}
wg.Add(1)
go func(key uint32) {
defer log.PanicHandler()
defer wg.Done()
success, err := w.searchKey(key, criteria, requiredParts)
if err != nil {
// don't return early so that we can still get some results
w.worker.Errorf("Failed to search key %d: %v", key, err)
} else if success {
mu.Lock()
matchedUids = append(matchedUids, key)
mu.Unlock()
}
<-limit
}(key)
}
}
wg.Wait()
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 || parts&DATE > 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
mi, err := message.MessageInfo()
if err != nil {
return false, err
}
path := lib.FindFirstNonMultipart(mi.BodyStructure, nil)
reader, err := message.NewBodyPartReader(path)
if err != nil {
return false, err
}
bytes, err := io.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
}
defer reader.Close()
bytes, err := io.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
}
}
}
if parts&DATE > 0 {
if date, err := header.RFC822Headers.Date(); err != nil {
w.worker.Errorf("Failed to get date from header: %v", err)
} else {
if !criteria.startDate.IsZero() {
if date.Before(criteria.startDate) {
return false, nil
}
}
if !criteria.endDate.IsZero() {
if date.After(criteria.endDate) {
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
DATE
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.startDate.IsZero() || !criteria.endDate.IsZero() {
required |= DATE
}
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
}