aboutsummaryrefslogtreecommitdiffstats
path: root/app/account.go
diff options
context:
space:
mode:
Diffstat (limited to 'app/account.go')
-rw-r--r--app/account.go649
1 files changed, 649 insertions, 0 deletions
diff --git a/app/account.go b/app/account.go
new file mode 100644
index 00000000..3ab7fc8f
--- /dev/null
+++ b/app/account.go
@@ -0,0 +1,649 @@
+package app
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "sync"
+ "time"
+
+ "github.com/gdamore/tcell/v2"
+
+ "git.sr.ht/~rjarry/aerc/config"
+ "git.sr.ht/~rjarry/aerc/lib"
+ "git.sr.ht/~rjarry/aerc/lib/hooks"
+ "git.sr.ht/~rjarry/aerc/lib/marker"
+ "git.sr.ht/~rjarry/aerc/lib/sort"
+ "git.sr.ht/~rjarry/aerc/lib/state"
+ "git.sr.ht/~rjarry/aerc/lib/templates"
+ "git.sr.ht/~rjarry/aerc/lib/ui"
+ "git.sr.ht/~rjarry/aerc/log"
+ "git.sr.ht/~rjarry/aerc/models"
+ "git.sr.ht/~rjarry/aerc/worker"
+ "git.sr.ht/~rjarry/aerc/worker/types"
+)
+
+var _ ProvidesMessages = (*AccountView)(nil)
+
+type AccountView struct {
+ sync.Mutex
+ acct *config.AccountConfig
+ aerc *Aerc
+ dirlist DirectoryLister
+ labels []string
+ grid *ui.Grid
+ host TabHost
+ tab *ui.Tab
+ msglist *MessageList
+ worker *types.Worker
+ state state.AccountState
+ newConn bool // True if this is a first run after a new connection/reconnection
+ uiConf *config.UIConfig
+
+ split *MessageViewer
+ splitSize int
+ splitDebounce *time.Timer
+ splitDir string
+
+ // Check-mail ticker
+ ticker *time.Ticker
+ checkingMail bool
+}
+
+func (acct *AccountView) UiConfig() *config.UIConfig {
+ if dirlist := acct.Directories(); dirlist != nil {
+ return dirlist.UiConfig("")
+ }
+ return acct.uiConf
+}
+
+func NewAccountView(
+ aerc *Aerc, acct *config.AccountConfig,
+ host TabHost, deferLoop chan struct{},
+) (*AccountView, error) {
+ acctUiConf := config.Ui.ForAccount(acct.Name)
+
+ view := &AccountView{
+ acct: acct,
+ aerc: aerc,
+ host: host,
+ uiConf: acctUiConf,
+ }
+
+ view.grid = ui.NewGrid().Rows([]ui.GridSpec{
+ {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
+ }).Columns([]ui.GridSpec{
+ {Strategy: ui.SIZE_EXACT, Size: func() int {
+ return view.UiConfig().SidebarWidth
+ }},
+ {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
+ })
+
+ worker, err := worker.NewWorker(acct.Source, acct.Name)
+ if err != nil {
+ host.SetError(fmt.Sprintf("%s: %s", acct.Name, err))
+ log.Errorf("%s: %v", acct.Name, err)
+ return view, err
+ }
+ view.worker = worker
+
+ view.dirlist = NewDirectoryList(acct, worker)
+ if acctUiConf.SidebarWidth > 0 {
+ view.grid.AddChild(ui.NewBordered(view.dirlist, ui.BORDER_RIGHT, acctUiConf))
+ }
+
+ view.msglist = NewMessageList(aerc, view)
+ view.grid.AddChild(view.msglist).At(0, 1)
+
+ view.dirlist.OnVirtualNode(func() {
+ view.msglist.SetStore(nil)
+ view.Invalidate()
+ })
+
+ go func() {
+ defer log.PanicHandler()
+
+ if deferLoop != nil {
+ <-deferLoop
+ }
+
+ worker.Backend.Run()
+ }()
+
+ worker.PostAction(&types.Configure{Config: acct}, nil)
+ worker.PostAction(&types.Connect{}, nil)
+ view.SetStatus(state.ConnectionActivity("Connecting..."))
+ if acct.CheckMail.Minutes() > 0 {
+ view.CheckMailTimer(acct.CheckMail)
+ }
+
+ return view, nil
+}
+
+func (acct *AccountView) SetStatus(setters ...state.SetStateFunc) {
+ for _, fn := range setters {
+ fn(&acct.state, acct.SelectedDirectory())
+ }
+ acct.UpdateStatus()
+}
+
+func (acct *AccountView) UpdateStatus() {
+ if acct.isSelected() {
+ acct.host.UpdateStatus()
+ }
+}
+
+func (acct *AccountView) PushStatus(status string, expiry time.Duration) {
+ acct.aerc.PushStatus(fmt.Sprintf("%s: %s", acct.acct.Name, status), expiry)
+}
+
+func (acct *AccountView) PushError(err error) {
+ acct.aerc.PushError(fmt.Sprintf("%s: %v", acct.acct.Name, err))
+}
+
+func (acct *AccountView) PushWarning(warning string) {
+ acct.aerc.PushWarning(fmt.Sprintf("%s: %s", acct.acct.Name, warning))
+}
+
+func (acct *AccountView) AccountConfig() *config.AccountConfig {
+ return acct.acct
+}
+
+func (acct *AccountView) Worker() *types.Worker {
+ return acct.worker
+}
+
+func (acct *AccountView) Name() string {
+ return acct.acct.Name
+}
+
+func (acct *AccountView) Invalidate() {
+ ui.Invalidate()
+}
+
+func (acct *AccountView) Draw(ctx *ui.Context) {
+ acct.grid.Draw(ctx)
+}
+
+func (acct *AccountView) MouseEvent(localX int, localY int, event tcell.Event) {
+ acct.grid.MouseEvent(localX, localY, event)
+}
+
+func (acct *AccountView) Focus(focus bool) {
+ // TODO: Unfocus children I guess
+}
+
+func (acct *AccountView) Directories() DirectoryLister {
+ return acct.dirlist
+}
+
+func (acct *AccountView) Labels() []string {
+ return acct.labels
+}
+
+func (acct *AccountView) Messages() *MessageList {
+ return acct.msglist
+}
+
+func (acct *AccountView) Store() *lib.MessageStore {
+ if acct.msglist == nil {
+ return nil
+ }
+ return acct.msglist.Store()
+}
+
+func (acct *AccountView) SelectedAccount() *AccountView {
+ return acct
+}
+
+func (acct *AccountView) SelectedDirectory() string {
+ return acct.dirlist.Selected()
+}
+
+func (acct *AccountView) SelectedMessage() (*models.MessageInfo, error) {
+ if acct.msglist == nil || acct.msglist.Store() == nil {
+ return nil, errors.New("init in progress")
+ }
+ if len(acct.msglist.Store().Uids()) == 0 {
+ return nil, errors.New("no message selected")
+ }
+ msg := acct.msglist.Selected()
+ if msg == nil {
+ return nil, errors.New("message not loaded")
+ }
+ return msg, nil
+}
+
+func (acct *AccountView) MarkedMessages() ([]uint32, error) {
+ if store := acct.Store(); store != nil {
+ return store.Marker().Marked(), nil
+ }
+ return nil, errors.New("no store available")
+}
+
+func (acct *AccountView) SelectedMessagePart() *PartInfo {
+ return nil
+}
+
+func (acct *AccountView) isSelected() bool {
+ return acct == acct.aerc.SelectedAccount()
+}
+
+func (acct *AccountView) newStore(name string) *lib.MessageStore {
+ uiConf := acct.dirlist.UiConfig(name)
+ store := lib.NewMessageStore(acct.worker,
+ acct.sortCriteria(uiConf),
+ uiConf.ThreadingEnabled,
+ uiConf.ForceClientThreads,
+ uiConf.ClientThreadsDelay,
+ uiConf.ReverseOrder,
+ uiConf.ReverseThreadOrder,
+ uiConf.SortThreadSiblings,
+ func(msg *models.MessageInfo) {
+ err := hooks.RunHook(&hooks.MailReceived{
+ Account: acct.Name(),
+ Folder: name,
+ MsgInfo: msg,
+ })
+ if err != nil {
+ msg := fmt.Sprintf("mail-received hook: %s", err)
+ acct.aerc.PushError(msg)
+ }
+ }, func() {
+ if uiConf.NewMessageBell {
+ acct.host.Beep()
+ }
+ },
+ acct.updateSplitView,
+ acct.dirlist.UiConfig(name).ThreadContext,
+ )
+ store.SetMarker(marker.New(store))
+ return store
+}
+
+func (acct *AccountView) onMessage(msg types.WorkerMessage) {
+ msg = acct.worker.ProcessMessage(msg)
+ switch msg := msg.(type) {
+ case *types.Done:
+ switch resp := msg.InResponseTo().(type) {
+ case *types.Connect, *types.Reconnect:
+ acct.SetStatus(state.ConnectionActivity("Listing mailboxes..."))
+ log.Infof("[%s] connected.", acct.acct.Name)
+ acct.SetStatus(state.SetConnected(true))
+ log.Tracef("Listing mailboxes...")
+ acct.worker.PostAction(&types.ListDirectories{}, nil)
+ case *types.Disconnect:
+ acct.dirlist.ClearList()
+ acct.msglist.SetStore(nil)
+ log.Infof("[%s] disconnected.", acct.acct.Name)
+ acct.SetStatus(state.SetConnected(false))
+ case *types.OpenDirectory:
+ acct.dirlist.Update(msg)
+ if store, ok := acct.dirlist.SelectedMsgStore(); ok {
+ // If we've opened this dir before, we can re-render it from
+ // memory while we wait for the update and the UI feels
+ // snappier. If not, we'll unset the store and show the spinner
+ // while we download the UID list.
+ acct.msglist.SetStore(store)
+ acct.Store().Update(msg.InResponseTo())
+ } else {
+ acct.msglist.SetStore(nil)
+ }
+ case *types.CreateDirectory:
+ store := acct.newStore(resp.Directory)
+ acct.dirlist.SetMsgStore(&models.Directory{
+ Name: resp.Directory,
+ }, store)
+ acct.dirlist.Update(msg)
+ case *types.RemoveDirectory:
+ acct.dirlist.Update(msg)
+ case *types.FetchMessageHeaders:
+ if acct.newConn {
+ acct.checkMailOnStartup()
+ }
+ case *types.ListDirectories:
+ acct.dirlist.Update(msg)
+ if dir := acct.dirlist.Selected(); dir != "" {
+ acct.dirlist.Select(dir)
+ return
+ }
+ // Nothing selected, select based on config
+ dirs := acct.dirlist.List()
+ var dir string
+ for _, _dir := range dirs {
+ if _dir == acct.acct.Default {
+ dir = _dir
+ break
+ }
+ }
+ if dir == "" && len(dirs) > 0 {
+ dir = dirs[0]
+ }
+ if dir != "" {
+ acct.dirlist.Select(dir)
+ }
+ acct.msglist.SetInitDone()
+ acct.newConn = true
+ }
+ case *types.Directory:
+ store, ok := acct.dirlist.MsgStore(msg.Dir.Name)
+ if !ok {
+ store = acct.newStore(msg.Dir.Name)
+ }
+ acct.dirlist.SetMsgStore(msg.Dir, store)
+ case *types.DirectoryInfo:
+ acct.dirlist.Update(msg)
+ case *types.DirectoryContents:
+ if store, ok := acct.dirlist.SelectedMsgStore(); ok {
+ if acct.msglist.Store() == nil {
+ acct.msglist.SetStore(store)
+ }
+ store.Update(msg)
+ acct.SetStatus(state.Threading(store.ThreadedView()))
+ }
+ if acct.newConn && len(msg.Uids) == 0 {
+ acct.checkMailOnStartup()
+ }
+ case *types.DirectoryThreaded:
+ if store, ok := acct.dirlist.SelectedMsgStore(); ok {
+ if acct.msglist.Store() == nil {
+ acct.msglist.SetStore(store)
+ }
+ store.Update(msg)
+ acct.SetStatus(state.Threading(store.ThreadedView()))
+ }
+ if acct.newConn && len(msg.Threads) == 0 {
+ acct.checkMailOnStartup()
+ }
+ case *types.FullMessage:
+ if store, ok := acct.dirlist.SelectedMsgStore(); ok {
+ store.Update(msg)
+ }
+ case *types.MessageInfo:
+ if store, ok := acct.dirlist.SelectedMsgStore(); ok {
+ store.Update(msg)
+ }
+ case *types.MessagesDeleted:
+ if dir := acct.dirlist.SelectedDirectory(); dir != nil {
+ dir.Exists -= len(msg.Uids)
+ }
+ if store, ok := acct.dirlist.SelectedMsgStore(); ok {
+ store.Update(msg)
+ }
+ case *types.MessagesCopied:
+ acct.updateDirCounts(msg.Destination, msg.Uids)
+ case *types.MessagesMoved:
+ acct.updateDirCounts(msg.Destination, msg.Uids)
+ case *types.LabelList:
+ acct.labels = msg.Labels
+ case *types.ConnError:
+ log.Errorf("[%s] connection error: %v", acct.acct.Name, msg.Error)
+ acct.SetStatus(state.SetConnected(false))
+ acct.PushError(msg.Error)
+ acct.msglist.SetStore(nil)
+ acct.worker.PostAction(&types.Reconnect{}, nil)
+ case *types.Error:
+ log.Errorf("[%s] unexpected error: %v", acct.acct.Name, msg.Error)
+ acct.PushError(msg.Error)
+ }
+ acct.UpdateStatus()
+ acct.setTitle()
+}
+
+func (acct *AccountView) updateDirCounts(destination string, uids []uint32) {
+ // Only update the destination destDir if it is initialized
+ if destDir := acct.dirlist.Directory(destination); destDir != nil {
+ var recent, unseen int
+ var accurate bool = true
+ for _, uid := range uids {
+ // Get the message from the originating store
+ msg, ok := acct.Store().Messages[uid]
+ if !ok {
+ continue
+ }
+ // If message that was not yet loaded is copied
+ if msg == nil {
+ accurate = false
+ break
+ }
+ seen := msg.Flags.Has(models.SeenFlag)
+ if msg.Flags.Has(models.RecentFlag) {
+ recent++
+ }
+ if !seen {
+ unseen++
+ }
+ }
+ if accurate {
+ destDir.Recent += recent
+ destDir.Unseen += unseen
+ destDir.Exists += len(uids)
+ } else {
+ destDir.Exists += len(uids)
+ }
+ }
+}
+
+func (acct *AccountView) sortCriteria(uiConf *config.UIConfig) []*types.SortCriterion {
+ if uiConf == nil {
+ return nil
+ }
+ if len(uiConf.Sort) == 0 {
+ return nil
+ }
+ criteria, err := sort.GetSortCriteria(uiConf.Sort)
+ if err != nil {
+ acct.PushError(fmt.Errorf("ui sort: %w", err))
+ return nil
+ }
+ return criteria
+}
+
+func (acct *AccountView) GetSortCriteria() []*types.SortCriterion {
+ return acct.sortCriteria(acct.UiConfig())
+}
+
+func (acct *AccountView) CheckMail() {
+ acct.Lock()
+ defer acct.Unlock()
+ if acct.checkingMail {
+ return
+ }
+ // Exclude selected mailbox, per IMAP specification
+ exclude := append(acct.AccountConfig().CheckMailExclude, acct.dirlist.Selected()) //nolint:gocritic // intentional append to different slice
+ dirs := acct.dirlist.List()
+ dirs = acct.dirlist.FilterDirs(dirs, acct.AccountConfig().CheckMailInclude, false)
+ dirs = acct.dirlist.FilterDirs(dirs, exclude, true)
+ log.Debugf("Checking for new mail on account %s", acct.Name())
+ acct.SetStatus(state.ConnectionActivity("Checking for new mail..."))
+ msg := &types.CheckMail{
+ Directories: dirs,
+ Command: acct.acct.CheckMailCmd,
+ Timeout: acct.acct.CheckMailTimeout,
+ }
+ acct.checkingMail = true
+
+ var cb func(types.WorkerMessage)
+ cb = func(response types.WorkerMessage) {
+ dirsMsg, ok := response.(*types.CheckMailDirectories)
+ if ok {
+ checkMailMsg := &types.CheckMail{
+ Directories: dirsMsg.Directories,
+ Command: acct.acct.CheckMailCmd,
+ Timeout: acct.acct.CheckMailTimeout,
+ }
+ acct.worker.PostAction(checkMailMsg, cb)
+ } else { // Done
+ acct.SetStatus(state.ConnectionActivity(""))
+ acct.Lock()
+ acct.checkingMail = false
+ acct.Unlock()
+ }
+ }
+ acct.worker.PostAction(msg, cb)
+}
+
+// CheckMailReset resets the check-mail timer
+func (acct *AccountView) CheckMailReset() {
+ if acct.ticker != nil {
+ d := acct.AccountConfig().CheckMail
+ acct.ticker = time.NewTicker(d)
+ }
+}
+
+func (acct *AccountView) checkMailOnStartup() {
+ if acct.AccountConfig().CheckMail.Minutes() > 0 {
+ acct.newConn = false
+ acct.CheckMail()
+ }
+}
+
+func (acct *AccountView) CheckMailTimer(d time.Duration) {
+ acct.ticker = time.NewTicker(d)
+ go func() {
+ defer log.PanicHandler()
+ for range acct.ticker.C {
+ if !acct.state.Connected {
+ continue
+ }
+ acct.CheckMail()
+ }
+ }()
+}
+
+func (acct *AccountView) closeSplit() {
+ if acct.split != nil {
+ acct.split.Close()
+ }
+ acct.splitSize = 0
+ acct.splitDir = ""
+ acct.split = nil
+ acct.grid = ui.NewGrid().Rows([]ui.GridSpec{
+ {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
+ }).Columns([]ui.GridSpec{
+ {Strategy: ui.SIZE_EXACT, Size: func() int {
+ return acct.UiConfig().SidebarWidth
+ }},
+ {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
+ })
+
+ acct.grid.AddChild(ui.NewBordered(acct.dirlist, ui.BORDER_RIGHT, acct.uiConf))
+ acct.grid.AddChild(acct.msglist).At(0, 1)
+ ui.Invalidate()
+}
+
+func (acct *AccountView) updateSplitView(msg *models.MessageInfo) {
+ if acct.splitSize == 0 {
+ return
+ }
+ if acct.splitDebounce != nil {
+ acct.splitDebounce.Stop()
+ }
+ fn := func() {
+ if acct.split != nil {
+ acct.grid.RemoveChild(acct.split)
+ acct.split.Close()
+ }
+ lib.NewMessageStoreView(msg, false, acct.Store(), acct.aerc.Crypto, acct.aerc.DecryptKeys,
+ func(view lib.MessageView, err error) {
+ if err != nil {
+ acct.aerc.PushError(err.Error())
+ return
+ }
+ acct.split = NewMessageViewer(acct, view)
+ switch acct.splitDir {
+ case "split":
+ acct.grid.AddChild(acct.split).At(1, 1)
+ case "vsplit":
+ acct.grid.AddChild(acct.split).At(0, 2)
+ }
+ })
+ }
+ acct.splitDebounce = time.AfterFunc(100*time.Millisecond, func() {
+ ui.QueueFunc(fn)
+ })
+}
+
+func (acct *AccountView) SplitSize() int {
+ return acct.splitSize
+}
+
+func (acct *AccountView) SetSplitSize(n int) {
+ if n == 0 {
+ acct.closeSplit()
+ }
+ acct.splitSize = n
+}
+
+// Split splits the message list view horizontally. The message list will be n
+// rows high. If n is 0, any existing split is removed
+func (acct *AccountView) Split(n int) error {
+ acct.SetSplitSize(n)
+ if acct.splitDir == "split" || n == 0 {
+ return nil
+ }
+ acct.splitDir = "split"
+ acct.grid = ui.NewGrid().Rows([]ui.GridSpec{
+ // Add 1 so that the splitSize is the number of visible messages
+ {Strategy: ui.SIZE_EXACT, Size: func() int { return acct.SplitSize() + 1 }},
+ {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
+ }).Columns([]ui.GridSpec{
+ {Strategy: ui.SIZE_EXACT, Size: func() int {
+ return acct.UiConfig().SidebarWidth
+ }},
+ {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
+ })
+
+ acct.grid.AddChild(ui.NewBordered(acct.dirlist, ui.BORDER_RIGHT, acct.uiConf)).Span(2, 1)
+ acct.grid.AddChild(ui.NewBordered(acct.msglist, ui.BORDER_BOTTOM, acct.uiConf)).At(0, 1)
+ acct.split = NewMessageViewer(acct, nil)
+ acct.grid.AddChild(acct.split).At(1, 1)
+ acct.updateSplitView(acct.msglist.Selected())
+ return nil
+}
+
+// Vsplit splits the message list view vertically. The message list will be n
+// rows wide. If n is 0, any existing split is removed
+func (acct *AccountView) Vsplit(n int) error {
+ acct.SetSplitSize(n)
+ if acct.splitDir == "vsplit" || n == 0 {
+ return nil
+ }
+ acct.splitDir = "vsplit"
+ acct.grid = ui.NewGrid().Rows([]ui.GridSpec{
+ {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
+ }).Columns([]ui.GridSpec{
+ {Strategy: ui.SIZE_EXACT, Size: func() int {
+ return acct.UiConfig().SidebarWidth
+ }},
+ {Strategy: ui.SIZE_EXACT, Size: acct.SplitSize},
+ {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
+ })
+
+ acct.grid.AddChild(ui.NewBordered(acct.dirlist, ui.BORDER_RIGHT, acct.uiConf)).At(0, 0)
+ acct.grid.AddChild(ui.NewBordered(acct.msglist, ui.BORDER_RIGHT, acct.uiConf)).At(0, 1)
+ acct.split = NewMessageViewer(acct, nil)
+ acct.grid.AddChild(acct.split).At(0, 2)
+ acct.updateSplitView(acct.msglist.Selected())
+ return nil
+}
+
+// setTitle executes the title template and sets the tab title
+func (acct *AccountView) setTitle() {
+ if acct.tab == nil {
+ return
+ }
+
+ data := state.NewDataSetter()
+ data.SetAccount(acct.acct)
+ data.SetFolder(acct.Directories().SelectedDirectory())
+ data.SetRUE(acct.dirlist.List(), acct.dirlist.GetRUECount)
+
+ var buf bytes.Buffer
+ err := templates.Render(acct.uiConf.TabTitleAccount, &buf, data.Data())
+ if err != nil {
+ acct.PushError(err)
+ return
+ }
+ acct.tab.SetTitle(buf.String())
+}