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 "github.com/charmbracelet/bubbletea"
  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)
  36
  37// Version variables are injected by the build (GoReleaser ldflags).
  38// They default to "dev" when not set by the build system.
  39var (
  40	version = "dev"
  41	commit  = ""
  42	date    = ""
  43)
  44
  45// UpdateAvailableMsg is sent into the TUI when a newer release is detected.
  46type UpdateAvailableMsg struct {
  47	Latest  string
  48	Current string
  49}
  50
  51// internal struct for parsing GitHub release JSON.
  52type githubRelease struct {
  53	TagName string `json:"tag_name"`
  54	Assets  []struct {
  55		Name               string `json:"name"`
  56		BrowserDownloadURL string `json:"browser_download_url"`
  57	} `json:"assets"`
  58}
  59
  60type mainModel struct {
  61	current       tea.Model
  62	previousModel tea.Model
  63	config        *config.Config
  64	emails        []fetcher.Email
  65	emailsByAcct  map[string][]fetcher.Email
  66	sentEmails   []fetcher.Email
  67	sentByAcct   map[string][]fetcher.Email
  68	inbox         *tui.Inbox
  69	sentInbox    *tui.Inbox
  70	width         int
  71	height        int
  72	err           error
  73}
  74
  75func newInitialModel(cfg *config.Config) *mainModel {
  76	initialModel := &mainModel{
  77		emailsByAcct: make(map[string][]fetcher.Email),
  78		sentByAcct:   make(map[string][]fetcher.Email),
  79	}
  80
  81	if cfg == nil || !cfg.HasAccounts() {
  82		initialModel.current = tui.NewLogin()
  83	} else {
  84		initialModel.current = tui.NewChoice()
  85		initialModel.config = cfg
  86	}
  87	return initialModel
  88}
  89
  90func (m *mainModel) Init() tea.Cmd {
  91	return tea.Batch(m.current.Init(), checkForUpdatesCmd())
  92}
  93
  94func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  95	var cmd tea.Cmd
  96	var cmds []tea.Cmd
  97
  98	m.current, cmd = m.current.Update(msg)
  99	cmds = append(cmds, cmd)
 100
 101	switch msg := msg.(type) {
 102	case tea.WindowSizeMsg:
 103		m.width = msg.Width
 104		m.height = msg.Height
 105		return m, nil
 106
 107	case tea.KeyMsg:
 108		if msg.String() == "ctrl+c" {
 109			return m, tea.Quit
 110		}
 111		if msg.String() == "esc" {
 112			switch m.current.(type) {
 113			case *tui.FilePicker:
 114				return m, func() tea.Msg { return tui.CancelFilePickerMsg{} }
 115			case *tui.Inbox, *tui.Login:
 116				m.current = tui.NewChoice()
 117				return m, m.current.Init()
 118			}
 119		}
 120
 121	case tui.BackToInboxMsg:
 122		if m.inbox != nil {
 123			m.current = m.inbox
 124		} else {
 125			m.current = tui.NewChoice()
 126		}
 127		return m, nil
 128
 129	case tui.BackToMailboxMsg:
 130		switch msg.Mailbox {
 131		case tui.MailboxSent:
 132			if m.sentInbox != nil {
 133				m.current = m.sentInbox
 134				return m, nil
 135			}
 136		case tui.MailboxInbox:
 137			if m.inbox != nil {
 138				m.current = m.inbox
 139				return m, nil
 140			}
 141		}
 142		m.current = tui.NewChoice()
 143		return m, nil
 144
 145	case tui.DiscardDraftMsg:
 146		// Save draft to disk
 147		if msg.ComposerState != nil {
 148			draft := msg.ComposerState.ToDraft()
 149			go func() {
 150				if err := config.SaveDraft(draft); err != nil {
 151					log.Printf("Error saving draft: %v", err)
 152				}
 153			}()
 154		}
 155		m.current = tui.NewChoice()
 156		return m, m.current.Init()
 157
 158	case tui.Credentials:
 159		// Add new account or update existing
 160		account := config.Account{
 161			ID:              uuid.New().String(),
 162			Name:            msg.Name,
 163			Email:           msg.Host, // login/email used for authentication comes from Host field in the form
 164			Password:        msg.Password,
 165			ServiceProvider: msg.Provider,
 166			FetchEmail:      msg.FetchEmail,
 167		}
 168
 169		if msg.Provider == "custom" {
 170			account.IMAPServer = msg.IMAPServer
 171			account.IMAPPort = msg.IMAPPort
 172			account.SMTPServer = msg.SMTPServer
 173			account.SMTPPort = msg.SMTPPort
 174		}
 175
 176		// Ensure FetchEmail defaults to the login Email (Host) if not explicitly set
 177		if account.FetchEmail == "" && account.Email != "" {
 178			account.FetchEmail = account.Email
 179		}
 180
 181		if m.config == nil {
 182			m.config = &config.Config{}
 183		}
 184
 185		// Check if we're editing an existing account
 186		if login, ok := m.current.(*tui.Login); ok && login.IsEditMode() {
 187			// Find and update the existing account
 188			existingID := login.GetAccountID()
 189			for i, acc := range m.config.Accounts {
 190				if acc.ID == existingID {
 191					account.ID = existingID
 192					m.config.Accounts[i] = account
 193					break
 194				}
 195			}
 196		} else {
 197			m.config.AddAccount(account)
 198		}
 199
 200		if err := config.SaveConfig(m.config); err != nil {
 201			log.Printf("could not save config: %v", err)
 202			return m, tea.Quit
 203		}
 204
 205		m.current = tui.NewChoice()
 206		return m, m.current.Init()
 207
 208	case tui.GoToInboxMsg:
 209		if m.config == nil || !m.config.HasAccounts() {
 210			m.current = tui.NewLogin()
 211			return m, m.current.Init()
 212		}
 213		// Try to load from cache first for instant display
 214		if config.HasEmailCache() {
 215			return m, loadCachedEmails()
 216		}
 217		// No cache, fetch normally
 218		m.current = tui.NewStatus("Fetching emails from all accounts...")
 219		return m, tea.Batch(m.current.Init(), fetchAllAccountsEmails(m.config, tui.MailboxInbox))
 220
 221	case tui.GoToSentInboxMsg:
 222		if m.config == nil || !m.config.HasAccounts() {
 223			m.current = tui.NewLogin()
 224			return m, m.current.Init()
 225		}
 226		m.current = tui.NewStatus("Fetching sent emails from all accounts...")
 227		return m, tea.Batch(m.current.Init(), fetchAllAccountsEmails(m.config, tui.MailboxSent))
 228
 229	case tui.CachedEmailsLoadedMsg:
 230		if msg.Cache == nil {
 231			// Cache load failed, fetch normally
 232			m.current = tui.NewStatus("Fetching emails from all accounts...")
 233			return m, tea.Batch(m.current.Init(), fetchAllAccountsEmails(m.config, tui.MailboxInbox))
 234		}
 235
 236		// Convert cached emails to fetcher.Email
 237		var cachedEmails []fetcher.Email
 238		emailsByAcct := make(map[string][]fetcher.Email)
 239		for _, cached := range msg.Cache.Emails {
 240			email := fetcher.Email{
 241				UID:       cached.UID,
 242				From:      cached.From,
 243				To:        cached.To,
 244				Subject:   cached.Subject,
 245				Date:      cached.Date,
 246				MessageID: cached.MessageID,
 247				AccountID: cached.AccountID,
 248			}
 249			cachedEmails = append(cachedEmails, email)
 250			emailsByAcct[cached.AccountID] = append(emailsByAcct[cached.AccountID], email)
 251		}
 252
 253		m.emails = cachedEmails
 254		m.emailsByAcct = emailsByAcct
 255		m.inbox = tui.NewInbox(m.emails, m.config.Accounts)
 256		m.current = m.inbox
 257		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 258
 259		// Start background refresh
 260		return m, tea.Batch(
 261			m.current.Init(),
 262			func() tea.Msg { return tui.RefreshingEmailsMsg{Mailbox: tui.MailboxInbox} },
 263			refreshEmails(m.config, tui.MailboxInbox),
 264		)
 265
 266	case tui.EmailsRefreshedMsg:
 267		if msg.Mailbox == tui.MailboxSent {
 268			m.sentByAcct = msg.EmailsByAccount
 269			m.sentEmails = flattenAndSort(msg.EmailsByAccount)
 270			if m.sentInbox != nil {
 271				m.sentInbox.SetEmails(m.sentEmails, m.config.Accounts)
 272				m.current, _ = m.current.Update(msg)
 273			}
 274			return m, nil
 275		}
 276
 277		m.emailsByAcct = msg.EmailsByAccount
 278		m.emails = flattenAndSort(msg.EmailsByAccount)
 279
 280		// Save to cache (inbox only)
 281		go saveEmailsToCache(m.emails)
 282
 283		// Update inbox if it exists
 284		if m.inbox != nil {
 285			m.inbox.SetEmails(m.emails, m.config.Accounts)
 286			// Forward the message to inbox to clear refreshing state
 287			m.current, _ = m.current.Update(msg)
 288		}
 289		return m, nil
 290
 291	case tui.AllEmailsFetchedMsg:
 292		if msg.Mailbox == tui.MailboxSent {
 293			m.sentByAcct = msg.EmailsByAccount
 294			m.sentEmails = flattenAndSort(msg.EmailsByAccount)
 295
 296			m.sentInbox = tui.NewSentInbox(m.sentEmails, m.config.Accounts)
 297			m.current = m.sentInbox
 298			m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 299			return m, m.current.Init()
 300		}
 301
 302		m.emailsByAcct = msg.EmailsByAccount
 303		m.emails = flattenAndSort(msg.EmailsByAccount)
 304
 305		// Save to cache
 306		go saveEmailsToCache(m.emails)
 307
 308		m.inbox = tui.NewInbox(m.emails, m.config.Accounts)
 309		m.current = m.inbox
 310		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 311		return m, m.current.Init()
 312
 313	case tui.EmailsFetchedMsg:
 314		if msg.Mailbox == tui.MailboxSent {
 315			if m.sentByAcct == nil {
 316				m.sentByAcct = make(map[string][]fetcher.Email)
 317			}
 318			m.sentByAcct[msg.AccountID] = msg.Emails
 319			m.sentEmails = flattenAndSort(m.sentByAcct)
 320			if m.sentInbox == nil {
 321				m.sentInbox = tui.NewSentInbox(m.sentEmails, m.config.Accounts)
 322			} else {
 323				m.sentInbox.SetEmails(m.sentEmails, m.config.Accounts)
 324			}
 325			m.current = m.sentInbox
 326			m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 327			return m, m.current.Init()
 328		}
 329
 330		// Inbox path
 331		if m.emailsByAcct == nil {
 332			m.emailsByAcct = make(map[string][]fetcher.Email)
 333		}
 334		m.emailsByAcct[msg.AccountID] = msg.Emails
 335		m.emails = flattenAndSort(m.emailsByAcct)
 336		if m.inbox == nil {
 337			m.inbox = tui.NewInbox(m.emails, m.config.Accounts)
 338		} else {
 339			m.inbox.SetEmails(m.emails, m.config.Accounts)
 340		}
 341		m.current = m.inbox
 342		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 343		return m, m.current.Init()
 344
 345	case tui.FetchMoreEmailsMsg:
 346		if msg.AccountID == "" {
 347			return m, nil // Don't fetch more for "ALL" view
 348		}
 349		account := m.config.GetAccountByID(msg.AccountID)
 350		if account == nil {
 351			return m, nil
 352		}
 353		return m, tea.Batch(
 354			func() tea.Msg { return tui.FetchingMoreEmailsMsg{} },
 355			fetchEmails(account, paginationLimit, msg.Offset, msg.Mailbox),
 356		)
 357
 358	case tui.EmailsAppendedMsg:
 359		if msg.Mailbox == tui.MailboxSent {
 360			if m.sentByAcct == nil {
 361				m.sentByAcct = make(map[string][]fetcher.Email)
 362			}
 363			m.sentByAcct[msg.AccountID] = append(m.sentByAcct[msg.AccountID], msg.Emails...)
 364			m.sentEmails = append(m.sentEmails, msg.Emails...)
 365			return m, nil
 366		}
 367		// Inbox
 368		if m.emailsByAcct == nil {
 369			m.emailsByAcct = make(map[string][]fetcher.Email)
 370		}
 371		m.emailsByAcct[msg.AccountID] = append(m.emailsByAcct[msg.AccountID], msg.Emails...)
 372		m.emails = append(m.emails, msg.Emails...)
 373		return m, nil
 374
 375	case tui.GoToSendMsg:
 376		if m.config != nil && len(m.config.Accounts) > 0 {
 377			firstAccount := m.config.GetFirstAccount()
 378			composer := tui.NewComposerWithAccounts(m.config.Accounts, firstAccount.ID, msg.To, msg.Subject, msg.Body)
 379			m.current = composer
 380		} else {
 381			m.current = tui.NewComposer("", msg.To, msg.Subject, msg.Body)
 382		}
 383		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 384		return m, m.current.Init()
 385
 386	case tui.GoToDraftsMsg:
 387		drafts := config.GetAllDrafts()
 388		m.current = tui.NewDrafts(drafts)
 389		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 390		return m, m.current.Init()
 391
 392	case tui.OpenDraftMsg:
 393		var accounts []config.Account
 394		if m.config != nil {
 395			accounts = m.config.Accounts
 396		}
 397		composer := tui.NewComposerFromDraft(msg.Draft, accounts)
 398		m.current = composer
 399		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 400		return m, m.current.Init()
 401
 402	case tui.DeleteSavedDraftMsg:
 403		go func() {
 404			if err := config.DeleteDraft(msg.DraftID); err != nil {
 405				log.Printf("Error deleting draft: %v", err)
 406			}
 407		}()
 408		// Send message back to drafts view
 409		m.current, cmd = m.current.Update(tui.DraftDeletedMsg{DraftID: msg.DraftID})
 410		return m, cmd
 411
 412	case tui.GoToSettingsMsg:
 413		if m.config != nil {
 414			m.current = tui.NewSettings(m.config.Accounts)
 415		} else {
 416			m.current = tui.NewSettings(nil)
 417		}
 418		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 419		return m, m.current.Init()
 420
 421	case tui.GoToAddAccountMsg:
 422		m.current = tui.NewLogin()
 423		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 424		return m, m.current.Init()
 425
 426	case tui.GoToChoiceMenuMsg:
 427		m.current = tui.NewChoice()
 428		return m, m.current.Init()
 429
 430	case tui.DeleteAccountMsg:
 431		if m.config != nil {
 432			m.config.RemoveAccount(msg.AccountID)
 433			if err := config.SaveConfig(m.config); err != nil {
 434				log.Printf("could not save config: %v", err)
 435			}
 436			// Remove emails for this account
 437			delete(m.emailsByAcct, msg.AccountID)
 438
 439			// Rebuild all emails
 440			var allEmails []fetcher.Email
 441			for _, emails := range m.emailsByAcct {
 442				allEmails = append(allEmails, emails...)
 443			}
 444			m.emails = allEmails
 445
 446			// Go back to settings
 447			m.current = tui.NewSettings(m.config.Accounts)
 448			m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 449		}
 450		return m, m.current.Init()
 451
 452	case tui.ViewEmailMsg:
 453		email := m.getEmailByUIDAndAccount(msg.UID, msg.AccountID, msg.Mailbox)
 454		if email == nil {
 455			return m, nil
 456		}
 457		m.current = tui.NewStatus("Fetching email content...")
 458		return m, tea.Batch(m.current.Init(), fetchEmailBodyCmd(m.config, *email, msg.UID, msg.AccountID, msg.Mailbox))
 459
 460	case tui.EmailBodyFetchedMsg:
 461		if msg.Err != nil {
 462			log.Printf("could not fetch email body: %v", msg.Err)
 463			if msg.Mailbox == tui.MailboxSent && m.sentInbox != nil {
 464				m.current = m.sentInbox
 465			} else {
 466				m.current = m.inbox
 467			}
 468			return m, nil
 469		}
 470
 471		// Update the email in our stores
 472		m.updateEmailBodyByUID(msg.UID, msg.AccountID, msg.Mailbox, msg.Body, msg.Attachments)
 473
 474		email := m.getEmailByUIDAndAccount(msg.UID, msg.AccountID, msg.Mailbox)
 475		if email == nil {
 476			if msg.Mailbox == tui.MailboxSent && m.sentInbox != nil {
 477				m.current = m.sentInbox
 478			} else {
 479				m.current = m.inbox
 480			}
 481			return m, nil
 482		}
 483
 484		// Find the index for the email view (used for display purposes)
 485		emailIndex := m.getEmailIndex(msg.UID, msg.AccountID, msg.Mailbox)
 486		emailView := tui.NewEmailView(*email, emailIndex, m.width, m.height, msg.Mailbox)
 487		m.current = emailView
 488		return m, m.current.Init()
 489
 490	case tui.ReplyToEmailMsg:
 491		to := msg.Email.From
 492		subject := "Re: " + msg.Email.Subject
 493		body := 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> "))
 494
 495		if m.config != nil && len(m.config.Accounts) > 0 {
 496			// Use the account that received the email
 497			accountID := msg.Email.AccountID
 498			if accountID == "" {
 499				accountID = m.config.GetFirstAccount().ID
 500			}
 501			composer := tui.NewComposerWithAccounts(m.config.Accounts, accountID, to, subject, body)
 502			m.current = composer
 503		} else {
 504			m.current = tui.NewComposer("", to, subject, body)
 505		}
 506		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 507		return m, m.current.Init()
 508
 509	case tui.GoToFilePickerMsg:
 510		m.previousModel = m.current
 511		wd, _ := os.Getwd()
 512		m.current = tui.NewFilePicker(wd)
 513		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 514		return m, m.current.Init()
 515
 516	case tui.FileSelectedMsg, tui.CancelFilePickerMsg:
 517		if m.previousModel != nil {
 518			m.current = m.previousModel
 519			m.previousModel = nil
 520		}
 521		m.current, cmd = m.current.Update(msg)
 522		cmds = append(cmds, cmd)
 523
 524	case tui.SendEmailMsg:
 525		// Get draft ID before clearing composer (if it's a composer)
 526		var draftID string
 527		if composer, ok := m.current.(*tui.Composer); ok {
 528			draftID = composer.GetDraftID()
 529		}
 530		m.current = tui.NewStatus("Sending email...")
 531
 532		// Get the account to send from
 533		var account *config.Account
 534		if msg.AccountID != "" && m.config != nil {
 535			account = m.config.GetAccountByID(msg.AccountID)
 536		}
 537		if account == nil && m.config != nil {
 538			account = m.config.GetFirstAccount()
 539		}
 540
 541		// Save contact and delete draft in background
 542		go func() {
 543			// Save the recipient as a contact
 544			if msg.To != "" {
 545				// Parse "Name <email>" format
 546				name, email := parseEmailAddress(msg.To)
 547				if err := config.AddContact(name, email); err != nil {
 548					log.Printf("Error saving contact: %v", err)
 549				}
 550			}
 551			// Delete the draft since email is being sent
 552			if draftID != "" {
 553				if err := config.DeleteDraft(draftID); err != nil {
 554					log.Printf("Error deleting draft after send: %v", err)
 555				}
 556			}
 557		}()
 558
 559		return m, tea.Batch(m.current.Init(), sendEmail(account, msg))
 560
 561	case tui.EmailResultMsg:
 562		m.current = tui.NewChoice()
 563		return m, m.current.Init()
 564
 565	case tui.DeleteEmailMsg:
 566		m.previousModel = m.current
 567		m.current = tui.NewStatus("Deleting email...")
 568
 569		account := m.config.GetAccountByID(msg.AccountID)
 570		if account == nil {
 571			if msg.Mailbox == tui.MailboxSent && m.sentInbox != nil {
 572				m.current = m.sentInbox
 573			} else {
 574				m.current = m.inbox
 575			}
 576			return m, nil
 577		}
 578
 579		return m, tea.Batch(m.current.Init(), deleteEmailCmd(account, msg.UID, msg.AccountID, msg.Mailbox))
 580
 581	case tui.ArchiveEmailMsg:
 582		m.previousModel = m.current
 583		m.current = tui.NewStatus("Archiving email...")
 584
 585		account := m.config.GetAccountByID(msg.AccountID)
 586		if account == nil {
 587			if msg.Mailbox == tui.MailboxSent && m.sentInbox != nil {
 588				m.current = m.sentInbox
 589			} else {
 590				m.current = m.inbox
 591			}
 592			return m, nil
 593		}
 594
 595		return m, tea.Batch(m.current.Init(), archiveEmailCmd(account, msg.UID, msg.AccountID, msg.Mailbox))
 596
 597	case tui.EmailActionDoneMsg:
 598		if msg.Err != nil {
 599			log.Printf("Action failed: %v", msg.Err)
 600			if msg.Mailbox == tui.MailboxSent && m.sentInbox != nil {
 601				m.current = m.sentInbox
 602			} else {
 603				m.current = m.inbox
 604			}
 605			return m, nil
 606		}
 607
 608		// Remove email from stores
 609		m.removeEmailByMailbox(msg.UID, msg.AccountID, msg.Mailbox)
 610
 611		if msg.Mailbox == tui.MailboxSent {
 612			if m.sentInbox != nil {
 613				m.sentInbox.RemoveEmail(msg.UID, msg.AccountID)
 614				m.current = m.sentInbox
 615				m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 616				return m, m.current.Init()
 617			}
 618			m.current = tui.NewChoice()
 619			return m, m.current.Init()
 620		}
 621
 622		if m.inbox != nil {
 623			m.inbox.RemoveEmail(msg.UID, msg.AccountID)
 624			m.current = m.inbox
 625			m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 626			return m, m.current.Init()
 627		}
 628		m.current = tui.NewChoice()
 629		return m, m.current.Init()
 630
 631	case tui.DownloadAttachmentMsg:
 632		m.previousModel = m.current
 633		m.current = tui.NewStatus(fmt.Sprintf("Downloading %s...", msg.Filename))
 634
 635		account := m.config.GetAccountByID(msg.AccountID)
 636		if account == nil {
 637			m.current = m.previousModel
 638			return m, nil
 639		}
 640
 641		email := m.getEmailByIndex(msg.Index, msg.Mailbox)
 642		if email == nil {
 643			m.current = m.previousModel
 644			return m, nil
 645		}
 646
 647		// Find the correct attachment to get encoding
 648		var encoding string
 649		for _, att := range email.Attachments {
 650			if att.PartID == msg.PartID {
 651				encoding = att.Encoding
 652				break
 653			}
 654		}
 655		newMsg := tui.DownloadAttachmentMsg{
 656			Index:     msg.Index,
 657			Filename:  msg.Filename,
 658			PartID:    msg.PartID,
 659			Data:      msg.Data,
 660			AccountID: msg.AccountID,
 661			Encoding:  encoding,
 662			Mailbox:   msg.Mailbox,
 663		}
 664		return m, tea.Batch(m.current.Init(), downloadAttachmentCmd(account, email.UID, newMsg))
 665
 666	case tui.AttachmentDownloadedMsg:
 667		var statusMsg string
 668		if msg.Err != nil {
 669			statusMsg = fmt.Sprintf("Error downloading: %v", msg.Err)
 670		} else {
 671			statusMsg = fmt.Sprintf("Saved to %s", msg.Path)
 672		}
 673		m.current = tui.NewStatus(statusMsg)
 674		return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
 675			return tui.RestoreViewMsg{}
 676		})
 677
 678	case tui.RestoreViewMsg:
 679		if m.previousModel != nil {
 680			m.current = m.previousModel
 681			m.previousModel = nil
 682		}
 683		return m, nil
 684	}
 685
 686	return m, tea.Batch(cmds...)
 687}
 688
 689func (m *mainModel) getEmailByIndex(index int, mailbox tui.MailboxKind) *fetcher.Email {
 690	switch mailbox {
 691	case tui.MailboxSent:
 692		if index >= 0 && index < len(m.sentEmails) {
 693			return &m.sentEmails[index]
 694		}
 695	default:
 696		if index >= 0 && index < len(m.emails) {
 697			return &m.emails[index]
 698		}
 699	}
 700	return nil
 701}
 702
 703func (m *mainModel) getEmailByUIDAndAccount(uid uint32, accountID string, mailbox tui.MailboxKind) *fetcher.Email {
 704	switch mailbox {
 705	case tui.MailboxSent:
 706		for i := range m.sentEmails {
 707			if m.sentEmails[i].UID == uid && m.sentEmails[i].AccountID == accountID {
 708				return &m.sentEmails[i]
 709			}
 710		}
 711	default:
 712		for i := range m.emails {
 713			if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
 714				return &m.emails[i]
 715			}
 716		}
 717	}
 718	return nil
 719}
 720
 721func (m *mainModel) getEmailIndex(uid uint32, accountID string, mailbox tui.MailboxKind) int {
 722	switch mailbox {
 723	case tui.MailboxSent:
 724		for i := range m.sentEmails {
 725			if m.sentEmails[i].UID == uid && m.sentEmails[i].AccountID == accountID {
 726				return i
 727			}
 728		}
 729	default:
 730		for i := range m.emails {
 731			if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
 732				return i
 733			}
 734		}
 735	}
 736	return -1
 737}
 738
 739func (m *mainModel) updateEmailBodyByUID(uid uint32, accountID string, mailbox tui.MailboxKind, body string, attachments []fetcher.Attachment) {
 740	switch mailbox {
 741	case tui.MailboxSent:
 742		for i := range m.sentEmails {
 743			if m.sentEmails[i].UID == uid && m.sentEmails[i].AccountID == accountID {
 744				m.sentEmails[i].Body = body
 745				m.sentEmails[i].Attachments = attachments
 746				break
 747			}
 748		}
 749		if emails, ok := m.sentByAcct[accountID]; ok {
 750			for i := range emails {
 751				if emails[i].UID == uid {
 752					emails[i].Body = body
 753					emails[i].Attachments = attachments
 754					break
 755				}
 756			}
 757		}
 758	default:
 759		for i := range m.emails {
 760			if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
 761				m.emails[i].Body = body
 762				m.emails[i].Attachments = attachments
 763				break
 764			}
 765		}
 766		if emails, ok := m.emailsByAcct[accountID]; ok {
 767			for i := range emails {
 768				if emails[i].UID == uid {
 769					emails[i].Body = body
 770					emails[i].Attachments = attachments
 771					break
 772				}
 773			}
 774		}
 775	}
 776}
 777
 778func (m *mainModel) removeEmailByMailbox(uid uint32, accountID string, mailbox tui.MailboxKind) {
 779	switch mailbox {
 780	case tui.MailboxSent:
 781		var filtered []fetcher.Email
 782		for _, e := range m.sentEmails {
 783			if !(e.UID == uid && e.AccountID == accountID) {
 784				filtered = append(filtered, e)
 785			}
 786		}
 787		m.sentEmails = filtered
 788		if emails, ok := m.sentByAcct[accountID]; ok {
 789			var filteredAcct []fetcher.Email
 790			for _, e := range emails {
 791				if e.UID != uid {
 792					filteredAcct = append(filteredAcct, e)
 793				}
 794			}
 795			m.sentByAcct[accountID] = filteredAcct
 796		}
 797	default:
 798		var filtered []fetcher.Email
 799		for _, e := range m.emails {
 800			if !(e.UID == uid && e.AccountID == accountID) {
 801				filtered = append(filtered, e)
 802			}
 803		}
 804		m.emails = filtered
 805		if emails, ok := m.emailsByAcct[accountID]; ok {
 806			var filteredAcct []fetcher.Email
 807			for _, e := range emails {
 808				if e.UID != uid {
 809					filteredAcct = append(filteredAcct, e)
 810				}
 811			}
 812			m.emailsByAcct[accountID] = filteredAcct
 813		}
 814	}
 815}
 816
 817func (m *mainModel) View() string {
 818	return m.current.View()
 819}
 820
 821func flattenAndSort(emailsByAccount map[string][]fetcher.Email) []fetcher.Email {
 822	var allEmails []fetcher.Email
 823	for _, emails := range emailsByAccount {
 824		allEmails = append(allEmails, emails...)
 825	}
 826	for i := 0; i < len(allEmails); i++ {
 827		for j := i + 1; j < len(allEmails); j++ {
 828			if allEmails[j].Date.After(allEmails[i].Date) {
 829				allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
 830			}
 831		}
 832	}
 833	return allEmails
 834}
 835
 836func fetchAllAccountsEmails(cfg *config.Config, mailbox tui.MailboxKind) tea.Cmd {
 837	return func() tea.Msg {
 838		emailsByAccount := make(map[string][]fetcher.Email)
 839		var mu sync.Mutex
 840		var wg sync.WaitGroup
 841
 842		for _, account := range cfg.Accounts {
 843			wg.Add(1)
 844			go func(acc config.Account) {
 845				defer wg.Done()
 846				var emails []fetcher.Email
 847				var err error
 848				if mailbox == tui.MailboxSent {
 849					emails, err = fetcher.FetchSentEmails(&acc, initialEmailLimit, 0)
 850				} else {
 851					emails, err = fetcher.FetchEmails(&acc, initialEmailLimit, 0)
 852				}
 853				if err != nil {
 854					log.Printf("Error fetching from %s: %v", acc.Email, err)
 855					return
 856				}
 857				mu.Lock()
 858				emailsByAccount[acc.ID] = emails
 859				mu.Unlock()
 860			}(account)
 861		}
 862
 863		wg.Wait()
 864		return tui.AllEmailsFetchedMsg{EmailsByAccount: emailsByAccount, Mailbox: mailbox}
 865	}
 866}
 867
 868func fetchEmails(account *config.Account, limit, offset uint32, mailbox tui.MailboxKind) tea.Cmd {
 869	return func() tea.Msg {
 870		var emails []fetcher.Email
 871		var err error
 872		if mailbox == tui.MailboxSent {
 873			emails, err = fetcher.FetchSentEmails(account, limit, offset)
 874		} else {
 875			emails, err = fetcher.FetchEmails(account, limit, offset)
 876		}
 877		if err != nil {
 878			return tui.FetchErr(err)
 879		}
 880		if offset == 0 {
 881			return tui.EmailsFetchedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
 882		}
 883		return tui.EmailsAppendedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
 884	}
 885}
 886
 887func loadCachedEmails() tea.Cmd {
 888	return func() tea.Msg {
 889		cache, err := config.LoadEmailCache()
 890		if err != nil {
 891			return tui.CachedEmailsLoadedMsg{Cache: nil}
 892		}
 893		return tui.CachedEmailsLoadedMsg{Cache: cache}
 894	}
 895}
 896
 897func refreshEmails(cfg *config.Config, mailbox tui.MailboxKind) tea.Cmd {
 898	return func() tea.Msg {
 899		emailsByAccount := make(map[string][]fetcher.Email)
 900		var mu sync.Mutex
 901		var wg sync.WaitGroup
 902
 903		for _, account := range cfg.Accounts {
 904			wg.Add(1)
 905			go func(acc config.Account) {
 906				defer wg.Done()
 907				var emails []fetcher.Email
 908				var err error
 909				if mailbox == tui.MailboxSent {
 910					emails, err = fetcher.FetchSentEmails(&acc, initialEmailLimit, 0)
 911				} else {
 912					emails, err = fetcher.FetchEmails(&acc, initialEmailLimit, 0)
 913				}
 914				if err != nil {
 915					log.Printf("Error fetching from %s: %v", acc.Email, err)
 916					return
 917				}
 918				mu.Lock()
 919				emailsByAccount[acc.ID] = emails
 920				mu.Unlock()
 921			}(account)
 922		}
 923
 924		wg.Wait()
 925		return tui.EmailsRefreshedMsg{EmailsByAccount: emailsByAccount, Mailbox: mailbox}
 926	}
 927}
 928
 929func saveEmailsToCache(emails []fetcher.Email) {
 930	var cachedEmails []config.CachedEmail
 931	for _, email := range emails {
 932		cachedEmails = append(cachedEmails, config.CachedEmail{
 933			UID:       email.UID,
 934			From:      email.From,
 935			To:        email.To,
 936			Subject:   email.Subject,
 937			Date:      email.Date,
 938			MessageID: email.MessageID,
 939			AccountID: email.AccountID,
 940		})
 941
 942		// Save sender as a contact
 943		if email.From != "" {
 944			name, emailAddr := parseEmailAddress(email.From)
 945			if err := config.AddContact(name, emailAddr); err != nil {
 946				log.Printf("Error saving contact from email: %v", err)
 947			}
 948		}
 949	}
 950	cache := &config.EmailCache{Emails: cachedEmails}
 951	if err := config.SaveEmailCache(cache); err != nil {
 952		log.Printf("Error saving email cache: %v", err)
 953	}
 954}
 955
 956// parseEmailAddress parses "Name <email>" or just "email" format
 957func parseEmailAddress(addr string) (name, email string) {
 958	addr = strings.TrimSpace(addr)
 959	if idx := strings.Index(addr, "<"); idx != -1 {
 960		name = strings.TrimSpace(addr[:idx])
 961		endIdx := strings.Index(addr, ">")
 962		if endIdx > idx {
 963			email = strings.TrimSpace(addr[idx+1 : endIdx])
 964		} else {
 965			email = strings.TrimSpace(addr[idx+1:])
 966		}
 967	} else {
 968		email = addr
 969	}
 970	return name, email
 971}
 972
 973func fetchEmailBodyCmd(cfg *config.Config, email fetcher.Email, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
 974	return func() tea.Msg {
 975		account := cfg.GetAccountByID(accountID)
 976		if account == nil {
 977			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: fmt.Errorf("account not found")}
 978		}
 979
 980		var (
 981			body        string
 982			attachments []fetcher.Attachment
 983			err         error
 984		)
 985		if mailbox == tui.MailboxSent {
 986			body, attachments, err = fetcher.FetchSentEmailBody(account, uid)
 987		} else {
 988			body, attachments, err = fetcher.FetchEmailBody(account, uid)
 989		}
 990		if err != nil {
 991			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
 992		}
 993
 994		return tui.EmailBodyFetchedMsg{
 995			UID:         uid,
 996			Body:        body,
 997			Attachments: attachments,
 998			AccountID:   accountID,
 999			Mailbox:     mailbox,
1000		}
1001	}
1002}
1003
1004func markdownToHTML(md []byte) []byte {
1005	var buf bytes.Buffer
1006	p := goldmark.New(goldmark.WithRendererOptions(html.WithUnsafe()))
1007	if err := p.Convert(md, &buf); err != nil {
1008		return md
1009	}
1010	return buf.Bytes()
1011}
1012
1013func sendEmail(account *config.Account, msg tui.SendEmailMsg) tea.Cmd {
1014	return func() tea.Msg {
1015		if account == nil {
1016			return tui.EmailResultMsg{Err: fmt.Errorf("no account configured")}
1017		}
1018
1019		recipients := []string{msg.To}
1020		body := msg.Body
1021		images := make(map[string][]byte)
1022		attachments := make(map[string][]byte)
1023
1024		re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
1025		matches := re.FindAllStringSubmatch(body, -1)
1026
1027		for _, match := range matches {
1028			imgPath := match[1]
1029			imgData, err := os.ReadFile(imgPath)
1030			if err != nil {
1031				log.Printf("Could not read image file %s: %v", imgPath, err)
1032				continue
1033			}
1034			cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "matcha")
1035			images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
1036			body = strings.Replace(body, imgPath, "cid:"+cid, 1)
1037		}
1038
1039		htmlBody := markdownToHTML([]byte(body))
1040
1041		if msg.AttachmentPath != "" {
1042			fileData, err := os.ReadFile(msg.AttachmentPath)
1043			if err != nil {
1044				log.Printf("Could not read attachment file %s: %v", msg.AttachmentPath, err)
1045			} else {
1046				_, filename := filepath.Split(msg.AttachmentPath)
1047				attachments[filename] = fileData
1048			}
1049		}
1050
1051		err := sender.SendEmail(account, recipients, msg.Subject, msg.Body, string(htmlBody), images, attachments, msg.InReplyTo, msg.References)
1052		if err != nil {
1053			log.Printf("Failed to send email: %v", err)
1054			return tui.EmailResultMsg{Err: err}
1055		}
1056		return tui.EmailResultMsg{}
1057	}
1058}
1059
1060func deleteEmailCmd(account *config.Account, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
1061	return func() tea.Msg {
1062		var err error
1063		if mailbox == tui.MailboxSent {
1064			err = fetcher.DeleteSentEmail(account, uid)
1065		} else {
1066			err = fetcher.DeleteEmail(account, uid)
1067		}
1068		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1069	}
1070}
1071
1072func archiveEmailCmd(account *config.Account, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
1073	return func() tea.Msg {
1074		var err error
1075		if mailbox == tui.MailboxSent {
1076			err = fetcher.ArchiveSentEmail(account, uid)
1077		} else {
1078			err = fetcher.ArchiveEmail(account, uid)
1079		}
1080		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1081	}
1082}
1083
1084func downloadAttachmentCmd(account *config.Account, uid uint32, msg tui.DownloadAttachmentMsg) tea.Cmd {
1085	return func() tea.Msg {
1086		// Download and decode the attachment using encoding provided in msg.Encoding.
1087		var data []byte
1088		var err error
1089		if msg.Mailbox == tui.MailboxSent {
1090			data, err = fetcher.FetchSentAttachment(account, uid, msg.PartID, msg.Encoding)
1091		} else {
1092			data, err = fetcher.FetchAttachment(account, uid, msg.PartID, msg.Encoding)
1093		}
1094		if err != nil {
1095			return tui.AttachmentDownloadedMsg{Err: err}
1096		}
1097
1098		homeDir, err := os.UserHomeDir()
1099		if err != nil {
1100			return tui.AttachmentDownloadedMsg{Err: err}
1101		}
1102		downloadsPath := filepath.Join(homeDir, "Downloads")
1103		if _, err := os.Stat(downloadsPath); os.IsNotExist(err) {
1104			if mkErr := os.MkdirAll(downloadsPath, 0755); mkErr != nil {
1105				return tui.AttachmentDownloadedMsg{Err: mkErr}
1106			}
1107		}
1108
1109		// Save the attachment using an exclusive create so we never overwrite an existing file.
1110		// If the filename already exists, append \" (n)\" before the extension.
1111		origName := msg.Filename
1112		ext := filepath.Ext(origName)
1113		base := strings.TrimSuffix(origName, ext)
1114		candidate := origName
1115		i := 1
1116		var filePath string
1117
1118		for {
1119			filePath = filepath.Join(downloadsPath, candidate)
1120
1121			// Try to create file exclusively. If it already exists, os.OpenFile will return an error
1122			// that satisfies os.IsExist(err), so we can increment the candidate.
1123			f, err := os.OpenFile(filePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
1124			if err != nil {
1125				if os.IsExist(err) {
1126					// file exists, try next candidate
1127					candidate = fmt.Sprintf("%s (%d)%s", base, i, ext)
1128					i++
1129					continue
1130				}
1131				// Some other error while attempting to create file
1132				log.Printf("error creating file %s: %v", filePath, err)
1133				return tui.AttachmentDownloadedMsg{Err: err}
1134			}
1135
1136			// Successfully created the file descriptor; write and close.
1137			if _, writeErr := f.Write(data); writeErr != nil {
1138				_ = f.Close()
1139				log.Printf("error writing to file %s: %v", filePath, writeErr)
1140				return tui.AttachmentDownloadedMsg{Err: writeErr}
1141			}
1142			if closeErr := f.Close(); closeErr != nil {
1143				log.Printf("warning: error closing file %s: %v", filePath, closeErr)
1144			}
1145
1146			// file saved successfully
1147			break
1148		}
1149
1150		log.Printf("attachment saved to %s", filePath)
1151
1152		// Try to open the file using a platform-specific opener asynchronously and log the outcome.
1153		go func(p string) {
1154			var cmd *exec.Cmd
1155			switch runtime.GOOS {
1156			case "darwin":
1157				cmd = exec.Command("open", p)
1158			case "linux":
1159				cmd = exec.Command("xdg-open", p)
1160			case "windows":
1161				// 'start' is a cmd builtin; provide an empty title argument to avoid interpreting the path as the title.
1162				cmd = exec.Command("cmd", "/c", "start", "", p)
1163			default:
1164				// Unsupported OS: nothing to do.
1165				return
1166			}
1167			if err := cmd.Start(); err != nil {
1168				log.Printf("failed to open file %s: %v", p, err)
1169			}
1170		}(filePath)
1171
1172		return tui.AttachmentDownloadedMsg{Path: filePath, Err: nil}
1173	}
1174}
1175
1176/*
1177detectInstalledVersion returns a best-effort installed version string.
1178Priority:
1179 1. If the build-in `version` variable is set to something other than "dev", return it.
1180 2. If Homebrew is present and reports a version for `matcha`, return that.
1181 3. If snap is present and lists `matcha`, return that.
1182 4. Fallback to the build `version` (likely "dev").
1183*/
1184func detectInstalledVersion() string {
1185	v := strings.TrimSpace(version)
1186	if v != "dev" && v != "" {
1187		return v
1188	}
1189
1190	// Try Homebrew (macOS)
1191	if runtime.GOOS == "darwin" {
1192		if _, err := exec.LookPath("brew"); err == nil {
1193			// `brew list --versions matcha` prints: matcha 1.2.3
1194			if out, err := exec.Command("brew", "list", "--versions", "matcha").Output(); err == nil {
1195				parts := strings.Fields(string(out))
1196				if len(parts) >= 2 {
1197					return parts[1]
1198				}
1199			}
1200		}
1201	}
1202
1203	// Try snap (Linux)
1204	if runtime.GOOS == "linux" {
1205		if _, err := exec.LookPath("snap"); err == nil {
1206			if out, err := exec.Command("snap", "list", "matcha").Output(); err == nil {
1207				lines := strings.Split(strings.TrimSpace(string(out)), "\n")
1208				if len(lines) >= 2 {
1209					fields := strings.Fields(lines[1])
1210					if len(fields) >= 2 {
1211						return fields[1]
1212					}
1213				}
1214			}
1215		}
1216	}
1217
1218	return v
1219}
1220
1221/*
1222checkForUpdatesCmd queries GitHub for the latest release tag and returns a
1223tea.Msg (UpdateAvailableMsg) if the latest version differs from the current
1224installed version. This runs in the background when the TUI initializes.
1225*/
1226func checkForUpdatesCmd() tea.Cmd {
1227	return func() tea.Msg {
1228		// Non-fatal: if anything goes wrong we just don't show the update message.
1229		const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
1230		resp, err := http.Get(api)
1231		if err != nil {
1232			return nil
1233		}
1234		defer resp.Body.Close()
1235
1236		var rel githubRelease
1237		if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
1238			return nil
1239		}
1240
1241		latest := strings.TrimPrefix(rel.TagName, "v")
1242		installed := strings.TrimPrefix(detectInstalledVersion(), "v")
1243		if latest != "" && installed != "" && latest != installed {
1244			return UpdateAvailableMsg{Latest: latest, Current: installed}
1245		}
1246		return nil
1247	}
1248}
1249
1250// runUpdateCLI implements the CLI entrypoint for `matcha update`.
1251// It detects the likely installation method and attempts the appropriate
1252// update path (Homebrew, Snap, or GitHub release binary extract).
1253func runUpdateCLI() error {
1254	const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
1255	resp, err := http.Get(api)
1256	if err != nil {
1257		return fmt.Errorf("could not query releases: %w", err)
1258	}
1259	defer resp.Body.Close()
1260
1261	var rel githubRelease
1262	if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
1263		return fmt.Errorf("could not parse release info: %w", err)
1264	}
1265
1266	latestTag := rel.TagName
1267	if strings.HasPrefix(latestTag, "v") {
1268		latestTag = latestTag[1:]
1269	}
1270
1271	fmt.Printf("Current version: %s\n", version)
1272	fmt.Printf("Latest version: %s\n", latestTag)
1273
1274	// Quick check: if already up-to-date, exit
1275	cur := version
1276	if strings.HasPrefix(cur, "v") {
1277		cur = cur[1:]
1278	}
1279	if latestTag == "" || cur == latestTag {
1280		fmt.Println("Already up to date.")
1281		return nil
1282	}
1283
1284	// Detect Homebrew
1285	if _, err := exec.LookPath("brew"); err == nil {
1286		fmt.Println("Detected Homebrew — attempting to upgrade via brew.")
1287		cmd := exec.Command("brew", "upgrade", "matcha")
1288		cmd.Stdout = os.Stdout
1289		cmd.Stderr = os.Stderr
1290		if err := cmd.Run(); err == nil {
1291			fmt.Println("Successfully upgraded via Homebrew.")
1292			return nil
1293		}
1294		fmt.Printf("Homebrew upgrade failed: %v\n", err)
1295		// fallthrough to other methods
1296	}
1297
1298	// Detect snap
1299	if _, err := exec.LookPath("snap"); err == nil {
1300		// Check if matcha is installed as a snap
1301		cmdCheck := exec.Command("snap", "list", "matcha")
1302		if err := cmdCheck.Run(); err == nil {
1303			fmt.Println("Detected Snap package — attempting to refresh.")
1304			cmd := exec.Command("snap", "refresh", "matcha")
1305			cmd.Stdout = os.Stdout
1306			cmd.Stderr = os.Stderr
1307			if err := cmd.Run(); err == nil {
1308				fmt.Println("Successfully refreshed snap.")
1309				return nil
1310			}
1311			fmt.Printf("Snap refresh failed: %v\n", err)
1312			// fallthrough
1313		}
1314	}
1315
1316	// Otherwise attempt to download the proper release asset and replace the binary.
1317	osName := runtime.GOOS
1318	arch := runtime.GOARCH
1319
1320	// Try to find a matching asset
1321	var assetURL, assetName string
1322	for _, a := range rel.Assets {
1323		n := strings.ToLower(a.Name)
1324		if strings.Contains(n, osName) && strings.Contains(n, arch) && (strings.HasSuffix(n, ".tar.gz") || strings.HasSuffix(n, ".tgz") || strings.HasSuffix(n, ".zip")) {
1325			assetURL = a.BrowserDownloadURL
1326			assetName = a.Name
1327			break
1328		}
1329	}
1330	if assetURL == "" {
1331		// Try any asset that contains 'matcha' and os/arch as a fallback
1332		for _, a := range rel.Assets {
1333			n := strings.ToLower(a.Name)
1334			if strings.Contains(n, "matcha") && (strings.Contains(n, osName) || strings.Contains(n, arch)) {
1335				assetURL = a.BrowserDownloadURL
1336				assetName = a.Name
1337				break
1338			}
1339		}
1340	}
1341
1342	if assetURL == "" {
1343		return fmt.Errorf("no suitable release artifact found for %s/%s", osName, arch)
1344	}
1345
1346	fmt.Printf("Found release asset: %s\n", assetName)
1347	fmt.Println("Downloading...")
1348
1349	// Download asset
1350	respAsset, err := http.Get(assetURL)
1351	if err != nil {
1352		return fmt.Errorf("download failed: %w", err)
1353	}
1354	defer respAsset.Body.Close()
1355
1356	// Create a temp file for the download
1357	tmpDir, err := os.MkdirTemp("", "matcha-update-*")
1358	if err != nil {
1359		return fmt.Errorf("could not create temp dir: %w", err)
1360	}
1361	defer os.RemoveAll(tmpDir)
1362
1363	assetPath := filepath.Join(tmpDir, assetName)
1364	outFile, err := os.Create(assetPath)
1365	if err != nil {
1366		return fmt.Errorf("could not create temp file: %w", err)
1367	}
1368	_, err = io.Copy(outFile, respAsset.Body)
1369	outFile.Close()
1370	if err != nil {
1371		return fmt.Errorf("could not write asset to disk: %w", err)
1372	}
1373
1374	// If it's a tar.gz, extract and find the `matcha` binary
1375	var binPath string
1376	if strings.HasSuffix(assetName, ".tar.gz") || strings.HasSuffix(assetName, ".tgz") {
1377		f, err := os.Open(assetPath)
1378		if err != nil {
1379			return fmt.Errorf("could not open archive: %w", err)
1380		}
1381		defer f.Close()
1382		gzr, err := gzip.NewReader(f)
1383		if err != nil {
1384			return fmt.Errorf("could not create gzip reader: %w", err)
1385		}
1386		tr := tar.NewReader(gzr)
1387		for {
1388			hdr, err := tr.Next()
1389			if err == io.EOF {
1390				break
1391			}
1392			if err != nil {
1393				return fmt.Errorf("error reading tar: %w", err)
1394			}
1395			name := filepath.Base(hdr.Name)
1396			if name == "matcha" || strings.Contains(strings.ToLower(name), "matcha") && (hdr.Typeflag == tar.TypeReg) {
1397				// write out the file
1398				binPath = filepath.Join(tmpDir, "matcha")
1399				out, err := os.Create(binPath)
1400				if err != nil {
1401					return fmt.Errorf("could not create binary file: %w", err)
1402				}
1403				if _, err := io.Copy(out, tr); err != nil {
1404					out.Close()
1405					return fmt.Errorf("could not extract binary: %w", err)
1406				}
1407				out.Close()
1408				if err := os.Chmod(binPath, 0755); err != nil {
1409					return fmt.Errorf("could not make binary executable: %w", err)
1410				}
1411				break
1412			}
1413		}
1414	} else {
1415		// For non-archive assets, assume the asset is the binary itself.
1416		binPath = assetPath
1417		if err := os.Chmod(binPath, 0755); err != nil {
1418			// ignore chmod errors but warn
1419			fmt.Printf("warning: could not chmod downloaded binary: %v\n", err)
1420		}
1421	}
1422
1423	if binPath == "" {
1424		return fmt.Errorf("could not locate matcha binary inside the release artifact")
1425	}
1426
1427	// Replace the running executable with the new binary
1428	execPath, err := os.Executable()
1429	if err != nil {
1430		return fmt.Errorf("could not determine executable path: %w", err)
1431	}
1432
1433	// Write the new binary to a temp file in same dir, then rename for atomic replacement.
1434	execDir := filepath.Dir(execPath)
1435	tmpNew := filepath.Join(execDir, fmt.Sprintf("matcha.new.%d", time.Now().Unix()))
1436	in, err := os.Open(binPath)
1437	if err != nil {
1438		return fmt.Errorf("could not open new binary: %w", err)
1439	}
1440	out, err := os.OpenFile(tmpNew, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
1441	if err != nil {
1442		in.Close()
1443		return fmt.Errorf("could not create temp binary in target dir: %w", err)
1444	}
1445	if _, err := io.Copy(out, in); err != nil {
1446		in.Close()
1447		out.Close()
1448		return fmt.Errorf("could not write new binary to disk: %w", err)
1449	}
1450	in.Close()
1451	out.Close()
1452
1453	// Attempt to atomically replace
1454	if err := os.Rename(tmpNew, execPath); err != nil {
1455		return fmt.Errorf("could not replace executable: %w", err)
1456	}
1457
1458	fmt.Println("Successfully updated matcha to", latestTag)
1459	return nil
1460}
1461
1462func main() {
1463	// If invoked as CLI update command, run updater and exit.
1464	if len(os.Args) > 1 && os.Args[1] == "update" {
1465		if err := runUpdateCLI(); err != nil {
1466			fmt.Fprintf(os.Stderr, "update failed: %v\n", err)
1467			os.Exit(1)
1468		}
1469		os.Exit(0)
1470	}
1471
1472	cfg, err := config.LoadConfig()
1473	var initialModel *mainModel
1474	if err != nil {
1475		initialModel = newInitialModel(nil)
1476	} else {
1477		initialModel = newInitialModel(cfg)
1478	}
1479
1480	p := tea.NewProgram(initialModel, tea.WithAltScreen())
1481
1482	if _, err := p.Run(); err != nil {
1483		fmt.Printf("Alas, there's been an error: %v", err)
1484		os.Exit(1)
1485	}
1486}