main.go

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