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