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