aerc/lib/msgstore.go
Tim Culverhouse 2551dd1bfa feat: add background mail polling option for all workers
Check for new mail (recent, unseen, exists counts) with an external
command, or for imap with the STATUS command, at start or on
reconnection and every X time duration

IMAP:
The selected folder is skipped, per specification. Additional config
options are included for including/excluding folders explicitly.

Maildir/Notmuch:
An external command will be run in the background to check for new mail.
An optional timeout can be used with maildir/notmuch. Default is 10s

New account options:
check-mail
check-mail-cmd (maildir/notmuch only)
check-mail-timeout (maildir/notmuch only), default 10s
check-mail-include (IMAP only)
check-mail-exclude (IMAP only)

If unset, or set less than or equal to 0, check-mail will be ignored

Signed-off-by: Tim Culverhouse <tim@timculverhouse.com>
Tested-by: Moritz Poldrack <moritz@poldrack.dev>
Acked-by: Robin Jarry <robin@jarry.cc>
2022-05-31 14:32:51 +02:00

822 lines
19 KiB
Go

package lib
import (
"io"
"time"
"git.sr.ht/~rjarry/aerc/lib/sort"
"git.sr.ht/~rjarry/aerc/models"
"git.sr.ht/~rjarry/aerc/worker/types"
)
// Accesses to fields must be guarded by MessageStore.Lock/Unlock
type MessageStore struct {
Deleted map[uint32]interface{}
DirInfo models.DirectoryInfo
Messages map[uint32]*models.MessageInfo
Sorting bool
// Ordered list of known UIDs
uids []uint32
Threads []*types.Thread
selected int
bodyCallbacks map[uint32][]func(*types.FullMessage)
headerCallbacks map[uint32][]func(*types.MessageInfo)
//marking
marked map[uint32]struct{}
visualStartUid uint32
visualMarkMode bool
// Search/filter results
results []uint32
resultIndex int
filtered []uint32
filter bool
sortCriteria []*types.SortCriterion
thread bool
buildThreads bool
builder *ThreadBuilder
// Map of uids we've asked the worker to fetch
onUpdate func(store *MessageStore) // TODO: multiple onUpdate handlers
onFilterChange func(store *MessageStore)
onUpdateDirs func()
pendingBodies map[uint32]interface{}
pendingHeaders map[uint32]interface{}
worker *types.Worker
triggerNewEmail func(*models.MessageInfo)
triggerDirectoryChange func()
dirInfoUpdateDebounce *time.Timer
dirInfoUpdateDelay time.Duration
}
func NewMessageStore(worker *types.Worker,
dirInfo *models.DirectoryInfo,
defaultSortCriteria []*types.SortCriterion,
thread bool,
triggerNewEmail func(*models.MessageInfo),
triggerDirectoryChange func()) *MessageStore {
dirInfoUpdateDelay := 5 * time.Second
return &MessageStore{
Deleted: make(map[uint32]interface{}),
DirInfo: *dirInfo,
Messages: make(map[uint32]*models.MessageInfo),
selected: 0,
marked: make(map[uint32]struct{}),
bodyCallbacks: make(map[uint32][]func(*types.FullMessage)),
headerCallbacks: make(map[uint32][]func(*types.MessageInfo)),
thread: thread,
sortCriteria: defaultSortCriteria,
pendingBodies: make(map[uint32]interface{}),
pendingHeaders: make(map[uint32]interface{}),
worker: worker,
triggerNewEmail: triggerNewEmail,
triggerDirectoryChange: triggerDirectoryChange,
dirInfoUpdateDelay: dirInfoUpdateDelay,
dirInfoUpdateDebounce: time.NewTimer(dirInfoUpdateDelay),
}
}
func (store *MessageStore) FetchHeaders(uids []uint32,
cb func(*types.MessageInfo)) {
// TODO: this could be optimized by pre-allocating toFetch and trimming it
// at the end. In practice we expect to get most messages back in one frame.
var toFetch []uint32
for _, uid := range uids {
if _, ok := store.pendingHeaders[uid]; !ok {
toFetch = append(toFetch, uid)
store.pendingHeaders[uid] = nil
if cb != nil {
if list, ok := store.headerCallbacks[uid]; ok {
store.headerCallbacks[uid] = append(list, cb)
} else {
store.headerCallbacks[uid] = []func(*types.MessageInfo){cb}
}
}
}
}
if len(toFetch) > 0 {
store.worker.PostAction(&types.FetchMessageHeaders{Uids: toFetch}, func(msg types.WorkerMessage) {
switch msg.(type) {
case *types.Error:
for _, uid := range toFetch {
delete(store.pendingHeaders, uid)
delete(store.headerCallbacks, uid)
}
}
})
}
}
func (store *MessageStore) FetchFull(uids []uint32, cb func(*types.FullMessage)) {
// TODO: this could be optimized by pre-allocating toFetch and trimming it
// at the end. In practice we expect to get most messages back in one frame.
var toFetch []uint32
for _, uid := range uids {
if _, ok := store.pendingBodies[uid]; !ok {
toFetch = append(toFetch, uid)
store.pendingBodies[uid] = nil
if cb != nil {
if list, ok := store.bodyCallbacks[uid]; ok {
store.bodyCallbacks[uid] = append(list, cb)
} else {
store.bodyCallbacks[uid] = []func(*types.FullMessage){cb}
}
}
}
}
if len(toFetch) > 0 {
store.worker.PostAction(&types.FetchFullMessages{
Uids: toFetch,
}, func(msg types.WorkerMessage) {
switch msg.(type) {
case *types.Error:
for _, uid := range toFetch {
delete(store.pendingBodies, uid)
delete(store.bodyCallbacks, uid)
}
}
})
}
}
func (store *MessageStore) FetchBodyPart(uid uint32, part []int, cb func(io.Reader)) {
store.worker.PostAction(&types.FetchMessageBodyPart{
Uid: uid,
Part: part,
}, func(resp types.WorkerMessage) {
msg, ok := resp.(*types.MessageBodyPart)
if !ok {
return
}
cb(msg.Part.Reader)
})
}
func merge(to *models.MessageInfo, from *models.MessageInfo) {
if from.BodyStructure != nil {
to.BodyStructure = from.BodyStructure
}
if from.Envelope != nil {
to.Envelope = from.Envelope
}
to.Flags = from.Flags
to.Labels = from.Labels
if from.Size != 0 {
to.Size = from.Size
}
var zero time.Time
if from.InternalDate != zero {
to.InternalDate = from.InternalDate
}
}
func (store *MessageStore) Update(msg types.WorkerMessage) {
update := false
directoryChange := false
switch msg := msg.(type) {
case *types.DirectoryInfo:
store.DirInfo = *msg.Info
if !msg.SkipSort {
store.Sort(store.sortCriteria, nil)
}
update = true
case *types.DirectoryContents:
newMap := make(map[uint32]*models.MessageInfo)
for _, uid := range msg.Uids {
if msg, ok := store.Messages[uid]; ok {
newMap[uid] = msg
} else {
newMap[uid] = nil
directoryChange = true
}
}
store.Messages = newMap
store.uids = msg.Uids
sort.SortBy(store.filtered, store.uids)
store.checkMark()
update = true
case *types.DirectoryThreaded:
var uids []uint32
newMap := make(map[uint32]*models.MessageInfo)
for i := len(msg.Threads) - 1; i >= 0; i-- {
msg.Threads[i].Walk(func(t *types.Thread, level int, currentErr error) error {
uid := t.Uid
uids = append([]uint32{uid}, uids...)
if msg, ok := store.Messages[uid]; ok {
newMap[uid] = msg
} else {
newMap[uid] = nil
directoryChange = true
}
return nil
})
}
store.Messages = newMap
store.uids = uids
store.checkMark()
store.Threads = msg.Threads
update = true
case *types.MessageInfo:
if existing, ok := store.Messages[msg.Info.Uid]; ok && existing != nil {
merge(existing, msg.Info)
} else {
if msg.Info.Envelope != nil {
store.Messages[msg.Info.Uid] = msg.Info
}
}
seen := false
recent := false
for _, flag := range msg.Info.Flags {
if flag == models.RecentFlag {
recent = true
} else if flag == models.SeenFlag {
seen = true
}
}
if !seen && recent {
store.triggerNewEmail(msg.Info)
}
if _, ok := store.pendingHeaders[msg.Info.Uid]; msg.Info.Envelope != nil && ok {
delete(store.pendingHeaders, msg.Info.Uid)
if cbs, ok := store.headerCallbacks[msg.Info.Uid]; ok {
for _, cb := range cbs {
cb(msg)
}
}
}
if store.builder != nil {
store.builder.Update(msg.Info)
}
update = true
case *types.FullMessage:
if _, ok := store.pendingBodies[msg.Content.Uid]; ok {
delete(store.pendingBodies, msg.Content.Uid)
if cbs, ok := store.bodyCallbacks[msg.Content.Uid]; ok {
for _, cb := range cbs {
cb(msg)
}
delete(store.bodyCallbacks, msg.Content.Uid)
}
}
case *types.MessagesDeleted:
if len(store.uids) < len(msg.Uids) {
update = true
break
}
toDelete := make(map[uint32]interface{})
for _, uid := range msg.Uids {
toDelete[uid] = nil
delete(store.Messages, uid)
delete(store.Deleted, uid)
delete(store.marked, uid)
}
uids := make([]uint32, len(store.uids)-len(msg.Uids))
j := 0
for _, uid := range store.uids {
if _, deleted := toDelete[uid]; !deleted && j < len(uids) {
uids[j] = uid
j += 1
}
}
store.uids = uids
var newResults []uint32
for _, res := range store.results {
if _, deleted := toDelete[res]; !deleted {
newResults = append(newResults, res)
}
}
store.results = newResults
var newFiltered []uint32
for _, res := range store.filtered {
if _, deleted := toDelete[res]; !deleted {
newFiltered = append(newFiltered, res)
}
}
store.filtered = newFiltered
for _, thread := range store.Threads {
thread.Walk(func(t *types.Thread, _ int, _ error) error {
if _, deleted := toDelete[t.Uid]; deleted {
t.Deleted = true
}
return nil
})
}
update = true
}
if update {
store.update()
}
if directoryChange && store.triggerDirectoryChange != nil {
store.triggerDirectoryChange()
}
}
func (store *MessageStore) OnUpdate(fn func(store *MessageStore)) {
store.onUpdate = fn
}
func (store *MessageStore) OnFilterChange(fn func(store *MessageStore)) {
store.onFilterChange = fn
}
func (store *MessageStore) OnUpdateDirs(fn func()) {
store.onUpdateDirs = fn
}
func (store *MessageStore) update() {
if store.onUpdate != nil {
store.onUpdate(store)
}
if store.onUpdateDirs != nil {
store.onUpdateDirs()
}
if store.BuildThreads() {
store.runThreadBuilder()
}
}
func (store *MessageStore) SetBuildThreads(buildThreads bool) {
// if worker provides threading, don't build our own threads
if store.thread {
return
}
store.buildThreads = buildThreads
if store.BuildThreads() {
store.runThreadBuilder()
}
}
func (store *MessageStore) BuildThreads() bool {
// if worker provides threading, don't build our own threads
if store.thread {
return false
}
return store.buildThreads
}
func (store *MessageStore) runThreadBuilder() {
if store.builder == nil {
store.builder = NewThreadBuilder(store.worker.Logger)
for _, msg := range store.Messages {
store.builder.Update(msg)
}
}
var uids []uint32
if store.filter {
uids = store.filtered
} else {
uids = store.uids
}
store.Threads = store.builder.Threads(uids)
}
func (store *MessageStore) Delete(uids []uint32,
cb func(msg types.WorkerMessage)) {
for _, uid := range uids {
store.Deleted[uid] = nil
}
store.worker.PostAction(&types.DeleteMessages{Uids: uids},
func(msg types.WorkerMessage) {
switch msg.(type) {
case *types.Error:
store.revertDeleted(uids)
}
cb(msg)
})
store.update()
}
func (store *MessageStore) revertDeleted(uids []uint32) {
for _, uid := range uids {
if _, ok := store.Deleted[uid]; ok {
delete(store.Deleted, uid)
}
}
}
func (store *MessageStore) Copy(uids []uint32, dest string, createDest bool,
cb func(msg types.WorkerMessage)) {
if createDest {
store.worker.PostAction(&types.CreateDirectory{
Directory: dest,
Quiet: true,
}, cb)
}
store.worker.PostAction(&types.CopyMessages{
Destination: dest,
Uids: uids,
}, cb)
}
func (store *MessageStore) Move(uids []uint32, dest string, createDest bool,
cb func(msg types.WorkerMessage)) {
for _, uid := range uids {
store.Deleted[uid] = nil
}
if createDest {
store.worker.PostAction(&types.CreateDirectory{
Directory: dest,
Quiet: true,
}, nil) // quiet doesn't return an error, don't want the done cb here
}
store.worker.PostAction(&types.CopyMessages{
Destination: dest,
Uids: uids,
}, func(msg types.WorkerMessage) {
switch msg.(type) {
case *types.Error:
store.revertDeleted(uids)
cb(msg)
case *types.Done:
store.Delete(uids, cb)
}
})
store.update()
}
func (store *MessageStore) Flag(uids []uint32, flag models.Flag,
enable bool, cb func(msg types.WorkerMessage)) {
store.worker.PostAction(&types.FlagMessages{
Enable: enable,
Flag: flag,
Uids: uids,
}, cb)
}
func (store *MessageStore) Answered(uids []uint32, answered bool,
cb func(msg types.WorkerMessage)) {
store.worker.PostAction(&types.AnsweredMessages{
Answered: answered,
Uids: uids,
}, cb)
}
func (store *MessageStore) Uids() []uint32 {
if store.BuildThreads() && store.builder != nil {
if uids := store.builder.Uids(); len(uids) > 0 {
return uids
}
}
if store.filter {
return store.filtered
}
return store.uids
}
func (store *MessageStore) Selected() *models.MessageInfo {
uids := store.Uids()
idx := len(uids) - store.selected - 1
if len(uids) == 0 || idx < 0 || idx >= len(uids) {
return nil
}
return store.Messages[uids[idx]]
}
func (store *MessageStore) SelectedIndex() int {
return store.selected
}
func (store *MessageStore) Select(index int) {
uids := store.Uids()
store.selected = index
if store.selected < 0 {
store.selected = len(uids) - 1
} else if store.selected > len(uids) {
store.selected = len(uids)
}
store.updateVisual()
}
func (store *MessageStore) Reselect(info *models.MessageInfo) {
if info == nil {
return
}
uid := info.Uid
newIdx := 0
for idx, uidStore := range store.Uids() {
if uidStore == uid {
newIdx = len(store.Uids()) - idx - 1
break
}
}
store.Select(newIdx)
}
// Mark sets the marked state on a MessageInfo
func (store *MessageStore) Mark(uid uint32) {
if store.visualMarkMode {
// visual mode has override, bogus input from user
return
}
store.marked[uid] = struct{}{}
}
// Unmark removes the marked state on a MessageInfo
func (store *MessageStore) Unmark(uid uint32) {
if store.visualMarkMode {
// user probably wanted to clear the visual marking
store.ClearVisualMark()
return
}
delete(store.marked, uid)
}
// ToggleMark toggles the marked state on a MessageInfo
func (store *MessageStore) ToggleMark(uid uint32) {
if store.visualMarkMode {
// visual mode has override, bogus input from user
return
}
if store.IsMarked(uid) {
store.Unmark(uid)
} else {
store.Mark(uid)
}
}
// resetMark removes the marking from all messages
func (store *MessageStore) resetMark() {
store.marked = make(map[uint32]struct{})
}
// checkMark checks that no stale uids remain marked
func (store *MessageStore) checkMark() {
for mark := range store.marked {
present := false
for _, uid := range store.uids {
if mark == uid {
present = true
break
}
}
if !present {
delete(store.marked, mark)
}
}
}
//IsMarked checks whether a MessageInfo has been marked
func (store *MessageStore) IsMarked(uid uint32) bool {
_, marked := store.marked[uid]
return marked
}
//ToggleVisualMark enters or leaves the visual marking mode
func (store *MessageStore) ToggleVisualMark() {
store.visualMarkMode = !store.visualMarkMode
switch store.visualMarkMode {
case true:
// just entered visual mode, reset whatever marking was already done
store.resetMark()
store.visualStartUid = store.Selected().Uid
store.marked[store.visualStartUid] = struct{}{}
case false:
// visual mode ended, nothing to do
return
}
}
//ClearVisualMark leaves the visual marking mode and resets any marking
func (store *MessageStore) ClearVisualMark() {
store.resetMark()
store.visualMarkMode = false
store.visualStartUid = 0
}
// Marked returns the uids of all marked messages
func (store *MessageStore) Marked() []uint32 {
marked := make([]uint32, len(store.marked))
i := 0
for uid := range store.marked {
marked[i] = uid
i++
}
return marked
}
func (store *MessageStore) updateVisual() {
if !store.visualMarkMode {
// nothing to do
return
}
startIdx := store.visualStartIdx()
if startIdx < 0 {
// something deleted the startuid, abort the marking process
store.ClearVisualMark()
return
}
uidLen := len(store.Uids())
// store.selected is the inverted form of the actual array
selectedIdx := uidLen - store.selected - 1
var visUids []uint32
if selectedIdx > startIdx {
visUids = store.Uids()[startIdx : selectedIdx+1]
} else {
visUids = store.Uids()[selectedIdx : startIdx+1]
}
store.resetMark()
for _, uid := range visUids {
store.marked[uid] = struct{}{}
}
missing := make([]uint32, 0)
for _, uid := range visUids {
if msg := store.Messages[uid]; msg == nil {
missing = append(missing, uid)
}
}
store.FetchHeaders(missing, nil)
}
func (store *MessageStore) NextPrev(delta int) {
uids := store.Uids()
if len(uids) == 0 {
return
}
store.selected += delta
if store.selected < 0 {
store.selected = 0
}
if store.selected >= len(uids) {
store.selected = len(uids) - 1
}
store.updateVisual()
nextResultIndex := len(store.results) - store.resultIndex - 2*delta
if nextResultIndex < 0 || nextResultIndex >= len(store.results) {
return
}
nextResultUid := store.results[nextResultIndex]
selectedUid := uids[len(uids)-store.selected-1]
if nextResultUid == selectedUid {
store.resultIndex += delta
}
}
func (store *MessageStore) Next() {
store.NextPrev(1)
}
func (store *MessageStore) Prev() {
store.NextPrev(-1)
}
func (store *MessageStore) Search(args []string, cb func([]uint32)) {
store.worker.PostAction(&types.SearchDirectory{
Argv: args,
}, func(msg types.WorkerMessage) {
switch msg := msg.(type) {
case *types.SearchResults:
allowedUids := store.Uids()
uids := make([]uint32, 0, len(msg.Uids))
for _, uid := range msg.Uids {
for _, uidCheck := range allowedUids {
if uid == uidCheck {
uids = append(uids, uid)
break
}
}
}
sort.SortBy(uids, allowedUids)
cb(uids)
}
})
}
func (store *MessageStore) ApplySearch(results []uint32) {
store.results = results
store.resultIndex = -1
store.NextResult()
}
func (store *MessageStore) ApplyFilter(results []uint32) {
defer store.Reselect(store.Selected())
store.results = nil
store.filtered = results
store.filter = true
if store.onFilterChange != nil {
store.onFilterChange(store)
}
store.update()
// any marking is now invalid
// TODO: could save that probably
store.ClearVisualMark()
}
func (store *MessageStore) ApplyClear() {
store.results = nil
store.filtered = nil
store.filter = false
if store.BuildThreads() {
store.runThreadBuilder()
}
if store.onFilterChange != nil {
store.onFilterChange(store)
}
}
func (store *MessageStore) nextPrevResult(delta int) {
if len(store.results) == 0 {
return
}
store.resultIndex += delta
if store.resultIndex >= len(store.results) {
store.resultIndex = 0
}
if store.resultIndex < 0 {
store.resultIndex = len(store.results) - 1
}
uids := store.Uids()
for i, uid := range uids {
if store.results[len(store.results)-store.resultIndex-1] == uid {
store.Select(len(uids) - i - 1)
break
}
}
store.update()
}
func (store *MessageStore) NextResult() {
store.nextPrevResult(1)
}
func (store *MessageStore) PrevResult() {
store.nextPrevResult(-1)
}
func (store *MessageStore) ModifyLabels(uids []uint32, add, remove []string,
cb func(msg types.WorkerMessage)) {
store.worker.PostAction(&types.ModifyLabels{
Uids: uids,
Add: add,
Remove: remove,
}, cb)
}
func (store *MessageStore) Sort(criteria []*types.SortCriterion, cb func()) {
store.Sorting = true
store.sortCriteria = criteria
handle_return := func(msg types.WorkerMessage) {
store.Sorting = false
if cb != nil {
cb()
}
}
if store.thread {
store.worker.PostAction(&types.FetchDirectoryThreaded{
SortCriteria: criteria,
}, handle_return)
} else {
store.worker.PostAction(&types.FetchDirectoryContents{
SortCriteria: criteria,
}, handle_return)
}
}
// returns the index of needle in haystack or -1 if not found
func (store *MessageStore) visualStartIdx() int {
for idx, u := range store.Uids() {
if u == store.visualStartUid {
return idx
}
}
return -1
}