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		// Find the index for the email view (used for display purposes)
 682		emailIndex := m.getEmailIndex(msg.UID, msg.AccountID, msg.Mailbox)
 683		emailView := tui.NewEmailView(*email, emailIndex, m.width, m.height, msg.Mailbox, m.config.DisableImages)
 684		m.current = emailView
 685		return m, m.current.Init()
 686
 687	case tui.ReplyToEmailMsg:
 688		to := msg.Email.From
 689		subject := msg.Email.Subject
 690		normalizedSubject := strings.ToLower(strings.TrimSpace(subject))
 691		if !strings.HasPrefix(normalizedSubject, "re:") {
 692			subject = "Re: " + subject
 693		}
 694		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> "))
 695
 696		var composer *tui.Composer
 697		hideTips := false
 698		if m.config != nil {
 699			hideTips = m.config.HideTips
 700		}
 701		if m.config != nil && len(m.config.Accounts) > 0 {
 702			// Use the account that received the email
 703			accountID := msg.Email.AccountID
 704			if accountID == "" {
 705				accountID = m.config.GetFirstAccount().ID
 706			}
 707			composer = tui.NewComposerWithAccounts(m.config.Accounts, accountID, to, subject, "", hideTips)
 708		} else {
 709			composer = tui.NewComposer("", to, subject, "", hideTips)
 710		}
 711		composer.SetQuotedText(quotedText)
 712
 713		// Set reply headers
 714		inReplyTo := msg.Email.MessageID
 715		references := append(msg.Email.References, msg.Email.MessageID)
 716		composer.SetReplyContext(inReplyTo, references)
 717
 718		m.current = composer
 719		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 720		return m, m.current.Init()
 721
 722	case tui.ForwardEmailMsg:
 723		subject := msg.Email.Subject
 724		if !strings.HasPrefix(strings.ToLower(subject), "fwd:") {
 725			subject = "Fwd: " + subject
 726		}
 727
 728		forwardHeader := fmt.Sprintf("\n\n---------- Forwarded message ----------\nFrom: %s\nDate: %s\nSubject: %s\nTo: %s\n\n",
 729			msg.Email.From,
 730			msg.Email.Date.Format("Mon, Jan 2, 2006 at 3:04 PM"),
 731			msg.Email.Subject,
 732			msg.Email.To,
 733		)
 734
 735		body := forwardHeader + msg.Email.Body
 736
 737		var composer *tui.Composer
 738		hideTips := false
 739		if m.config != nil {
 740			hideTips = m.config.HideTips
 741		}
 742		if m.config != nil && len(m.config.Accounts) > 0 {
 743			// Use the account that received the email
 744			accountID := msg.Email.AccountID
 745			if accountID == "" {
 746				accountID = m.config.GetFirstAccount().ID
 747			}
 748			composer = tui.NewComposerWithAccounts(m.config.Accounts, accountID, "", subject, body, hideTips)
 749		} else {
 750			composer = tui.NewComposer("", "", subject, body, hideTips)
 751		}
 752
 753		m.current = composer
 754		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 755		return m, m.current.Init()
 756
 757	case tui.GoToFilePickerMsg:
 758		m.previousModel = m.current
 759		wd, _ := os.Getwd()
 760		m.current = tui.NewFilePicker(wd)
 761		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 762		return m, m.current.Init()
 763
 764	case tui.FileSelectedMsg, tui.CancelFilePickerMsg:
 765		if m.previousModel != nil {
 766			m.current = m.previousModel
 767			m.previousModel = nil
 768		}
 769		m.current, cmd = m.current.Update(msg)
 770		cmds = append(cmds, cmd)
 771
 772	case tui.SendEmailMsg:
 773		// Get draft ID before clearing composer (if it's a composer)
 774		var draftID string
 775		if composer, ok := m.current.(*tui.Composer); ok {
 776			draftID = composer.GetDraftID()
 777		}
 778		m.current = tui.NewStatus("Sending email...")
 779
 780		// Get the account to send from
 781		var account *config.Account
 782		if msg.AccountID != "" && m.config != nil {
 783			account = m.config.GetAccountByID(msg.AccountID)
 784		}
 785		if account == nil && m.config != nil {
 786			account = m.config.GetFirstAccount()
 787		}
 788
 789		// Save contact and delete draft in background
 790		go func() {
 791			// Save the recipient as a contact
 792			if msg.To != "" {
 793				recipients := strings.Split(msg.To, ",")
 794				for _, r := range recipients {
 795					r = strings.TrimSpace(r)
 796					if r == "" {
 797						continue
 798					}
 799					name, email := parseEmailAddress(r)
 800					if err := config.AddContact(name, email); err != nil {
 801						log.Printf("Error saving contact: %v", err)
 802					}
 803				}
 804			}
 805			// Delete the draft since email is being sent
 806			if draftID != "" {
 807				if err := config.DeleteDraft(draftID); err != nil {
 808					log.Printf("Error deleting draft after send: %v", err)
 809				}
 810			}
 811		}()
 812
 813		return m, tea.Batch(m.current.Init(), sendEmail(account, msg))
 814
 815	case tui.EmailResultMsg:
 816		if msg.Err != nil {
 817			log.Printf("Failed to send email: %v", msg.Err)
 818			m.previousModel = tui.NewChoice()
 819			m.previousModel, _ = m.previousModel.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 820			m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
 821			return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
 822				return tui.RestoreViewMsg{}
 823			})
 824		}
 825		m.current = tui.NewChoice()
 826		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 827		return m, m.current.Init()
 828
 829	case tui.DeleteEmailMsg:
 830		tui.ClearKittyGraphics()
 831		m.previousModel = m.current
 832		m.current = tui.NewStatus("Deleting email...")
 833
 834		account := m.config.GetAccountByID(msg.AccountID)
 835		if account == nil {
 836			if m.folderInbox != nil {
 837				m.current = m.folderInbox
 838			}
 839			return m, nil
 840		}
 841
 842		folderName := "INBOX"
 843		if m.folderInbox != nil {
 844			folderName = m.folderInbox.GetCurrentFolder()
 845		}
 846		return m, tea.Batch(m.current.Init(), deleteFolderEmailCmd(account, msg.UID, msg.AccountID, folderName, msg.Mailbox))
 847
 848	case tui.ArchiveEmailMsg:
 849		tui.ClearKittyGraphics()
 850		m.previousModel = m.current
 851		m.current = tui.NewStatus("Archiving 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(), archiveFolderEmailCmd(account, msg.UID, msg.AccountID, folderName, msg.Mailbox))
 866
 867	case tui.EmailActionDoneMsg:
 868		if msg.Err != nil {
 869			log.Printf("Action failed: %v", msg.Err)
 870			if m.folderInbox != nil {
 871				m.previousModel = m.folderInbox
 872			}
 873			m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
 874			return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
 875				return tui.RestoreViewMsg{}
 876			})
 877		}
 878
 879		// Remove email from stores
 880		m.removeEmailFromStores(msg.UID, msg.AccountID)
 881
 882		if m.folderInbox != nil {
 883			m.folderInbox.RemoveEmail(msg.UID, msg.AccountID)
 884			m.current = m.folderInbox
 885			m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 886			return m, m.current.Init()
 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.DownloadAttachmentMsg:
 893		m.previousModel = m.current
 894		m.current = tui.NewStatus(fmt.Sprintf("Downloading %s...", msg.Filename))
 895
 896		account := m.config.GetAccountByID(msg.AccountID)
 897		if account == nil {
 898			m.current = m.previousModel
 899			return m, nil
 900		}
 901
 902		email := m.getEmailByIndex(msg.Index, msg.Mailbox)
 903		if email == nil {
 904			m.current = m.previousModel
 905			return m, nil
 906		}
 907
 908		// Find the correct attachment to get encoding
 909		var encoding string
 910		for _, att := range email.Attachments {
 911			if att.PartID == msg.PartID {
 912				encoding = att.Encoding
 913				break
 914			}
 915		}
 916		newMsg := tui.DownloadAttachmentMsg{
 917			Index:     msg.Index,
 918			Filename:  msg.Filename,
 919			PartID:    msg.PartID,
 920			Data:      msg.Data,
 921			AccountID: msg.AccountID,
 922			Encoding:  encoding,
 923			Mailbox:   msg.Mailbox,
 924		}
 925		return m, tea.Batch(m.current.Init(), downloadAttachmentCmd(account, email.UID, newMsg))
 926
 927	case tui.AttachmentDownloadedMsg:
 928		var statusMsg string
 929		if msg.Err != nil {
 930			statusMsg = fmt.Sprintf("Error downloading: %v", msg.Err)
 931		} else {
 932			statusMsg = fmt.Sprintf("Saved to %s", msg.Path)
 933		}
 934		m.current = tui.NewStatus(statusMsg)
 935		return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
 936			return tui.RestoreViewMsg{}
 937		})
 938
 939	case tui.RestoreViewMsg:
 940		if m.previousModel != nil {
 941			m.current = m.previousModel
 942			m.previousModel = nil
 943		}
 944		return m, nil
 945	}
 946
 947	return m, tea.Batch(cmds...)
 948}
 949
 950func (m *mainModel) View() tea.View {
 951	v := m.current.View()
 952	v.AltScreen = true
 953	return v
 954}
 955
 956func (m *mainModel) getEmailByIndex(index int, mailbox tui.MailboxKind) *fetcher.Email {
 957	if index >= 0 && index < len(m.emails) {
 958		return &m.emails[index]
 959	}
 960	return nil
 961}
 962
 963func (m *mainModel) getEmailByUIDAndAccount(uid uint32, accountID string, mailbox tui.MailboxKind) *fetcher.Email {
 964	for i := range m.emails {
 965		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
 966			return &m.emails[i]
 967		}
 968	}
 969	return nil
 970}
 971
 972func (m *mainModel) getEmailIndex(uid uint32, accountID string, mailbox tui.MailboxKind) int {
 973	for i := range m.emails {
 974		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
 975			return i
 976		}
 977	}
 978	return -1
 979}
 980
 981func (m *mainModel) updateEmailBodyByUID(uid uint32, accountID string, mailbox tui.MailboxKind, body string, attachments []fetcher.Attachment) {
 982	for i := range m.emails {
 983		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
 984			m.emails[i].Body = body
 985			m.emails[i].Attachments = attachments
 986			break
 987		}
 988	}
 989	if emails, ok := m.emailsByAcct[accountID]; ok {
 990		for i := range emails {
 991			if emails[i].UID == uid {
 992				emails[i].Body = body
 993				emails[i].Attachments = attachments
 994				break
 995			}
 996		}
 997	}
 998}
 999
1000func (m *mainModel) removeEmailFromStores(uid uint32, accountID string) {
1001	var filtered []fetcher.Email
1002	for _, e := range m.emails {
1003		if !(e.UID == uid && e.AccountID == accountID) {
1004			filtered = append(filtered, e)
1005		}
1006	}
1007	m.emails = filtered
1008	if emails, ok := m.emailsByAcct[accountID]; ok {
1009		var filteredAcct []fetcher.Email
1010		for _, e := range emails {
1011			if e.UID != uid {
1012				filteredAcct = append(filteredAcct, e)
1013			}
1014		}
1015		m.emailsByAcct[accountID] = filteredAcct
1016	}
1017}
1018
1019func flattenAndSort(emailsByAccount map[string][]fetcher.Email) []fetcher.Email {
1020	var allEmails []fetcher.Email
1021	for _, emails := range emailsByAccount {
1022		allEmails = append(allEmails, emails...)
1023	}
1024	for i := 0; i < len(allEmails); i++ {
1025		for j := i + 1; j < len(allEmails); j++ {
1026			if allEmails[j].Date.After(allEmails[i].Date) {
1027				allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
1028			}
1029		}
1030	}
1031	return allEmails
1032}
1033
1034func fetchAllAccountsEmails(cfg *config.Config, mailbox tui.MailboxKind) tea.Cmd {
1035	return func() tea.Msg {
1036		emailsByAccount := make(map[string][]fetcher.Email)
1037		var mu sync.Mutex
1038		var wg sync.WaitGroup
1039
1040		for _, account := range cfg.Accounts {
1041			wg.Add(1)
1042			go func(acc config.Account) {
1043				defer wg.Done()
1044				var emails []fetcher.Email
1045				var err error
1046				switch mailbox {
1047				case tui.MailboxSent:
1048					emails, err = fetcher.FetchSentEmails(&acc, initialEmailLimit, 0)
1049				case tui.MailboxTrash:
1050					emails, err = fetcher.FetchTrashEmails(&acc, initialEmailLimit, 0)
1051				case tui.MailboxArchive:
1052					emails, err = fetcher.FetchArchiveEmails(&acc, initialEmailLimit, 0)
1053				default:
1054					emails, err = fetcher.FetchEmails(&acc, initialEmailLimit, 0)
1055				}
1056				if err != nil {
1057					log.Printf("Error fetching from %s: %v", acc.Email, err)
1058					return
1059				}
1060				mu.Lock()
1061				emailsByAccount[acc.ID] = emails
1062				mu.Unlock()
1063			}(account)
1064		}
1065
1066		wg.Wait()
1067		return tui.AllEmailsFetchedMsg{EmailsByAccount: emailsByAccount, Mailbox: mailbox}
1068	}
1069}
1070
1071func fetchEmails(account *config.Account, limit, offset uint32, mailbox tui.MailboxKind) tea.Cmd {
1072	return func() tea.Msg {
1073		var emails []fetcher.Email
1074		var err error
1075		if mailbox == tui.MailboxSent {
1076			emails, err = fetcher.FetchSentEmails(account, limit, offset)
1077		} else {
1078			emails, err = fetcher.FetchEmails(account, limit, offset)
1079		}
1080		if err != nil {
1081			return tui.FetchErr(err)
1082		}
1083		if offset == 0 {
1084			return tui.EmailsFetchedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1085		}
1086		return tui.EmailsAppendedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1087	}
1088}
1089
1090func fetchEmailsForMailbox(account *config.Account, limit, offset uint32, mailbox tui.MailboxKind) tea.Cmd {
1091	return func() tea.Msg {
1092		var emails []fetcher.Email
1093		var err error
1094		switch mailbox {
1095		case tui.MailboxSent:
1096			emails, err = fetcher.FetchSentEmails(account, limit, offset)
1097		case tui.MailboxTrash:
1098			emails, err = fetcher.FetchTrashEmails(account, limit, offset)
1099		case tui.MailboxArchive:
1100			emails, err = fetcher.FetchArchiveEmails(account, limit, offset)
1101		default:
1102			emails, err = fetcher.FetchEmails(account, limit, offset)
1103		}
1104		if err != nil {
1105			return tui.FetchErr(err)
1106		}
1107		if offset == 0 {
1108			return tui.EmailsFetchedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1109		}
1110		return tui.EmailsAppendedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1111	}
1112}
1113
1114func loadCachedEmails() tea.Cmd {
1115	return func() tea.Msg {
1116		cache, err := config.LoadEmailCache()
1117		if err != nil {
1118			return tui.CachedEmailsLoadedMsg{Cache: nil}
1119		}
1120		return tui.CachedEmailsLoadedMsg{Cache: cache}
1121	}
1122}
1123
1124func refreshEmails(cfg *config.Config, mailbox tui.MailboxKind, counts map[string]int) tea.Cmd {
1125	return func() tea.Msg {
1126		emailsByAccount := make(map[string][]fetcher.Email)
1127		var mu sync.Mutex
1128		var wg sync.WaitGroup
1129
1130		for _, account := range cfg.Accounts {
1131			wg.Add(1)
1132			go func(acc config.Account) {
1133				defer wg.Done()
1134				var emails []fetcher.Email
1135				var err error
1136
1137				limit := uint32(initialEmailLimit)
1138				if counts != nil {
1139					if c, ok := counts[acc.ID]; ok && c > 0 {
1140						limit = uint32(c)
1141					}
1142				}
1143
1144				if mailbox == tui.MailboxSent {
1145					emails, err = fetcher.FetchSentEmails(&acc, limit, 0)
1146				} else {
1147					emails, err = fetcher.FetchEmails(&acc, limit, 0)
1148				}
1149				if err != nil {
1150					log.Printf("Error fetching from %s: %v", acc.Email, err)
1151					return
1152				}
1153				mu.Lock()
1154				emailsByAccount[acc.ID] = emails
1155				mu.Unlock()
1156			}(account)
1157		}
1158
1159		wg.Wait()
1160		return tui.EmailsRefreshedMsg{EmailsByAccount: emailsByAccount, Mailbox: mailbox}
1161	}
1162}
1163
1164func emailsToCache(emails []fetcher.Email) []config.CachedEmail {
1165	var cached []config.CachedEmail
1166	for _, email := range emails {
1167		cached = append(cached, config.CachedEmail{
1168			UID:       email.UID,
1169			From:      email.From,
1170			To:        email.To,
1171			Subject:   email.Subject,
1172			Date:      email.Date,
1173			MessageID: email.MessageID,
1174			AccountID: email.AccountID,
1175		})
1176	}
1177	return cached
1178}
1179
1180func cacheToEmails(cached []config.CachedEmail) []fetcher.Email {
1181	var emails []fetcher.Email
1182	for _, c := range cached {
1183		emails = append(emails, fetcher.Email{
1184			UID:       c.UID,
1185			From:      c.From,
1186			To:        c.To,
1187			Subject:   c.Subject,
1188			Date:      c.Date,
1189			MessageID: c.MessageID,
1190			AccountID: c.AccountID,
1191		})
1192	}
1193	return emails
1194}
1195
1196func saveFolderEmailsToCache(folderName string, emails []fetcher.Email) {
1197	cached := emailsToCache(emails)
1198	if err := config.SaveFolderEmailCache(folderName, cached); err != nil {
1199		log.Printf("Error saving folder email cache for %s: %v", folderName, err)
1200	}
1201}
1202
1203func loadFolderEmailsFromCache(folderName string) []fetcher.Email {
1204	cached, err := config.LoadFolderEmailCache(folderName)
1205	if err != nil {
1206		return nil
1207	}
1208	return cacheToEmails(cached)
1209}
1210
1211func saveEmailsToCache(emails []fetcher.Email) {
1212	if len(emails) > maxCacheEmails {
1213		emails = emails[:maxCacheEmails]
1214	}
1215	var cachedEmails []config.CachedEmail
1216	for _, email := range emails {
1217		cachedEmails = append(cachedEmails, config.CachedEmail{
1218			UID:       email.UID,
1219			From:      email.From,
1220			To:        email.To,
1221			Subject:   email.Subject,
1222			Date:      email.Date,
1223			MessageID: email.MessageID,
1224			AccountID: email.AccountID,
1225		})
1226
1227		// Save sender as a contact
1228		if email.From != "" {
1229			name, emailAddr := parseEmailAddress(email.From)
1230			if err := config.AddContact(name, emailAddr); err != nil {
1231				log.Printf("Error saving contact from email: %v", err)
1232			}
1233		}
1234	}
1235	cache := &config.EmailCache{Emails: cachedEmails}
1236	if err := config.SaveEmailCache(cache); err != nil {
1237		log.Printf("Error saving email cache: %v", err)
1238	}
1239}
1240
1241// parseEmailAddress parses "Name <email>" or just "email" format
1242func parseEmailAddress(addr string) (name, email string) {
1243	addr = strings.TrimSpace(addr)
1244	if idx := strings.Index(addr, "<"); idx != -1 {
1245		name = strings.TrimSpace(addr[:idx])
1246		endIdx := strings.Index(addr, ">")
1247		if endIdx > idx {
1248			email = strings.TrimSpace(addr[idx+1 : endIdx])
1249		} else {
1250			email = strings.TrimSpace(addr[idx+1:])
1251		}
1252	} else {
1253		email = addr
1254	}
1255	return name, email
1256}
1257
1258func fetchEmailBodyCmd(cfg *config.Config, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
1259	return func() tea.Msg {
1260		account := cfg.GetAccountByID(accountID)
1261		if account == nil {
1262			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: fmt.Errorf("account not found")}
1263		}
1264
1265		var (
1266			body        string
1267			attachments []fetcher.Attachment
1268			err         error
1269		)
1270		switch mailbox {
1271		case tui.MailboxSent:
1272			body, attachments, err = fetcher.FetchSentEmailBody(account, uid)
1273		case tui.MailboxTrash:
1274			body, attachments, err = fetcher.FetchTrashEmailBody(account, uid)
1275		case tui.MailboxArchive:
1276			body, attachments, err = fetcher.FetchArchiveEmailBody(account, uid)
1277		default:
1278			body, attachments, err = fetcher.FetchEmailBody(account, uid)
1279		}
1280		if err != nil {
1281			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1282		}
1283
1284		return tui.EmailBodyFetchedMsg{
1285			UID:         uid,
1286			Body:        body,
1287			Attachments: attachments,
1288			AccountID:   accountID,
1289			Mailbox:     mailbox,
1290		}
1291	}
1292}
1293
1294func markdownToHTML(md []byte) []byte {
1295	var buf bytes.Buffer
1296	p := goldmark.New(goldmark.WithRendererOptions(html.WithUnsafe()))
1297	if err := p.Convert(md, &buf); err != nil {
1298		return md
1299	}
1300	return buf.Bytes()
1301}
1302
1303func splitEmails(s string) []string {
1304	if s == "" {
1305		return nil
1306	}
1307	parts := strings.Split(s, ",")
1308	var res []string
1309	for _, p := range parts {
1310		if trimmed := strings.TrimSpace(p); trimmed != "" {
1311			res = append(res, trimmed)
1312		}
1313	}
1314	return res
1315}
1316
1317func sendEmail(account *config.Account, msg tui.SendEmailMsg) tea.Cmd {
1318	return func() tea.Msg {
1319		if account == nil {
1320			return tui.EmailResultMsg{Err: fmt.Errorf("no account configured")}
1321		}
1322
1323		recipients := splitEmails(msg.To)
1324		cc := splitEmails(msg.Cc)
1325		bcc := splitEmails(msg.Bcc)
1326		body := msg.Body
1327		// Append signature if present
1328		if msg.Signature != "" {
1329			body = body + "\n\n" + msg.Signature
1330		}
1331		// Append quoted text if present (for replies)
1332		if msg.QuotedText != "" {
1333			body = body + msg.QuotedText
1334		}
1335		images := make(map[string][]byte)
1336		attachments := make(map[string][]byte)
1337
1338		re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
1339		matches := re.FindAllStringSubmatch(body, -1)
1340
1341		for _, match := range matches {
1342			imgPath := match[1]
1343			imgData, err := os.ReadFile(imgPath)
1344			if err != nil {
1345				log.Printf("Could not read image file %s: %v", imgPath, err)
1346				continue
1347			}
1348			cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "matcha")
1349			images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
1350			body = strings.Replace(body, imgPath, "cid:"+cid, 1)
1351		}
1352
1353		htmlBody := markdownToHTML([]byte(body))
1354
1355		for _, attachPath := range msg.AttachmentPaths {
1356			fileData, err := os.ReadFile(attachPath)
1357			if err != nil {
1358				log.Printf("Could not read attachment file %s: %v", attachPath, err)
1359				continue
1360			}
1361			_, filename := filepath.Split(attachPath)
1362			attachments[filename] = fileData
1363		}
1364
1365		err := sender.SendEmail(account, recipients, cc, bcc, msg.Subject, body, string(htmlBody), images, attachments, msg.InReplyTo, msg.References, msg.SignSMIME, msg.EncryptSMIME)
1366		if err != nil {
1367			log.Printf("Failed to send email: %v", err)
1368			return tui.EmailResultMsg{Err: err}
1369		}
1370		return tui.EmailResultMsg{}
1371	}
1372}
1373
1374func deleteEmailCmd(account *config.Account, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
1375	return func() tea.Msg {
1376		var err error
1377		switch mailbox {
1378		case tui.MailboxSent:
1379			err = fetcher.DeleteSentEmail(account, uid)
1380		case tui.MailboxTrash:
1381			err = fetcher.DeleteTrashEmail(account, uid)
1382		case tui.MailboxArchive:
1383			err = fetcher.DeleteArchiveEmail(account, uid)
1384		default:
1385			err = fetcher.DeleteEmail(account, uid)
1386		}
1387		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1388	}
1389}
1390
1391func archiveEmailCmd(account *config.Account, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
1392	return func() tea.Msg {
1393		var err error
1394		if mailbox == tui.MailboxSent {
1395			err = fetcher.ArchiveSentEmail(account, uid)
1396		} else {
1397			err = fetcher.ArchiveEmail(account, uid)
1398		}
1399		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1400	}
1401}
1402
1403// --- Folder-based command functions ---
1404
1405func fetchFoldersCmd(cfg *config.Config) tea.Cmd {
1406	return func() tea.Msg {
1407		if !cfg.HasAccounts() {
1408			return nil
1409		}
1410		foldersByAccount := make(map[string][]fetcher.Folder)
1411		seen := make(map[string]fetcher.Folder)
1412		var mu sync.Mutex
1413		var wg sync.WaitGroup
1414
1415		for _, account := range cfg.Accounts {
1416			wg.Add(1)
1417			go func(acc config.Account) {
1418				defer wg.Done()
1419				folders, err := fetcher.FetchFolders(&acc)
1420				if err != nil {
1421					return
1422				}
1423				mu.Lock()
1424				foldersByAccount[acc.ID] = folders
1425				for _, f := range folders {
1426					if _, ok := seen[f.Name]; !ok {
1427						seen[f.Name] = f
1428					}
1429				}
1430				mu.Unlock()
1431			}(account)
1432		}
1433		wg.Wait()
1434
1435		var merged []fetcher.Folder
1436		for _, f := range seen {
1437			merged = append(merged, f)
1438		}
1439
1440		return tui.FoldersFetchedMsg{
1441			FoldersByAccount: foldersByAccount,
1442			MergedFolders:    merged,
1443		}
1444	}
1445}
1446
1447func fetchFolderEmailsCmd(cfg *config.Config, folderName string) tea.Cmd {
1448	return func() tea.Msg {
1449		emailsByAccount := make(map[string][]fetcher.Email)
1450		var mu sync.Mutex
1451		var wg sync.WaitGroup
1452
1453		for _, account := range cfg.Accounts {
1454			wg.Add(1)
1455			go func(acc config.Account) {
1456				defer wg.Done()
1457				emails, err := fetcher.FetchFolderEmails(&acc, folderName, initialEmailLimit, 0)
1458				if err != nil {
1459					// Folder may not exist for this account — silently skip
1460					return
1461				}
1462				mu.Lock()
1463				emailsByAccount[acc.ID] = emails
1464				mu.Unlock()
1465			}(account)
1466		}
1467
1468		wg.Wait()
1469
1470		// Flatten all account emails
1471		var allEmails []fetcher.Email
1472		for _, emails := range emailsByAccount {
1473			allEmails = append(allEmails, emails...)
1474		}
1475		// Sort newest first
1476		for i := 0; i < len(allEmails); i++ {
1477			for j := i + 1; j < len(allEmails); j++ {
1478				if allEmails[j].Date.After(allEmails[i].Date) {
1479					allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
1480				}
1481			}
1482		}
1483
1484		return tui.FolderEmailsFetchedMsg{
1485			Emails:     allEmails,
1486			FolderName: folderName,
1487		}
1488	}
1489}
1490
1491func fetchFolderEmailsPaginatedCmd(account *config.Account, folderName string, limit, offset uint32) tea.Cmd {
1492	return func() tea.Msg {
1493		emails, err := fetcher.FetchFolderEmails(account, folderName, limit, offset)
1494		if err != nil {
1495			return tui.FetchErr(err)
1496		}
1497		return tui.FolderEmailsAppendedMsg{
1498			Emails:     emails,
1499			AccountID:  account.ID,
1500			FolderName: folderName,
1501		}
1502	}
1503}
1504
1505func fetchFolderEmailBodyCmd(cfg *config.Config, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
1506	return func() tea.Msg {
1507		account := cfg.GetAccountByID(accountID)
1508		if account == nil {
1509			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: fmt.Errorf("account not found")}
1510		}
1511
1512		body, attachments, err := fetcher.FetchFolderEmailBody(account, folderName, uid)
1513		if err != nil {
1514			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1515		}
1516
1517		return tui.EmailBodyFetchedMsg{
1518			UID:         uid,
1519			Body:        body,
1520			Attachments: attachments,
1521			AccountID:   accountID,
1522			Mailbox:     mailbox,
1523		}
1524	}
1525}
1526
1527func deleteFolderEmailCmd(account *config.Account, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
1528	return func() tea.Msg {
1529		err := fetcher.DeleteFolderEmail(account, folderName, uid)
1530		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1531	}
1532}
1533
1534func archiveFolderEmailCmd(account *config.Account, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
1535	return func() tea.Msg {
1536		err := fetcher.ArchiveFolderEmail(account, folderName, uid)
1537		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1538	}
1539}
1540
1541func moveEmailToFolderCmd(account *config.Account, uid uint32, accountID string, sourceFolder, destFolder string) tea.Cmd {
1542	return func() tea.Msg {
1543		err := fetcher.MoveEmailToFolder(account, uid, sourceFolder, destFolder)
1544		return tui.EmailMovedMsg{
1545			UID:          uid,
1546			AccountID:    accountID,
1547			SourceFolder: sourceFolder,
1548			DestFolder:   destFolder,
1549			Err:          err,
1550		}
1551	}
1552}
1553
1554func downloadAttachmentCmd(account *config.Account, uid uint32, msg tui.DownloadAttachmentMsg) tea.Cmd {
1555	return func() tea.Msg {
1556		// Download and decode the attachment using encoding provided in msg.Encoding.
1557		var data []byte
1558		var err error
1559		switch msg.Mailbox {
1560		case tui.MailboxSent:
1561			data, err = fetcher.FetchSentAttachment(account, uid, msg.PartID, msg.Encoding)
1562		case tui.MailboxTrash:
1563			data, err = fetcher.FetchTrashAttachment(account, uid, msg.PartID, msg.Encoding)
1564		case tui.MailboxArchive:
1565			data, err = fetcher.FetchArchiveAttachment(account, uid, msg.PartID, msg.Encoding)
1566		default:
1567			data, err = fetcher.FetchAttachment(account, uid, msg.PartID, msg.Encoding)
1568		}
1569		if err != nil {
1570			return tui.AttachmentDownloadedMsg{Err: err}
1571		}
1572
1573		homeDir, err := os.UserHomeDir()
1574		if err != nil {
1575			return tui.AttachmentDownloadedMsg{Err: err}
1576		}
1577		downloadsPath := filepath.Join(homeDir, "Downloads")
1578		if _, err := os.Stat(downloadsPath); os.IsNotExist(err) {
1579			if mkErr := os.MkdirAll(downloadsPath, 0755); mkErr != nil {
1580				return tui.AttachmentDownloadedMsg{Err: mkErr}
1581			}
1582		}
1583
1584		// Save the attachment using an exclusive create so we never overwrite an existing file.
1585		// If the filename already exists, append \" (n)\" before the extension.
1586		origName := msg.Filename
1587		ext := filepath.Ext(origName)
1588		base := strings.TrimSuffix(origName, ext)
1589		candidate := origName
1590		i := 1
1591		var filePath string
1592
1593		for {
1594			filePath = filepath.Join(downloadsPath, candidate)
1595
1596			// Try to create file exclusively. If it already exists, os.OpenFile will return an error
1597			// that satisfies os.IsExist(err), so we can increment the candidate.
1598			f, err := os.OpenFile(filePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
1599			if err != nil {
1600				if os.IsExist(err) {
1601					// file exists, try next candidate
1602					candidate = fmt.Sprintf("%s (%d)%s", base, i, ext)
1603					i++
1604					continue
1605				}
1606				// Some other error while attempting to create file
1607				log.Printf("error creating file %s: %v", filePath, err)
1608				return tui.AttachmentDownloadedMsg{Err: err}
1609			}
1610
1611			// Successfully created the file descriptor; write and close.
1612			if _, writeErr := f.Write(data); writeErr != nil {
1613				_ = f.Close()
1614				log.Printf("error writing to file %s: %v", filePath, writeErr)
1615				return tui.AttachmentDownloadedMsg{Err: writeErr}
1616			}
1617			if closeErr := f.Close(); closeErr != nil {
1618				log.Printf("warning: error closing file %s: %v", filePath, closeErr)
1619			}
1620
1621			// file saved successfully
1622			break
1623		}
1624
1625		log.Printf("attachment saved to %s", filePath)
1626
1627		// Try to open the file using a platform-specific opener asynchronously and log the outcome.
1628		go func(p string) {
1629			var cmd *exec.Cmd
1630			switch runtime.GOOS {
1631			case "darwin":
1632				cmd = exec.Command("open", p)
1633			case "linux":
1634				cmd = exec.Command("xdg-open", p)
1635			case "windows":
1636				// 'start' is a cmd builtin; provide an empty title argument to avoid interpreting the path as the title.
1637				cmd = exec.Command("cmd", "/c", "start", "", p)
1638			default:
1639				// Unsupported OS: nothing to do.
1640				return
1641			}
1642			if err := cmd.Start(); err != nil {
1643				log.Printf("failed to open file %s: %v", p, err)
1644			}
1645		}(filePath)
1646
1647		return tui.AttachmentDownloadedMsg{Path: filePath, Err: nil}
1648	}
1649}
1650
1651/*
1652detectInstalledVersion returns a best-effort installed version string.
1653Priority:
1654 1. If the build-in `version` variable is set to something other than "dev", return it.
1655 2. If Homebrew is present and reports a version for `matcha`, return that.
1656 3. If snap is present and lists `matcha`, return that.
1657 4. Fallback to the build `version` (likely "dev").
1658*/
1659func detectInstalledVersion() string {
1660	v := strings.TrimSpace(version)
1661	if v != "dev" && v != "" {
1662		return v
1663	}
1664
1665	// Try Homebrew (macOS)
1666	if runtime.GOOS == "darwin" {
1667		if _, err := exec.LookPath("brew"); err == nil {
1668			// `brew list --versions matcha` prints: matcha 1.2.3
1669			if out, err := exec.Command("brew", "list", "--versions", "matcha").Output(); err == nil {
1670				parts := strings.Fields(string(out))
1671				if len(parts) >= 2 {
1672					return parts[1]
1673				}
1674			}
1675		}
1676	}
1677
1678	// Try snap (Linux)
1679	if runtime.GOOS == "linux" {
1680		if _, err := exec.LookPath("snap"); err == nil {
1681			if out, err := exec.Command("snap", "list", "matcha").Output(); err == nil {
1682				lines := strings.Split(strings.TrimSpace(string(out)), "\n")
1683				if len(lines) >= 2 {
1684					fields := strings.Fields(lines[1])
1685					if len(fields) >= 2 {
1686						return fields[1]
1687					}
1688				}
1689			}
1690		}
1691
1692		if _, err := exec.LookPath("flatpak"); err == nil {
1693			if out, err := exec.Command("flatpak", "info", "com.floatpane.matcha").Output(); err == nil {
1694				lines := strings.Split(strings.TrimSpace(string(out)), "\n")
1695				for _, line := range lines {
1696					line = strings.TrimSpace(line)
1697					if strings.HasPrefix(line, "Version:") {
1698						fields := strings.Fields(line)
1699						if len(fields) >= 2 {
1700							return fields[1]
1701						}
1702					}
1703				}
1704			}
1705		}
1706	}
1707
1708	return v
1709}
1710
1711/*
1712checkForUpdatesCmd queries GitHub for the latest release tag and returns a
1713tea.Msg (UpdateAvailableMsg) if the latest version differs from the current
1714installed version. This runs in the background when the TUI initializes.
1715*/
1716func checkForUpdatesCmd() tea.Cmd {
1717	return func() tea.Msg {
1718		// Non-fatal: if anything goes wrong we just don't show the update message.
1719		const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
1720		resp, err := http.Get(api)
1721		if err != nil {
1722			return nil
1723		}
1724		defer resp.Body.Close()
1725
1726		var rel githubRelease
1727		if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
1728			return nil
1729		}
1730
1731		latest := strings.TrimPrefix(rel.TagName, "v")
1732		installed := strings.TrimPrefix(detectInstalledVersion(), "v")
1733		if latest != "" && installed != "" && latest != installed {
1734			return UpdateAvailableMsg{Latest: latest, Current: installed}
1735		}
1736		return nil
1737	}
1738}
1739
1740// runUpdateCLI implements the CLI entrypoint for `matcha update`.
1741// It detects the likely installation method and attempts the appropriate
1742// update path (Homebrew, Snap, or GitHub release binary extract).
1743func runUpdateCLI() error {
1744	const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
1745	resp, err := http.Get(api)
1746	if err != nil {
1747		return fmt.Errorf("could not query releases: %w", err)
1748	}
1749	defer resp.Body.Close()
1750
1751	var rel githubRelease
1752	if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
1753		return fmt.Errorf("could not parse release info: %w", err)
1754	}
1755
1756	latestTag := rel.TagName
1757	if strings.HasPrefix(latestTag, "v") {
1758		latestTag = latestTag[1:]
1759	}
1760
1761	fmt.Printf("Current version: %s\n", version)
1762	fmt.Printf("Latest version: %s\n", latestTag)
1763
1764	// Quick check: if already up-to-date, exit
1765	cur := version
1766	if strings.HasPrefix(cur, "v") {
1767		cur = cur[1:]
1768	}
1769	if latestTag == "" || cur == latestTag {
1770		fmt.Println("Already up to date.")
1771		return nil
1772	}
1773
1774	// Detect Homebrew
1775	if _, err := exec.LookPath("brew"); err == nil {
1776		fmt.Println("Detected Homebrew — updating taps and attempting to upgrade via brew.")
1777
1778		updateCmd := exec.Command("brew", "update")
1779		updateCmd.Stdout = os.Stdout
1780		updateCmd.Stderr = os.Stderr
1781		if err := updateCmd.Run(); err != nil {
1782			fmt.Printf("Homebrew update failed: %v\n", err)
1783			// continue to attempt upgrade even if update failed
1784		}
1785
1786		upgradeCmd := exec.Command("brew", "upgrade", "floatpane/matcha/matcha")
1787		upgradeCmd.Stdout = os.Stdout
1788		upgradeCmd.Stderr = os.Stderr
1789		if err := upgradeCmd.Run(); err == nil {
1790			fmt.Println("Successfully upgraded via Homebrew.")
1791			return nil
1792		}
1793		fmt.Printf("Homebrew upgrade failed: %v\n", err)
1794		// fallthrough to other methods
1795	}
1796
1797	// Detect snap
1798	if _, err := exec.LookPath("snap"); err == nil {
1799		// Check if matcha is installed as a snap
1800		cmdCheck := exec.Command("snap", "list", "matcha")
1801		if err := cmdCheck.Run(); err == nil {
1802			fmt.Println("Detected Snap package — attempting to refresh.")
1803			cmd := exec.Command("snap", "refresh", "matcha")
1804			cmd.Stdout = os.Stdout
1805			cmd.Stderr = os.Stderr
1806			if err := cmd.Run(); err == nil {
1807				fmt.Println("Successfully refreshed snap.")
1808				return nil
1809			}
1810			fmt.Printf("Snap refresh failed: %v\n", err)
1811			// fallthrough
1812		}
1813	}
1814	// Detect flatpak
1815	if _, err := exec.LookPath("flatpak"); err == nil {
1816		// Check if matcha is installed as a flatpak
1817		cmdCheck := exec.Command("flatpak", "info", "com.floatpane.matcha")
1818		if err := cmdCheck.Run(); err == nil {
1819			fmt.Println("Detected Flatpak package — attempting to update.")
1820			cmd := exec.Command("flatpak", "update", "-y", "com.floatpane.matcha")
1821			cmd.Stdout = os.Stdout
1822			cmd.Stderr = os.Stderr
1823			if err := cmd.Run(); err == nil {
1824				fmt.Println("Successfully updated flatpak.")
1825				return nil
1826			}
1827			fmt.Printf("Flatpak update failed: %v\n", err)
1828			// fallthrough
1829		}
1830	}
1831
1832	// Otherwise attempt to download the proper release asset and replace the binary.
1833	osName := runtime.GOOS
1834	arch := runtime.GOARCH
1835
1836	// Try to find a matching asset
1837	var assetURL, assetName string
1838	for _, a := range rel.Assets {
1839		n := strings.ToLower(a.Name)
1840		if strings.Contains(n, osName) && strings.Contains(n, arch) && (strings.HasSuffix(n, ".tar.gz") || strings.HasSuffix(n, ".tgz") || strings.HasSuffix(n, ".zip")) {
1841			assetURL = a.BrowserDownloadURL
1842			assetName = a.Name
1843			break
1844		}
1845	}
1846	if assetURL == "" {
1847		// Try any asset that contains 'matcha' and os/arch as a fallback
1848		for _, a := range rel.Assets {
1849			n := strings.ToLower(a.Name)
1850			if strings.Contains(n, "matcha") && (strings.Contains(n, osName) || strings.Contains(n, arch)) {
1851				assetURL = a.BrowserDownloadURL
1852				assetName = a.Name
1853				break
1854			}
1855		}
1856	}
1857
1858	if assetURL == "" {
1859		return fmt.Errorf("no suitable release artifact found for %s/%s", osName, arch)
1860	}
1861
1862	fmt.Printf("Found release asset: %s\n", assetName)
1863	fmt.Println("Downloading...")
1864
1865	// Download asset
1866	respAsset, err := http.Get(assetURL)
1867	if err != nil {
1868		return fmt.Errorf("download failed: %w", err)
1869	}
1870	defer respAsset.Body.Close()
1871
1872	// Create a temp file for the download
1873	tmpDir, err := os.MkdirTemp("", "matcha-update-*")
1874	if err != nil {
1875		return fmt.Errorf("could not create temp dir: %w", err)
1876	}
1877	defer os.RemoveAll(tmpDir)
1878
1879	assetPath := filepath.Join(tmpDir, assetName)
1880	outFile, err := os.Create(assetPath)
1881	if err != nil {
1882		return fmt.Errorf("could not create temp file: %w", err)
1883	}
1884	_, err = io.Copy(outFile, respAsset.Body)
1885	outFile.Close()
1886	if err != nil {
1887		return fmt.Errorf("could not write asset to disk: %w", err)
1888	}
1889
1890	// If it's a tar.gz, extract and find the `matcha` binary
1891	var binPath string
1892	if strings.HasSuffix(assetName, ".tar.gz") || strings.HasSuffix(assetName, ".tgz") {
1893		f, err := os.Open(assetPath)
1894		if err != nil {
1895			return fmt.Errorf("could not open archive: %w", err)
1896		}
1897		defer f.Close()
1898		gzr, err := gzip.NewReader(f)
1899		if err != nil {
1900			return fmt.Errorf("could not create gzip reader: %w", err)
1901		}
1902		tr := tar.NewReader(gzr)
1903		for {
1904			hdr, err := tr.Next()
1905			if err == io.EOF {
1906				break
1907			}
1908			if err != nil {
1909				return fmt.Errorf("error reading tar: %w", err)
1910			}
1911			name := filepath.Base(hdr.Name)
1912			if name == "matcha" || strings.Contains(strings.ToLower(name), "matcha") && (hdr.Typeflag == tar.TypeReg) {
1913				// write out the file
1914				binPath = filepath.Join(tmpDir, "matcha")
1915				out, err := os.Create(binPath)
1916				if err != nil {
1917					return fmt.Errorf("could not create binary file: %w", err)
1918				}
1919				if _, err := io.Copy(out, tr); err != nil {
1920					out.Close()
1921					return fmt.Errorf("could not extract binary: %w", err)
1922				}
1923				out.Close()
1924				if err := os.Chmod(binPath, 0755); err != nil {
1925					return fmt.Errorf("could not make binary executable: %w", err)
1926				}
1927				break
1928			}
1929		}
1930	} else {
1931		// For non-archive assets, assume the asset is the binary itself.
1932		binPath = assetPath
1933		if err := os.Chmod(binPath, 0755); err != nil {
1934			// ignore chmod errors but warn
1935			fmt.Printf("warning: could not chmod downloaded binary: %v\n", err)
1936		}
1937	}
1938
1939	if binPath == "" {
1940		return fmt.Errorf("could not locate matcha binary inside the release artifact")
1941	}
1942
1943	// Replace the running executable with the new binary
1944	execPath, err := os.Executable()
1945	if err != nil {
1946		return fmt.Errorf("could not determine executable path: %w", err)
1947	}
1948
1949	// Write the new binary to a temp file in same dir, then rename for atomic replacement.
1950	execDir := filepath.Dir(execPath)
1951	tmpNew := filepath.Join(execDir, fmt.Sprintf("matcha.new.%d", time.Now().Unix()))
1952	in, err := os.Open(binPath)
1953	if err != nil {
1954		return fmt.Errorf("could not open new binary: %w", err)
1955	}
1956	out, err := os.OpenFile(tmpNew, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
1957	if err != nil {
1958		in.Close()
1959		return fmt.Errorf("could not create temp binary in target dir: %w", err)
1960	}
1961	if _, err := io.Copy(out, in); err != nil {
1962		in.Close()
1963		out.Close()
1964		return fmt.Errorf("could not write new binary to disk: %w", err)
1965	}
1966	in.Close()
1967	out.Close()
1968
1969	// Attempt to atomically replace
1970	if err := os.Rename(tmpNew, execPath); err != nil {
1971		return fmt.Errorf("could not replace executable: %w", err)
1972	}
1973
1974	fmt.Println("Successfully updated matcha to", latestTag)
1975	return nil
1976}
1977
1978func filterUnique(existing, incoming []fetcher.Email) []fetcher.Email {
1979	seen := make(map[uint32]struct{})
1980	for _, e := range existing {
1981		seen[e.UID] = struct{}{}
1982	}
1983	var unique []fetcher.Email
1984	for _, e := range incoming {
1985		if _, ok := seen[e.UID]; !ok {
1986			unique = append(unique, e)
1987		}
1988	}
1989	return unique
1990}
1991
1992func main() {
1993	// If invoked with version flag, print version and exit
1994	if len(os.Args) > 1 && (os.Args[1] == "-v" || os.Args[1] == "--version" || os.Args[1] == "version") {
1995		fmt.Printf("matcha version %s", version)
1996		if commit != "" {
1997			fmt.Printf(" (%s)", commit)
1998		}
1999		if date != "" {
2000			fmt.Printf(" built on %s", date)
2001		}
2002		fmt.Println()
2003		os.Exit(0)
2004	}
2005
2006	// If invoked as CLI update command, run updater and exit.
2007	if len(os.Args) > 1 && os.Args[1] == "update" {
2008		if err := runUpdateCLI(); err != nil {
2009			fmt.Fprintf(os.Stderr, "update failed: %v\n", err)
2010			os.Exit(1)
2011		}
2012		os.Exit(0)
2013	}
2014
2015	cfg, err := config.LoadConfig()
2016	if err == nil && cfg.Theme != "" {
2017		theme.SetTheme(cfg.Theme)
2018	}
2019	tui.RebuildStyles()
2020
2021	var initialModel *mainModel
2022	if err != nil {
2023		initialModel = newInitialModel(nil)
2024	} else {
2025		initialModel = newInitialModel(cfg)
2026	}
2027
2028	p := tea.NewProgram(initialModel)
2029
2030	if _, err := p.Run(); err != nil {
2031		fmt.Printf("Alas, there's been an error: %v", err)
2032		os.Exit(1)
2033	}
2034}