main.go

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