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