feat: IMAP IDLE (#410)

Drew Smirnoff created

## What?

<!-- Describe what this PR changes. Keep it concise — what code was
added, removed, or modified? -->

Adds IMAP IDLE (RFC 2177) support so new emails appear in the inbox
automatically without manual refresh.

## Why?

<!-- Explain the motivation behind this change. What problem does it
solve, or what addition does it enable? Link related issues if
applicable. -->

Closes #396

---------

Signed-off-by: drew <me@andrinoff.com>

Change summary

fetcher/idle.go | 181 +++++++++++++++++++++++++++++++++++++++++++++++++++
main.go         |  44 ++++++++++++
tui/messages.go |   8 ++
3 files changed, 233 insertions(+)

Detailed changes

fetcher/idle.go 🔗

@@ -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
+		}
+	}
+}

main.go 🔗

@@ -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 {

tui/messages.go 🔗

@@ -391,6 +391,14 @@ type FetchFolderMoreEmailsMsg struct {
 	Limit      uint32
 }
 
+// --- IDLE Messages ---
+
+// IdleNewMailMsg signals that IMAP IDLE detected new mail for an account/folder.
+type IdleNewMailMsg struct {
+	AccountID  string
+	FolderName string
+}
+
 // --- Plugin Messages ---
 
 // PluginNotifyMsg signals that a plugin wants to show a notification.