main.go

   1package main
   2
   3import (
   4	"archive/tar"
   5	"archive/zip"
   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/clib"
  24	"github.com/floatpane/matcha/config"
  25	"github.com/floatpane/matcha/fetcher"
  26	"github.com/floatpane/matcha/plugin"
  27	"github.com/floatpane/matcha/sender"
  28	"github.com/floatpane/matcha/theme"
  29	"github.com/floatpane/matcha/tui"
  30	"github.com/google/uuid"
  31)
  32
  33const (
  34	initialEmailLimit = 50
  35	paginationLimit   = 50
  36	maxCacheEmails    = 100
  37)
  38
  39// Version variables are injected by the build (GoReleaser ldflags).
  40// They default to "dev" when not set by the build system.
  41var (
  42	version = "dev"
  43	commit  = ""
  44	date    = ""
  45)
  46
  47// UpdateAvailableMsg is sent into the TUI when a newer release is detected.
  48type UpdateAvailableMsg struct {
  49	Latest  string
  50	Current string
  51}
  52
  53// internal struct for parsing GitHub release JSON.
  54type githubRelease struct {
  55	TagName string `json:"tag_name"`
  56	Assets  []struct {
  57		Name               string `json:"name"`
  58		BrowserDownloadURL string `json:"browser_download_url"`
  59	} `json:"assets"`
  60}
  61
  62type mainModel struct {
  63	current       tea.Model
  64	previousModel tea.Model
  65	config        *config.Config
  66	plugins       *plugin.Manager
  67	// Folder-based email storage
  68	folderEmails map[string][]fetcher.Email // key: folderName
  69	folderInbox  *tui.FolderInbox
  70	// Legacy fields kept for email actions
  71	emails       []fetcher.Email
  72	emailsByAcct map[string][]fetcher.Email
  73	width        int
  74	height       int
  75	err          error
  76	// IMAP IDLE
  77	idleWatcher *fetcher.IdleWatcher
  78	idleUpdates chan fetcher.IdleUpdate
  79}
  80
  81func newInitialModel(cfg *config.Config) *mainModel {
  82	idleUpdates := make(chan fetcher.IdleUpdate, 16)
  83	initialModel := &mainModel{
  84		emailsByAcct: make(map[string][]fetcher.Email),
  85		folderEmails: make(map[string][]fetcher.Email),
  86		idleUpdates:  idleUpdates,
  87		idleWatcher:  fetcher.NewIdleWatcher(idleUpdates),
  88	}
  89
  90	if cfg == nil || !cfg.HasAccounts() {
  91		hideTips := false
  92		if cfg != nil {
  93			hideTips = cfg.HideTips
  94		}
  95		initialModel.current = tui.NewLogin(hideTips)
  96	} else {
  97		initialModel.current = tui.NewChoice()
  98		initialModel.config = cfg
  99	}
 100	return initialModel
 101}
 102
 103func (m *mainModel) Init() tea.Cmd {
 104	return tea.Batch(m.current.Init(), checkForUpdatesCmd())
 105}
 106
 107func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 108	var cmd tea.Cmd
 109	var cmds []tea.Cmd
 110
 111	m.current, cmd = m.current.Update(msg)
 112	cmds = append(cmds, cmd)
 113
 114	// Fire composer_updated hook on key presses when the composer is active
 115	if _, isKey := msg.(tea.KeyPressMsg); isKey {
 116		if composer, ok := m.current.(*tui.Composer); ok && m.plugins != nil {
 117			m.plugins.CallComposerHook(plugin.HookComposerUpdated, composer.GetBody(), composer.GetSubject(), composer.GetTo())
 118			m.syncPluginStatus()
 119		}
 120	}
 121
 122	switch msg := msg.(type) {
 123	case tea.WindowSizeMsg:
 124		m.width = msg.Width
 125		m.height = msg.Height
 126		return m, nil
 127
 128	case tea.KeyPressMsg:
 129		if msg.String() == "ctrl+c" {
 130			m.idleWatcher.StopAll()
 131			return m, tea.Quit
 132		}
 133		if msg.String() == "esc" {
 134			switch m.current.(type) {
 135			case *tui.FilePicker:
 136				return m, func() tea.Msg { return tui.CancelFilePickerMsg{} }
 137			case *tui.FolderInbox, *tui.Inbox, *tui.Login:
 138				m.idleWatcher.StopAll()
 139				m.current = tui.NewChoice()
 140				m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 141				return m, m.current.Init()
 142			}
 143		}
 144
 145	case tui.BackToInboxMsg:
 146		if m.folderInbox != nil {
 147			m.current = m.folderInbox
 148		} else {
 149			m.current = tui.NewChoice()
 150			m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 151		}
 152		return m, nil
 153
 154	case tui.BackToMailboxMsg:
 155		// Ensure kitty graphics are cleared when leaving email view
 156		tui.ClearKittyGraphics()
 157		if m.folderInbox != nil {
 158			m.current = m.folderInbox
 159			return m, nil
 160		}
 161		m.current = tui.NewChoice()
 162		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 163		return m, nil
 164
 165	case tui.DiscardDraftMsg:
 166		// Save draft to disk
 167		if msg.ComposerState != nil {
 168			draft := msg.ComposerState.ToDraft()
 169
 170			if err := config.SaveDraft(draft); err != nil {
 171				log.Printf("Error saving draft: %v", err)
 172			}
 173
 174		}
 175		m.current = tui.NewChoice()
 176		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 177		return m, m.current.Init()
 178
 179	case tui.Credentials:
 180		// Add new account or update existing
 181		account := config.Account{
 182			ID:              uuid.New().String(),
 183			Name:            msg.Name,
 184			Email:           msg.Host, // login/email used for authentication comes from Host field in the form
 185			Password:        msg.Password,
 186			ServiceProvider: msg.Provider,
 187			FetchEmail:      msg.FetchEmail,
 188		}
 189
 190		if msg.Provider == "custom" {
 191			account.IMAPServer = msg.IMAPServer
 192			account.IMAPPort = msg.IMAPPort
 193			account.SMTPServer = msg.SMTPServer
 194			account.SMTPPort = msg.SMTPPort
 195		}
 196
 197		// Ensure FetchEmail defaults to the login Email (Host) if not explicitly set
 198		if account.FetchEmail == "" && account.Email != "" {
 199			account.FetchEmail = account.Email
 200		}
 201
 202		if m.config == nil {
 203			m.config = &config.Config{}
 204		}
 205
 206		// Check if we're editing an existing account
 207		isEdit := false
 208		if login, ok := m.current.(*tui.Login); ok && login.IsEditMode() {
 209			isEdit = true
 210			// Find and update the existing account, preserving S/MIME settings
 211			existingID := login.GetAccountID()
 212			for i, acc := range m.config.Accounts {
 213				if acc.ID == existingID {
 214					account.ID = existingID
 215					account.SMIMECert = acc.SMIMECert
 216					account.SMIMEKey = acc.SMIMEKey
 217					account.SMIMESignByDefault = acc.SMIMESignByDefault
 218					if account.Password == "" {
 219						account.Password = acc.Password
 220					}
 221					m.config.Accounts[i] = account
 222					break
 223				}
 224			}
 225		} else {
 226			m.config.AddAccount(account)
 227		}
 228
 229		if err := config.SaveConfig(m.config); err != nil {
 230			log.Printf("could not save config: %v", err)
 231			return m, tea.Quit
 232		}
 233
 234		if isEdit {
 235			m.current = tui.NewSettings(m.config)
 236		} else {
 237			m.current = tui.NewChoice()
 238		}
 239		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 240		return m, m.current.Init()
 241
 242	case tui.GoToInboxMsg:
 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		// Load cached folders from all accounts, merge unique names
 252		seen := make(map[string]bool)
 253		var cachedFolders []string
 254		for _, acc := range m.config.Accounts {
 255			for _, f := range config.GetCachedFolders(acc.ID) {
 256				if !seen[f] {
 257					seen[f] = true
 258					cachedFolders = append(cachedFolders, f)
 259				}
 260			}
 261		}
 262		if len(cachedFolders) == 0 {
 263			cachedFolders = []string{"INBOX"}
 264		}
 265		m.folderInbox = tui.NewFolderInbox(cachedFolders, m.config.Accounts)
 266		// Use cached INBOX emails for instant display (memory first, then disk)
 267		if cached, ok := m.folderEmails["INBOX"]; ok && len(cached) > 0 {
 268			m.folderInbox.SetEmails(cached, m.config.Accounts)
 269		} else if diskCached := loadFolderEmailsFromCache("INBOX"); len(diskCached) > 0 {
 270			m.folderEmails["INBOX"] = diskCached
 271			m.emails = diskCached
 272			m.emailsByAcct = make(map[string][]fetcher.Email)
 273			for _, email := range diskCached {
 274				m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
 275			}
 276			m.folderInbox.SetEmails(diskCached, m.config.Accounts)
 277		}
 278		m.current = m.folderInbox
 279		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 280		// Start IDLE watchers for all accounts on INBOX
 281		for i := range m.config.Accounts {
 282			m.idleWatcher.Watch(&m.config.Accounts[i], "INBOX")
 283		}
 284		// Fetch folders and INBOX emails in parallel (background refresh)
 285		return m, tea.Batch(
 286			m.current.Init(),
 287			fetchFoldersCmd(m.config),
 288			fetchFolderEmailsCmd(m.config, "INBOX"),
 289			listenForIdleUpdates(m.idleUpdates),
 290		)
 291
 292	case tui.FoldersFetchedMsg:
 293		if m.folderInbox == nil {
 294			return m, nil
 295		}
 296		var folderNames []string
 297		for _, f := range msg.MergedFolders {
 298			folderNames = append(folderNames, f.Name)
 299		}
 300		m.folderInbox.SetFolders(folderNames)
 301		// Cache folder lists per account
 302		for accID, folders := range msg.FoldersByAccount {
 303			var names []string
 304			for _, f := range folders {
 305				names = append(names, f.Name)
 306			}
 307			go config.SaveAccountFolders(accID, names)
 308		}
 309		return m, nil
 310
 311	case tui.SwitchFolderMsg:
 312		if m.config == nil {
 313			return m, nil
 314		}
 315		// Update IDLE watchers to monitor the new folder
 316		for i := range m.config.Accounts {
 317			m.idleWatcher.Watch(&m.config.Accounts[i], msg.FolderName)
 318		}
 319		if m.plugins != nil {
 320			m.plugins.CallFolderHook(plugin.HookFolderChanged, msg.FolderName)
 321			m.syncPluginStatus()
 322		}
 323		// Use in-memory cache if available
 324		if cached, ok := m.folderEmails[msg.FolderName]; ok {
 325			m.emails = cached
 326			m.emailsByAcct = make(map[string][]fetcher.Email)
 327			for _, email := range cached {
 328				m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
 329			}
 330			if m.folderInbox != nil {
 331				m.folderInbox.SetEmails(cached, m.config.Accounts)
 332				m.folderInbox.GetInbox().SetFolderName(msg.FolderName)
 333				m.folderInbox.SetLoadingEmails(false)
 334			}
 335			return m, m.pluginNotifyCmd()
 336		}
 337		// Fall back to disk cache for instant display, then fetch fresh in background
 338		if diskCached := loadFolderEmailsFromCache(msg.FolderName); len(diskCached) > 0 {
 339			m.folderEmails[msg.FolderName] = diskCached
 340			m.emails = diskCached
 341			m.emailsByAcct = make(map[string][]fetcher.Email)
 342			for _, email := range diskCached {
 343				m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
 344			}
 345			if m.folderInbox != nil {
 346				m.folderInbox.SetEmails(diskCached, m.config.Accounts)
 347				m.folderInbox.GetInbox().SetFolderName(msg.FolderName)
 348				m.folderInbox.SetLoadingEmails(false)
 349			}
 350			// Still fetch fresh emails in background
 351			return m, tea.Batch(fetchFolderEmailsCmd(m.config, msg.FolderName), m.pluginNotifyCmd())
 352		}
 353		if m.folderInbox != nil {
 354			m.folderInbox.SetLoadingEmails(true)
 355		}
 356		return m, tea.Batch(fetchFolderEmailsCmd(m.config, msg.FolderName), m.pluginNotifyCmd())
 357
 358	case tui.PluginNotifyMsg:
 359		m.previousModel = m.current
 360		m.current = tui.NewStatus(msg.Message)
 361		dur := time.Duration(msg.Duration * float64(time.Second))
 362		if dur <= 0 {
 363			dur = 2 * time.Second
 364		}
 365		return m, tea.Tick(dur, func(t time.Time) tea.Msg {
 366			return tui.RestoreViewMsg{}
 367		})
 368
 369	case tui.FolderEmailsFetchedMsg:
 370		if m.folderInbox == nil {
 371			return m, nil
 372		}
 373		// Call plugin hooks for received emails
 374		if m.plugins != nil {
 375			for _, email := range msg.Emails {
 376				t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, msg.FolderName)
 377				m.plugins.CallHook(plugin.HookEmailReceived, t)
 378			}
 379		}
 380		// Always cache in memory and to disk
 381		m.folderEmails[msg.FolderName] = msg.Emails
 382		go saveFolderEmailsToCache(msg.FolderName, msg.Emails)
 383		// Only update the view if the user is still on this folder
 384		if m.folderInbox.GetCurrentFolder() != msg.FolderName {
 385			return m, nil
 386		}
 387		m.emails = msg.Emails
 388		m.emailsByAcct = make(map[string][]fetcher.Email)
 389		for _, email := range msg.Emails {
 390			m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
 391		}
 392		m.folderInbox.SetEmails(msg.Emails, m.config.Accounts)
 393		m.folderInbox.GetInbox().SetFolderName(msg.FolderName)
 394		m.folderInbox.SetLoadingEmails(false)
 395		m.syncPluginStatus()
 396		return m, m.pluginNotifyCmd()
 397
 398	case tui.FetchFolderMoreEmailsMsg:
 399		if msg.AccountID == "" || m.config == nil {
 400			return m, nil
 401		}
 402		account := m.config.GetAccountByID(msg.AccountID)
 403		if account == nil {
 404			return m, nil
 405		}
 406		limit := uint32(paginationLimit)
 407		if msg.Limit > 0 {
 408			limit = msg.Limit
 409		}
 410		return m, tea.Batch(
 411			func() tea.Msg { return tui.FetchingMoreEmailsMsg{} },
 412			fetchFolderEmailsPaginatedCmd(account, msg.FolderName, limit, msg.Offset),
 413		)
 414
 415	case tui.FolderEmailsAppendedMsg:
 416		// Ignore stale appends for a folder the user has moved away from
 417		if m.folderInbox == nil || m.folderInbox.GetCurrentFolder() != msg.FolderName {
 418			return m, nil
 419		}
 420		m.folderInbox.Update(msg)
 421		// Update local stores and per-folder cache
 422		for _, email := range msg.Emails {
 423			m.emails = append(m.emails, email)
 424			m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
 425		}
 426		m.folderEmails[msg.FolderName] = append(m.folderEmails[msg.FolderName], msg.Emails...)
 427		go saveFolderEmailsToCache(msg.FolderName, m.folderEmails[msg.FolderName])
 428		return m, nil
 429
 430	case tui.MoveEmailToFolderMsg:
 431		if m.config == nil {
 432			return m, nil
 433		}
 434		account := m.config.GetAccountByID(msg.AccountID)
 435		if account == nil {
 436			return m, nil
 437		}
 438		m.previousModel = m.current
 439		m.current = tui.NewStatus("Moving email...")
 440		return m, tea.Batch(m.current.Init(), moveEmailToFolderCmd(account, msg.UID, msg.AccountID, msg.SourceFolder, msg.DestFolder))
 441
 442	case tui.EmailMovedMsg:
 443		if msg.Err != nil {
 444			log.Printf("Move failed: %v", msg.Err)
 445			if m.folderInbox != nil {
 446				m.previousModel = m.folderInbox
 447			}
 448			m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
 449			return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
 450				return tui.RestoreViewMsg{}
 451			})
 452		}
 453		// Remove email from current view
 454		if m.folderInbox != nil {
 455			m.folderInbox.RemoveEmail(msg.UID, msg.AccountID)
 456			m.current = m.folderInbox
 457		}
 458		return m, nil
 459
 460	case tui.CachedEmailsLoadedMsg:
 461		// Cache is no longer used for the folder-based inbox flow
 462		// This handler is kept for backwards compatibility but simply fetches normally
 463		if m.folderInbox == nil {
 464			return m, nil
 465		}
 466		return m, fetchFolderEmailsCmd(m.config, m.folderInbox.GetCurrentFolder())
 467
 468	case tui.IdleNewMailMsg:
 469		// IDLE detected new mail — refetch the folder if we're viewing it
 470		if m.folderInbox != nil && m.folderInbox.GetCurrentFolder() == msg.FolderName {
 471			return m, tea.Batch(
 472				fetchFolderEmailsCmd(m.config, msg.FolderName),
 473				listenForIdleUpdates(m.idleUpdates),
 474			)
 475		}
 476		// Re-subscribe even if not viewing the affected folder
 477		return m, listenForIdleUpdates(m.idleUpdates)
 478
 479	case tui.RequestRefreshMsg:
 480		// Folder-based refresh: clear folder cache and refetch
 481		if msg.FolderName != "" && m.config != nil {
 482			delete(m.folderEmails, msg.FolderName)
 483			if m.folderInbox != nil {
 484				m.folderInbox.SetRefreshing(true)
 485			}
 486			return m, fetchFolderEmailsCmd(m.config, msg.FolderName)
 487		}
 488		return m, tea.Batch(
 489			func() tea.Msg { return tui.RefreshingEmailsMsg{Mailbox: msg.Mailbox} },
 490			refreshEmails(m.config, msg.Mailbox, msg.Counts),
 491		)
 492
 493	case tui.EmailsRefreshedMsg:
 494		// Merge refreshed emails with any paginated emails already loaded.
 495		for accID, refreshed := range msg.EmailsByAccount {
 496			refreshedUIDs := make(map[uint32]struct{}, len(refreshed))
 497			for _, e := range refreshed {
 498				refreshedUIDs[e.UID] = struct{}{}
 499			}
 500			if existing, ok := m.emailsByAcct[accID]; ok {
 501				for _, e := range existing {
 502					if _, found := refreshedUIDs[e.UID]; !found {
 503						refreshed = append(refreshed, e)
 504					}
 505				}
 506			}
 507			m.emailsByAcct[accID] = refreshed
 508		}
 509		m.emails = flattenAndSort(m.emailsByAcct)
 510
 511		// Update folder inbox if it exists
 512		if m.folderInbox != nil {
 513			m.folderInbox.SetEmails(m.emails, m.config.Accounts)
 514			m.folderInbox.GetInbox().Update(msg)
 515		}
 516		return m, nil
 517
 518	case tui.AllEmailsFetchedMsg:
 519		m.emailsByAcct = msg.EmailsByAccount
 520		m.emails = flattenAndSort(msg.EmailsByAccount)
 521
 522		if m.folderInbox != nil {
 523			m.folderInbox.SetEmails(m.emails, m.config.Accounts)
 524			m.folderInbox.SetLoadingEmails(false)
 525		}
 526		return m, nil
 527
 528	case tui.EmailsFetchedMsg:
 529		if m.emailsByAcct == nil {
 530			m.emailsByAcct = make(map[string][]fetcher.Email)
 531		}
 532		m.emailsByAcct[msg.AccountID] = msg.Emails
 533		m.emails = flattenAndSort(m.emailsByAcct)
 534		if m.folderInbox != nil {
 535			m.folderInbox.SetEmails(m.emails, m.config.Accounts)
 536		}
 537		return m, nil
 538
 539	case tui.FetchMoreEmailsMsg:
 540		if msg.AccountID == "" {
 541			return m, nil
 542		}
 543		account := m.config.GetAccountByID(msg.AccountID)
 544		if account == nil {
 545			return m, nil
 546		}
 547		limit := uint32(paginationLimit)
 548		if msg.Limit > 0 {
 549			limit = msg.Limit
 550		}
 551		folderName := "INBOX"
 552		if m.folderInbox != nil {
 553			folderName = m.folderInbox.GetCurrentFolder()
 554		}
 555		return m, tea.Batch(
 556			func() tea.Msg { return tui.FetchingMoreEmailsMsg{} },
 557			fetchFolderEmailsPaginatedCmd(account, folderName, limit, msg.Offset),
 558		)
 559
 560	case tui.EmailsAppendedMsg:
 561		if m.emailsByAcct == nil {
 562			m.emailsByAcct = make(map[string][]fetcher.Email)
 563		}
 564		unique := filterUnique(m.emailsByAcct[msg.AccountID], msg.Emails)
 565		m.emailsByAcct[msg.AccountID] = append(m.emailsByAcct[msg.AccountID], unique...)
 566		m.emails = append(m.emails, unique...)
 567		return m, nil
 568
 569	case tui.GoToSendMsg:
 570		hideTips := false
 571		if m.config != nil {
 572			hideTips = m.config.HideTips
 573		}
 574		if m.config != nil && len(m.config.Accounts) > 0 {
 575			firstAccount := m.config.GetFirstAccount()
 576			composer := tui.NewComposerWithAccounts(m.config.Accounts, firstAccount.ID, msg.To, msg.Subject, msg.Body, hideTips)
 577			m.current = composer
 578		} else {
 579			m.current = tui.NewComposer("", msg.To, msg.Subject, msg.Body, hideTips)
 580		}
 581		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 582		return m, m.current.Init()
 583
 584	case tui.GoToDraftsMsg:
 585		drafts := config.GetAllDrafts()
 586		m.current = tui.NewDrafts(drafts)
 587		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 588		return m, m.current.Init()
 589
 590	case tui.OpenDraftMsg:
 591		var accounts []config.Account
 592		hideTips := false
 593		if m.config != nil {
 594			accounts = m.config.Accounts
 595			hideTips = m.config.HideTips
 596		}
 597		composer := tui.NewComposerFromDraft(msg.Draft, accounts, hideTips)
 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.DeleteSavedDraftMsg:
 603		go func() {
 604			if err := config.DeleteDraft(msg.DraftID); err != nil {
 605				log.Printf("Error deleting draft: %v", err)
 606			}
 607		}()
 608		// Send message back to drafts view
 609		m.current, cmd = m.current.Update(tui.DraftDeletedMsg{DraftID: msg.DraftID})
 610		return m, cmd
 611
 612	case tui.GoToSettingsMsg:
 613		m.current = tui.NewSettings(m.config)
 614		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 615		return m, m.current.Init()
 616
 617	case tui.GoToAddAccountMsg:
 618		hideTips := false
 619		if m.config != nil {
 620			hideTips = m.config.HideTips
 621		}
 622		m.current = tui.NewLogin(hideTips)
 623		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 624		return m, m.current.Init()
 625
 626	case tui.GoToAddMailingListMsg:
 627		m.current = tui.NewMailingListEditor()
 628		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 629		return m, m.current.Init()
 630
 631	case tui.GoToEditAccountMsg:
 632		hideTips := false
 633		if m.config != nil {
 634			hideTips = m.config.HideTips
 635		}
 636		login := tui.NewLogin(hideTips)
 637		login.SetEditMode(msg.AccountID, msg.Provider, msg.Name, msg.Email, msg.FetchEmail, msg.IMAPServer, msg.IMAPPort, msg.SMTPServer, msg.SMTPPort)
 638		m.current = login
 639		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 640		return m, m.current.Init()
 641
 642	case tui.GoToEditMailingListMsg:
 643		editor := tui.NewMailingListEditor()
 644		editor.SetEditMode(msg.Index, msg.Name, msg.Addresses)
 645		m.current = editor
 646		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 647		return m, m.current.Init()
 648
 649	case tui.SaveMailingListMsg:
 650		if m.config != nil {
 651			var addrs []string
 652			for _, part := range strings.Split(msg.Addresses, ",") {
 653				if trimmed := strings.TrimSpace(part); trimmed != "" {
 654					addrs = append(addrs, trimmed)
 655				}
 656			}
 657			if msg.EditIndex >= 0 && msg.EditIndex < len(m.config.MailingLists) {
 658				m.config.MailingLists[msg.EditIndex] = config.MailingList{
 659					Name:      msg.Name,
 660					Addresses: addrs,
 661				}
 662			} else {
 663				m.config.MailingLists = append(m.config.MailingLists, config.MailingList{
 664					Name:      msg.Name,
 665					Addresses: addrs,
 666				})
 667			}
 668			if err := config.SaveConfig(m.config); err != nil {
 669				log.Printf("could not save config: %v", err)
 670			}
 671		}
 672		// Return to settings
 673		m.current = tui.NewSettings(m.config)
 674		// Try to navigate to the mailing list view internally if possible, but NewSettings will go to SettingsMain by default.
 675		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 676		return m, m.current.Init()
 677
 678	case tui.GoToSignatureEditorMsg:
 679		m.current = tui.NewSignatureEditor()
 680		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 681		return m, m.current.Init()
 682
 683	case tui.GoToChoiceMenuMsg:
 684		m.current = tui.NewChoice()
 685		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 686		return m, m.current.Init()
 687
 688	case tui.DeleteAccountMsg:
 689		if m.config != nil {
 690			m.config.RemoveAccount(msg.AccountID)
 691			if err := config.SaveConfig(m.config); err != nil {
 692				log.Printf("could not save config: %v", err)
 693			}
 694			// Remove emails for this account
 695			delete(m.emailsByAcct, msg.AccountID)
 696
 697			// Rebuild all emails
 698			var allEmails []fetcher.Email
 699			for _, emails := range m.emailsByAcct {
 700				allEmails = append(allEmails, emails...)
 701			}
 702			m.emails = allEmails
 703
 704			// Go back to settings
 705			m.current = tui.NewSettings(m.config)
 706			m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 707		}
 708		return m, m.current.Init()
 709
 710	case tui.ViewEmailMsg:
 711		email := m.getEmailByUIDAndAccount(msg.UID, msg.AccountID, msg.Mailbox)
 712		if email == nil {
 713			return m, nil
 714		}
 715		folderName := "INBOX"
 716		if m.folderInbox != nil {
 717			folderName = m.folderInbox.GetCurrentFolder()
 718		}
 719		if m.plugins != nil {
 720			t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, folderName)
 721			m.plugins.CallHook(plugin.HookEmailViewed, t)
 722		}
 723		m.current = tui.NewStatus("Fetching email content...")
 724		return m, tea.Batch(m.current.Init(), fetchFolderEmailBodyCmd(m.config, msg.UID, msg.AccountID, folderName, msg.Mailbox), m.pluginNotifyCmd())
 725
 726	case tui.EmailBodyFetchedMsg:
 727		if msg.Err != nil {
 728			log.Printf("could not fetch email body: %v", msg.Err)
 729			if m.folderInbox != nil {
 730				m.current = m.folderInbox
 731			}
 732			return m, nil
 733		}
 734
 735		// Update the email in our stores
 736		m.updateEmailBodyByUID(msg.UID, msg.AccountID, msg.Mailbox, msg.Body, msg.Attachments)
 737
 738		email := m.getEmailByUIDAndAccount(msg.UID, msg.AccountID, msg.Mailbox)
 739		if email == nil {
 740			if m.folderInbox != nil {
 741				m.current = m.folderInbox
 742			}
 743			return m, nil
 744		}
 745
 746		// Mark as read in UI immediately and on the server
 747		var markReadCmd tea.Cmd
 748		if !email.IsRead {
 749			m.markEmailAsReadInStores(msg.UID, msg.AccountID)
 750
 751			folderName := "INBOX"
 752			if m.folderInbox != nil {
 753				folderName = m.folderInbox.GetCurrentFolder()
 754			}
 755			account := m.config.GetAccountByID(msg.AccountID)
 756			if account != nil {
 757				markReadCmd = markEmailAsReadCmd(account, msg.UID, msg.AccountID, folderName)
 758			}
 759		}
 760
 761		// Find the index for the email view (used for display purposes)
 762		emailIndex := m.getEmailIndex(msg.UID, msg.AccountID, msg.Mailbox)
 763		emailView := tui.NewEmailView(*email, emailIndex, m.width, m.height, msg.Mailbox, m.config.DisableImages)
 764		m.current = emailView
 765		m.syncPluginStatus()
 766		cmds := []tea.Cmd{m.current.Init()}
 767		if markReadCmd != nil {
 768			cmds = append(cmds, markReadCmd)
 769		}
 770		return m, tea.Batch(cmds...)
 771
 772	case tui.ReplyToEmailMsg:
 773		to := msg.Email.From
 774		subject := msg.Email.Subject
 775		normalizedSubject := strings.ToLower(strings.TrimSpace(subject))
 776		if !strings.HasPrefix(normalizedSubject, "re:") {
 777			subject = "Re: " + subject
 778		}
 779		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> "))
 780
 781		var composer *tui.Composer
 782		hideTips := false
 783		if m.config != nil {
 784			hideTips = m.config.HideTips
 785		}
 786		if m.config != nil && len(m.config.Accounts) > 0 {
 787			// Use the account that received the email
 788			accountID := msg.Email.AccountID
 789			if accountID == "" {
 790				accountID = m.config.GetFirstAccount().ID
 791			}
 792			composer = tui.NewComposerWithAccounts(m.config.Accounts, accountID, to, subject, "", hideTips)
 793		} else {
 794			composer = tui.NewComposer("", to, subject, "", hideTips)
 795		}
 796		composer.SetQuotedText(quotedText)
 797
 798		// Set reply headers
 799		inReplyTo := msg.Email.MessageID
 800		references := append(msg.Email.References, msg.Email.MessageID)
 801		composer.SetReplyContext(inReplyTo, references)
 802
 803		m.current = composer
 804		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 805		return m, m.current.Init()
 806
 807	case tui.ForwardEmailMsg:
 808		subject := msg.Email.Subject
 809		if !strings.HasPrefix(strings.ToLower(subject), "fwd:") {
 810			subject = "Fwd: " + subject
 811		}
 812
 813		forwardHeader := fmt.Sprintf("\n\n---------- Forwarded message ----------\nFrom: %s\nDate: %s\nSubject: %s\nTo: %s\n\n",
 814			msg.Email.From,
 815			msg.Email.Date.Format("Mon, Jan 2, 2006 at 3:04 PM"),
 816			msg.Email.Subject,
 817			msg.Email.To,
 818		)
 819
 820		body := forwardHeader + msg.Email.Body
 821
 822		var composer *tui.Composer
 823		hideTips := false
 824		if m.config != nil {
 825			hideTips = m.config.HideTips
 826		}
 827		if m.config != nil && len(m.config.Accounts) > 0 {
 828			// Use the account that received the email
 829			accountID := msg.Email.AccountID
 830			if accountID == "" {
 831				accountID = m.config.GetFirstAccount().ID
 832			}
 833			composer = tui.NewComposerWithAccounts(m.config.Accounts, accountID, "", subject, body, hideTips)
 834		} else {
 835			composer = tui.NewComposer("", "", subject, body, hideTips)
 836		}
 837
 838		m.current = composer
 839		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 840		return m, m.current.Init()
 841
 842	case tui.OpenEditorMsg:
 843		composer, ok := m.current.(*tui.Composer)
 844		if !ok {
 845			return m, nil
 846		}
 847		return m, openExternalEditor(composer.GetBody())
 848
 849	case tui.EditorFinishedMsg:
 850		if msg.Err != nil {
 851			log.Printf("Editor error: %v", msg.Err)
 852			return m, nil
 853		}
 854		if composer, ok := m.current.(*tui.Composer); ok {
 855			composer.SetBody(msg.Body)
 856		}
 857		return m, nil
 858
 859	case tui.GoToFilePickerMsg:
 860		m.previousModel = m.current
 861		wd, _ := os.Getwd()
 862		m.current = tui.NewFilePicker(wd)
 863		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 864		return m, m.current.Init()
 865
 866	case tui.FileSelectedMsg, tui.CancelFilePickerMsg:
 867		if m.previousModel != nil {
 868			m.current = m.previousModel
 869			m.previousModel = nil
 870		}
 871		m.current, cmd = m.current.Update(msg)
 872		cmds = append(cmds, cmd)
 873
 874	case tui.SendEmailMsg:
 875		if m.plugins != nil {
 876			m.plugins.CallSendHook(plugin.HookEmailSendBefore, msg.To, msg.Cc, msg.Subject, msg.AccountID)
 877		}
 878		// Get draft ID before clearing composer (if it's a composer)
 879		var draftID string
 880		if composer, ok := m.current.(*tui.Composer); ok {
 881			draftID = composer.GetDraftID()
 882		}
 883		m.current = tui.NewStatus("Sending email...")
 884
 885		// Get the account to send from
 886		var account *config.Account
 887		if msg.AccountID != "" && m.config != nil {
 888			account = m.config.GetAccountByID(msg.AccountID)
 889		}
 890		if account == nil && m.config != nil {
 891			account = m.config.GetFirstAccount()
 892		}
 893
 894		// Save contact and delete draft in background
 895		go func() {
 896			// Save the recipient as a contact
 897			if msg.To != "" {
 898				recipients := strings.Split(msg.To, ",")
 899				for _, r := range recipients {
 900					r = strings.TrimSpace(r)
 901					if r == "" {
 902						continue
 903					}
 904					name, email := parseEmailAddress(r)
 905					if err := config.AddContact(name, email); err != nil {
 906						log.Printf("Error saving contact: %v", err)
 907					}
 908				}
 909			}
 910			// Delete the draft since email is being sent
 911			if draftID != "" {
 912				if err := config.DeleteDraft(draftID); err != nil {
 913					log.Printf("Error deleting draft after send: %v", err)
 914				}
 915			}
 916		}()
 917
 918		return m, tea.Batch(m.current.Init(), sendEmail(account, msg))
 919
 920	case tui.EmailResultMsg:
 921		if msg.Err != nil {
 922			log.Printf("Failed to send email: %v", msg.Err)
 923			m.previousModel = tui.NewChoice()
 924			m.previousModel, _ = m.previousModel.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 925			m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
 926			return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
 927				return tui.RestoreViewMsg{}
 928			})
 929		}
 930		if m.plugins != nil {
 931			m.plugins.CallHook(plugin.HookEmailSendAfter)
 932		}
 933		m.current = tui.NewChoice()
 934		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 935		return m, m.current.Init()
 936
 937	case tui.DeleteEmailMsg:
 938		tui.ClearKittyGraphics()
 939		m.previousModel = m.current
 940		m.current = tui.NewStatus("Deleting email...")
 941
 942		account := m.config.GetAccountByID(msg.AccountID)
 943		if account == nil {
 944			if m.folderInbox != nil {
 945				m.current = m.folderInbox
 946			}
 947			return m, nil
 948		}
 949
 950		folderName := "INBOX"
 951		if m.folderInbox != nil {
 952			folderName = m.folderInbox.GetCurrentFolder()
 953		}
 954		return m, tea.Batch(m.current.Init(), deleteFolderEmailCmd(account, msg.UID, msg.AccountID, folderName, msg.Mailbox))
 955
 956	case tui.ArchiveEmailMsg:
 957		tui.ClearKittyGraphics()
 958		m.previousModel = m.current
 959		m.current = tui.NewStatus("Archiving email...")
 960
 961		account := m.config.GetAccountByID(msg.AccountID)
 962		if account == nil {
 963			if m.folderInbox != nil {
 964				m.current = m.folderInbox
 965			}
 966			return m, nil
 967		}
 968
 969		folderName := "INBOX"
 970		if m.folderInbox != nil {
 971			folderName = m.folderInbox.GetCurrentFolder()
 972		}
 973		return m, tea.Batch(m.current.Init(), archiveFolderEmailCmd(account, msg.UID, msg.AccountID, folderName, msg.Mailbox))
 974
 975	case tui.EmailMarkedReadMsg:
 976		if msg.Err != nil {
 977			log.Printf("Error marking email as read: %v", msg.Err)
 978		}
 979		return m, nil
 980
 981	case tui.EmailActionDoneMsg:
 982		if msg.Err != nil {
 983			log.Printf("Action failed: %v", msg.Err)
 984			if m.folderInbox != nil {
 985				m.previousModel = m.folderInbox
 986			}
 987			m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
 988			return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
 989				return tui.RestoreViewMsg{}
 990			})
 991		}
 992
 993		// Remove email from stores
 994		m.removeEmailFromStores(msg.UID, msg.AccountID)
 995
 996		if m.folderInbox != nil {
 997			m.folderInbox.RemoveEmail(msg.UID, msg.AccountID)
 998			m.current = m.folderInbox
 999			m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1000			return m, m.current.Init()
1001		}
1002		m.current = tui.NewChoice()
1003		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1004		return m, m.current.Init()
1005
1006	case tui.DownloadAttachmentMsg:
1007		m.previousModel = m.current
1008		m.current = tui.NewStatus(fmt.Sprintf("Downloading %s...", msg.Filename))
1009
1010		account := m.config.GetAccountByID(msg.AccountID)
1011		if account == nil {
1012			m.current = m.previousModel
1013			return m, nil
1014		}
1015
1016		email := m.getEmailByIndex(msg.Index, msg.Mailbox)
1017		if email == nil {
1018			m.current = m.previousModel
1019			return m, nil
1020		}
1021
1022		// Find the correct attachment to get encoding
1023		var encoding string
1024		for _, att := range email.Attachments {
1025			if att.PartID == msg.PartID {
1026				encoding = att.Encoding
1027				break
1028			}
1029		}
1030		newMsg := tui.DownloadAttachmentMsg{
1031			Index:     msg.Index,
1032			Filename:  msg.Filename,
1033			PartID:    msg.PartID,
1034			Data:      msg.Data,
1035			AccountID: msg.AccountID,
1036			Encoding:  encoding,
1037			Mailbox:   msg.Mailbox,
1038		}
1039		return m, tea.Batch(m.current.Init(), downloadAttachmentCmd(account, email.UID, newMsg))
1040
1041	case tui.AttachmentDownloadedMsg:
1042		var statusMsg string
1043		if msg.Err != nil {
1044			statusMsg = fmt.Sprintf("Error downloading: %v", msg.Err)
1045		} else {
1046			statusMsg = fmt.Sprintf("Saved to %s", msg.Path)
1047		}
1048		m.current = tui.NewStatus(statusMsg)
1049		return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1050			return tui.RestoreViewMsg{}
1051		})
1052
1053	case tui.RestoreViewMsg:
1054		if m.previousModel != nil {
1055			m.current = m.previousModel
1056			m.previousModel = nil
1057		}
1058		return m, nil
1059	}
1060
1061	if cmd := m.pluginNotifyCmd(); cmd != nil {
1062		cmds = append(cmds, cmd)
1063	}
1064
1065	return m, tea.Batch(cmds...)
1066}
1067
1068func (m *mainModel) View() tea.View {
1069	v := m.current.View()
1070	v.AltScreen = true
1071	return v
1072}
1073
1074func (m *mainModel) getEmailByIndex(index int, mailbox tui.MailboxKind) *fetcher.Email {
1075	if index >= 0 && index < len(m.emails) {
1076		return &m.emails[index]
1077	}
1078	return nil
1079}
1080
1081func (m *mainModel) getEmailByUIDAndAccount(uid uint32, accountID string, mailbox tui.MailboxKind) *fetcher.Email {
1082	for i := range m.emails {
1083		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1084			return &m.emails[i]
1085		}
1086	}
1087	return nil
1088}
1089
1090func (m *mainModel) getEmailIndex(uid uint32, accountID string, mailbox tui.MailboxKind) int {
1091	for i := range m.emails {
1092		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1093			return i
1094		}
1095	}
1096	return -1
1097}
1098
1099func (m *mainModel) updateEmailBodyByUID(uid uint32, accountID string, mailbox tui.MailboxKind, body string, attachments []fetcher.Attachment) {
1100	for i := range m.emails {
1101		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1102			m.emails[i].Body = body
1103			m.emails[i].Attachments = attachments
1104			break
1105		}
1106	}
1107	if emails, ok := m.emailsByAcct[accountID]; ok {
1108		for i := range emails {
1109			if emails[i].UID == uid {
1110				emails[i].Body = body
1111				emails[i].Attachments = attachments
1112				break
1113			}
1114		}
1115	}
1116}
1117
1118func (m *mainModel) markEmailAsReadInStores(uid uint32, accountID string) {
1119	for i := range m.emails {
1120		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1121			m.emails[i].IsRead = true
1122			break
1123		}
1124	}
1125	if emails, ok := m.emailsByAcct[accountID]; ok {
1126		for i := range emails {
1127			if emails[i].UID == uid {
1128				emails[i].IsRead = true
1129				break
1130			}
1131		}
1132	}
1133	// Update folder email cache
1134	for folderName, folderEmails := range m.folderEmails {
1135		for i := range folderEmails {
1136			if folderEmails[i].UID == uid && folderEmails[i].AccountID == accountID {
1137				folderEmails[i].IsRead = true
1138				m.folderEmails[folderName] = folderEmails
1139				go saveFolderEmailsToCache(folderName, folderEmails)
1140				break
1141			}
1142		}
1143	}
1144	// Update the inbox UI
1145	if m.folderInbox != nil {
1146		m.folderInbox.GetInbox().MarkEmailAsRead(uid, accountID)
1147	}
1148}
1149
1150func (m *mainModel) removeEmailFromStores(uid uint32, accountID string) {
1151	var filtered []fetcher.Email
1152	for _, e := range m.emails {
1153		if !(e.UID == uid && e.AccountID == accountID) {
1154			filtered = append(filtered, e)
1155		}
1156	}
1157	m.emails = filtered
1158	if emails, ok := m.emailsByAcct[accountID]; ok {
1159		var filteredAcct []fetcher.Email
1160		for _, e := range emails {
1161			if e.UID != uid {
1162				filteredAcct = append(filteredAcct, e)
1163			}
1164		}
1165		m.emailsByAcct[accountID] = filteredAcct
1166	}
1167}
1168
1169// pluginNotifyCmd checks for a pending plugin notification and returns a command if one exists.
1170func (m *mainModel) pluginNotifyCmd() tea.Cmd {
1171	if m.plugins == nil {
1172		return nil
1173	}
1174	if n, ok := m.plugins.TakePendingNotification(); ok {
1175		return func() tea.Msg {
1176			return tui.PluginNotifyMsg{Message: n.Message, Duration: n.Duration}
1177		}
1178	}
1179	return nil
1180}
1181
1182func (m *mainModel) syncPluginStatus() {
1183	if m.plugins == nil {
1184		return
1185	}
1186	if m.folderInbox != nil {
1187		m.folderInbox.GetInbox().SetPluginStatus(m.plugins.StatusText(plugin.StatusInbox))
1188	}
1189	switch v := m.current.(type) {
1190	case *tui.Composer:
1191		v.SetPluginStatus(m.plugins.StatusText(plugin.StatusComposer))
1192	case *tui.EmailView:
1193		v.SetPluginStatus(m.plugins.StatusText(plugin.StatusEmailView))
1194	}
1195}
1196
1197func flattenAndSort(emailsByAccount map[string][]fetcher.Email) []fetcher.Email {
1198	var allEmails []fetcher.Email
1199	for _, emails := range emailsByAccount {
1200		allEmails = append(allEmails, emails...)
1201	}
1202	for i := 0; i < len(allEmails); i++ {
1203		for j := i + 1; j < len(allEmails); j++ {
1204			if allEmails[j].Date.After(allEmails[i].Date) {
1205				allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
1206			}
1207		}
1208	}
1209	return allEmails
1210}
1211
1212func fetchAllAccountsEmails(cfg *config.Config, mailbox tui.MailboxKind) tea.Cmd {
1213	return func() tea.Msg {
1214		emailsByAccount := make(map[string][]fetcher.Email)
1215		var mu sync.Mutex
1216		var wg sync.WaitGroup
1217
1218		for _, account := range cfg.Accounts {
1219			wg.Add(1)
1220			go func(acc config.Account) {
1221				defer wg.Done()
1222				var emails []fetcher.Email
1223				var err error
1224				switch mailbox {
1225				case tui.MailboxSent:
1226					emails, err = fetcher.FetchSentEmails(&acc, initialEmailLimit, 0)
1227				case tui.MailboxTrash:
1228					emails, err = fetcher.FetchTrashEmails(&acc, initialEmailLimit, 0)
1229				case tui.MailboxArchive:
1230					emails, err = fetcher.FetchArchiveEmails(&acc, initialEmailLimit, 0)
1231				default:
1232					emails, err = fetcher.FetchEmails(&acc, initialEmailLimit, 0)
1233				}
1234				if err != nil {
1235					log.Printf("Error fetching from %s: %v", acc.Email, err)
1236					return
1237				}
1238				mu.Lock()
1239				emailsByAccount[acc.ID] = emails
1240				mu.Unlock()
1241			}(account)
1242		}
1243
1244		wg.Wait()
1245		return tui.AllEmailsFetchedMsg{EmailsByAccount: emailsByAccount, Mailbox: mailbox}
1246	}
1247}
1248
1249func fetchEmails(account *config.Account, limit, offset uint32, mailbox tui.MailboxKind) tea.Cmd {
1250	return func() tea.Msg {
1251		var emails []fetcher.Email
1252		var err error
1253		if mailbox == tui.MailboxSent {
1254			emails, err = fetcher.FetchSentEmails(account, limit, offset)
1255		} else {
1256			emails, err = fetcher.FetchEmails(account, limit, offset)
1257		}
1258		if err != nil {
1259			return tui.FetchErr(err)
1260		}
1261		if offset == 0 {
1262			return tui.EmailsFetchedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1263		}
1264		return tui.EmailsAppendedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1265	}
1266}
1267
1268func fetchEmailsForMailbox(account *config.Account, limit, offset uint32, mailbox tui.MailboxKind) tea.Cmd {
1269	return func() tea.Msg {
1270		var emails []fetcher.Email
1271		var err error
1272		switch mailbox {
1273		case tui.MailboxSent:
1274			emails, err = fetcher.FetchSentEmails(account, limit, offset)
1275		case tui.MailboxTrash:
1276			emails, err = fetcher.FetchTrashEmails(account, limit, offset)
1277		case tui.MailboxArchive:
1278			emails, err = fetcher.FetchArchiveEmails(account, limit, offset)
1279		default:
1280			emails, err = fetcher.FetchEmails(account, limit, offset)
1281		}
1282		if err != nil {
1283			return tui.FetchErr(err)
1284		}
1285		if offset == 0 {
1286			return tui.EmailsFetchedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1287		}
1288		return tui.EmailsAppendedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1289	}
1290}
1291
1292func loadCachedEmails() tea.Cmd {
1293	return func() tea.Msg {
1294		cache, err := config.LoadEmailCache()
1295		if err != nil {
1296			return tui.CachedEmailsLoadedMsg{Cache: nil}
1297		}
1298		return tui.CachedEmailsLoadedMsg{Cache: cache}
1299	}
1300}
1301
1302func refreshEmails(cfg *config.Config, mailbox tui.MailboxKind, counts map[string]int) tea.Cmd {
1303	return func() tea.Msg {
1304		emailsByAccount := make(map[string][]fetcher.Email)
1305		var mu sync.Mutex
1306		var wg sync.WaitGroup
1307
1308		for _, account := range cfg.Accounts {
1309			wg.Add(1)
1310			go func(acc config.Account) {
1311				defer wg.Done()
1312				var emails []fetcher.Email
1313				var err error
1314
1315				limit := uint32(initialEmailLimit)
1316				if counts != nil {
1317					if c, ok := counts[acc.ID]; ok && c > 0 {
1318						limit = uint32(c)
1319					}
1320				}
1321
1322				if mailbox == tui.MailboxSent {
1323					emails, err = fetcher.FetchSentEmails(&acc, limit, 0)
1324				} else {
1325					emails, err = fetcher.FetchEmails(&acc, limit, 0)
1326				}
1327				if err != nil {
1328					log.Printf("Error fetching from %s: %v", acc.Email, err)
1329					return
1330				}
1331				mu.Lock()
1332				emailsByAccount[acc.ID] = emails
1333				mu.Unlock()
1334			}(account)
1335		}
1336
1337		wg.Wait()
1338		return tui.EmailsRefreshedMsg{EmailsByAccount: emailsByAccount, Mailbox: mailbox}
1339	}
1340}
1341
1342func emailsToCache(emails []fetcher.Email) []config.CachedEmail {
1343	var cached []config.CachedEmail
1344	for _, email := range emails {
1345		cached = append(cached, config.CachedEmail{
1346			UID:       email.UID,
1347			From:      email.From,
1348			To:        email.To,
1349			Subject:   email.Subject,
1350			Date:      email.Date,
1351			MessageID: email.MessageID,
1352			AccountID: email.AccountID,
1353			IsRead:    email.IsRead,
1354		})
1355	}
1356	return cached
1357}
1358
1359func cacheToEmails(cached []config.CachedEmail) []fetcher.Email {
1360	var emails []fetcher.Email
1361	for _, c := range cached {
1362		emails = append(emails, fetcher.Email{
1363			UID:       c.UID,
1364			From:      c.From,
1365			To:        c.To,
1366			Subject:   c.Subject,
1367			Date:      c.Date,
1368			MessageID: c.MessageID,
1369			AccountID: c.AccountID,
1370			IsRead:    c.IsRead,
1371		})
1372	}
1373	return emails
1374}
1375
1376func saveFolderEmailsToCache(folderName string, emails []fetcher.Email) {
1377	cached := emailsToCache(emails)
1378	if err := config.SaveFolderEmailCache(folderName, cached); err != nil {
1379		log.Printf("Error saving folder email cache for %s: %v", folderName, err)
1380	}
1381}
1382
1383func loadFolderEmailsFromCache(folderName string) []fetcher.Email {
1384	cached, err := config.LoadFolderEmailCache(folderName)
1385	if err != nil {
1386		return nil
1387	}
1388	return cacheToEmails(cached)
1389}
1390
1391func saveEmailsToCache(emails []fetcher.Email) {
1392	if len(emails) > maxCacheEmails {
1393		emails = emails[:maxCacheEmails]
1394	}
1395	var cachedEmails []config.CachedEmail
1396	for _, email := range emails {
1397		cachedEmails = append(cachedEmails, config.CachedEmail{
1398			UID:       email.UID,
1399			From:      email.From,
1400			To:        email.To,
1401			Subject:   email.Subject,
1402			Date:      email.Date,
1403			MessageID: email.MessageID,
1404			AccountID: email.AccountID,
1405			IsRead:    email.IsRead,
1406		})
1407
1408		// Save sender as a contact
1409		if email.From != "" {
1410			name, emailAddr := parseEmailAddress(email.From)
1411			if err := config.AddContact(name, emailAddr); err != nil {
1412				log.Printf("Error saving contact from email: %v", err)
1413			}
1414		}
1415	}
1416	cache := &config.EmailCache{Emails: cachedEmails}
1417	if err := config.SaveEmailCache(cache); err != nil {
1418		log.Printf("Error saving email cache: %v", err)
1419	}
1420}
1421
1422// parseEmailAddress parses "Name <email>" or just "email" format
1423func parseEmailAddress(addr string) (name, email string) {
1424	addr = strings.TrimSpace(addr)
1425	if idx := strings.Index(addr, "<"); idx != -1 {
1426		name = strings.TrimSpace(addr[:idx])
1427		endIdx := strings.Index(addr, ">")
1428		if endIdx > idx {
1429			email = strings.TrimSpace(addr[idx+1 : endIdx])
1430		} else {
1431			email = strings.TrimSpace(addr[idx+1:])
1432		}
1433	} else {
1434		email = addr
1435	}
1436	return name, email
1437}
1438
1439func fetchEmailBodyCmd(cfg *config.Config, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
1440	return func() tea.Msg {
1441		account := cfg.GetAccountByID(accountID)
1442		if account == nil {
1443			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: fmt.Errorf("account not found")}
1444		}
1445
1446		var (
1447			body        string
1448			attachments []fetcher.Attachment
1449			err         error
1450		)
1451		switch mailbox {
1452		case tui.MailboxSent:
1453			body, attachments, err = fetcher.FetchSentEmailBody(account, uid)
1454		case tui.MailboxTrash:
1455			body, attachments, err = fetcher.FetchTrashEmailBody(account, uid)
1456		case tui.MailboxArchive:
1457			body, attachments, err = fetcher.FetchArchiveEmailBody(account, uid)
1458		default:
1459			body, attachments, err = fetcher.FetchEmailBody(account, uid)
1460		}
1461		if err != nil {
1462			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1463		}
1464
1465		return tui.EmailBodyFetchedMsg{
1466			UID:         uid,
1467			Body:        body,
1468			Attachments: attachments,
1469			AccountID:   accountID,
1470			Mailbox:     mailbox,
1471		}
1472	}
1473}
1474
1475func markdownToHTML(md []byte) []byte {
1476	return clib.MarkdownToHTML(md)
1477}
1478
1479func splitEmails(s string) []string {
1480	if s == "" {
1481		return nil
1482	}
1483	parts := strings.Split(s, ",")
1484	var res []string
1485	for _, p := range parts {
1486		if trimmed := strings.TrimSpace(p); trimmed != "" {
1487			res = append(res, trimmed)
1488		}
1489	}
1490	return res
1491}
1492
1493func sendEmail(account *config.Account, msg tui.SendEmailMsg) tea.Cmd {
1494	return func() tea.Msg {
1495		if account == nil {
1496			return tui.EmailResultMsg{Err: fmt.Errorf("no account configured")}
1497		}
1498
1499		recipients := splitEmails(msg.To)
1500		cc := splitEmails(msg.Cc)
1501		bcc := splitEmails(msg.Bcc)
1502		body := msg.Body
1503		// Append signature if present
1504		if msg.Signature != "" {
1505			body = body + "\n\n" + msg.Signature
1506		}
1507		// Append quoted text if present (for replies)
1508		if msg.QuotedText != "" {
1509			body = body + msg.QuotedText
1510		}
1511		images := make(map[string][]byte)
1512		attachments := make(map[string][]byte)
1513
1514		re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
1515		matches := re.FindAllStringSubmatch(body, -1)
1516
1517		for _, match := range matches {
1518			imgPath := match[1]
1519			imgData, err := os.ReadFile(imgPath)
1520			if err != nil {
1521				log.Printf("Could not read image file %s: %v", imgPath, err)
1522				continue
1523			}
1524			cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "matcha")
1525			images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
1526			body = strings.Replace(body, imgPath, "cid:"+cid, 1)
1527		}
1528
1529		htmlBody := markdownToHTML([]byte(body))
1530
1531		for _, attachPath := range msg.AttachmentPaths {
1532			fileData, err := os.ReadFile(attachPath)
1533			if err != nil {
1534				log.Printf("Could not read attachment file %s: %v", attachPath, err)
1535				continue
1536			}
1537			_, filename := filepath.Split(attachPath)
1538			attachments[filename] = fileData
1539		}
1540
1541		err := sender.SendEmail(account, recipients, cc, bcc, msg.Subject, body, string(htmlBody), images, attachments, msg.InReplyTo, msg.References, msg.SignSMIME, msg.EncryptSMIME)
1542		if err != nil {
1543			log.Printf("Failed to send email: %v", err)
1544			return tui.EmailResultMsg{Err: err}
1545		}
1546		return tui.EmailResultMsg{}
1547	}
1548}
1549
1550func deleteEmailCmd(account *config.Account, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
1551	return func() tea.Msg {
1552		var err error
1553		switch mailbox {
1554		case tui.MailboxSent:
1555			err = fetcher.DeleteSentEmail(account, uid)
1556		case tui.MailboxTrash:
1557			err = fetcher.DeleteTrashEmail(account, uid)
1558		case tui.MailboxArchive:
1559			err = fetcher.DeleteArchiveEmail(account, uid)
1560		default:
1561			err = fetcher.DeleteEmail(account, uid)
1562		}
1563		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1564	}
1565}
1566
1567func archiveEmailCmd(account *config.Account, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
1568	return func() tea.Msg {
1569		var err error
1570		if mailbox == tui.MailboxSent {
1571			err = fetcher.ArchiveSentEmail(account, uid)
1572		} else {
1573			err = fetcher.ArchiveEmail(account, uid)
1574		}
1575		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1576	}
1577}
1578
1579// --- External editor command ---
1580
1581// openExternalEditor writes the body to a temp file, opens $EDITOR, and reads back the result.
1582func openExternalEditor(body string) tea.Cmd {
1583	editor := os.Getenv("EDITOR")
1584	if editor == "" {
1585		editor = os.Getenv("VISUAL")
1586	}
1587	if editor == "" {
1588		editor = "vi"
1589	}
1590
1591	tmpFile, err := os.CreateTemp("", "matcha-*.md")
1592	if err != nil {
1593		return func() tea.Msg {
1594			return tui.EditorFinishedMsg{Err: fmt.Errorf("creating temp file: %w", err)}
1595		}
1596	}
1597	tmpPath := tmpFile.Name()
1598
1599	if _, err := tmpFile.WriteString(body); err != nil {
1600		tmpFile.Close()
1601		os.Remove(tmpPath)
1602		return func() tea.Msg {
1603			return tui.EditorFinishedMsg{Err: fmt.Errorf("writing temp file: %w", err)}
1604		}
1605	}
1606	tmpFile.Close()
1607
1608	parts := strings.Fields(editor)
1609	args := append(parts[1:], tmpPath)
1610	c := exec.Command(parts[0], args...)
1611	return tea.ExecProcess(c, func(err error) tea.Msg {
1612		defer os.Remove(tmpPath)
1613		if err != nil {
1614			return tui.EditorFinishedMsg{Err: err}
1615		}
1616		content, readErr := os.ReadFile(tmpPath)
1617		if readErr != nil {
1618			return tui.EditorFinishedMsg{Err: readErr}
1619		}
1620		return tui.EditorFinishedMsg{Body: string(content)}
1621	})
1622}
1623
1624// --- IDLE command ---
1625
1626// listenForIdleUpdates blocks until an IDLE update arrives, then returns it as a tea.Msg.
1627func listenForIdleUpdates(ch <-chan fetcher.IdleUpdate) tea.Cmd {
1628	return func() tea.Msg {
1629		update, ok := <-ch
1630		if !ok {
1631			return nil
1632		}
1633		return tui.IdleNewMailMsg{
1634			AccountID:  update.AccountID,
1635			FolderName: update.FolderName,
1636		}
1637	}
1638}
1639
1640// --- Folder-based command functions ---
1641
1642func fetchFoldersCmd(cfg *config.Config) tea.Cmd {
1643	return func() tea.Msg {
1644		if !cfg.HasAccounts() {
1645			return nil
1646		}
1647		foldersByAccount := make(map[string][]fetcher.Folder)
1648		seen := make(map[string]fetcher.Folder)
1649		var mu sync.Mutex
1650		var wg sync.WaitGroup
1651
1652		for _, account := range cfg.Accounts {
1653			wg.Add(1)
1654			go func(acc config.Account) {
1655				defer wg.Done()
1656				folders, err := fetcher.FetchFolders(&acc)
1657				if err != nil {
1658					return
1659				}
1660				mu.Lock()
1661				foldersByAccount[acc.ID] = folders
1662				for _, f := range folders {
1663					if _, ok := seen[f.Name]; !ok {
1664						seen[f.Name] = f
1665					}
1666				}
1667				mu.Unlock()
1668			}(account)
1669		}
1670		wg.Wait()
1671
1672		var merged []fetcher.Folder
1673		for _, f := range seen {
1674			merged = append(merged, f)
1675		}
1676
1677		return tui.FoldersFetchedMsg{
1678			FoldersByAccount: foldersByAccount,
1679			MergedFolders:    merged,
1680		}
1681	}
1682}
1683
1684func fetchFolderEmailsCmd(cfg *config.Config, folderName string) tea.Cmd {
1685	return func() tea.Msg {
1686		emailsByAccount := make(map[string][]fetcher.Email)
1687		var mu sync.Mutex
1688		var wg sync.WaitGroup
1689
1690		for _, account := range cfg.Accounts {
1691			wg.Add(1)
1692			go func(acc config.Account) {
1693				defer wg.Done()
1694				emails, err := fetcher.FetchFolderEmails(&acc, folderName, initialEmailLimit, 0)
1695				if err != nil {
1696					// Folder may not exist for this account — silently skip
1697					return
1698				}
1699				mu.Lock()
1700				emailsByAccount[acc.ID] = emails
1701				mu.Unlock()
1702			}(account)
1703		}
1704
1705		wg.Wait()
1706
1707		// Flatten all account emails
1708		var allEmails []fetcher.Email
1709		for _, emails := range emailsByAccount {
1710			allEmails = append(allEmails, emails...)
1711		}
1712		// Sort newest first
1713		for i := 0; i < len(allEmails); i++ {
1714			for j := i + 1; j < len(allEmails); j++ {
1715				if allEmails[j].Date.After(allEmails[i].Date) {
1716					allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
1717				}
1718			}
1719		}
1720
1721		return tui.FolderEmailsFetchedMsg{
1722			Emails:     allEmails,
1723			FolderName: folderName,
1724		}
1725	}
1726}
1727
1728func fetchFolderEmailsPaginatedCmd(account *config.Account, folderName string, limit, offset uint32) tea.Cmd {
1729	return func() tea.Msg {
1730		emails, err := fetcher.FetchFolderEmails(account, folderName, limit, offset)
1731		if err != nil {
1732			return tui.FetchErr(err)
1733		}
1734		return tui.FolderEmailsAppendedMsg{
1735			Emails:     emails,
1736			AccountID:  account.ID,
1737			FolderName: folderName,
1738		}
1739	}
1740}
1741
1742func fetchFolderEmailBodyCmd(cfg *config.Config, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
1743	return func() tea.Msg {
1744		account := cfg.GetAccountByID(accountID)
1745		if account == nil {
1746			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: fmt.Errorf("account not found")}
1747		}
1748
1749		body, attachments, err := fetcher.FetchFolderEmailBody(account, folderName, uid)
1750		if err != nil {
1751			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1752		}
1753
1754		return tui.EmailBodyFetchedMsg{
1755			UID:         uid,
1756			Body:        body,
1757			Attachments: attachments,
1758			AccountID:   accountID,
1759			Mailbox:     mailbox,
1760		}
1761	}
1762}
1763
1764func markEmailAsReadCmd(account *config.Account, uid uint32, accountID string, folderName string) tea.Cmd {
1765	return func() tea.Msg {
1766		err := fetcher.MarkEmailAsReadInMailbox(account, folderName, uid)
1767		return tui.EmailMarkedReadMsg{UID: uid, AccountID: accountID, Err: err}
1768	}
1769}
1770
1771func deleteFolderEmailCmd(account *config.Account, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
1772	return func() tea.Msg {
1773		err := fetcher.DeleteFolderEmail(account, folderName, uid)
1774		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1775	}
1776}
1777
1778func archiveFolderEmailCmd(account *config.Account, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
1779	return func() tea.Msg {
1780		err := fetcher.ArchiveFolderEmail(account, folderName, uid)
1781		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1782	}
1783}
1784
1785func moveEmailToFolderCmd(account *config.Account, uid uint32, accountID string, sourceFolder, destFolder string) tea.Cmd {
1786	return func() tea.Msg {
1787		err := fetcher.MoveEmailToFolder(account, uid, sourceFolder, destFolder)
1788		return tui.EmailMovedMsg{
1789			UID:          uid,
1790			AccountID:    accountID,
1791			SourceFolder: sourceFolder,
1792			DestFolder:   destFolder,
1793			Err:          err,
1794		}
1795	}
1796}
1797
1798func downloadAttachmentCmd(account *config.Account, uid uint32, msg tui.DownloadAttachmentMsg) tea.Cmd {
1799	return func() tea.Msg {
1800		// Download and decode the attachment using encoding provided in msg.Encoding.
1801		var data []byte
1802		var err error
1803		switch msg.Mailbox {
1804		case tui.MailboxSent:
1805			data, err = fetcher.FetchSentAttachment(account, uid, msg.PartID, msg.Encoding)
1806		case tui.MailboxTrash:
1807			data, err = fetcher.FetchTrashAttachment(account, uid, msg.PartID, msg.Encoding)
1808		case tui.MailboxArchive:
1809			data, err = fetcher.FetchArchiveAttachment(account, uid, msg.PartID, msg.Encoding)
1810		default:
1811			data, err = fetcher.FetchAttachment(account, uid, msg.PartID, msg.Encoding)
1812		}
1813		if err != nil {
1814			return tui.AttachmentDownloadedMsg{Err: err}
1815		}
1816
1817		homeDir, err := os.UserHomeDir()
1818		if err != nil {
1819			return tui.AttachmentDownloadedMsg{Err: err}
1820		}
1821		downloadsPath := filepath.Join(homeDir, "Downloads")
1822		if _, err := os.Stat(downloadsPath); os.IsNotExist(err) {
1823			if mkErr := os.MkdirAll(downloadsPath, 0755); mkErr != nil {
1824				return tui.AttachmentDownloadedMsg{Err: mkErr}
1825			}
1826		}
1827
1828		// Save the attachment using an exclusive create so we never overwrite an existing file.
1829		// If the filename already exists, append \" (n)\" before the extension.
1830		origName := msg.Filename
1831		ext := filepath.Ext(origName)
1832		base := strings.TrimSuffix(origName, ext)
1833		candidate := origName
1834		i := 1
1835		var filePath string
1836
1837		for {
1838			filePath = filepath.Join(downloadsPath, candidate)
1839
1840			// Try to create file exclusively. If it already exists, os.OpenFile will return an error
1841			// that satisfies os.IsExist(err), so we can increment the candidate.
1842			f, err := os.OpenFile(filePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
1843			if err != nil {
1844				if os.IsExist(err) {
1845					// file exists, try next candidate
1846					candidate = fmt.Sprintf("%s (%d)%s", base, i, ext)
1847					i++
1848					continue
1849				}
1850				// Some other error while attempting to create file
1851				log.Printf("error creating file %s: %v", filePath, err)
1852				return tui.AttachmentDownloadedMsg{Err: err}
1853			}
1854
1855			// Successfully created the file descriptor; write and close.
1856			if _, writeErr := f.Write(data); writeErr != nil {
1857				_ = f.Close()
1858				log.Printf("error writing to file %s: %v", filePath, writeErr)
1859				return tui.AttachmentDownloadedMsg{Err: writeErr}
1860			}
1861			if closeErr := f.Close(); closeErr != nil {
1862				log.Printf("warning: error closing file %s: %v", filePath, closeErr)
1863			}
1864
1865			// file saved successfully
1866			break
1867		}
1868
1869		log.Printf("attachment saved to %s", filePath)
1870
1871		// Try to open the file using a platform-specific opener asynchronously and log the outcome.
1872		go func(p string) {
1873			var cmd *exec.Cmd
1874			switch runtime.GOOS {
1875			case "darwin":
1876				cmd = exec.Command("open", p)
1877			case "linux":
1878				cmd = exec.Command("xdg-open", p)
1879			case "windows":
1880				// 'start' is a cmd builtin; provide an empty title argument to avoid interpreting the path as the title.
1881				cmd = exec.Command("cmd", "/c", "start", "", p)
1882			default:
1883				// Unsupported OS: nothing to do.
1884				return
1885			}
1886			if err := cmd.Start(); err != nil {
1887				log.Printf("failed to open file %s: %v", p, err)
1888			}
1889		}(filePath)
1890
1891		return tui.AttachmentDownloadedMsg{Path: filePath, Err: nil}
1892	}
1893}
1894
1895/*
1896detectInstalledVersion returns a best-effort installed version string.
1897Priority:
1898 1. If the build-in `version` variable is set to something other than "dev", return it.
1899 2. If Homebrew is present and reports a version for `matcha`, return that.
1900 3. If snap is present and lists `matcha`, return that.
1901 4. Fallback to the build `version` (likely "dev").
1902*/
1903func detectInstalledVersion() string {
1904	v := strings.TrimSpace(version)
1905	if v != "dev" && v != "" {
1906		return v
1907	}
1908
1909	// Try Homebrew (macOS)
1910	if runtime.GOOS == "darwin" {
1911		if _, err := exec.LookPath("brew"); err == nil {
1912			// `brew list --versions matcha` prints: matcha 1.2.3
1913			if out, err := exec.Command("brew", "list", "--versions", "matcha").Output(); err == nil {
1914				parts := strings.Fields(string(out))
1915				if len(parts) >= 2 {
1916					return parts[1]
1917				}
1918			}
1919		}
1920	}
1921
1922	// Try snap (Linux)
1923	if runtime.GOOS == "linux" {
1924		if _, err := exec.LookPath("snap"); err == nil {
1925			if out, err := exec.Command("snap", "list", "matcha").Output(); err == nil {
1926				lines := strings.Split(strings.TrimSpace(string(out)), "\n")
1927				if len(lines) >= 2 {
1928					fields := strings.Fields(lines[1])
1929					if len(fields) >= 2 {
1930						return fields[1]
1931					}
1932				}
1933			}
1934		}
1935
1936		if _, err := exec.LookPath("flatpak"); err == nil {
1937			if out, err := exec.Command("flatpak", "info", "com.floatpane.matcha").Output(); err == nil {
1938				lines := strings.Split(strings.TrimSpace(string(out)), "\n")
1939				for _, line := range lines {
1940					line = strings.TrimSpace(line)
1941					if strings.HasPrefix(line, "Version:") {
1942						fields := strings.Fields(line)
1943						if len(fields) >= 2 {
1944							return fields[1]
1945						}
1946					}
1947				}
1948			}
1949		}
1950	}
1951
1952	return v
1953}
1954
1955/*
1956checkForUpdatesCmd queries GitHub for the latest release tag and returns a
1957tea.Msg (UpdateAvailableMsg) if the latest version differs from the current
1958installed version. This runs in the background when the TUI initializes.
1959*/
1960func checkForUpdatesCmd() tea.Cmd {
1961	return func() tea.Msg {
1962		// Non-fatal: if anything goes wrong we just don't show the update message.
1963		const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
1964		resp, err := http.Get(api)
1965		if err != nil {
1966			return nil
1967		}
1968		defer resp.Body.Close()
1969
1970		var rel githubRelease
1971		if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
1972			return nil
1973		}
1974
1975		latest := strings.TrimPrefix(rel.TagName, "v")
1976		installed := strings.TrimPrefix(detectInstalledVersion(), "v")
1977		if latest != "" && installed != "" && latest != installed {
1978			return UpdateAvailableMsg{Latest: latest, Current: installed}
1979		}
1980		return nil
1981	}
1982}
1983
1984// runUpdateCLI implements the CLI entrypoint for `matcha update`.
1985// It detects the likely installation method and attempts the appropriate
1986// update path (Homebrew, Snap, or GitHub release binary extract).
1987func runUpdateCLI() error {
1988	const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
1989	resp, err := http.Get(api)
1990	if err != nil {
1991		return fmt.Errorf("could not query releases: %w", err)
1992	}
1993	defer resp.Body.Close()
1994
1995	var rel githubRelease
1996	if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
1997		return fmt.Errorf("could not parse release info: %w", err)
1998	}
1999
2000	latestTag := rel.TagName
2001	if strings.HasPrefix(latestTag, "v") {
2002		latestTag = latestTag[1:]
2003	}
2004
2005	fmt.Printf("Current version: %s\n", version)
2006	fmt.Printf("Latest version: %s\n", latestTag)
2007
2008	// Quick check: if already up-to-date, exit
2009	cur := version
2010	if strings.HasPrefix(cur, "v") {
2011		cur = cur[1:]
2012	}
2013	if latestTag == "" || cur == latestTag {
2014		fmt.Println("Already up to date.")
2015		return nil
2016	}
2017
2018	// Detect Homebrew
2019	if _, err := exec.LookPath("brew"); err == nil {
2020		fmt.Println("Detected Homebrew — updating taps and attempting to upgrade via brew.")
2021
2022		updateCmd := exec.Command("brew", "update")
2023		updateCmd.Stdout = os.Stdout
2024		updateCmd.Stderr = os.Stderr
2025		if err := updateCmd.Run(); err != nil {
2026			fmt.Printf("Homebrew update failed: %v\n", err)
2027			// continue to attempt upgrade even if update failed
2028		}
2029
2030		upgradeCmd := exec.Command("brew", "upgrade", "floatpane/matcha/matcha")
2031		upgradeCmd.Stdout = os.Stdout
2032		upgradeCmd.Stderr = os.Stderr
2033		if err := upgradeCmd.Run(); err == nil {
2034			fmt.Println("Successfully upgraded via Homebrew.")
2035			return nil
2036		}
2037		fmt.Printf("Homebrew upgrade failed: %v\n", err)
2038		// fallthrough to other methods
2039	}
2040
2041	// Detect snap
2042	if _, err := exec.LookPath("snap"); err == nil {
2043		// Check if matcha is installed as a snap
2044		cmdCheck := exec.Command("snap", "list", "matcha")
2045		if err := cmdCheck.Run(); err == nil {
2046			fmt.Println("Detected Snap package — attempting to refresh.")
2047			cmd := exec.Command("snap", "refresh", "matcha")
2048			cmd.Stdout = os.Stdout
2049			cmd.Stderr = os.Stderr
2050			if err := cmd.Run(); err == nil {
2051				fmt.Println("Successfully refreshed snap.")
2052				return nil
2053			}
2054			fmt.Printf("Snap refresh failed: %v\n", err)
2055			// fallthrough
2056		}
2057	}
2058	// Detect flatpak
2059	if _, err := exec.LookPath("flatpak"); err == nil {
2060		// Check if matcha is installed as a flatpak
2061		cmdCheck := exec.Command("flatpak", "info", "com.floatpane.matcha")
2062		if err := cmdCheck.Run(); err == nil {
2063			fmt.Println("Detected Flatpak package — attempting to update.")
2064			cmd := exec.Command("flatpak", "update", "-y", "com.floatpane.matcha")
2065			cmd.Stdout = os.Stdout
2066			cmd.Stderr = os.Stderr
2067			if err := cmd.Run(); err == nil {
2068				fmt.Println("Successfully updated flatpak.")
2069				return nil
2070			}
2071			fmt.Printf("Flatpak update failed: %v\n", err)
2072			// fallthrough
2073		}
2074	}
2075
2076	// Otherwise attempt to download the proper release asset and replace the binary.
2077	osName := runtime.GOOS
2078	arch := runtime.GOARCH
2079
2080	// Try to find a matching asset
2081	var assetURL, assetName string
2082	for _, a := range rel.Assets {
2083		n := strings.ToLower(a.Name)
2084		if strings.Contains(n, osName) && strings.Contains(n, arch) && (strings.HasSuffix(n, ".tar.gz") || strings.HasSuffix(n, ".tgz") || strings.HasSuffix(n, ".zip")) {
2085			assetURL = a.BrowserDownloadURL
2086			assetName = a.Name
2087			break
2088		}
2089	}
2090	if assetURL == "" {
2091		// Try any asset that contains 'matcha' and os/arch as a fallback
2092		for _, a := range rel.Assets {
2093			n := strings.ToLower(a.Name)
2094			if strings.Contains(n, "matcha") && (strings.Contains(n, osName) || strings.Contains(n, arch)) {
2095				assetURL = a.BrowserDownloadURL
2096				assetName = a.Name
2097				break
2098			}
2099		}
2100	}
2101
2102	if assetURL == "" {
2103		return fmt.Errorf("no suitable release artifact found for %s/%s", osName, arch)
2104	}
2105
2106	fmt.Printf("Found release asset: %s\n", assetName)
2107	fmt.Println("Downloading...")
2108
2109	// Download asset
2110	respAsset, err := http.Get(assetURL)
2111	if err != nil {
2112		return fmt.Errorf("download failed: %w", err)
2113	}
2114	defer respAsset.Body.Close()
2115
2116	// Create a temp file for the download
2117	tmpDir, err := os.MkdirTemp("", "matcha-update-*")
2118	if err != nil {
2119		return fmt.Errorf("could not create temp dir: %w", err)
2120	}
2121	defer os.RemoveAll(tmpDir)
2122
2123	assetPath := filepath.Join(tmpDir, assetName)
2124	outFile, err := os.Create(assetPath)
2125	if err != nil {
2126		return fmt.Errorf("could not create temp file: %w", err)
2127	}
2128	_, err = io.Copy(outFile, respAsset.Body)
2129	outFile.Close()
2130	if err != nil {
2131		return fmt.Errorf("could not write asset to disk: %w", err)
2132	}
2133
2134	// Determine the expected binary name based on the OS.
2135	binaryName := "matcha"
2136	if runtime.GOOS == "windows" {
2137		binaryName = "matcha.exe"
2138	}
2139
2140	// Extract the binary from the archive.
2141	var binPath string
2142	if strings.HasSuffix(assetName, ".tar.gz") || strings.HasSuffix(assetName, ".tgz") {
2143		f, err := os.Open(assetPath)
2144		if err != nil {
2145			return fmt.Errorf("could not open archive: %w", err)
2146		}
2147		defer f.Close()
2148		gzr, err := gzip.NewReader(f)
2149		if err != nil {
2150			return fmt.Errorf("could not create gzip reader: %w", err)
2151		}
2152		tr := tar.NewReader(gzr)
2153		for {
2154			hdr, err := tr.Next()
2155			if err == io.EOF {
2156				break
2157			}
2158			if err != nil {
2159				return fmt.Errorf("error reading tar: %w", err)
2160			}
2161			name := filepath.Base(hdr.Name)
2162			if name == binaryName || strings.Contains(strings.ToLower(name), "matcha") && (hdr.Typeflag == tar.TypeReg) {
2163				binPath = filepath.Join(tmpDir, binaryName)
2164				out, err := os.Create(binPath)
2165				if err != nil {
2166					return fmt.Errorf("could not create binary file: %w", err)
2167				}
2168				if _, err := io.Copy(out, tr); err != nil {
2169					out.Close()
2170					return fmt.Errorf("could not extract binary: %w", err)
2171				}
2172				out.Close()
2173				if err := os.Chmod(binPath, 0755); err != nil {
2174					return fmt.Errorf("could not make binary executable: %w", err)
2175				}
2176				break
2177			}
2178		}
2179	} else if strings.HasSuffix(assetName, ".zip") {
2180		zr, err := zip.OpenReader(assetPath)
2181		if err != nil {
2182			return fmt.Errorf("could not open zip archive: %w", err)
2183		}
2184		defer zr.Close()
2185		for _, zf := range zr.File {
2186			name := filepath.Base(zf.Name)
2187			if name == binaryName || strings.Contains(strings.ToLower(name), "matcha") && !zf.FileInfo().IsDir() {
2188				rc, err := zf.Open()
2189				if err != nil {
2190					return fmt.Errorf("could not open file in zip: %w", err)
2191				}
2192				binPath = filepath.Join(tmpDir, binaryName)
2193				out, err := os.Create(binPath)
2194				if err != nil {
2195					rc.Close()
2196					return fmt.Errorf("could not create binary file: %w", err)
2197				}
2198				if _, err := io.Copy(out, rc); err != nil {
2199					out.Close()
2200					rc.Close()
2201					return fmt.Errorf("could not extract binary: %w", err)
2202				}
2203				out.Close()
2204				rc.Close()
2205				if err := os.Chmod(binPath, 0755); err != nil {
2206					return fmt.Errorf("could not make binary executable: %w", err)
2207				}
2208				break
2209			}
2210		}
2211	} else {
2212		// For non-archive assets, assume the asset is the binary itself.
2213		binPath = assetPath
2214		if err := os.Chmod(binPath, 0755); err != nil {
2215			// ignore chmod errors but warn
2216			fmt.Printf("warning: could not chmod downloaded binary: %v\n", err)
2217		}
2218	}
2219
2220	if binPath == "" {
2221		return fmt.Errorf("could not locate matcha binary inside the release artifact")
2222	}
2223
2224	// Replace the running executable with the new binary
2225	execPath, err := os.Executable()
2226	if err != nil {
2227		return fmt.Errorf("could not determine executable path: %w", err)
2228	}
2229
2230	// Write the new binary to a temp file in same dir, then rename for atomic replacement.
2231	execDir := filepath.Dir(execPath)
2232	tmpNew := filepath.Join(execDir, fmt.Sprintf("matcha.new.%d", time.Now().Unix()))
2233	in, err := os.Open(binPath)
2234	if err != nil {
2235		return fmt.Errorf("could not open new binary: %w", err)
2236	}
2237	out, err := os.OpenFile(tmpNew, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
2238	if err != nil {
2239		in.Close()
2240		return fmt.Errorf("could not create temp binary in target dir: %w", err)
2241	}
2242	if _, err := io.Copy(out, in); err != nil {
2243		in.Close()
2244		out.Close()
2245		return fmt.Errorf("could not write new binary to disk: %w", err)
2246	}
2247	in.Close()
2248	out.Close()
2249
2250	// On Windows, a running executable cannot be overwritten directly.
2251	// Move the old binary out of the way first, then rename the new one in.
2252	if runtime.GOOS == "windows" {
2253		oldPath := execPath + ".old"
2254		_ = os.Remove(oldPath) // clean up any previous leftover
2255		if err := os.Rename(execPath, oldPath); err != nil {
2256			return fmt.Errorf("could not move old executable out of the way: %w", err)
2257		}
2258	}
2259
2260	if err := os.Rename(tmpNew, execPath); err != nil {
2261		return fmt.Errorf("could not replace executable: %w", err)
2262	}
2263
2264	fmt.Println("Successfully updated matcha to", latestTag)
2265	return nil
2266}
2267
2268func filterUnique(existing, incoming []fetcher.Email) []fetcher.Email {
2269	seen := make(map[uint32]struct{})
2270	for _, e := range existing {
2271		seen[e.UID] = struct{}{}
2272	}
2273	var unique []fetcher.Email
2274	for _, e := range incoming {
2275		if _, ok := seen[e.UID]; !ok {
2276			unique = append(unique, e)
2277		}
2278	}
2279	return unique
2280}
2281
2282func main() {
2283	// If invoked with version flag, print version and exit
2284	if len(os.Args) > 1 && (os.Args[1] == "-v" || os.Args[1] == "--version" || os.Args[1] == "version") {
2285		fmt.Printf("matcha version %s", version)
2286		if commit != "" {
2287			fmt.Printf(" (%s)", commit)
2288		}
2289		if date != "" {
2290			fmt.Printf(" built on %s", date)
2291		}
2292		fmt.Println()
2293		os.Exit(0)
2294	}
2295
2296	// If invoked as CLI update command, run updater and exit.
2297	if len(os.Args) > 1 && os.Args[1] == "update" {
2298		if err := runUpdateCLI(); err != nil {
2299			fmt.Fprintf(os.Stderr, "update failed: %v\n", err)
2300			os.Exit(1)
2301		}
2302		os.Exit(0)
2303	}
2304
2305	cfg, err := config.LoadConfig()
2306	if err == nil && cfg.Theme != "" {
2307		theme.SetTheme(cfg.Theme)
2308	}
2309	tui.RebuildStyles()
2310
2311	var initialModel *mainModel
2312	if err != nil {
2313		initialModel = newInitialModel(nil)
2314	} else {
2315		initialModel = newInitialModel(cfg)
2316	}
2317
2318	// Initialize plugin system
2319	plugins := plugin.NewManager()
2320	plugins.LoadPlugins()
2321	initialModel.plugins = plugins
2322	plugins.CallHook(plugin.HookStartup)
2323
2324	p := tea.NewProgram(initialModel)
2325
2326	if _, err := p.Run(); err != nil {
2327		plugins.Close()
2328		fmt.Printf("Alas, there's been an error: %v", err)
2329		os.Exit(1)
2330	}
2331
2332	plugins.CallHook(plugin.HookShutdown)
2333	plugins.Close()
2334}