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>
This commit is contained in:
Tim Culverhouse 2022-05-30 07:34:18 -05:00 committed by Robin Jarry
parent 30d5788974
commit 2551dd1bfa
14 changed files with 267 additions and 42 deletions

View file

@ -105,6 +105,13 @@ type AccountConfig struct {
EnableFoldersSort bool `ini:"enable-folders-sort"` EnableFoldersSort bool `ini:"enable-folders-sort"`
FoldersSort []string `ini:"folders-sort" delim:","` FoldersSort []string `ini:"folders-sort" delim:","`
// CheckMail
CheckMail time.Duration `ini:"check-mail"`
CheckMailCmd string `ini:"check-mail-cmd"`
CheckMailTimeout time.Duration `ini:"check-mail-timeout"`
CheckMailInclude []string `ini:"check-mail-include"`
CheckMailExclude []string `ini:"check-mail-exclude"`
// PGP Config // PGP Config
PgpKeyId string `ini:"pgp-key-id"` PgpKeyId string `ini:"pgp-key-id"`
PgpAutoSign bool `ini:"pgp-auto-sign"` PgpAutoSign bool `ini:"pgp-auto-sign"`
@ -224,6 +231,7 @@ func loadAccountConfig(path string) ([]AccountConfig, error) {
Name: _sec, Name: _sec,
Params: make(map[string]string), Params: make(map[string]string),
EnableFoldersSort: true, EnableFoldersSort: true,
CheckMailTimeout: 10 * time.Second,
} }
if err = sec.MapTo(&account); err != nil { if err = sec.MapTo(&account); err != nil {
return nil, err return nil, err

View file

@ -525,6 +525,20 @@ Note that many of these configuration options are written for you, such as
Default: Archive Default: Archive
*check-mail*
Specifies an interval to check for new mail. Mail will be checked at
startup, and every interval. IMAP accounts will check for mail in all
unselected folders, and the selected folder will continue to receive PUSH
mail notifications. Maildir/Notmuch folders must use *check-mail-cmd* in
conjunction with this option. See *aerc-maildir* and *aerc-notmuch* for
more information.
Setting this option to 0 will disable check-mail
Example: 5m
Default: 0
*copy-to* *copy-to*
Specifies a folder to copy sent mails to, usually "Sent". Specifies a folder to copy sent mails to, usually "Sent".

View file

@ -96,6 +96,21 @@ available:
This option is only supported on linux. On other platforms, it will be This option is only supported on linux. On other platforms, it will be
ignored. ignored.
*check-mail-include*
Specifies the comma separated list of folders to include when checking for
new mail with *check-mail*. Names prefixed with ~ are interpreted as regular
expressions.
Default: all folders
*check-mail-exclude*
Specifies the comma separated list of folders to exclude when checking for
new mail with *check-mail*. Names prefixed with ~ are interpreted as regular
expressions.
Note that this overrides anything from *check-mail-include*.
Default: no folders
# SEE ALSO # SEE ALSO
*aerc*(1) *aerc-config*(5) *aerc*(1) *aerc-config*(5)

View file

@ -15,6 +15,21 @@ must be added manually to the *aerc-config*(5) file.
The following maildir-specific options are available: The following maildir-specific options are available:
*check-mail-cmd*
Command to run in conjunction with *check-mail* option.
Example:
mbsync -a
Default: none
*check-mail-timeout*
Timeout for the *check-mail-cmd*. The command will be stopped if it does
not complete in this interval and an error will be displayed. Increase from
the default if repeated errors occur
Default: 10s
*source* *source*
maildir://path maildir://path

View file

@ -20,6 +20,21 @@ must be added manually.
In accounts.conf (see *aerc-config*(5)), the following notmuch-specific In accounts.conf (see *aerc-config*(5)), the following notmuch-specific
options are available: options are available:
*check-mail-cmd*
Command to run in conjunction with *check-mail* option.
Example:
mbsync -a
Default: none
*check-mail-timeout*
Timeout for the *check-mail-cmd*. The command will be stopped if it does
not complete in this interval and an error will be displayed. Increase from
the default if repeated errors occur
Default: 10s
*source* *source*
notmuch://path notmuch://path

View file

@ -193,7 +193,9 @@ func (store *MessageStore) Update(msg types.WorkerMessage) {
switch msg := msg.(type) { switch msg := msg.(type) {
case *types.DirectoryInfo: case *types.DirectoryInfo:
store.DirInfo = *msg.Info store.DirInfo = *msg.Info
if !msg.SkipSort {
store.Sort(store.sortCriteria, nil) store.Sort(store.sortCriteria, nil)
}
update = true update = true
case *types.DirectoryContents: case *types.DirectoryContents:
newMap := make(map[uint32]*models.MessageInfo) newMap := make(map[uint32]*models.MessageInfo)

View file

@ -64,9 +64,13 @@ func (s *State) SetWidth(w int) bool {
return changeState return changeState
} }
func (s *State) Connected() bool {
return s.acct.Connected
}
type SetStateFunc func(s *State, folder string) type SetStateFunc func(s *State, folder string)
func Connected(state bool) SetStateFunc { func SetConnected(state bool) SetStateFunc {
return func(s *State, folder string) { return func(s *State, folder string) {
s.acct.ConnActivity = "" s.acct.ConnActivity = ""
s.acct.Connected = state s.acct.Connected = state

View file

@ -33,6 +33,7 @@ type AccountView struct {
msglist *MessageList msglist *MessageList
worker *types.Worker worker *types.Worker
state *statusline.State state *statusline.State
newConn bool // True if this is a first run after a new connection/reconnection
} }
func (acct *AccountView) UiConfig() config.UIConfig { func (acct *AccountView) UiConfig() config.UIConfig {
@ -100,6 +101,9 @@ func NewAccountView(aerc *Aerc, conf *config.AercConfig, acct *config.AccountCon
worker.PostAction(&types.Configure{Config: acct}, nil) worker.PostAction(&types.Configure{Config: acct}, nil)
worker.PostAction(&types.Connect{}, nil) worker.PostAction(&types.Connect{}, nil)
view.SetStatus(statusline.ConnectionActivity("Connecting...")) view.SetStatus(statusline.ConnectionActivity("Connecting..."))
if acct.CheckMail.Minutes() > 0 {
view.CheckMailTimer(acct.CheckMail)
}
return view, nil return view, nil
} }
@ -258,13 +262,14 @@ func (acct *AccountView) onMessage(msg types.WorkerMessage) {
} }
acct.msglist.SetInitDone() acct.msglist.SetInitDone()
acct.logger.Println("Connected.") acct.logger.Println("Connected.")
acct.SetStatus(statusline.Connected(true)) acct.SetStatus(statusline.SetConnected(true))
acct.newConn = true
}) })
case *types.Disconnect: case *types.Disconnect:
acct.dirlist.UpdateList(nil) acct.dirlist.UpdateList(nil)
acct.msglist.SetStore(nil) acct.msglist.SetStore(nil)
acct.logger.Println("Disconnected.") acct.logger.Println("Disconnected.")
acct.SetStatus(statusline.Connected(false)) acct.SetStatus(statusline.SetConnected(false))
case *types.OpenDirectory: case *types.OpenDirectory:
if store, ok := acct.dirlist.SelectedMsgStore(); ok { if store, ok := acct.dirlist.SelectedMsgStore(); ok {
// If we've opened this dir before, we can re-render it from // If we've opened this dir before, we can re-render it from
@ -279,6 +284,11 @@ func (acct *AccountView) onMessage(msg types.WorkerMessage) {
acct.dirlist.UpdateList(nil) acct.dirlist.UpdateList(nil)
case *types.RemoveDirectory: case *types.RemoveDirectory:
acct.dirlist.UpdateList(nil) acct.dirlist.UpdateList(nil)
case *types.FetchMessageHeaders:
if acct.newConn && acct.AccountConfig().CheckMail.Minutes() > 0 {
acct.newConn = false
acct.CheckMail()
}
} }
case *types.DirectoryInfo: case *types.DirectoryInfo:
if store, ok := acct.dirlist.MsgStore(msg.Info.Name); ok { if store, ok := acct.dirlist.MsgStore(msg.Info.Name); ok {
@ -327,7 +337,7 @@ func (acct *AccountView) onMessage(msg types.WorkerMessage) {
acct.labels = msg.Labels acct.labels = msg.Labels
case *types.ConnError: case *types.ConnError:
acct.logger.Printf("connection error: [%s] %v", acct.acct.Name, msg.Error) acct.logger.Printf("connection error: [%s] %v", acct.acct.Name, msg.Error)
acct.SetStatus(statusline.Connected(false)) acct.SetStatus(statusline.SetConnected(false))
acct.PushError(msg.Error) acct.PushError(msg.Error)
acct.msglist.SetStore(nil) acct.msglist.SetStore(nil)
acct.worker.PostAction(&types.Reconnect{}, nil) acct.worker.PostAction(&types.Reconnect{}, nil)
@ -349,3 +359,33 @@ func (acct *AccountView) GetSortCriteria() []*types.SortCriterion {
} }
return criteria return criteria
} }
func (acct *AccountView) CheckMail() {
// Exclude selected mailbox, per IMAP specification
exclude := append(acct.AccountConfig().CheckMailExclude, acct.dirlist.Selected())
dirs := acct.dirlist.List()
dirs = acct.dirlist.FilterDirs(dirs, acct.AccountConfig().CheckMailInclude, false)
dirs = acct.dirlist.FilterDirs(dirs, exclude, true)
acct.logger.Printf("Checking for new mail on account %s", acct.Name())
acct.SetStatus(statusline.ConnectionActivity("Checking for new mail..."))
msg := &types.CheckMail{
Directories: dirs,
Command: acct.acct.CheckMailCmd,
Timeout: acct.acct.CheckMailTimeout,
}
acct.worker.PostAction(msg, func(_ types.WorkerMessage) {
acct.SetStatus(statusline.ConnectionActivity(""))
})
}
func (acct *AccountView) CheckMailTimer(d time.Duration) {
ticker := time.NewTicker(d)
go func() {
for range ticker.C {
if !acct.state.Connected() {
continue
}
acct.CheckMail()
}
}()
}

View file

@ -40,6 +40,8 @@ type DirectoryLister interface {
SelectedMsgStore() (*lib.MessageStore, bool) SelectedMsgStore() (*lib.MessageStore, bool)
MsgStore(string) (*lib.MessageStore, bool) MsgStore(string) (*lib.MessageStore, bool)
SetMsgStore(string, *lib.MessageStore) SetMsgStore(string, *lib.MessageStore)
FilterDirs([]string, []string, bool) []string
} }
type DirectoryList struct { type DirectoryList struct {
@ -441,7 +443,21 @@ func (dirlist *DirectoryList) sortDirsByFoldersSortConfig() {
// dirstore, based on AccountConfig.Folders (inclusion) and // dirstore, based on AccountConfig.Folders (inclusion) and
// AccountConfig.FoldersExclude (exclusion), in that order. // AccountConfig.FoldersExclude (exclusion), in that order.
func (dirlist *DirectoryList) filterDirsByFoldersConfig() { func (dirlist *DirectoryList) filterDirsByFoldersConfig() {
filterDirs := func(orig, filters []string, exclude bool) []string { dirlist.dirs = dirlist.store.List()
// 'folders' (if available) is used to make the initial list and
// 'folders-exclude' removes from that list.
configFolders := dirlist.acctConf.Folders
dirlist.dirs = dirlist.FilterDirs(dirlist.dirs, configFolders, false)
configFoldersExclude := dirlist.acctConf.FoldersExclude
dirlist.dirs = dirlist.FilterDirs(dirlist.dirs, configFoldersExclude, true)
}
// FilterDirs filters directories by the supplied filter. If exclude is false,
// the filter will only include directories from orig which exist in filters.
// If exclude is true, the directories in filters are removed from orig
func (dirlist *DirectoryList) FilterDirs(orig, filters []string, exclude bool) []string {
if len(filters) == 0 { if len(filters) == 0 {
return orig return orig
} }
@ -464,17 +480,6 @@ func (dirlist *DirectoryList) filterDirsByFoldersConfig() {
return dest return dest
} }
dirlist.dirs = dirlist.store.List()
// 'folders' (if available) is used to make the initial list and
// 'folders-exclude' removes from that list.
configFolders := dirlist.acctConf.Folders
dirlist.dirs = filterDirs(dirlist.dirs, configFolders, false)
configFoldersExclude := dirlist.acctConf.FoldersExclude
dirlist.dirs = filterDirs(dirlist.dirs, configFoldersExclude, true)
}
func (dirlist *DirectoryList) SelectedMsgStore() (*lib.MessageStore, bool) { func (dirlist *DirectoryList) SelectedMsgStore() (*lib.MessageStore, bool) {
return dirlist.store.MessageStore(dirlist.selected) return dirlist.store.MessageStore(dirlist.selected)
} }

40
worker/imap/checkmail.go Normal file
View file

@ -0,0 +1,40 @@
package imap
import (
"git.sr.ht/~rjarry/aerc/models"
"git.sr.ht/~rjarry/aerc/worker/types"
"github.com/emersion/go-imap"
)
func (w *IMAPWorker) handleCheckMailMessage(msg *types.CheckMail) {
items := []imap.StatusItem{
imap.StatusMessages,
imap.StatusRecent,
imap.StatusUnseen,
}
for _, dir := range msg.Directories {
w.worker.Logger.Printf("Getting status of directory %s", dir)
status, err := w.client.Status(dir, items)
if err != nil {
w.worker.PostMessage(&types.Error{
Message: types.RespondTo(msg),
Error: err,
}, nil)
} else {
w.worker.PostMessage(&types.DirectoryInfo{
Info: &models.DirectoryInfo{
Flags: status.Flags,
Name: status.Name,
ReadOnly: status.ReadOnly,
AccurateCounts: true,
Exists: int(status.Messages),
Recent: int(status.Recent),
Unseen: int(status.Unseen),
},
SkipSort: true,
}, nil)
}
}
w.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil)
}

View file

@ -190,6 +190,8 @@ func (w *IMAPWorker) handleMessage(msg types.WorkerMessage) error {
w.handleAppendMessage(msg) w.handleAppendMessage(msg)
case *types.SearchDirectory: case *types.SearchDirectory:
w.handleSearchDirectory(msg) w.handleSearchDirectory(msg)
case *types.CheckMail:
w.handleCheckMailMessage(msg)
default: default:
reterr = errUnsupported reterr = errUnsupported
} }

View file

@ -2,12 +2,14 @@ package maildir
import ( import (
"bytes" "bytes"
"context"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"io/ioutil" "io/ioutil"
"net/url" "net/url"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
@ -60,6 +62,13 @@ func (w *Worker) Run() {
func (w *Worker) handleAction(action types.WorkerMessage) { func (w *Worker) handleAction(action types.WorkerMessage) {
msg := w.worker.ProcessAction(action) msg := w.worker.ProcessAction(action)
switch msg := msg.(type) {
// Explicitly handle all asynchronous actions. Async actions are
// responsible for posting their own Done message
case *types.CheckMail:
go w.handleCheckMail(msg)
default:
// Default handling, will be performed synchronously
if err := w.handleMessage(msg); err == errUnsupported { if err := w.handleMessage(msg); err == errUnsupported {
w.worker.PostMessage(&types.Unsupported{ w.worker.PostMessage(&types.Unsupported{
Message: types.RespondTo(msg), Message: types.RespondTo(msg),
@ -73,6 +82,7 @@ func (w *Worker) handleAction(action types.WorkerMessage) {
w.done(msg) w.done(msg)
} }
} }
}
func (w *Worker) handleFSEvent(ev fsnotify.Event) { func (w *Worker) handleFSEvent(ev fsnotify.Event) {
// we only care about files being created, removed or renamed // we only care about files being created, removed or renamed
@ -672,3 +682,24 @@ func (w *Worker) msgInfoFromUid(uid uint32) (*models.MessageInfo, error) {
} }
return info, nil return info, nil
} }
func (w *Worker) handleCheckMail(msg *types.CheckMail) {
ctx, cancel := context.WithTimeout(context.Background(), msg.Timeout)
defer cancel()
cmd := exec.CommandContext(ctx, "sh", "-c", msg.Command)
ch := make(chan error)
go func() {
err := cmd.Run()
ch <- err
}()
select {
case <-ctx.Done():
w.err(msg, fmt.Errorf("checkmail: timed out"))
case err := <-ch:
if err != nil {
w.err(msg, fmt.Errorf("checkmail: error running command: %v", err))
} else {
w.done(msg)
}
}
}

View file

@ -6,10 +6,12 @@ package notmuch
import ( import (
"bufio" "bufio"
"bytes" "bytes"
"context"
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"net/url" "net/url"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
@ -128,6 +130,9 @@ func (w *worker) handleMessage(msg types.WorkerMessage) error {
return w.handleSearchDirectory(msg) return w.handleSearchDirectory(msg)
case *types.ModifyLabels: case *types.ModifyLabels:
return w.handleModifyLabels(msg) return w.handleModifyLabels(msg)
case *types.CheckMail:
go w.handleCheckMail(msg)
return nil
// not implemented, they are generally not used // not implemented, they are generally not used
// in a notmuch based workflow // in a notmuch based workflow
@ -616,3 +621,24 @@ func (w *worker) sort(uids []uint32,
} }
return sortedUids, nil return sortedUids, nil
} }
func (w *worker) handleCheckMail(msg *types.CheckMail) {
ctx, cancel := context.WithTimeout(context.Background(), msg.Timeout)
defer cancel()
cmd := exec.CommandContext(ctx, "sh", "-c", msg.Command)
ch := make(chan error)
go func() {
err := cmd.Run()
ch <- err
}()
select {
case <-ctx.Done():
w.err(msg, fmt.Errorf("checkmail: timed out"))
case err := <-ch:
if err != nil {
w.err(msg, fmt.Errorf("checkmail: error running command: %v", err))
} else {
w.done(msg)
}
}
}

View file

@ -167,6 +167,13 @@ type AppendMessage struct {
Length int Length int
} }
type CheckMail struct {
Message
Directories []string
Command string
Timeout time.Duration
}
// Messages // Messages
type Directory struct { type Directory struct {
@ -177,6 +184,7 @@ type Directory struct {
type DirectoryInfo struct { type DirectoryInfo struct {
Message Message
Info *models.DirectoryInfo Info *models.DirectoryInfo
SkipSort bool
} }
type DirectoryContents struct { type DirectoryContents struct {