main.go

   1package main
   2
   3import (
   4	"archive/tar"
   5	"archive/zip"
   6	"compress/gzip"
   7	"encoding/base64"
   8	"encoding/json"
   9	"fmt"
  10	"io"
  11	"log"
  12	"net/http"
  13	"os"
  14	"os/exec"
  15	"path/filepath"
  16	"regexp"
  17	"runtime"
  18	"strings"
  19	"sync"
  20	"time"
  21
  22	tea "charm.land/bubbletea/v2"
  23	"github.com/floatpane/matcha/clib"
  24	"github.com/floatpane/matcha/config"
  25	"github.com/floatpane/matcha/fetcher"
  26	"github.com/floatpane/matcha/notify"
  27	"github.com/floatpane/matcha/plugin"
  28	"github.com/floatpane/matcha/sender"
  29	"github.com/floatpane/matcha/theme"
  30	"github.com/floatpane/matcha/tui"
  31	"github.com/google/uuid"
  32)
  33
  34const (
  35	initialEmailLimit = 50
  36	paginationLimit   = 50
  37	maxCacheEmails    = 100
  38)
  39
  40// Version variables are injected by the build (GoReleaser ldflags).
  41// They default to "dev" when not set by the build system.
  42var (
  43	version = "dev"
  44	commit  = ""
  45	date    = ""
  46)
  47
  48// UpdateAvailableMsg is sent into the TUI when a newer release is detected.
  49type UpdateAvailableMsg struct {
  50	Latest  string
  51	Current string
  52}
  53
  54// internal struct for parsing GitHub release JSON.
  55type githubRelease struct {
  56	TagName string `json:"tag_name"`
  57	Assets  []struct {
  58		Name               string `json:"name"`
  59		BrowserDownloadURL string `json:"browser_download_url"`
  60	} `json:"assets"`
  61}
  62
  63type mainModel struct {
  64	current       tea.Model
  65	previousModel tea.Model
  66	config        *config.Config
  67	plugins       *plugin.Manager
  68	// Folder-based email storage
  69	folderEmails map[string][]fetcher.Email // key: folderName
  70	folderInbox  *tui.FolderInbox
  71	// Legacy fields kept for email actions
  72	emails       []fetcher.Email
  73	emailsByAcct map[string][]fetcher.Email
  74	width        int
  75	height       int
  76	err          error
  77	// IMAP IDLE
  78	idleWatcher *fetcher.IdleWatcher
  79	idleUpdates chan fetcher.IdleUpdate
  80}
  81
  82func newInitialModel(cfg *config.Config) *mainModel {
  83	idleUpdates := make(chan fetcher.IdleUpdate, 16)
  84	initialModel := &mainModel{
  85		emailsByAcct: make(map[string][]fetcher.Email),
  86		folderEmails: make(map[string][]fetcher.Email),
  87		idleUpdates:  idleUpdates,
  88		idleWatcher:  fetcher.NewIdleWatcher(idleUpdates),
  89	}
  90
  91	if cfg == nil || !cfg.HasAccounts() {
  92		hideTips := false
  93		if cfg != nil {
  94			hideTips = cfg.HideTips
  95		}
  96		initialModel.current = tui.NewLogin(hideTips)
  97	} else {
  98		initialModel.current = tui.NewChoice()
  99		initialModel.config = cfg
 100	}
 101	return initialModel
 102}
 103
 104func (m *mainModel) Init() tea.Cmd {
 105	return tea.Batch(m.current.Init(), checkForUpdatesCmd())
 106}
 107
 108func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 109	var cmd tea.Cmd
 110	var cmds []tea.Cmd
 111
 112	m.current, cmd = m.current.Update(msg)
 113	cmds = append(cmds, cmd)
 114
 115	// Fire composer_updated hook on key presses when the composer is active
 116	if _, isKey := msg.(tea.KeyPressMsg); isKey {
 117		if composer, ok := m.current.(*tui.Composer); ok && m.plugins != nil {
 118			m.plugins.CallComposerHook(plugin.HookComposerUpdated, composer.GetBody(), composer.GetSubject(), composer.GetTo())
 119			m.syncPluginStatus()
 120		}
 121	}
 122
 123	switch msg := msg.(type) {
 124	case tea.WindowSizeMsg:
 125		m.width = msg.Width
 126		m.height = msg.Height
 127		return m, nil
 128
 129	case tea.KeyPressMsg:
 130		if msg.String() == "ctrl+c" {
 131			m.idleWatcher.StopAll()
 132			return m, tea.Quit
 133		}
 134		if msg.String() == "esc" {
 135			switch m.current.(type) {
 136			case *tui.FilePicker:
 137				return m, func() tea.Msg { return tui.CancelFilePickerMsg{} }
 138			case *tui.FolderInbox, *tui.Inbox, *tui.Login:
 139				m.idleWatcher.StopAll()
 140				m.current = tui.NewChoice()
 141				m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 142				return m, m.current.Init()
 143			}
 144		}
 145
 146	case tui.BackToInboxMsg:
 147		if m.folderInbox != nil {
 148			m.current = m.folderInbox
 149		} else {
 150			m.current = tui.NewChoice()
 151			m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 152		}
 153		return m, nil
 154
 155	case tui.BackToMailboxMsg:
 156		// Ensure kitty graphics are cleared when leaving email view
 157		tui.ClearKittyGraphics()
 158		if m.folderInbox != nil {
 159			m.current = m.folderInbox
 160			return m, nil
 161		}
 162		m.current = tui.NewChoice()
 163		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 164		return m, nil
 165
 166	case tui.DiscardDraftMsg:
 167		// Save draft to disk
 168		if msg.ComposerState != nil {
 169			draft := msg.ComposerState.ToDraft()
 170
 171			if err := config.SaveDraft(draft); err != nil {
 172				log.Printf("Error saving draft: %v", err)
 173			}
 174
 175		}
 176		m.current = tui.NewChoice()
 177		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 178		return m, m.current.Init()
 179
 180	case tui.OAuth2CompleteMsg:
 181		if msg.Err != nil {
 182			log.Printf("OAuth2 authorization failed: %v", msg.Err)
 183		}
 184		// After OAuth2 flow, go to the choice menu so user can proceed
 185		m.current = tui.NewChoice()
 186		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 187		return m, m.current.Init()
 188
 189	case tui.Credentials:
 190		// Add new account or update existing
 191		account := config.Account{
 192			ID:              uuid.New().String(),
 193			Name:            msg.Name,
 194			Email:           msg.Host, // login/email used for authentication comes from Host field in the form
 195			Password:        msg.Password,
 196			ServiceProvider: msg.Provider,
 197			FetchEmail:      msg.FetchEmail,
 198			AuthMethod:      msg.AuthMethod,
 199		}
 200
 201		if msg.Provider == "custom" {
 202			account.IMAPServer = msg.IMAPServer
 203			account.IMAPPort = msg.IMAPPort
 204			account.SMTPServer = msg.SMTPServer
 205			account.SMTPPort = msg.SMTPPort
 206		}
 207
 208		// Ensure FetchEmail defaults to the login Email (Host) if not explicitly set
 209		if account.FetchEmail == "" && account.Email != "" {
 210			account.FetchEmail = account.Email
 211		}
 212
 213		if m.config == nil {
 214			m.config = &config.Config{}
 215		}
 216
 217		// Check if we're editing an existing account
 218		isEdit := false
 219		if login, ok := m.current.(*tui.Login); ok && login.IsEditMode() {
 220			isEdit = true
 221			// Find and update the existing account, preserving S/MIME settings
 222			existingID := login.GetAccountID()
 223			for i, acc := range m.config.Accounts {
 224				if acc.ID == existingID {
 225					account.ID = existingID
 226					account.SMIMECert = acc.SMIMECert
 227					account.SMIMEKey = acc.SMIMEKey
 228					account.SMIMESignByDefault = acc.SMIMESignByDefault
 229					if account.Password == "" {
 230						account.Password = acc.Password
 231					}
 232					m.config.Accounts[i] = account
 233					break
 234				}
 235			}
 236		} else {
 237			m.config.AddAccount(account)
 238		}
 239
 240		if err := config.SaveConfig(m.config); err != nil {
 241			log.Printf("could not save config: %v", err)
 242			return m, tea.Quit
 243		}
 244
 245		// If OAuth2, launch the authorization flow after saving the account
 246		if account.IsOAuth2() {
 247			email := account.Email
 248			return m, func() tea.Msg {
 249				err := config.RunOAuth2Flow(email, "", "")
 250				return tui.OAuth2CompleteMsg{Email: email, Err: err}
 251			}
 252		}
 253
 254		if isEdit {
 255			m.current = tui.NewSettings(m.config)
 256		} else {
 257			m.current = tui.NewChoice()
 258		}
 259		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 260		return m, m.current.Init()
 261
 262	case tui.GoToInboxMsg:
 263		if m.config == nil || !m.config.HasAccounts() {
 264			hideTips := false
 265			if m.config != nil {
 266				hideTips = m.config.HideTips
 267			}
 268			m.current = tui.NewLogin(hideTips)
 269			return m, m.current.Init()
 270		}
 271		// Load cached folders from all accounts, merge unique names
 272		seen := make(map[string]bool)
 273		var cachedFolders []string
 274		for _, acc := range m.config.Accounts {
 275			for _, f := range config.GetCachedFolders(acc.ID) {
 276				if !seen[f] {
 277					seen[f] = true
 278					cachedFolders = append(cachedFolders, f)
 279				}
 280			}
 281		}
 282		if len(cachedFolders) == 0 {
 283			cachedFolders = []string{"INBOX"}
 284		}
 285		m.folderInbox = tui.NewFolderInbox(cachedFolders, m.config.Accounts)
 286		// Use cached INBOX emails for instant display (memory first, then disk)
 287		if cached, ok := m.folderEmails["INBOX"]; ok && len(cached) > 0 {
 288			m.folderInbox.SetEmails(cached, m.config.Accounts)
 289		} else if diskCached := loadFolderEmailsFromCache("INBOX"); len(diskCached) > 0 {
 290			m.folderEmails["INBOX"] = diskCached
 291			m.emails = diskCached
 292			m.emailsByAcct = make(map[string][]fetcher.Email)
 293			for _, email := range diskCached {
 294				m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
 295			}
 296			m.folderInbox.SetEmails(diskCached, m.config.Accounts)
 297		}
 298		m.current = m.folderInbox
 299		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 300		// Start IDLE watchers for all accounts on INBOX
 301		for i := range m.config.Accounts {
 302			m.idleWatcher.Watch(&m.config.Accounts[i], "INBOX")
 303		}
 304		// Fetch folders and INBOX emails in parallel (background refresh)
 305		return m, tea.Batch(
 306			m.current.Init(),
 307			fetchFoldersCmd(m.config),
 308			fetchFolderEmailsCmd(m.config, "INBOX"),
 309			listenForIdleUpdates(m.idleUpdates),
 310		)
 311
 312	case tui.FoldersFetchedMsg:
 313		if m.folderInbox == nil {
 314			return m, nil
 315		}
 316		var folderNames []string
 317		for _, f := range msg.MergedFolders {
 318			folderNames = append(folderNames, f.Name)
 319		}
 320		m.folderInbox.SetFolders(folderNames)
 321		// Cache folder lists per account
 322		for accID, folders := range msg.FoldersByAccount {
 323			var names []string
 324			for _, f := range folders {
 325				names = append(names, f.Name)
 326			}
 327			go config.SaveAccountFolders(accID, names)
 328		}
 329		return m, nil
 330
 331	case tui.SwitchFolderMsg:
 332		if m.config == nil {
 333			return m, nil
 334		}
 335		// Update IDLE watchers to monitor the new folder
 336		for i := range m.config.Accounts {
 337			m.idleWatcher.Watch(&m.config.Accounts[i], msg.FolderName)
 338		}
 339		if m.plugins != nil {
 340			m.plugins.CallFolderHook(plugin.HookFolderChanged, msg.FolderName)
 341			m.syncPluginStatus()
 342		}
 343		// Use in-memory cache if available
 344		if cached, ok := m.folderEmails[msg.FolderName]; ok {
 345			m.emails = cached
 346			m.emailsByAcct = make(map[string][]fetcher.Email)
 347			for _, email := range cached {
 348				m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
 349			}
 350			if m.folderInbox != nil {
 351				m.folderInbox.SetEmails(cached, m.config.Accounts)
 352				m.folderInbox.GetInbox().SetFolderName(msg.FolderName)
 353				m.folderInbox.SetLoadingEmails(false)
 354			}
 355			return m, m.pluginNotifyCmd()
 356		}
 357		// Fall back to disk cache for instant display, then fetch fresh in background
 358		if diskCached := loadFolderEmailsFromCache(msg.FolderName); len(diskCached) > 0 {
 359			m.folderEmails[msg.FolderName] = diskCached
 360			m.emails = diskCached
 361			m.emailsByAcct = make(map[string][]fetcher.Email)
 362			for _, email := range diskCached {
 363				m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
 364			}
 365			if m.folderInbox != nil {
 366				m.folderInbox.SetEmails(diskCached, m.config.Accounts)
 367				m.folderInbox.GetInbox().SetFolderName(msg.FolderName)
 368				m.folderInbox.SetLoadingEmails(false)
 369			}
 370			// Still fetch fresh emails in background
 371			return m, tea.Batch(fetchFolderEmailsCmd(m.config, msg.FolderName), m.pluginNotifyCmd())
 372		}
 373		if m.folderInbox != nil {
 374			m.folderInbox.SetLoadingEmails(true)
 375		}
 376		return m, tea.Batch(fetchFolderEmailsCmd(m.config, msg.FolderName), m.pluginNotifyCmd())
 377
 378	case tui.PluginNotifyMsg:
 379		m.previousModel = m.current
 380		m.current = tui.NewStatus(msg.Message)
 381		dur := time.Duration(msg.Duration * float64(time.Second))
 382		if dur <= 0 {
 383			dur = 2 * time.Second
 384		}
 385		return m, tea.Tick(dur, func(t time.Time) tea.Msg {
 386			return tui.RestoreViewMsg{}
 387		})
 388
 389	case tui.FolderEmailsFetchedMsg:
 390		if m.folderInbox == nil {
 391			return m, nil
 392		}
 393		// Call plugin hooks for received emails
 394		if m.plugins != nil {
 395			for _, email := range msg.Emails {
 396				t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, msg.FolderName)
 397				m.plugins.CallHook(plugin.HookEmailReceived, t)
 398			}
 399		}
 400		// Always cache in memory and to disk
 401		m.folderEmails[msg.FolderName] = msg.Emails
 402		go saveFolderEmailsToCache(msg.FolderName, msg.Emails)
 403		// Only update the view if the user is still on this folder
 404		if m.folderInbox.GetCurrentFolder() != msg.FolderName {
 405			return m, nil
 406		}
 407		m.emails = msg.Emails
 408		m.emailsByAcct = make(map[string][]fetcher.Email)
 409		for _, email := range msg.Emails {
 410			m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
 411		}
 412		m.folderInbox.SetEmails(msg.Emails, m.config.Accounts)
 413		m.folderInbox.GetInbox().SetFolderName(msg.FolderName)
 414		m.folderInbox.SetLoadingEmails(false)
 415		m.syncPluginStatus()
 416		return m, m.pluginNotifyCmd()
 417
 418	case tui.FetchFolderMoreEmailsMsg:
 419		if msg.AccountID == "" || m.config == nil {
 420			return m, nil
 421		}
 422		account := m.config.GetAccountByID(msg.AccountID)
 423		if account == nil {
 424			return m, nil
 425		}
 426		limit := uint32(paginationLimit)
 427		if msg.Limit > 0 {
 428			limit = msg.Limit
 429		}
 430		return m, tea.Batch(
 431			func() tea.Msg { return tui.FetchingMoreEmailsMsg{} },
 432			fetchFolderEmailsPaginatedCmd(account, msg.FolderName, limit, msg.Offset),
 433		)
 434
 435	case tui.FolderEmailsAppendedMsg:
 436		// Ignore stale appends for a folder the user has moved away from
 437		if m.folderInbox == nil || m.folderInbox.GetCurrentFolder() != msg.FolderName {
 438			return m, nil
 439		}
 440		m.folderInbox.Update(msg)
 441		// Update local stores and per-folder cache
 442		for _, email := range msg.Emails {
 443			m.emails = append(m.emails, email)
 444			m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
 445		}
 446		m.folderEmails[msg.FolderName] = append(m.folderEmails[msg.FolderName], msg.Emails...)
 447		go saveFolderEmailsToCache(msg.FolderName, m.folderEmails[msg.FolderName])
 448		return m, nil
 449
 450	case tui.MoveEmailToFolderMsg:
 451		if m.config == nil {
 452			return m, nil
 453		}
 454		account := m.config.GetAccountByID(msg.AccountID)
 455		if account == nil {
 456			return m, nil
 457		}
 458		m.previousModel = m.current
 459		m.current = tui.NewStatus("Moving email...")
 460		return m, tea.Batch(m.current.Init(), moveEmailToFolderCmd(account, msg.UID, msg.AccountID, msg.SourceFolder, msg.DestFolder))
 461
 462	case tui.EmailMovedMsg:
 463		if msg.Err != nil {
 464			log.Printf("Move failed: %v", msg.Err)
 465			if m.folderInbox != nil {
 466				m.previousModel = m.folderInbox
 467			}
 468			m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
 469			return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
 470				return tui.RestoreViewMsg{}
 471			})
 472		}
 473		// Remove email from current view
 474		if m.folderInbox != nil {
 475			m.folderInbox.RemoveEmail(msg.UID, msg.AccountID)
 476			m.current = m.folderInbox
 477		}
 478		return m, nil
 479
 480	case tui.CachedEmailsLoadedMsg:
 481		// Cache is no longer used for the folder-based inbox flow
 482		// This handler is kept for backwards compatibility but simply fetches normally
 483		if m.folderInbox == nil {
 484			return m, nil
 485		}
 486		return m, fetchFolderEmailsCmd(m.config, m.folderInbox.GetCurrentFolder())
 487
 488	case tui.IdleNewMailMsg:
 489		// Send desktop notification for new mail (if enabled)
 490		if m.config == nil || !m.config.DisableNotifications {
 491			accountName := msg.AccountID
 492			if m.config != nil {
 493				if acc := m.config.GetAccountByID(msg.AccountID); acc != nil {
 494					accountName = acc.Email
 495				}
 496			}
 497			go notify.Send("Matcha", fmt.Sprintf("New mail in %s (%s)", msg.FolderName, accountName))
 498		}
 499
 500		// IDLE detected new mail — refetch the folder if we're viewing it
 501		if m.folderInbox != nil && m.folderInbox.GetCurrentFolder() == msg.FolderName {
 502			return m, tea.Batch(
 503				fetchFolderEmailsCmd(m.config, msg.FolderName),
 504				listenForIdleUpdates(m.idleUpdates),
 505			)
 506		}
 507		// Re-subscribe even if not viewing the affected folder
 508		return m, listenForIdleUpdates(m.idleUpdates)
 509
 510	case tui.RequestRefreshMsg:
 511		// Folder-based refresh: clear folder cache and refetch
 512		if msg.FolderName != "" && m.config != nil {
 513			delete(m.folderEmails, msg.FolderName)
 514			if m.folderInbox != nil {
 515				m.folderInbox.SetRefreshing(true)
 516			}
 517			return m, fetchFolderEmailsCmd(m.config, msg.FolderName)
 518		}
 519		return m, tea.Batch(
 520			func() tea.Msg { return tui.RefreshingEmailsMsg{Mailbox: msg.Mailbox} },
 521			refreshEmails(m.config, msg.Mailbox, msg.Counts),
 522		)
 523
 524	case tui.EmailsRefreshedMsg:
 525		// Merge refreshed emails with any paginated emails already loaded.
 526		for accID, refreshed := range msg.EmailsByAccount {
 527			refreshedUIDs := make(map[uint32]struct{}, len(refreshed))
 528			for _, e := range refreshed {
 529				refreshedUIDs[e.UID] = struct{}{}
 530			}
 531			if existing, ok := m.emailsByAcct[accID]; ok {
 532				for _, e := range existing {
 533					if _, found := refreshedUIDs[e.UID]; !found {
 534						refreshed = append(refreshed, e)
 535					}
 536				}
 537			}
 538			m.emailsByAcct[accID] = refreshed
 539		}
 540		m.emails = flattenAndSort(m.emailsByAcct)
 541
 542		// Update folder inbox if it exists
 543		if m.folderInbox != nil {
 544			m.folderInbox.SetEmails(m.emails, m.config.Accounts)
 545			m.folderInbox.GetInbox().Update(msg)
 546		}
 547		return m, nil
 548
 549	case tui.AllEmailsFetchedMsg:
 550		m.emailsByAcct = msg.EmailsByAccount
 551		m.emails = flattenAndSort(msg.EmailsByAccount)
 552
 553		if m.folderInbox != nil {
 554			m.folderInbox.SetEmails(m.emails, m.config.Accounts)
 555			m.folderInbox.SetLoadingEmails(false)
 556		}
 557		return m, nil
 558
 559	case tui.EmailsFetchedMsg:
 560		if m.emailsByAcct == nil {
 561			m.emailsByAcct = make(map[string][]fetcher.Email)
 562		}
 563		m.emailsByAcct[msg.AccountID] = msg.Emails
 564		m.emails = flattenAndSort(m.emailsByAcct)
 565		if m.folderInbox != nil {
 566			m.folderInbox.SetEmails(m.emails, m.config.Accounts)
 567		}
 568		return m, nil
 569
 570	case tui.FetchMoreEmailsMsg:
 571		if msg.AccountID == "" {
 572			return m, nil
 573		}
 574		account := m.config.GetAccountByID(msg.AccountID)
 575		if account == nil {
 576			return m, nil
 577		}
 578		limit := uint32(paginationLimit)
 579		if msg.Limit > 0 {
 580			limit = msg.Limit
 581		}
 582		folderName := "INBOX"
 583		if m.folderInbox != nil {
 584			folderName = m.folderInbox.GetCurrentFolder()
 585		}
 586		return m, tea.Batch(
 587			func() tea.Msg { return tui.FetchingMoreEmailsMsg{} },
 588			fetchFolderEmailsPaginatedCmd(account, folderName, limit, msg.Offset),
 589		)
 590
 591	case tui.EmailsAppendedMsg:
 592		if m.emailsByAcct == nil {
 593			m.emailsByAcct = make(map[string][]fetcher.Email)
 594		}
 595		unique := filterUnique(m.emailsByAcct[msg.AccountID], msg.Emails)
 596		m.emailsByAcct[msg.AccountID] = append(m.emailsByAcct[msg.AccountID], unique...)
 597		m.emails = append(m.emails, unique...)
 598		return m, nil
 599
 600	case tui.GoToSendMsg:
 601		hideTips := false
 602		if m.config != nil {
 603			hideTips = m.config.HideTips
 604		}
 605		if m.config != nil && len(m.config.Accounts) > 0 {
 606			firstAccount := m.config.GetFirstAccount()
 607			composer := tui.NewComposerWithAccounts(m.config.Accounts, firstAccount.ID, msg.To, msg.Subject, msg.Body, hideTips)
 608			m.current = composer
 609		} else {
 610			m.current = tui.NewComposer("", msg.To, msg.Subject, msg.Body, hideTips)
 611		}
 612		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 613		return m, m.current.Init()
 614
 615	case tui.GoToDraftsMsg:
 616		drafts := config.GetAllDrafts()
 617		m.current = tui.NewDrafts(drafts)
 618		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 619		return m, m.current.Init()
 620
 621	case tui.OpenDraftMsg:
 622		var accounts []config.Account
 623		hideTips := false
 624		if m.config != nil {
 625			accounts = m.config.Accounts
 626			hideTips = m.config.HideTips
 627		}
 628		composer := tui.NewComposerFromDraft(msg.Draft, accounts, hideTips)
 629		m.current = composer
 630		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 631		return m, m.current.Init()
 632
 633	case tui.DeleteSavedDraftMsg:
 634		go func() {
 635			if err := config.DeleteDraft(msg.DraftID); err != nil {
 636				log.Printf("Error deleting draft: %v", err)
 637			}
 638		}()
 639		// Send message back to drafts view
 640		m.current, cmd = m.current.Update(tui.DraftDeletedMsg{DraftID: msg.DraftID})
 641		return m, cmd
 642
 643	case tui.GoToSettingsMsg:
 644		m.current = tui.NewSettings(m.config)
 645		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 646		return m, m.current.Init()
 647
 648	case tui.GoToAddAccountMsg:
 649		hideTips := false
 650		if m.config != nil {
 651			hideTips = m.config.HideTips
 652		}
 653		m.current = tui.NewLogin(hideTips)
 654		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 655		return m, m.current.Init()
 656
 657	case tui.GoToAddMailingListMsg:
 658		m.current = tui.NewMailingListEditor()
 659		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 660		return m, m.current.Init()
 661
 662	case tui.GoToEditAccountMsg:
 663		hideTips := false
 664		if m.config != nil {
 665			hideTips = m.config.HideTips
 666		}
 667		login := tui.NewLogin(hideTips)
 668		login.SetEditMode(msg.AccountID, msg.Provider, msg.Name, msg.Email, msg.FetchEmail, msg.IMAPServer, msg.IMAPPort, msg.SMTPServer, msg.SMTPPort)
 669		m.current = login
 670		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 671		return m, m.current.Init()
 672
 673	case tui.GoToEditMailingListMsg:
 674		editor := tui.NewMailingListEditor()
 675		editor.SetEditMode(msg.Index, msg.Name, msg.Addresses)
 676		m.current = editor
 677		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 678		return m, m.current.Init()
 679
 680	case tui.SaveMailingListMsg:
 681		if m.config != nil {
 682			var addrs []string
 683			for _, part := range strings.Split(msg.Addresses, ",") {
 684				if trimmed := strings.TrimSpace(part); trimmed != "" {
 685					addrs = append(addrs, trimmed)
 686				}
 687			}
 688			if msg.EditIndex >= 0 && msg.EditIndex < len(m.config.MailingLists) {
 689				m.config.MailingLists[msg.EditIndex] = config.MailingList{
 690					Name:      msg.Name,
 691					Addresses: addrs,
 692				}
 693			} else {
 694				m.config.MailingLists = append(m.config.MailingLists, config.MailingList{
 695					Name:      msg.Name,
 696					Addresses: addrs,
 697				})
 698			}
 699			if err := config.SaveConfig(m.config); err != nil {
 700				log.Printf("could not save config: %v", err)
 701			}
 702		}
 703		// Return to settings
 704		m.current = tui.NewSettings(m.config)
 705		// Try to navigate to the mailing list view internally if possible, but NewSettings will go to SettingsMain by default.
 706		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 707		return m, m.current.Init()
 708
 709	case tui.GoToSignatureEditorMsg:
 710		m.current = tui.NewSignatureEditor()
 711		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 712		return m, m.current.Init()
 713
 714	case tui.GoToChoiceMenuMsg:
 715		m.current = tui.NewChoice()
 716		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 717		return m, m.current.Init()
 718
 719	case tui.DeleteAccountMsg:
 720		if m.config != nil {
 721			m.config.RemoveAccount(msg.AccountID)
 722			if err := config.SaveConfig(m.config); err != nil {
 723				log.Printf("could not save config: %v", err)
 724			}
 725			// Remove emails for this account
 726			delete(m.emailsByAcct, msg.AccountID)
 727
 728			// Rebuild all emails
 729			var allEmails []fetcher.Email
 730			for _, emails := range m.emailsByAcct {
 731				allEmails = append(allEmails, emails...)
 732			}
 733			m.emails = allEmails
 734
 735			// Go back to settings
 736			m.current = tui.NewSettings(m.config)
 737			m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 738		}
 739		return m, m.current.Init()
 740
 741	case tui.ViewEmailMsg:
 742		email := m.getEmailByUIDAndAccount(msg.UID, msg.AccountID, msg.Mailbox)
 743		if email == nil {
 744			return m, nil
 745		}
 746		folderName := "INBOX"
 747		if m.folderInbox != nil {
 748			folderName = m.folderInbox.GetCurrentFolder()
 749		}
 750		if m.plugins != nil {
 751			t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, folderName)
 752			m.plugins.CallHook(plugin.HookEmailViewed, t)
 753		}
 754		m.current = tui.NewStatus("Fetching email content...")
 755		return m, tea.Batch(m.current.Init(), fetchFolderEmailBodyCmd(m.config, msg.UID, msg.AccountID, folderName, msg.Mailbox), m.pluginNotifyCmd())
 756
 757	case tui.EmailBodyFetchedMsg:
 758		if msg.Err != nil {
 759			log.Printf("could not fetch email body: %v", msg.Err)
 760			if m.folderInbox != nil {
 761				m.current = m.folderInbox
 762			}
 763			return m, nil
 764		}
 765
 766		// Update the email in our stores
 767		m.updateEmailBodyByUID(msg.UID, msg.AccountID, msg.Mailbox, msg.Body, msg.Attachments)
 768
 769		email := m.getEmailByUIDAndAccount(msg.UID, msg.AccountID, msg.Mailbox)
 770		if email == nil {
 771			if m.folderInbox != nil {
 772				m.current = m.folderInbox
 773			}
 774			return m, nil
 775		}
 776
 777		// Mark as read in UI immediately and on the server
 778		var markReadCmd tea.Cmd
 779		if !email.IsRead {
 780			m.markEmailAsReadInStores(msg.UID, msg.AccountID)
 781
 782			folderName := "INBOX"
 783			if m.folderInbox != nil {
 784				folderName = m.folderInbox.GetCurrentFolder()
 785			}
 786			account := m.config.GetAccountByID(msg.AccountID)
 787			if account != nil {
 788				markReadCmd = markEmailAsReadCmd(account, msg.UID, msg.AccountID, folderName)
 789			}
 790		}
 791
 792		// Find the index for the email view (used for display purposes)
 793		emailIndex := m.getEmailIndex(msg.UID, msg.AccountID, msg.Mailbox)
 794		emailView := tui.NewEmailView(*email, emailIndex, m.width, m.height, msg.Mailbox, m.config.DisableImages)
 795		m.current = emailView
 796		m.syncPluginStatus()
 797		cmds := []tea.Cmd{m.current.Init()}
 798		if markReadCmd != nil {
 799			cmds = append(cmds, markReadCmd)
 800		}
 801		return m, tea.Batch(cmds...)
 802
 803	case tui.ReplyToEmailMsg:
 804		to := msg.Email.From
 805		subject := msg.Email.Subject
 806		normalizedSubject := strings.ToLower(strings.TrimSpace(subject))
 807		if !strings.HasPrefix(normalizedSubject, "re:") {
 808			subject = "Re: " + subject
 809		}
 810		quotedText := fmt.Sprintf("\n\nOn %s, %s wrote:\n> %s", msg.Email.Date.Format("Jan 2, 2006 at 3:04 PM"), msg.Email.From, strings.ReplaceAll(msg.Email.Body, "\n", "\n> "))
 811
 812		var composer *tui.Composer
 813		hideTips := false
 814		if m.config != nil {
 815			hideTips = m.config.HideTips
 816		}
 817		if m.config != nil && len(m.config.Accounts) > 0 {
 818			// Use the account that received the email
 819			accountID := msg.Email.AccountID
 820			if accountID == "" {
 821				accountID = m.config.GetFirstAccount().ID
 822			}
 823			composer = tui.NewComposerWithAccounts(m.config.Accounts, accountID, to, subject, "", hideTips)
 824		} else {
 825			composer = tui.NewComposer("", to, subject, "", hideTips)
 826		}
 827		composer.SetQuotedText(quotedText)
 828
 829		// Set reply headers
 830		inReplyTo := msg.Email.MessageID
 831		references := append(msg.Email.References, msg.Email.MessageID)
 832		composer.SetReplyContext(inReplyTo, references)
 833
 834		m.current = composer
 835		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 836		return m, m.current.Init()
 837
 838	case tui.ForwardEmailMsg:
 839		subject := msg.Email.Subject
 840		if !strings.HasPrefix(strings.ToLower(subject), "fwd:") {
 841			subject = "Fwd: " + subject
 842		}
 843
 844		forwardHeader := fmt.Sprintf("\n\n---------- Forwarded message ----------\nFrom: %s\nDate: %s\nSubject: %s\nTo: %s\n\n",
 845			msg.Email.From,
 846			msg.Email.Date.Format("Mon, Jan 2, 2006 at 3:04 PM"),
 847			msg.Email.Subject,
 848			msg.Email.To,
 849		)
 850
 851		body := forwardHeader + msg.Email.Body
 852
 853		var composer *tui.Composer
 854		hideTips := false
 855		if m.config != nil {
 856			hideTips = m.config.HideTips
 857		}
 858		if m.config != nil && len(m.config.Accounts) > 0 {
 859			// Use the account that received the email
 860			accountID := msg.Email.AccountID
 861			if accountID == "" {
 862				accountID = m.config.GetFirstAccount().ID
 863			}
 864			composer = tui.NewComposerWithAccounts(m.config.Accounts, accountID, "", subject, body, hideTips)
 865		} else {
 866			composer = tui.NewComposer("", "", subject, body, hideTips)
 867		}
 868
 869		m.current = composer
 870		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 871		return m, m.current.Init()
 872
 873	case tui.OpenEditorMsg:
 874		composer, ok := m.current.(*tui.Composer)
 875		if !ok {
 876			return m, nil
 877		}
 878		return m, openExternalEditor(composer.GetBody())
 879
 880	case tui.EditorFinishedMsg:
 881		if msg.Err != nil {
 882			log.Printf("Editor error: %v", msg.Err)
 883			return m, nil
 884		}
 885		if composer, ok := m.current.(*tui.Composer); ok {
 886			composer.SetBody(msg.Body)
 887		}
 888		return m, nil
 889
 890	case tui.GoToFilePickerMsg:
 891		m.previousModel = m.current
 892		wd, _ := os.Getwd()
 893		m.current = tui.NewFilePicker(wd)
 894		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 895		return m, m.current.Init()
 896
 897	case tui.FileSelectedMsg, tui.CancelFilePickerMsg:
 898		if m.previousModel != nil {
 899			m.current = m.previousModel
 900			m.previousModel = nil
 901		}
 902		m.current, cmd = m.current.Update(msg)
 903		cmds = append(cmds, cmd)
 904
 905	case tui.SendEmailMsg:
 906		if m.plugins != nil {
 907			m.plugins.CallSendHook(plugin.HookEmailSendBefore, msg.To, msg.Cc, msg.Subject, msg.AccountID)
 908		}
 909		// Get draft ID before clearing composer (if it's a composer)
 910		var draftID string
 911		if composer, ok := m.current.(*tui.Composer); ok {
 912			draftID = composer.GetDraftID()
 913		}
 914		m.current = tui.NewStatus("Sending email...")
 915
 916		// Get the account to send from
 917		var account *config.Account
 918		if msg.AccountID != "" && m.config != nil {
 919			account = m.config.GetAccountByID(msg.AccountID)
 920		}
 921		if account == nil && m.config != nil {
 922			account = m.config.GetFirstAccount()
 923		}
 924
 925		// Save contact and delete draft in background
 926		go func() {
 927			// Save the recipient as a contact
 928			if msg.To != "" {
 929				recipients := strings.Split(msg.To, ",")
 930				for _, r := range recipients {
 931					r = strings.TrimSpace(r)
 932					if r == "" {
 933						continue
 934					}
 935					name, email := parseEmailAddress(r)
 936					if err := config.AddContact(name, email); err != nil {
 937						log.Printf("Error saving contact: %v", err)
 938					}
 939				}
 940			}
 941			// Delete the draft since email is being sent
 942			if draftID != "" {
 943				if err := config.DeleteDraft(draftID); err != nil {
 944					log.Printf("Error deleting draft after send: %v", err)
 945				}
 946			}
 947		}()
 948
 949		return m, tea.Batch(m.current.Init(), sendEmail(account, msg))
 950
 951	case tui.EmailResultMsg:
 952		if msg.Err != nil {
 953			log.Printf("Failed to send email: %v", msg.Err)
 954			m.previousModel = tui.NewChoice()
 955			m.previousModel, _ = m.previousModel.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 956			m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
 957			return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
 958				return tui.RestoreViewMsg{}
 959			})
 960		}
 961		if m.plugins != nil {
 962			m.plugins.CallHook(plugin.HookEmailSendAfter)
 963		}
 964		m.current = tui.NewChoice()
 965		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 966		return m, m.current.Init()
 967
 968	case tui.DeleteEmailMsg:
 969		tui.ClearKittyGraphics()
 970		m.previousModel = m.current
 971		m.current = tui.NewStatus("Deleting email...")
 972
 973		account := m.config.GetAccountByID(msg.AccountID)
 974		if account == nil {
 975			if m.folderInbox != nil {
 976				m.current = m.folderInbox
 977			}
 978			return m, nil
 979		}
 980
 981		folderName := "INBOX"
 982		if m.folderInbox != nil {
 983			folderName = m.folderInbox.GetCurrentFolder()
 984		}
 985		return m, tea.Batch(m.current.Init(), deleteFolderEmailCmd(account, msg.UID, msg.AccountID, folderName, msg.Mailbox))
 986
 987	case tui.ArchiveEmailMsg:
 988		tui.ClearKittyGraphics()
 989		m.previousModel = m.current
 990		m.current = tui.NewStatus("Archiving email...")
 991
 992		account := m.config.GetAccountByID(msg.AccountID)
 993		if account == nil {
 994			if m.folderInbox != nil {
 995				m.current = m.folderInbox
 996			}
 997			return m, nil
 998		}
 999
1000		folderName := "INBOX"
1001		if m.folderInbox != nil {
1002			folderName = m.folderInbox.GetCurrentFolder()
1003		}
1004		return m, tea.Batch(m.current.Init(), archiveFolderEmailCmd(account, msg.UID, msg.AccountID, folderName, msg.Mailbox))
1005
1006	case tui.EmailMarkedReadMsg:
1007		if msg.Err != nil {
1008			log.Printf("Error marking email as read: %v", msg.Err)
1009		}
1010		return m, nil
1011
1012	case tui.EmailActionDoneMsg:
1013		if msg.Err != nil {
1014			log.Printf("Action failed: %v", msg.Err)
1015			if m.folderInbox != nil {
1016				m.previousModel = m.folderInbox
1017			}
1018			m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
1019			return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1020				return tui.RestoreViewMsg{}
1021			})
1022		}
1023
1024		// Remove email from stores
1025		m.removeEmailFromStores(msg.UID, msg.AccountID)
1026
1027		if m.folderInbox != nil {
1028			m.folderInbox.RemoveEmail(msg.UID, msg.AccountID)
1029			m.current = m.folderInbox
1030			m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1031			return m, m.current.Init()
1032		}
1033		m.current = tui.NewChoice()
1034		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1035		return m, m.current.Init()
1036
1037	case tui.DownloadAttachmentMsg:
1038		m.previousModel = m.current
1039		m.current = tui.NewStatus(fmt.Sprintf("Downloading %s...", msg.Filename))
1040
1041		account := m.config.GetAccountByID(msg.AccountID)
1042		if account == nil {
1043			m.current = m.previousModel
1044			return m, nil
1045		}
1046
1047		email := m.getEmailByIndex(msg.Index, msg.Mailbox)
1048		if email == nil {
1049			m.current = m.previousModel
1050			return m, nil
1051		}
1052
1053		// Find the correct attachment to get encoding
1054		var encoding string
1055		for _, att := range email.Attachments {
1056			if att.PartID == msg.PartID {
1057				encoding = att.Encoding
1058				break
1059			}
1060		}
1061		newMsg := tui.DownloadAttachmentMsg{
1062			Index:     msg.Index,
1063			Filename:  msg.Filename,
1064			PartID:    msg.PartID,
1065			Data:      msg.Data,
1066			AccountID: msg.AccountID,
1067			Encoding:  encoding,
1068			Mailbox:   msg.Mailbox,
1069		}
1070		return m, tea.Batch(m.current.Init(), downloadAttachmentCmd(account, email.UID, newMsg))
1071
1072	case tui.AttachmentDownloadedMsg:
1073		var statusMsg string
1074		if msg.Err != nil {
1075			statusMsg = fmt.Sprintf("Error downloading: %v", msg.Err)
1076		} else {
1077			statusMsg = fmt.Sprintf("Saved to %s", msg.Path)
1078		}
1079		m.current = tui.NewStatus(statusMsg)
1080		return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1081			return tui.RestoreViewMsg{}
1082		})
1083
1084	case tui.RestoreViewMsg:
1085		if m.previousModel != nil {
1086			m.current = m.previousModel
1087			m.previousModel = nil
1088		}
1089		return m, nil
1090	}
1091
1092	if cmd := m.pluginNotifyCmd(); cmd != nil {
1093		cmds = append(cmds, cmd)
1094	}
1095
1096	return m, tea.Batch(cmds...)
1097}
1098
1099func (m *mainModel) View() tea.View {
1100	v := m.current.View()
1101	v.AltScreen = true
1102	return v
1103}
1104
1105func (m *mainModel) getEmailByIndex(index int, mailbox tui.MailboxKind) *fetcher.Email {
1106	if index >= 0 && index < len(m.emails) {
1107		return &m.emails[index]
1108	}
1109	return nil
1110}
1111
1112func (m *mainModel) getEmailByUIDAndAccount(uid uint32, accountID string, mailbox tui.MailboxKind) *fetcher.Email {
1113	for i := range m.emails {
1114		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1115			return &m.emails[i]
1116		}
1117	}
1118	return nil
1119}
1120
1121func (m *mainModel) getEmailIndex(uid uint32, accountID string, mailbox tui.MailboxKind) int {
1122	for i := range m.emails {
1123		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1124			return i
1125		}
1126	}
1127	return -1
1128}
1129
1130func (m *mainModel) updateEmailBodyByUID(uid uint32, accountID string, mailbox tui.MailboxKind, body string, attachments []fetcher.Attachment) {
1131	for i := range m.emails {
1132		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1133			m.emails[i].Body = body
1134			m.emails[i].Attachments = attachments
1135			break
1136		}
1137	}
1138	if emails, ok := m.emailsByAcct[accountID]; ok {
1139		for i := range emails {
1140			if emails[i].UID == uid {
1141				emails[i].Body = body
1142				emails[i].Attachments = attachments
1143				break
1144			}
1145		}
1146	}
1147}
1148
1149func (m *mainModel) markEmailAsReadInStores(uid uint32, accountID string) {
1150	for i := range m.emails {
1151		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1152			m.emails[i].IsRead = true
1153			break
1154		}
1155	}
1156	if emails, ok := m.emailsByAcct[accountID]; ok {
1157		for i := range emails {
1158			if emails[i].UID == uid {
1159				emails[i].IsRead = true
1160				break
1161			}
1162		}
1163	}
1164	// Update folder email cache
1165	for folderName, folderEmails := range m.folderEmails {
1166		for i := range folderEmails {
1167			if folderEmails[i].UID == uid && folderEmails[i].AccountID == accountID {
1168				folderEmails[i].IsRead = true
1169				m.folderEmails[folderName] = folderEmails
1170				go saveFolderEmailsToCache(folderName, folderEmails)
1171				break
1172			}
1173		}
1174	}
1175	// Update the inbox UI
1176	if m.folderInbox != nil {
1177		m.folderInbox.GetInbox().MarkEmailAsRead(uid, accountID)
1178	}
1179}
1180
1181func (m *mainModel) removeEmailFromStores(uid uint32, accountID string) {
1182	var filtered []fetcher.Email
1183	for _, e := range m.emails {
1184		if !(e.UID == uid && e.AccountID == accountID) {
1185			filtered = append(filtered, e)
1186		}
1187	}
1188	m.emails = filtered
1189	if emails, ok := m.emailsByAcct[accountID]; ok {
1190		var filteredAcct []fetcher.Email
1191		for _, e := range emails {
1192			if e.UID != uid {
1193				filteredAcct = append(filteredAcct, e)
1194			}
1195		}
1196		m.emailsByAcct[accountID] = filteredAcct
1197	}
1198}
1199
1200// pluginNotifyCmd checks for a pending plugin notification and returns a command if one exists.
1201func (m *mainModel) pluginNotifyCmd() tea.Cmd {
1202	if m.plugins == nil {
1203		return nil
1204	}
1205	if n, ok := m.plugins.TakePendingNotification(); ok {
1206		return func() tea.Msg {
1207			return tui.PluginNotifyMsg{Message: n.Message, Duration: n.Duration}
1208		}
1209	}
1210	return nil
1211}
1212
1213func (m *mainModel) syncPluginStatus() {
1214	if m.plugins == nil {
1215		return
1216	}
1217	if m.folderInbox != nil {
1218		m.folderInbox.GetInbox().SetPluginStatus(m.plugins.StatusText(plugin.StatusInbox))
1219	}
1220	switch v := m.current.(type) {
1221	case *tui.Composer:
1222		v.SetPluginStatus(m.plugins.StatusText(plugin.StatusComposer))
1223	case *tui.EmailView:
1224		v.SetPluginStatus(m.plugins.StatusText(plugin.StatusEmailView))
1225	}
1226}
1227
1228func flattenAndSort(emailsByAccount map[string][]fetcher.Email) []fetcher.Email {
1229	var allEmails []fetcher.Email
1230	for _, emails := range emailsByAccount {
1231		allEmails = append(allEmails, emails...)
1232	}
1233	for i := 0; i < len(allEmails); i++ {
1234		for j := i + 1; j < len(allEmails); j++ {
1235			if allEmails[j].Date.After(allEmails[i].Date) {
1236				allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
1237			}
1238		}
1239	}
1240	return allEmails
1241}
1242
1243func fetchAllAccountsEmails(cfg *config.Config, mailbox tui.MailboxKind) tea.Cmd {
1244	return func() tea.Msg {
1245		emailsByAccount := make(map[string][]fetcher.Email)
1246		var mu sync.Mutex
1247		var wg sync.WaitGroup
1248
1249		for _, account := range cfg.Accounts {
1250			wg.Add(1)
1251			go func(acc config.Account) {
1252				defer wg.Done()
1253				var emails []fetcher.Email
1254				var err error
1255				switch mailbox {
1256				case tui.MailboxSent:
1257					emails, err = fetcher.FetchSentEmails(&acc, initialEmailLimit, 0)
1258				case tui.MailboxTrash:
1259					emails, err = fetcher.FetchTrashEmails(&acc, initialEmailLimit, 0)
1260				case tui.MailboxArchive:
1261					emails, err = fetcher.FetchArchiveEmails(&acc, initialEmailLimit, 0)
1262				default:
1263					emails, err = fetcher.FetchEmails(&acc, initialEmailLimit, 0)
1264				}
1265				if err != nil {
1266					log.Printf("Error fetching from %s: %v", acc.Email, err)
1267					return
1268				}
1269				mu.Lock()
1270				emailsByAccount[acc.ID] = emails
1271				mu.Unlock()
1272			}(account)
1273		}
1274
1275		wg.Wait()
1276		return tui.AllEmailsFetchedMsg{EmailsByAccount: emailsByAccount, Mailbox: mailbox}
1277	}
1278}
1279
1280func fetchEmails(account *config.Account, limit, offset uint32, mailbox tui.MailboxKind) tea.Cmd {
1281	return func() tea.Msg {
1282		var emails []fetcher.Email
1283		var err error
1284		if mailbox == tui.MailboxSent {
1285			emails, err = fetcher.FetchSentEmails(account, limit, offset)
1286		} else {
1287			emails, err = fetcher.FetchEmails(account, limit, offset)
1288		}
1289		if err != nil {
1290			return tui.FetchErr(err)
1291		}
1292		if offset == 0 {
1293			return tui.EmailsFetchedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1294		}
1295		return tui.EmailsAppendedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1296	}
1297}
1298
1299func fetchEmailsForMailbox(account *config.Account, limit, offset uint32, mailbox tui.MailboxKind) tea.Cmd {
1300	return func() tea.Msg {
1301		var emails []fetcher.Email
1302		var err error
1303		switch mailbox {
1304		case tui.MailboxSent:
1305			emails, err = fetcher.FetchSentEmails(account, limit, offset)
1306		case tui.MailboxTrash:
1307			emails, err = fetcher.FetchTrashEmails(account, limit, offset)
1308		case tui.MailboxArchive:
1309			emails, err = fetcher.FetchArchiveEmails(account, limit, offset)
1310		default:
1311			emails, err = fetcher.FetchEmails(account, limit, offset)
1312		}
1313		if err != nil {
1314			return tui.FetchErr(err)
1315		}
1316		if offset == 0 {
1317			return tui.EmailsFetchedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1318		}
1319		return tui.EmailsAppendedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1320	}
1321}
1322
1323func loadCachedEmails() tea.Cmd {
1324	return func() tea.Msg {
1325		cache, err := config.LoadEmailCache()
1326		if err != nil {
1327			return tui.CachedEmailsLoadedMsg{Cache: nil}
1328		}
1329		return tui.CachedEmailsLoadedMsg{Cache: cache}
1330	}
1331}
1332
1333func refreshEmails(cfg *config.Config, mailbox tui.MailboxKind, counts map[string]int) tea.Cmd {
1334	return func() tea.Msg {
1335		emailsByAccount := make(map[string][]fetcher.Email)
1336		var mu sync.Mutex
1337		var wg sync.WaitGroup
1338
1339		for _, account := range cfg.Accounts {
1340			wg.Add(1)
1341			go func(acc config.Account) {
1342				defer wg.Done()
1343				var emails []fetcher.Email
1344				var err error
1345
1346				limit := uint32(initialEmailLimit)
1347				if counts != nil {
1348					if c, ok := counts[acc.ID]; ok && c > 0 {
1349						limit = uint32(c)
1350					}
1351				}
1352
1353				if mailbox == tui.MailboxSent {
1354					emails, err = fetcher.FetchSentEmails(&acc, limit, 0)
1355				} else {
1356					emails, err = fetcher.FetchEmails(&acc, limit, 0)
1357				}
1358				if err != nil {
1359					log.Printf("Error fetching from %s: %v", acc.Email, err)
1360					return
1361				}
1362				mu.Lock()
1363				emailsByAccount[acc.ID] = emails
1364				mu.Unlock()
1365			}(account)
1366		}
1367
1368		wg.Wait()
1369		return tui.EmailsRefreshedMsg{EmailsByAccount: emailsByAccount, Mailbox: mailbox}
1370	}
1371}
1372
1373func emailsToCache(emails []fetcher.Email) []config.CachedEmail {
1374	var cached []config.CachedEmail
1375	for _, email := range emails {
1376		cached = append(cached, config.CachedEmail{
1377			UID:       email.UID,
1378			From:      email.From,
1379			To:        email.To,
1380			Subject:   email.Subject,
1381			Date:      email.Date,
1382			MessageID: email.MessageID,
1383			AccountID: email.AccountID,
1384			IsRead:    email.IsRead,
1385		})
1386	}
1387	return cached
1388}
1389
1390func cacheToEmails(cached []config.CachedEmail) []fetcher.Email {
1391	var emails []fetcher.Email
1392	for _, c := range cached {
1393		emails = append(emails, fetcher.Email{
1394			UID:       c.UID,
1395			From:      c.From,
1396			To:        c.To,
1397			Subject:   c.Subject,
1398			Date:      c.Date,
1399			MessageID: c.MessageID,
1400			AccountID: c.AccountID,
1401			IsRead:    c.IsRead,
1402		})
1403	}
1404	return emails
1405}
1406
1407func saveFolderEmailsToCache(folderName string, emails []fetcher.Email) {
1408	cached := emailsToCache(emails)
1409	if err := config.SaveFolderEmailCache(folderName, cached); err != nil {
1410		log.Printf("Error saving folder email cache for %s: %v", folderName, err)
1411	}
1412}
1413
1414func loadFolderEmailsFromCache(folderName string) []fetcher.Email {
1415	cached, err := config.LoadFolderEmailCache(folderName)
1416	if err != nil {
1417		return nil
1418	}
1419	return cacheToEmails(cached)
1420}
1421
1422func saveEmailsToCache(emails []fetcher.Email) {
1423	if len(emails) > maxCacheEmails {
1424		emails = emails[:maxCacheEmails]
1425	}
1426	var cachedEmails []config.CachedEmail
1427	for _, email := range emails {
1428		cachedEmails = append(cachedEmails, config.CachedEmail{
1429			UID:       email.UID,
1430			From:      email.From,
1431			To:        email.To,
1432			Subject:   email.Subject,
1433			Date:      email.Date,
1434			MessageID: email.MessageID,
1435			AccountID: email.AccountID,
1436			IsRead:    email.IsRead,
1437		})
1438
1439		// Save sender as a contact
1440		if email.From != "" {
1441			name, emailAddr := parseEmailAddress(email.From)
1442			if err := config.AddContact(name, emailAddr); err != nil {
1443				log.Printf("Error saving contact from email: %v", err)
1444			}
1445		}
1446	}
1447	cache := &config.EmailCache{Emails: cachedEmails}
1448	if err := config.SaveEmailCache(cache); err != nil {
1449		log.Printf("Error saving email cache: %v", err)
1450	}
1451}
1452
1453// parseEmailAddress parses "Name <email>" or just "email" format
1454func parseEmailAddress(addr string) (name, email string) {
1455	addr = strings.TrimSpace(addr)
1456	if idx := strings.Index(addr, "<"); idx != -1 {
1457		name = strings.TrimSpace(addr[:idx])
1458		endIdx := strings.Index(addr, ">")
1459		if endIdx > idx {
1460			email = strings.TrimSpace(addr[idx+1 : endIdx])
1461		} else {
1462			email = strings.TrimSpace(addr[idx+1:])
1463		}
1464	} else {
1465		email = addr
1466	}
1467	return name, email
1468}
1469
1470func fetchEmailBodyCmd(cfg *config.Config, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
1471	return func() tea.Msg {
1472		account := cfg.GetAccountByID(accountID)
1473		if account == nil {
1474			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: fmt.Errorf("account not found")}
1475		}
1476
1477		var (
1478			body        string
1479			attachments []fetcher.Attachment
1480			err         error
1481		)
1482		switch mailbox {
1483		case tui.MailboxSent:
1484			body, attachments, err = fetcher.FetchSentEmailBody(account, uid)
1485		case tui.MailboxTrash:
1486			body, attachments, err = fetcher.FetchTrashEmailBody(account, uid)
1487		case tui.MailboxArchive:
1488			body, attachments, err = fetcher.FetchArchiveEmailBody(account, uid)
1489		default:
1490			body, attachments, err = fetcher.FetchEmailBody(account, uid)
1491		}
1492		if err != nil {
1493			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1494		}
1495
1496		return tui.EmailBodyFetchedMsg{
1497			UID:         uid,
1498			Body:        body,
1499			Attachments: attachments,
1500			AccountID:   accountID,
1501			Mailbox:     mailbox,
1502		}
1503	}
1504}
1505
1506func markdownToHTML(md []byte) []byte {
1507	return clib.MarkdownToHTML(md)
1508}
1509
1510func splitEmails(s string) []string {
1511	if s == "" {
1512		return nil
1513	}
1514	parts := strings.Split(s, ",")
1515	var res []string
1516	for _, p := range parts {
1517		if trimmed := strings.TrimSpace(p); trimmed != "" {
1518			res = append(res, trimmed)
1519		}
1520	}
1521	return res
1522}
1523
1524func sendEmail(account *config.Account, msg tui.SendEmailMsg) tea.Cmd {
1525	return func() tea.Msg {
1526		if account == nil {
1527			return tui.EmailResultMsg{Err: fmt.Errorf("no account configured")}
1528		}
1529
1530		recipients := splitEmails(msg.To)
1531		cc := splitEmails(msg.Cc)
1532		bcc := splitEmails(msg.Bcc)
1533		body := msg.Body
1534		// Append signature if present
1535		if msg.Signature != "" {
1536			body = body + "\n\n" + msg.Signature
1537		}
1538		// Append quoted text if present (for replies)
1539		if msg.QuotedText != "" {
1540			body = body + msg.QuotedText
1541		}
1542		images := make(map[string][]byte)
1543		attachments := make(map[string][]byte)
1544
1545		re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
1546		matches := re.FindAllStringSubmatch(body, -1)
1547
1548		for _, match := range matches {
1549			imgPath := match[1]
1550			imgData, err := os.ReadFile(imgPath)
1551			if err != nil {
1552				log.Printf("Could not read image file %s: %v", imgPath, err)
1553				continue
1554			}
1555			cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "matcha")
1556			images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
1557			body = strings.Replace(body, imgPath, "cid:"+cid, 1)
1558		}
1559
1560		htmlBody := markdownToHTML([]byte(body))
1561
1562		for _, attachPath := range msg.AttachmentPaths {
1563			fileData, err := os.ReadFile(attachPath)
1564			if err != nil {
1565				log.Printf("Could not read attachment file %s: %v", attachPath, err)
1566				continue
1567			}
1568			_, filename := filepath.Split(attachPath)
1569			attachments[filename] = fileData
1570		}
1571
1572		err := sender.SendEmail(account, recipients, cc, bcc, msg.Subject, body, string(htmlBody), images, attachments, msg.InReplyTo, msg.References, msg.SignSMIME, msg.EncryptSMIME)
1573		if err != nil {
1574			log.Printf("Failed to send email: %v", err)
1575			return tui.EmailResultMsg{Err: err}
1576		}
1577		return tui.EmailResultMsg{}
1578	}
1579}
1580
1581func deleteEmailCmd(account *config.Account, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
1582	return func() tea.Msg {
1583		var err error
1584		switch mailbox {
1585		case tui.MailboxSent:
1586			err = fetcher.DeleteSentEmail(account, uid)
1587		case tui.MailboxTrash:
1588			err = fetcher.DeleteTrashEmail(account, uid)
1589		case tui.MailboxArchive:
1590			err = fetcher.DeleteArchiveEmail(account, uid)
1591		default:
1592			err = fetcher.DeleteEmail(account, uid)
1593		}
1594		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1595	}
1596}
1597
1598func archiveEmailCmd(account *config.Account, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
1599	return func() tea.Msg {
1600		var err error
1601		if mailbox == tui.MailboxSent {
1602			err = fetcher.ArchiveSentEmail(account, uid)
1603		} else {
1604			err = fetcher.ArchiveEmail(account, uid)
1605		}
1606		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1607	}
1608}
1609
1610// --- External editor command ---
1611
1612// openExternalEditor writes the body to a temp file, opens $EDITOR, and reads back the result.
1613func openExternalEditor(body string) tea.Cmd {
1614	editor := os.Getenv("EDITOR")
1615	if editor == "" {
1616		editor = os.Getenv("VISUAL")
1617	}
1618	if editor == "" {
1619		editor = "vi"
1620	}
1621
1622	tmpFile, err := os.CreateTemp("", "matcha-*.md")
1623	if err != nil {
1624		return func() tea.Msg {
1625			return tui.EditorFinishedMsg{Err: fmt.Errorf("creating temp file: %w", err)}
1626		}
1627	}
1628	tmpPath := tmpFile.Name()
1629
1630	if _, err := tmpFile.WriteString(body); err != nil {
1631		tmpFile.Close()
1632		os.Remove(tmpPath)
1633		return func() tea.Msg {
1634			return tui.EditorFinishedMsg{Err: fmt.Errorf("writing temp file: %w", err)}
1635		}
1636	}
1637	tmpFile.Close()
1638
1639	parts := strings.Fields(editor)
1640	args := append(parts[1:], tmpPath)
1641	c := exec.Command(parts[0], args...)
1642	return tea.ExecProcess(c, func(err error) tea.Msg {
1643		defer os.Remove(tmpPath)
1644		if err != nil {
1645			return tui.EditorFinishedMsg{Err: err}
1646		}
1647		content, readErr := os.ReadFile(tmpPath)
1648		if readErr != nil {
1649			return tui.EditorFinishedMsg{Err: readErr}
1650		}
1651		return tui.EditorFinishedMsg{Body: string(content)}
1652	})
1653}
1654
1655// --- IDLE command ---
1656
1657// listenForIdleUpdates blocks until an IDLE update arrives, then returns it as a tea.Msg.
1658func listenForIdleUpdates(ch <-chan fetcher.IdleUpdate) tea.Cmd {
1659	return func() tea.Msg {
1660		update, ok := <-ch
1661		if !ok {
1662			return nil
1663		}
1664		return tui.IdleNewMailMsg{
1665			AccountID:  update.AccountID,
1666			FolderName: update.FolderName,
1667		}
1668	}
1669}
1670
1671// --- Folder-based command functions ---
1672
1673func fetchFoldersCmd(cfg *config.Config) tea.Cmd {
1674	return func() tea.Msg {
1675		if !cfg.HasAccounts() {
1676			return nil
1677		}
1678		foldersByAccount := make(map[string][]fetcher.Folder)
1679		seen := make(map[string]fetcher.Folder)
1680		var mu sync.Mutex
1681		var wg sync.WaitGroup
1682
1683		for _, account := range cfg.Accounts {
1684			wg.Add(1)
1685			go func(acc config.Account) {
1686				defer wg.Done()
1687				folders, err := fetcher.FetchFolders(&acc)
1688				if err != nil {
1689					return
1690				}
1691				mu.Lock()
1692				foldersByAccount[acc.ID] = folders
1693				for _, f := range folders {
1694					if _, ok := seen[f.Name]; !ok {
1695						seen[f.Name] = f
1696					}
1697				}
1698				mu.Unlock()
1699			}(account)
1700		}
1701		wg.Wait()
1702
1703		var merged []fetcher.Folder
1704		for _, f := range seen {
1705			merged = append(merged, f)
1706		}
1707
1708		return tui.FoldersFetchedMsg{
1709			FoldersByAccount: foldersByAccount,
1710			MergedFolders:    merged,
1711		}
1712	}
1713}
1714
1715func fetchFolderEmailsCmd(cfg *config.Config, folderName string) tea.Cmd {
1716	return func() tea.Msg {
1717		emailsByAccount := make(map[string][]fetcher.Email)
1718		var mu sync.Mutex
1719		var wg sync.WaitGroup
1720
1721		for _, account := range cfg.Accounts {
1722			wg.Add(1)
1723			go func(acc config.Account) {
1724				defer wg.Done()
1725				emails, err := fetcher.FetchFolderEmails(&acc, folderName, initialEmailLimit, 0)
1726				if err != nil {
1727					// Folder may not exist for this account — silently skip
1728					return
1729				}
1730				mu.Lock()
1731				emailsByAccount[acc.ID] = emails
1732				mu.Unlock()
1733			}(account)
1734		}
1735
1736		wg.Wait()
1737
1738		// Flatten all account emails
1739		var allEmails []fetcher.Email
1740		for _, emails := range emailsByAccount {
1741			allEmails = append(allEmails, emails...)
1742		}
1743		// Sort newest first
1744		for i := 0; i < len(allEmails); i++ {
1745			for j := i + 1; j < len(allEmails); j++ {
1746				if allEmails[j].Date.After(allEmails[i].Date) {
1747					allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
1748				}
1749			}
1750		}
1751
1752		return tui.FolderEmailsFetchedMsg{
1753			Emails:     allEmails,
1754			FolderName: folderName,
1755		}
1756	}
1757}
1758
1759func fetchFolderEmailsPaginatedCmd(account *config.Account, folderName string, limit, offset uint32) tea.Cmd {
1760	return func() tea.Msg {
1761		emails, err := fetcher.FetchFolderEmails(account, folderName, limit, offset)
1762		if err != nil {
1763			return tui.FetchErr(err)
1764		}
1765		return tui.FolderEmailsAppendedMsg{
1766			Emails:     emails,
1767			AccountID:  account.ID,
1768			FolderName: folderName,
1769		}
1770	}
1771}
1772
1773func fetchFolderEmailBodyCmd(cfg *config.Config, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
1774	return func() tea.Msg {
1775		account := cfg.GetAccountByID(accountID)
1776		if account == nil {
1777			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: fmt.Errorf("account not found")}
1778		}
1779
1780		body, attachments, err := fetcher.FetchFolderEmailBody(account, folderName, uid)
1781		if err != nil {
1782			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1783		}
1784
1785		return tui.EmailBodyFetchedMsg{
1786			UID:         uid,
1787			Body:        body,
1788			Attachments: attachments,
1789			AccountID:   accountID,
1790			Mailbox:     mailbox,
1791		}
1792	}
1793}
1794
1795func markEmailAsReadCmd(account *config.Account, uid uint32, accountID string, folderName string) tea.Cmd {
1796	return func() tea.Msg {
1797		err := fetcher.MarkEmailAsReadInMailbox(account, folderName, uid)
1798		return tui.EmailMarkedReadMsg{UID: uid, AccountID: accountID, Err: err}
1799	}
1800}
1801
1802func deleteFolderEmailCmd(account *config.Account, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
1803	return func() tea.Msg {
1804		err := fetcher.DeleteFolderEmail(account, folderName, uid)
1805		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1806	}
1807}
1808
1809func archiveFolderEmailCmd(account *config.Account, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
1810	return func() tea.Msg {
1811		err := fetcher.ArchiveFolderEmail(account, folderName, uid)
1812		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1813	}
1814}
1815
1816func moveEmailToFolderCmd(account *config.Account, uid uint32, accountID string, sourceFolder, destFolder string) tea.Cmd {
1817	return func() tea.Msg {
1818		err := fetcher.MoveEmailToFolder(account, uid, sourceFolder, destFolder)
1819		return tui.EmailMovedMsg{
1820			UID:          uid,
1821			AccountID:    accountID,
1822			SourceFolder: sourceFolder,
1823			DestFolder:   destFolder,
1824			Err:          err,
1825		}
1826	}
1827}
1828
1829func downloadAttachmentCmd(account *config.Account, uid uint32, msg tui.DownloadAttachmentMsg) tea.Cmd {
1830	return func() tea.Msg {
1831		// Download and decode the attachment using encoding provided in msg.Encoding.
1832		var data []byte
1833		var err error
1834		switch msg.Mailbox {
1835		case tui.MailboxSent:
1836			data, err = fetcher.FetchSentAttachment(account, uid, msg.PartID, msg.Encoding)
1837		case tui.MailboxTrash:
1838			data, err = fetcher.FetchTrashAttachment(account, uid, msg.PartID, msg.Encoding)
1839		case tui.MailboxArchive:
1840			data, err = fetcher.FetchArchiveAttachment(account, uid, msg.PartID, msg.Encoding)
1841		default:
1842			data, err = fetcher.FetchAttachment(account, uid, msg.PartID, msg.Encoding)
1843		}
1844		if err != nil {
1845			return tui.AttachmentDownloadedMsg{Err: err}
1846		}
1847
1848		homeDir, err := os.UserHomeDir()
1849		if err != nil {
1850			return tui.AttachmentDownloadedMsg{Err: err}
1851		}
1852		downloadsPath := filepath.Join(homeDir, "Downloads")
1853		if _, err := os.Stat(downloadsPath); os.IsNotExist(err) {
1854			if mkErr := os.MkdirAll(downloadsPath, 0755); mkErr != nil {
1855				return tui.AttachmentDownloadedMsg{Err: mkErr}
1856			}
1857		}
1858
1859		// Save the attachment using an exclusive create so we never overwrite an existing file.
1860		// If the filename already exists, append \" (n)\" before the extension.
1861		origName := msg.Filename
1862		ext := filepath.Ext(origName)
1863		base := strings.TrimSuffix(origName, ext)
1864		candidate := origName
1865		i := 1
1866		var filePath string
1867
1868		for {
1869			filePath = filepath.Join(downloadsPath, candidate)
1870
1871			// Try to create file exclusively. If it already exists, os.OpenFile will return an error
1872			// that satisfies os.IsExist(err), so we can increment the candidate.
1873			f, err := os.OpenFile(filePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
1874			if err != nil {
1875				if os.IsExist(err) {
1876					// file exists, try next candidate
1877					candidate = fmt.Sprintf("%s (%d)%s", base, i, ext)
1878					i++
1879					continue
1880				}
1881				// Some other error while attempting to create file
1882				log.Printf("error creating file %s: %v", filePath, err)
1883				return tui.AttachmentDownloadedMsg{Err: err}
1884			}
1885
1886			// Successfully created the file descriptor; write and close.
1887			if _, writeErr := f.Write(data); writeErr != nil {
1888				_ = f.Close()
1889				log.Printf("error writing to file %s: %v", filePath, writeErr)
1890				return tui.AttachmentDownloadedMsg{Err: writeErr}
1891			}
1892			if closeErr := f.Close(); closeErr != nil {
1893				log.Printf("warning: error closing file %s: %v", filePath, closeErr)
1894			}
1895
1896			// file saved successfully
1897			break
1898		}
1899
1900		log.Printf("attachment saved to %s", filePath)
1901
1902		// Try to open the file using a platform-specific opener asynchronously and log the outcome.
1903		go func(p string) {
1904			var cmd *exec.Cmd
1905			switch runtime.GOOS {
1906			case "darwin":
1907				cmd = exec.Command("open", p)
1908			case "linux":
1909				cmd = exec.Command("xdg-open", p)
1910			case "windows":
1911				// 'start' is a cmd builtin; provide an empty title argument to avoid interpreting the path as the title.
1912				cmd = exec.Command("cmd", "/c", "start", "", p)
1913			default:
1914				// Unsupported OS: nothing to do.
1915				return
1916			}
1917			if err := cmd.Start(); err != nil {
1918				log.Printf("failed to open file %s: %v", p, err)
1919			}
1920		}(filePath)
1921
1922		return tui.AttachmentDownloadedMsg{Path: filePath, Err: nil}
1923	}
1924}
1925
1926/*
1927detectInstalledVersion returns a best-effort installed version string.
1928Priority:
1929 1. If the build-in `version` variable is set to something other than "dev", return it.
1930 2. If Homebrew is present and reports a version for `matcha`, return that.
1931 3. If snap is present and lists `matcha`, return that.
1932 4. Fallback to the build `version` (likely "dev").
1933*/
1934func detectInstalledVersion() string {
1935	v := strings.TrimSpace(version)
1936	if v != "dev" && v != "" {
1937		return v
1938	}
1939
1940	// Try Homebrew (macOS)
1941	if runtime.GOOS == "darwin" {
1942		if _, err := exec.LookPath("brew"); err == nil {
1943			// `brew list --versions matcha` prints: matcha 1.2.3
1944			if out, err := exec.Command("brew", "list", "--versions", "matcha").Output(); err == nil {
1945				parts := strings.Fields(string(out))
1946				if len(parts) >= 2 {
1947					return parts[1]
1948				}
1949			}
1950		}
1951	}
1952
1953	// Try snap (Linux)
1954	if runtime.GOOS == "linux" {
1955		if _, err := exec.LookPath("snap"); err == nil {
1956			if out, err := exec.Command("snap", "list", "matcha").Output(); err == nil {
1957				lines := strings.Split(strings.TrimSpace(string(out)), "\n")
1958				if len(lines) >= 2 {
1959					fields := strings.Fields(lines[1])
1960					if len(fields) >= 2 {
1961						return fields[1]
1962					}
1963				}
1964			}
1965		}
1966
1967		if _, err := exec.LookPath("flatpak"); err == nil {
1968			if out, err := exec.Command("flatpak", "info", "com.floatpane.matcha").Output(); err == nil {
1969				lines := strings.Split(strings.TrimSpace(string(out)), "\n")
1970				for _, line := range lines {
1971					line = strings.TrimSpace(line)
1972					if strings.HasPrefix(line, "Version:") {
1973						fields := strings.Fields(line)
1974						if len(fields) >= 2 {
1975							return fields[1]
1976						}
1977					}
1978				}
1979			}
1980		}
1981	}
1982
1983	return v
1984}
1985
1986/*
1987checkForUpdatesCmd queries GitHub for the latest release tag and returns a
1988tea.Msg (UpdateAvailableMsg) if the latest version differs from the current
1989installed version. This runs in the background when the TUI initializes.
1990*/
1991func checkForUpdatesCmd() tea.Cmd {
1992	return func() tea.Msg {
1993		// Non-fatal: if anything goes wrong we just don't show the update message.
1994		const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
1995		resp, err := http.Get(api)
1996		if err != nil {
1997			return nil
1998		}
1999		defer resp.Body.Close()
2000
2001		var rel githubRelease
2002		if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
2003			return nil
2004		}
2005
2006		latest := strings.TrimPrefix(rel.TagName, "v")
2007		installed := strings.TrimPrefix(detectInstalledVersion(), "v")
2008		if latest != "" && installed != "" && latest != installed {
2009			return UpdateAvailableMsg{Latest: latest, Current: installed}
2010		}
2011		return nil
2012	}
2013}
2014
2015// runUpdateCLI implements the CLI entrypoint for `matcha update`.
2016// It detects the likely installation method and attempts the appropriate
2017// update path (Homebrew, Snap, or GitHub release binary extract).
2018// runGmailOAuthCLI handles the "matcha gmail" subcommand for OAuth2 management.
2019// Usage:
2020//
2021//	matcha gmail auth   <email> [--client-id ID --client-secret SECRET]
2022//	matcha gmail token  <email>
2023//	matcha gmail revoke <email>
2024func runGmailOAuthCLI(args []string) {
2025	if len(args) < 1 {
2026		fmt.Fprintln(os.Stderr, "Usage: matcha gmail <auth|token|revoke> <email> [flags]")
2027		fmt.Fprintln(os.Stderr, "")
2028		fmt.Fprintln(os.Stderr, "Commands:")
2029		fmt.Fprintln(os.Stderr, "  auth   <email>  Authorize a Gmail account via OAuth2 (opens browser)")
2030		fmt.Fprintln(os.Stderr, "  token  <email>  Print a fresh access token (refreshes automatically)")
2031		fmt.Fprintln(os.Stderr, "  revoke <email>  Revoke and delete stored OAuth2 tokens")
2032		fmt.Fprintln(os.Stderr, "")
2033		fmt.Fprintln(os.Stderr, "Before using OAuth2, create ~/.config/matcha/oauth_client.json with:")
2034		fmt.Fprintln(os.Stderr, `  {"client_id": "YOUR_ID", "client_secret": "YOUR_SECRET"}`)
2035		fmt.Fprintln(os.Stderr, "")
2036		fmt.Fprintln(os.Stderr, "Get credentials at: https://console.cloud.google.com/apis/credentials")
2037		os.Exit(1)
2038	}
2039
2040	// Find the Python script and pass through to it
2041	script, err := config.OAuthScriptPath()
2042	if err != nil {
2043		fmt.Fprintf(os.Stderr, "Error: %v\n", err)
2044		os.Exit(1)
2045	}
2046
2047	cmdArgs := append([]string{script}, args...)
2048	cmd := exec.Command("python3", cmdArgs...)
2049	cmd.Stdin = os.Stdin
2050	cmd.Stdout = os.Stdout
2051	cmd.Stderr = os.Stderr
2052
2053	if err := cmd.Run(); err != nil {
2054		if exitErr, ok := err.(*exec.ExitError); ok {
2055			os.Exit(exitErr.ExitCode())
2056		}
2057		fmt.Fprintf(os.Stderr, "Error: %v\n", err)
2058		os.Exit(1)
2059	}
2060}
2061
2062func runUpdateCLI() error {
2063	const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
2064	resp, err := http.Get(api)
2065	if err != nil {
2066		return fmt.Errorf("could not query releases: %w", err)
2067	}
2068	defer resp.Body.Close()
2069
2070	var rel githubRelease
2071	if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
2072		return fmt.Errorf("could not parse release info: %w", err)
2073	}
2074
2075	latestTag := rel.TagName
2076	if strings.HasPrefix(latestTag, "v") {
2077		latestTag = latestTag[1:]
2078	}
2079
2080	fmt.Printf("Current version: %s\n", version)
2081	fmt.Printf("Latest version: %s\n", latestTag)
2082
2083	// Quick check: if already up-to-date, exit
2084	cur := version
2085	if strings.HasPrefix(cur, "v") {
2086		cur = cur[1:]
2087	}
2088	if latestTag == "" || cur == latestTag {
2089		fmt.Println("Already up to date.")
2090		return nil
2091	}
2092
2093	// Detect Homebrew
2094	if _, err := exec.LookPath("brew"); err == nil {
2095		fmt.Println("Detected Homebrew — updating taps and attempting to upgrade via brew.")
2096
2097		updateCmd := exec.Command("brew", "update")
2098		updateCmd.Stdout = os.Stdout
2099		updateCmd.Stderr = os.Stderr
2100		if err := updateCmd.Run(); err != nil {
2101			fmt.Printf("Homebrew update failed: %v\n", err)
2102			// continue to attempt upgrade even if update failed
2103		}
2104
2105		upgradeCmd := exec.Command("brew", "upgrade", "floatpane/matcha/matcha")
2106		upgradeCmd.Stdout = os.Stdout
2107		upgradeCmd.Stderr = os.Stderr
2108		if err := upgradeCmd.Run(); err == nil {
2109			fmt.Println("Successfully upgraded via Homebrew.")
2110			return nil
2111		}
2112		fmt.Printf("Homebrew upgrade failed: %v\n", err)
2113		// fallthrough to other methods
2114	}
2115
2116	// Detect snap
2117	if _, err := exec.LookPath("snap"); err == nil {
2118		// Check if matcha is installed as a snap
2119		cmdCheck := exec.Command("snap", "list", "matcha")
2120		if err := cmdCheck.Run(); err == nil {
2121			fmt.Println("Detected Snap package — attempting to refresh.")
2122			cmd := exec.Command("snap", "refresh", "matcha")
2123			cmd.Stdout = os.Stdout
2124			cmd.Stderr = os.Stderr
2125			if err := cmd.Run(); err == nil {
2126				fmt.Println("Successfully refreshed snap.")
2127				return nil
2128			}
2129			fmt.Printf("Snap refresh failed: %v\n", err)
2130			// fallthrough
2131		}
2132	}
2133	// Detect flatpak
2134	if _, err := exec.LookPath("flatpak"); err == nil {
2135		// Check if matcha is installed as a flatpak
2136		cmdCheck := exec.Command("flatpak", "info", "com.floatpane.matcha")
2137		if err := cmdCheck.Run(); err == nil {
2138			fmt.Println("Detected Flatpak package — attempting to update.")
2139			cmd := exec.Command("flatpak", "update", "-y", "com.floatpane.matcha")
2140			cmd.Stdout = os.Stdout
2141			cmd.Stderr = os.Stderr
2142			if err := cmd.Run(); err == nil {
2143				fmt.Println("Successfully updated flatpak.")
2144				return nil
2145			}
2146			fmt.Printf("Flatpak update failed: %v\n", err)
2147			// fallthrough
2148		}
2149	}
2150
2151	// Otherwise attempt to download the proper release asset and replace the binary.
2152	osName := runtime.GOOS
2153	arch := runtime.GOARCH
2154
2155	// Try to find a matching asset
2156	var assetURL, assetName string
2157	for _, a := range rel.Assets {
2158		n := strings.ToLower(a.Name)
2159		if strings.Contains(n, osName) && strings.Contains(n, arch) && (strings.HasSuffix(n, ".tar.gz") || strings.HasSuffix(n, ".tgz") || strings.HasSuffix(n, ".zip")) {
2160			assetURL = a.BrowserDownloadURL
2161			assetName = a.Name
2162			break
2163		}
2164	}
2165	if assetURL == "" {
2166		// Try any asset that contains 'matcha' and os/arch as a fallback
2167		for _, a := range rel.Assets {
2168			n := strings.ToLower(a.Name)
2169			if strings.Contains(n, "matcha") && (strings.Contains(n, osName) || strings.Contains(n, arch)) {
2170				assetURL = a.BrowserDownloadURL
2171				assetName = a.Name
2172				break
2173			}
2174		}
2175	}
2176
2177	if assetURL == "" {
2178		return fmt.Errorf("no suitable release artifact found for %s/%s", osName, arch)
2179	}
2180
2181	fmt.Printf("Found release asset: %s\n", assetName)
2182	fmt.Println("Downloading...")
2183
2184	// Download asset
2185	respAsset, err := http.Get(assetURL)
2186	if err != nil {
2187		return fmt.Errorf("download failed: %w", err)
2188	}
2189	defer respAsset.Body.Close()
2190
2191	// Create a temp file for the download
2192	tmpDir, err := os.MkdirTemp("", "matcha-update-*")
2193	if err != nil {
2194		return fmt.Errorf("could not create temp dir: %w", err)
2195	}
2196	defer os.RemoveAll(tmpDir)
2197
2198	assetPath := filepath.Join(tmpDir, assetName)
2199	outFile, err := os.Create(assetPath)
2200	if err != nil {
2201		return fmt.Errorf("could not create temp file: %w", err)
2202	}
2203	_, err = io.Copy(outFile, respAsset.Body)
2204	outFile.Close()
2205	if err != nil {
2206		return fmt.Errorf("could not write asset to disk: %w", err)
2207	}
2208
2209	// Determine the expected binary name based on the OS.
2210	binaryName := "matcha"
2211	if runtime.GOOS == "windows" {
2212		binaryName = "matcha.exe"
2213	}
2214
2215	// Extract the binary from the archive.
2216	var binPath string
2217	if strings.HasSuffix(assetName, ".tar.gz") || strings.HasSuffix(assetName, ".tgz") {
2218		f, err := os.Open(assetPath)
2219		if err != nil {
2220			return fmt.Errorf("could not open archive: %w", err)
2221		}
2222		defer f.Close()
2223		gzr, err := gzip.NewReader(f)
2224		if err != nil {
2225			return fmt.Errorf("could not create gzip reader: %w", err)
2226		}
2227		tr := tar.NewReader(gzr)
2228		for {
2229			hdr, err := tr.Next()
2230			if err == io.EOF {
2231				break
2232			}
2233			if err != nil {
2234				return fmt.Errorf("error reading tar: %w", err)
2235			}
2236			name := filepath.Base(hdr.Name)
2237			if name == binaryName || strings.Contains(strings.ToLower(name), "matcha") && (hdr.Typeflag == tar.TypeReg) {
2238				binPath = filepath.Join(tmpDir, binaryName)
2239				out, err := os.Create(binPath)
2240				if err != nil {
2241					return fmt.Errorf("could not create binary file: %w", err)
2242				}
2243				if _, err := io.Copy(out, tr); err != nil {
2244					out.Close()
2245					return fmt.Errorf("could not extract binary: %w", err)
2246				}
2247				out.Close()
2248				if err := os.Chmod(binPath, 0755); err != nil {
2249					return fmt.Errorf("could not make binary executable: %w", err)
2250				}
2251				break
2252			}
2253		}
2254	} else if strings.HasSuffix(assetName, ".zip") {
2255		zr, err := zip.OpenReader(assetPath)
2256		if err != nil {
2257			return fmt.Errorf("could not open zip archive: %w", err)
2258		}
2259		defer zr.Close()
2260		for _, zf := range zr.File {
2261			name := filepath.Base(zf.Name)
2262			if name == binaryName || strings.Contains(strings.ToLower(name), "matcha") && !zf.FileInfo().IsDir() {
2263				rc, err := zf.Open()
2264				if err != nil {
2265					return fmt.Errorf("could not open file in zip: %w", err)
2266				}
2267				binPath = filepath.Join(tmpDir, binaryName)
2268				out, err := os.Create(binPath)
2269				if err != nil {
2270					rc.Close()
2271					return fmt.Errorf("could not create binary file: %w", err)
2272				}
2273				if _, err := io.Copy(out, rc); err != nil {
2274					out.Close()
2275					rc.Close()
2276					return fmt.Errorf("could not extract binary: %w", err)
2277				}
2278				out.Close()
2279				rc.Close()
2280				if err := os.Chmod(binPath, 0755); err != nil {
2281					return fmt.Errorf("could not make binary executable: %w", err)
2282				}
2283				break
2284			}
2285		}
2286	} else {
2287		// For non-archive assets, assume the asset is the binary itself.
2288		binPath = assetPath
2289		if err := os.Chmod(binPath, 0755); err != nil {
2290			// ignore chmod errors but warn
2291			fmt.Printf("warning: could not chmod downloaded binary: %v\n", err)
2292		}
2293	}
2294
2295	if binPath == "" {
2296		return fmt.Errorf("could not locate matcha binary inside the release artifact")
2297	}
2298
2299	// Replace the running executable with the new binary
2300	execPath, err := os.Executable()
2301	if err != nil {
2302		return fmt.Errorf("could not determine executable path: %w", err)
2303	}
2304
2305	// Write the new binary to a temp file in same dir, then rename for atomic replacement.
2306	execDir := filepath.Dir(execPath)
2307	tmpNew := filepath.Join(execDir, fmt.Sprintf("matcha.new.%d", time.Now().Unix()))
2308	in, err := os.Open(binPath)
2309	if err != nil {
2310		return fmt.Errorf("could not open new binary: %w", err)
2311	}
2312	out, err := os.OpenFile(tmpNew, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
2313	if err != nil {
2314		in.Close()
2315		return fmt.Errorf("could not create temp binary in target dir: %w", err)
2316	}
2317	if _, err := io.Copy(out, in); err != nil {
2318		in.Close()
2319		out.Close()
2320		return fmt.Errorf("could not write new binary to disk: %w", err)
2321	}
2322	in.Close()
2323	out.Close()
2324
2325	// On Windows, a running executable cannot be overwritten directly.
2326	// Move the old binary out of the way first, then rename the new one in.
2327	if runtime.GOOS == "windows" {
2328		oldPath := execPath + ".old"
2329		_ = os.Remove(oldPath) // clean up any previous leftover
2330		if err := os.Rename(execPath, oldPath); err != nil {
2331			return fmt.Errorf("could not move old executable out of the way: %w", err)
2332		}
2333	}
2334
2335	if err := os.Rename(tmpNew, execPath); err != nil {
2336		return fmt.Errorf("could not replace executable: %w", err)
2337	}
2338
2339	fmt.Println("Successfully updated matcha to", latestTag)
2340	return nil
2341}
2342
2343func filterUnique(existing, incoming []fetcher.Email) []fetcher.Email {
2344	seen := make(map[uint32]struct{})
2345	for _, e := range existing {
2346		seen[e.UID] = struct{}{}
2347	}
2348	var unique []fetcher.Email
2349	for _, e := range incoming {
2350		if _, ok := seen[e.UID]; !ok {
2351			unique = append(unique, e)
2352		}
2353	}
2354	return unique
2355}
2356
2357func main() {
2358	// If invoked with version flag, print version and exit
2359	if len(os.Args) > 1 && (os.Args[1] == "-v" || os.Args[1] == "--version" || os.Args[1] == "version") {
2360		fmt.Printf("matcha version %s", version)
2361		if commit != "" {
2362			fmt.Printf(" (%s)", commit)
2363		}
2364		if date != "" {
2365			fmt.Printf(" built on %s", date)
2366		}
2367		fmt.Println()
2368		os.Exit(0)
2369	}
2370
2371	// If invoked as CLI update command, run updater and exit.
2372	if len(os.Args) > 1 && os.Args[1] == "update" {
2373		if err := runUpdateCLI(); err != nil {
2374			fmt.Fprintf(os.Stderr, "update failed: %v\n", err)
2375			os.Exit(1)
2376		}
2377		os.Exit(0)
2378	}
2379
2380	// Gmail OAuth2 CLI subcommand: matcha gmail <auth|token|revoke> <email> [flags]
2381	if len(os.Args) > 1 && os.Args[1] == "gmail" {
2382		runGmailOAuthCLI(os.Args[2:])
2383		os.Exit(0)
2384	}
2385
2386	cfg, err := config.LoadConfig()
2387	if err == nil && cfg.Theme != "" {
2388		theme.SetTheme(cfg.Theme)
2389	}
2390	tui.RebuildStyles()
2391
2392	var initialModel *mainModel
2393	if err != nil {
2394		initialModel = newInitialModel(nil)
2395	} else {
2396		initialModel = newInitialModel(cfg)
2397	}
2398
2399	// Initialize plugin system
2400	plugins := plugin.NewManager()
2401	plugins.LoadPlugins()
2402	initialModel.plugins = plugins
2403	plugins.CallHook(plugin.HookStartup)
2404
2405	p := tea.NewProgram(initialModel)
2406
2407	if _, err := p.Run(); err != nil {
2408		plugins.Close()
2409		fmt.Printf("Alas, there's been an error: %v", err)
2410		os.Exit(1)
2411	}
2412
2413	plugins.CallHook(plugin.HookShutdown)
2414	plugins.Close()
2415}