main.go

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