init: pristine aerc 0.20.0 source

This commit is contained in:
Mortdecai
2026-04-07 19:54:54 -04:00
commit 083402a548
502 changed files with 68722 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
package lib
import (
"fmt"
"io"
"github.com/go-ini/ini"
)
func ParseFolderMap(r io.Reader) (map[string]string, []string, error) {
cfg, err := ini.Load(r)
if err != nil {
return nil, nil, err
}
sec, err := cfg.GetSection("")
if err != nil {
return nil, nil, err
}
order := sec.KeyStrings()
for _, k := range order {
v, err := sec.GetKey(k)
switch {
case v.String() == "":
return nil, nil, fmt.Errorf("no value for key '%s'", k)
case err != nil:
return nil, nil, err
}
}
return sec.KeysHash(), order, nil
}
+54
View File
@@ -0,0 +1,54 @@
package lib_test
import (
"reflect"
"strings"
"testing"
"git.sr.ht/~rjarry/aerc/worker/lib"
)
func TestFolderMap(t *testing.T) {
text := `#this is comment
Sent = [Gmail]/Sent
# a comment between entries
Spam=[Gmail]/Spam # this is comment after the values
`
fmap, order, err := lib.ParseFolderMap(strings.NewReader(text))
if err != nil {
t.Errorf("parsing failed: %v", err)
}
want_map := map[string]string{
"Sent": "[Gmail]/Sent",
"Spam": "[Gmail]/Spam",
}
want_order := []string{"Sent", "Spam"}
if !reflect.DeepEqual(order, want_order) {
t.Errorf("order is not correct; want: %v, got: %v",
want_order, order)
}
if !reflect.DeepEqual(fmap, want_map) {
t.Errorf("map is not correct; want: %v, got: %v",
want_map, fmap)
}
}
func TestFolderMap_ExpectFails(t *testing.T) {
tests := []string{
`key = `,
` = value`,
` = `,
`key = #value`,
}
for _, text := range tests {
_, _, err := lib.ParseFolderMap(strings.NewReader(text))
if err == nil {
t.Errorf("expected to fail, but it did not: %v", text)
}
}
}
+29
View File
@@ -0,0 +1,29 @@
package lib
import (
"strings"
"github.com/emersion/go-message/mail"
)
// LimitHeaders returns a new Header with the specified headers included or
// excluded
func LimitHeaders(hdr *mail.Header, fields []string, exclude bool) *mail.Header {
fieldMap := make(map[string]struct{}, len(fields))
for _, f := range fields {
fieldMap[strings.ToLower(f)] = struct{}{}
}
nh := &mail.Header{}
curFields := hdr.Fields()
for curFields.Next() {
key := strings.ToLower(curFields.Key())
_, present := fieldMap[key]
// XOR exclude and present. When they are equal, it means we
// should not add the header to the new header struct
if exclude == present {
continue
}
nh.Add(key, curFields.Value())
}
return nh
}
+152
View File
@@ -0,0 +1,152 @@
package lib
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"git.sr.ht/~rjarry/aerc/models"
"github.com/emersion/go-maildir"
)
type MaildirStore struct {
root string
maildirpp bool // whether to use Maildir++ directory layout
}
func NewMaildirStore(root string, maildirpp bool) (*MaildirStore, error) {
f, err := os.Open(root)
if err != nil {
return nil, err
}
defer f.Close()
s, err := f.Stat()
if err != nil {
return nil, err
}
if !s.IsDir() {
return nil, fmt.Errorf("Given maildir '%s' not a directory", root)
}
return &MaildirStore{
root: root, maildirpp: maildirpp,
}, nil
}
func (s *MaildirStore) FolderMap() (map[string]maildir.Dir, error) {
folders := make(map[string]maildir.Dir)
if s.maildirpp {
// In Maildir++ layout, INBOX is the root folder
folders["INBOX"] = maildir.Dir(s.root)
}
err := filepath.Walk(s.root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return fmt.Errorf("Invalid path '%s': error: %w", path, err)
}
if !info.IsDir() {
return nil
}
// Skip maildir's default directories
n := info.Name()
if n == "new" || n == "tmp" || n == "cur" {
return filepath.SkipDir
}
// Get the relative path from the parent directory
dirPath, err := filepath.Rel(s.root, path)
if err != nil {
return err
}
// Skip the parent directory
if dirPath == "." {
return nil
}
// Drop dirs that lack {new,tmp,cur} subdirs
for _, sub := range []string{"new", "tmp", "cur"} {
if _, err := os.Stat(filepath.Join(path, sub)); os.IsNotExist(err) {
return nil
}
}
if s.maildirpp {
// In Maildir++ layout, mailboxes are stored in a single directory
// and prefixed with a dot, and subfolders are separated by dots.
if !strings.HasPrefix(dirPath, ".") {
return filepath.SkipDir
}
dirPath = strings.TrimPrefix(dirPath, ".")
dirPath = strings.ReplaceAll(dirPath, ".", "/")
folders[dirPath] = maildir.Dir(path)
// Since all mailboxes are stored in a single directory, don't
// recurse into subdirectories
return filepath.SkipDir
}
folders[dirPath] = maildir.Dir(path)
return nil
})
return folders, err
}
// Folder returns a maildir.Dir with the specified name inside the Store
func (s *MaildirStore) Dir(name string) maildir.Dir {
if s.maildirpp {
// Use Maildir++ layout
if name == "INBOX" {
return maildir.Dir(s.root)
}
return maildir.Dir(filepath.Join(s.root, "."+strings.ReplaceAll(name, "/", ".")))
}
return maildir.Dir(filepath.Join(s.root, name))
}
// uidReg matches filename encoded UIDs in maildirs synched with mbsync or
// OfflineIMAP
var uidReg = regexp.MustCompile(`,U=\d+`)
func StripUIDFromMessageFilename(basename string) string {
return uidReg.ReplaceAllString(basename, "")
}
var MaildirToFlag = map[maildir.Flag]models.Flags{
maildir.FlagReplied: models.AnsweredFlag,
maildir.FlagSeen: models.SeenFlag,
maildir.FlagTrashed: models.DeletedFlag,
maildir.FlagFlagged: models.FlaggedFlag,
maildir.FlagDraft: models.DraftFlag,
maildir.FlagPassed: models.ForwardedFlag,
}
var FlagToMaildir = map[models.Flags]maildir.Flag{
models.AnsweredFlag: maildir.FlagReplied,
models.SeenFlag: maildir.FlagSeen,
models.DeletedFlag: maildir.FlagTrashed,
models.FlaggedFlag: maildir.FlagFlagged,
models.DraftFlag: maildir.FlagDraft,
models.ForwardedFlag: maildir.FlagPassed,
}
func FromMaildirFlags(maildirFlags []maildir.Flag) models.Flags {
var flags models.Flags
for _, maildirFlag := range maildirFlags {
if flag, ok := MaildirToFlag[maildirFlag]; ok {
flags |= flag
}
}
return flags
}
func ToMaildirFlags(flags models.Flags) []maildir.Flag {
var maildirFlags []maildir.Flag
for flag, maildirFlag := range FlagToMaildir {
if flags.Has(flag) {
maildirFlags = append(maildirFlags, maildirFlag)
}
}
return maildirFlags
}
+201
View File
@@ -0,0 +1,201 @@
package lib
import (
"io"
"strings"
"unicode"
"git.sr.ht/~rjarry/aerc/lib"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/rfc822"
"git.sr.ht/~rjarry/aerc/models"
"git.sr.ht/~rjarry/aerc/worker/types"
"git.sr.ht/~rjarry/go-opt/v2"
)
func Search(messages []rfc822.RawMessage, criteria *types.SearchCriteria) ([]models.UID, error) {
criteria.PrepareHeader()
requiredParts := GetRequiredParts(criteria)
var matchedUids []models.UID
for _, m := range messages {
success, err := SearchMessage(m, criteria, requiredParts)
if err != nil {
return nil, err
} else if success {
matchedUids = append(matchedUids, m.UID())
}
}
return matchedUids, nil
}
// searchMessage executes the search criteria for the given RawMessage,
// returns true if search succeeded
func SearchMessage(message rfc822.RawMessage, criteria *types.SearchCriteria,
parts MsgParts,
) (bool, error) {
if criteria == nil {
return true, nil
}
// setup parts of the message to use in the search
// this is so that we try to minimise reading unnecessary parts
var (
flags models.Flags
info *models.MessageInfo
text string
err error
)
if parts&FLAGS > 0 {
flags, err = message.ModelFlags()
if err != nil {
return false, err
}
}
info, err = rfc822.MessageInfo(message)
if err != nil {
return false, err
}
switch {
case parts&BODY > 0:
path := lib.FindFirstNonMultipart(info.BodyStructure, nil)
reader, err := message.NewReader()
if err != nil {
return false, err
}
defer reader.Close()
msg, err := rfc822.ReadMessage(reader)
if err != nil {
return false, err
}
part, err := rfc822.FetchEntityPartReader(msg, path)
if err != nil {
return false, err
}
bytes, err := io.ReadAll(part)
if err != nil {
return false, err
}
text = string(bytes)
case 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
}
text = string(bytes)
default:
text = info.Envelope.Subject
}
// now search through the criteria
// implicit AND at the moment so fail fast
if criteria.Headers != nil {
for k, v := range criteria.Headers {
headerValue := info.RFC822Headers.Get(k)
for _, text := range v {
if !containsSmartCase(headerValue, text) {
return false, nil
}
}
}
}
args := opt.LexArgs(strings.Join(criteria.Terms, " "))
for _, searchTerm := range args.Args() {
if !containsSmartCase(text, searchTerm) {
return false, nil
}
}
if criteria.WithFlags != 0 {
if !flags.Has(criteria.WithFlags) {
return false, nil
}
}
if criteria.WithoutFlags != 0 {
if flags.Has(criteria.WithoutFlags) {
return false, nil
}
}
if parts&DATE > 0 {
if date, err := info.RFC822Headers.Date(); err != nil {
log.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
}
// containsSmartCase is a 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 *types.SearchCriteria) MsgParts {
required := NONE
if criteria == nil {
return required
}
if len(criteria.Headers) > 0 {
required |= HEADER
}
if !criteria.StartDate.IsZero() || !criteria.EndDate.IsZero() {
required |= DATE
}
if criteria.SearchBody {
required |= BODY
}
if criteria.SearchAll {
required |= ALL
}
if criteria.WithFlags != 0 {
required |= FLAGS
}
if criteria.WithoutFlags != 0 {
required |= FLAGS
}
return required
}
+15
View File
@@ -0,0 +1,15 @@
package lib
import (
"fmt"
"os"
)
// FileSize returns the size of the file specified by name
func FileSize(name string) (uint32, error) {
fileInfo, err := os.Stat(name)
if err != nil {
return 0, fmt.Errorf("failed to obtain fileinfo: %w", err)
}
return uint32(fileInfo.Size()), nil
}
+149
View File
@@ -0,0 +1,149 @@
package lib
import (
"sort"
"strings"
"git.sr.ht/~rjarry/aerc/models"
"git.sr.ht/~rjarry/aerc/worker/types"
"github.com/emersion/go-message/mail"
)
func Sort(messageInfos []*models.MessageInfo,
criteria []*types.SortCriterion,
) ([]models.UID, error) {
// loop through in reverse to ensure we sort by non-primary fields first
for i := len(criteria) - 1; i >= 0; i-- {
criterion := criteria[i]
switch criterion.Field {
case types.SortArrival:
sortSlice(criterion, messageInfos, func(i, j int) bool {
return messageInfos[i].InternalDate.Before(messageInfos[j].InternalDate)
})
case types.SortCc:
sortAddresses(messageInfos, criterion,
func(msgInfo *models.MessageInfo) []*mail.Address {
return msgInfo.Envelope.Cc
})
case types.SortDate:
sortSlice(criterion, messageInfos, func(i, j int) bool {
return messageInfos[i].Envelope.Date.Before(messageInfos[j].Envelope.Date)
})
case types.SortFrom:
sortAddresses(messageInfos, criterion,
func(msgInfo *models.MessageInfo) []*mail.Address {
return msgInfo.Envelope.From
})
case types.SortRead:
sortFlags(messageInfos, criterion, models.SeenFlag)
case types.SortFlagged:
sortFlags(messageInfos, criterion, models.FlaggedFlag)
case types.SortSize:
sortSlice(criterion, messageInfos, func(i, j int) bool {
return messageInfos[i].Size < messageInfos[j].Size
})
case types.SortSubject:
sortStrings(messageInfos, criterion,
func(msgInfo *models.MessageInfo) string {
subject := strings.ToLower(msgInfo.Envelope.Subject)
subject = strings.TrimPrefix(subject, "re: ")
return strings.TrimPrefix(subject, "fwd: ")
})
case types.SortTo:
sortAddresses(messageInfos, criterion,
func(msgInfo *models.MessageInfo) []*mail.Address {
return msgInfo.Envelope.To
})
}
}
var uids []models.UID
// copy in reverse as msgList displays backwards
for i := len(messageInfos) - 1; i >= 0; i-- {
uids = append(uids, messageInfos[i].Uid)
}
return uids, nil
}
func sortAddresses(messageInfos []*models.MessageInfo, criterion *types.SortCriterion,
getValue func(*models.MessageInfo) []*mail.Address,
) {
sortSlice(criterion, messageInfos, func(i, j int) bool {
addressI, addressJ := getValue(messageInfos[i]), getValue(messageInfos[j])
var firstI, firstJ *mail.Address
if len(addressI) > 0 {
firstI = addressI[0]
}
if len(addressJ) > 0 {
firstJ = addressJ[0]
}
if firstI != nil && firstJ != nil {
getName := func(addr *mail.Address) string {
if addr.Name != "" {
return addr.Name
} else {
return addr.Address
}
}
return getName(firstI) < getName(firstJ)
} else {
return firstI != nil && firstJ == nil
}
})
}
func sortFlags(messageInfos []*models.MessageInfo, criterion *types.SortCriterion,
testFlag models.Flags,
) {
var slice []*boolStore
for _, msgInfo := range messageInfos {
slice = append(slice, &boolStore{
Value: msgInfo.Flags.Has(testFlag),
MsgInfo: msgInfo,
})
}
sortSlice(criterion, slice, func(i, j int) bool {
valI, valJ := slice[i].Value, slice[j].Value
return valI && !valJ
})
for i := 0; i < len(messageInfos); i++ {
messageInfos[i] = slice[i].MsgInfo
}
}
func sortStrings(messageInfos []*models.MessageInfo, criterion *types.SortCriterion,
getValue func(*models.MessageInfo) string,
) {
var slice []*lexiStore
for _, msgInfo := range messageInfos {
slice = append(slice, &lexiStore{
Value: getValue(msgInfo),
MsgInfo: msgInfo,
})
}
sortSlice(criterion, slice, func(i, j int) bool {
return slice[i].Value < slice[j].Value
})
for i := 0; i < len(messageInfos); i++ {
messageInfos[i] = slice[i].MsgInfo
}
}
type lexiStore struct {
Value string
MsgInfo *models.MessageInfo
}
type boolStore struct {
Value bool
MsgInfo *models.MessageInfo
}
func sortSlice(criterion *types.SortCriterion, slice interface{}, less func(i, j int) bool) {
if criterion.Reverse {
sort.SliceStable(slice, func(i, j int) bool {
return less(j, i)
})
} else {
sort.SliceStable(slice, less)
}
}