main.go

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