@@ -0,0 +1,181 @@
+package fetcher
+
+import (
+ "log"
+ "sync"
+ "time"
+
+ "github.com/emersion/go-imap/client"
+ "github.com/floatpane/matcha/config"
+)
+
+// IdleUpdate is sent when IDLE detects a mailbox change.
+type IdleUpdate struct {
+ AccountID string
+ FolderName string
+}
+
+// IdleWatcher manages IDLE connections for multiple accounts.
+type IdleWatcher struct {
+ mu sync.Mutex
+ watchers map[string]*accountIdle // key: account ID
+ notify chan<- IdleUpdate
+}
+
+// accountIdle manages a single IDLE connection for one account.
+type accountIdle struct {
+ account *config.Account
+ folder string
+ notify chan<- IdleUpdate
+ stop chan struct{}
+ done chan struct{}
+}
+
+// NewIdleWatcher creates a new IDLE watcher. Updates are sent to the notify channel.
+func NewIdleWatcher(notify chan<- IdleUpdate) *IdleWatcher {
+ return &IdleWatcher{
+ watchers: make(map[string]*accountIdle),
+ notify: notify,
+ }
+}
+
+// Watch starts (or restarts) an IDLE connection for the given account and folder.
+func (w *IdleWatcher) Watch(account *config.Account, folder string) {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+
+ // Stop existing watcher for this account if any
+ if existing, ok := w.watchers[account.ID]; ok {
+ close(existing.stop)
+ <-existing.done
+ delete(w.watchers, account.ID)
+ }
+
+ a := &accountIdle{
+ account: account,
+ folder: folder,
+ notify: w.notify,
+ stop: make(chan struct{}),
+ done: make(chan struct{}),
+ }
+ w.watchers[account.ID] = a
+ go a.run()
+}
+
+// StopAll stops all IDLE watchers.
+func (w *IdleWatcher) StopAll() {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+
+ for id, a := range w.watchers {
+ close(a.stop)
+ <-a.done
+ delete(w.watchers, id)
+ }
+}
+
+func (a *accountIdle) run() {
+ defer close(a.done)
+
+ initialBackoff := 5 * time.Second
+ maxBackoff := 2 * time.Minute
+ backoff := initialBackoff
+
+ for {
+ start := time.Now()
+ err := a.idleOnce()
+ if err == nil {
+ // Clean exit (stop was closed)
+ return
+ }
+
+ // Reset backoff if we had a successful IDLE session (ran for
+ // longer than the current backoff period without error).
+ if time.Since(start) > backoff {
+ backoff = initialBackoff
+ }
+
+ // Check if we were told to stop
+ select {
+ case <-a.stop:
+ return
+ default:
+ }
+
+ log.Printf("IDLE error for account %s: %v (reconnecting in %v)", a.account.ID, err, backoff)
+
+ // Wait with backoff before reconnecting
+ select {
+ case <-a.stop:
+ return
+ case <-time.After(backoff):
+ }
+
+ backoff *= 2
+ if backoff > maxBackoff {
+ backoff = maxBackoff
+ }
+ }
+}
+
+// idleOnce connects, selects the mailbox, and runs IDLE until an error or stop.
+// Returns nil if stopped cleanly.
+func (a *accountIdle) idleOnce() error {
+ c, err := connect(a.account)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ _ = c.Logout()
+ }()
+
+ // Select the mailbox in read-only mode
+ mbox, err := c.Select(a.folder, true)
+ if err != nil {
+ return err
+ }
+ prevExists := mbox.Messages
+
+ // Set up update channel
+ updates := make(chan client.Update, 32)
+ c.Updates = updates
+
+ // Run IDLE in a goroutine
+ idleDone := make(chan error, 1)
+ idleStop := make(chan struct{})
+ go func() {
+ idleDone <- c.Idle(idleStop, nil)
+ }()
+
+ for {
+ select {
+ case <-a.stop:
+ close(idleStop)
+ <-idleDone
+ return nil
+
+ case update := <-updates:
+ switch u := update.(type) {
+ case *client.MailboxUpdate:
+ newExists := u.Mailbox.Messages
+ if newExists > prevExists {
+ // New mail arrived
+ select {
+ case a.notify <- IdleUpdate{
+ AccountID: a.account.ID,
+ FolderName: a.folder,
+ }:
+ case <-a.stop:
+ close(idleStop)
+ <-idleDone
+ return nil
+ }
+ }
+ prevExists = newExists
+ }
+
+ case err := <-idleDone:
+ return err
+ }
+ }
+}
@@ -73,12 +73,18 @@ type mainModel struct {
width int
height int
err error
+ // IMAP IDLE
+ idleWatcher *fetcher.IdleWatcher
+ idleUpdates chan fetcher.IdleUpdate
}
func newInitialModel(cfg *config.Config) *mainModel {
+ idleUpdates := make(chan fetcher.IdleUpdate, 16)
initialModel := &mainModel{
emailsByAcct: make(map[string][]fetcher.Email),
folderEmails: make(map[string][]fetcher.Email),
+ idleUpdates: idleUpdates,
+ idleWatcher: fetcher.NewIdleWatcher(idleUpdates),
}
if cfg == nil || !cfg.HasAccounts() {
@@ -121,6 +127,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyPressMsg:
if msg.String() == "ctrl+c" {
+ m.idleWatcher.StopAll()
return m, tea.Quit
}
if msg.String() == "esc" {
@@ -128,6 +135,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case *tui.FilePicker:
return m, func() tea.Msg { return tui.CancelFilePickerMsg{} }
case *tui.FolderInbox, *tui.Inbox, *tui.Login:
+ m.idleWatcher.StopAll()
m.current = tui.NewChoice()
m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
return m, m.current.Init()
@@ -269,11 +277,16 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
m.current = m.folderInbox
m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
+ // Start IDLE watchers for all accounts on INBOX
+ for i := range m.config.Accounts {
+ m.idleWatcher.Watch(&m.config.Accounts[i], "INBOX")
+ }
// Fetch folders and INBOX emails in parallel (background refresh)
return m, tea.Batch(
m.current.Init(),
fetchFoldersCmd(m.config),
fetchFolderEmailsCmd(m.config, "INBOX"),
+ listenForIdleUpdates(m.idleUpdates),
)
case tui.FoldersFetchedMsg:
@@ -299,6 +312,10 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.config == nil {
return m, nil
}
+ // Update IDLE watchers to monitor the new folder
+ for i := range m.config.Accounts {
+ m.idleWatcher.Watch(&m.config.Accounts[i], msg.FolderName)
+ }
if m.plugins != nil {
m.plugins.CallFolderHook(plugin.HookFolderChanged, msg.FolderName)
m.syncPluginStatus()
@@ -448,6 +465,17 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, fetchFolderEmailsCmd(m.config, m.folderInbox.GetCurrentFolder())
+ case tui.IdleNewMailMsg:
+ // IDLE detected new mail — refetch the folder if we're viewing it
+ if m.folderInbox != nil && m.folderInbox.GetCurrentFolder() == msg.FolderName {
+ return m, tea.Batch(
+ fetchFolderEmailsCmd(m.config, msg.FolderName),
+ listenForIdleUpdates(m.idleUpdates),
+ )
+ }
+ // Re-subscribe even if not viewing the affected folder
+ return m, listenForIdleUpdates(m.idleUpdates)
+
case tui.RequestRefreshMsg:
// Folder-based refresh: clear folder cache and refetch
if msg.FolderName != "" && m.config != nil {
@@ -1531,6 +1559,22 @@ func archiveEmailCmd(account *config.Account, uid uint32, accountID string, mail
}
}
+// --- IDLE command ---
+
+// listenForIdleUpdates blocks until an IDLE update arrives, then returns it as a tea.Msg.
+func listenForIdleUpdates(ch <-chan fetcher.IdleUpdate) tea.Cmd {
+ return func() tea.Msg {
+ update, ok := <-ch
+ if !ok {
+ return nil
+ }
+ return tui.IdleNewMailMsg{
+ AccountID: update.AccountID,
+ FolderName: update.FolderName,
+ }
+ }
+}
+
// --- Folder-based command functions ---
func fetchFoldersCmd(cfg *config.Config) tea.Cmd {