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