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