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