main.go

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