main.go

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