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