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