main.go

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