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