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.GoToFilePickerMsg:
 843		m.previousModel = m.current
 844		wd, _ := os.Getwd()
 845		m.current = tui.NewFilePicker(wd)
 846		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 847		return m, m.current.Init()
 848
 849	case tui.FileSelectedMsg, tui.CancelFilePickerMsg:
 850		if m.previousModel != nil {
 851			m.current = m.previousModel
 852			m.previousModel = nil
 853		}
 854		m.current, cmd = m.current.Update(msg)
 855		cmds = append(cmds, cmd)
 856
 857	case tui.SendEmailMsg:
 858		if m.plugins != nil {
 859			m.plugins.CallSendHook(plugin.HookEmailSendBefore, msg.To, msg.Cc, msg.Subject, msg.AccountID)
 860		}
 861		// Get draft ID before clearing composer (if it's a composer)
 862		var draftID string
 863		if composer, ok := m.current.(*tui.Composer); ok {
 864			draftID = composer.GetDraftID()
 865		}
 866		m.current = tui.NewStatus("Sending email...")
 867
 868		// Get the account to send from
 869		var account *config.Account
 870		if msg.AccountID != "" && m.config != nil {
 871			account = m.config.GetAccountByID(msg.AccountID)
 872		}
 873		if account == nil && m.config != nil {
 874			account = m.config.GetFirstAccount()
 875		}
 876
 877		// Save contact and delete draft in background
 878		go func() {
 879			// Save the recipient as a contact
 880			if msg.To != "" {
 881				recipients := strings.Split(msg.To, ",")
 882				for _, r := range recipients {
 883					r = strings.TrimSpace(r)
 884					if r == "" {
 885						continue
 886					}
 887					name, email := parseEmailAddress(r)
 888					if err := config.AddContact(name, email); err != nil {
 889						log.Printf("Error saving contact: %v", err)
 890					}
 891				}
 892			}
 893			// Delete the draft since email is being sent
 894			if draftID != "" {
 895				if err := config.DeleteDraft(draftID); err != nil {
 896					log.Printf("Error deleting draft after send: %v", err)
 897				}
 898			}
 899		}()
 900
 901		return m, tea.Batch(m.current.Init(), sendEmail(account, msg))
 902
 903	case tui.EmailResultMsg:
 904		if msg.Err != nil {
 905			log.Printf("Failed to send email: %v", msg.Err)
 906			m.previousModel = tui.NewChoice()
 907			m.previousModel, _ = m.previousModel.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 908			m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
 909			return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
 910				return tui.RestoreViewMsg{}
 911			})
 912		}
 913		if m.plugins != nil {
 914			m.plugins.CallHook(plugin.HookEmailSendAfter)
 915		}
 916		m.current = tui.NewChoice()
 917		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 918		return m, m.current.Init()
 919
 920	case tui.DeleteEmailMsg:
 921		tui.ClearKittyGraphics()
 922		m.previousModel = m.current
 923		m.current = tui.NewStatus("Deleting email...")
 924
 925		account := m.config.GetAccountByID(msg.AccountID)
 926		if account == nil {
 927			if m.folderInbox != nil {
 928				m.current = m.folderInbox
 929			}
 930			return m, nil
 931		}
 932
 933		folderName := "INBOX"
 934		if m.folderInbox != nil {
 935			folderName = m.folderInbox.GetCurrentFolder()
 936		}
 937		return m, tea.Batch(m.current.Init(), deleteFolderEmailCmd(account, msg.UID, msg.AccountID, folderName, msg.Mailbox))
 938
 939	case tui.ArchiveEmailMsg:
 940		tui.ClearKittyGraphics()
 941		m.previousModel = m.current
 942		m.current = tui.NewStatus("Archiving email...")
 943
 944		account := m.config.GetAccountByID(msg.AccountID)
 945		if account == nil {
 946			if m.folderInbox != nil {
 947				m.current = m.folderInbox
 948			}
 949			return m, nil
 950		}
 951
 952		folderName := "INBOX"
 953		if m.folderInbox != nil {
 954			folderName = m.folderInbox.GetCurrentFolder()
 955		}
 956		return m, tea.Batch(m.current.Init(), archiveFolderEmailCmd(account, msg.UID, msg.AccountID, folderName, msg.Mailbox))
 957
 958	case tui.EmailMarkedReadMsg:
 959		if msg.Err != nil {
 960			log.Printf("Error marking email as read: %v", msg.Err)
 961		}
 962		return m, nil
 963
 964	case tui.EmailActionDoneMsg:
 965		if msg.Err != nil {
 966			log.Printf("Action failed: %v", msg.Err)
 967			if m.folderInbox != nil {
 968				m.previousModel = m.folderInbox
 969			}
 970			m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
 971			return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
 972				return tui.RestoreViewMsg{}
 973			})
 974		}
 975
 976		// Remove email from stores
 977		m.removeEmailFromStores(msg.UID, msg.AccountID)
 978
 979		if m.folderInbox != nil {
 980			m.folderInbox.RemoveEmail(msg.UID, msg.AccountID)
 981			m.current = m.folderInbox
 982			m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 983			return m, m.current.Init()
 984		}
 985		m.current = tui.NewChoice()
 986		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 987		return m, m.current.Init()
 988
 989	case tui.DownloadAttachmentMsg:
 990		m.previousModel = m.current
 991		m.current = tui.NewStatus(fmt.Sprintf("Downloading %s...", msg.Filename))
 992
 993		account := m.config.GetAccountByID(msg.AccountID)
 994		if account == nil {
 995			m.current = m.previousModel
 996			return m, nil
 997		}
 998
 999		email := m.getEmailByIndex(msg.Index, msg.Mailbox)
1000		if email == nil {
1001			m.current = m.previousModel
1002			return m, nil
1003		}
1004
1005		// Find the correct attachment to get encoding
1006		var encoding string
1007		for _, att := range email.Attachments {
1008			if att.PartID == msg.PartID {
1009				encoding = att.Encoding
1010				break
1011			}
1012		}
1013		newMsg := tui.DownloadAttachmentMsg{
1014			Index:     msg.Index,
1015			Filename:  msg.Filename,
1016			PartID:    msg.PartID,
1017			Data:      msg.Data,
1018			AccountID: msg.AccountID,
1019			Encoding:  encoding,
1020			Mailbox:   msg.Mailbox,
1021		}
1022		return m, tea.Batch(m.current.Init(), downloadAttachmentCmd(account, email.UID, newMsg))
1023
1024	case tui.AttachmentDownloadedMsg:
1025		var statusMsg string
1026		if msg.Err != nil {
1027			statusMsg = fmt.Sprintf("Error downloading: %v", msg.Err)
1028		} else {
1029			statusMsg = fmt.Sprintf("Saved to %s", msg.Path)
1030		}
1031		m.current = tui.NewStatus(statusMsg)
1032		return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1033			return tui.RestoreViewMsg{}
1034		})
1035
1036	case tui.RestoreViewMsg:
1037		if m.previousModel != nil {
1038			m.current = m.previousModel
1039			m.previousModel = nil
1040		}
1041		return m, nil
1042	}
1043
1044	if cmd := m.pluginNotifyCmd(); cmd != nil {
1045		cmds = append(cmds, cmd)
1046	}
1047
1048	return m, tea.Batch(cmds...)
1049}
1050
1051func (m *mainModel) View() tea.View {
1052	v := m.current.View()
1053	v.AltScreen = true
1054	return v
1055}
1056
1057func (m *mainModel) getEmailByIndex(index int, mailbox tui.MailboxKind) *fetcher.Email {
1058	if index >= 0 && index < len(m.emails) {
1059		return &m.emails[index]
1060	}
1061	return nil
1062}
1063
1064func (m *mainModel) getEmailByUIDAndAccount(uid uint32, accountID string, mailbox tui.MailboxKind) *fetcher.Email {
1065	for i := range m.emails {
1066		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1067			return &m.emails[i]
1068		}
1069	}
1070	return nil
1071}
1072
1073func (m *mainModel) getEmailIndex(uid uint32, accountID string, mailbox tui.MailboxKind) int {
1074	for i := range m.emails {
1075		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1076			return i
1077		}
1078	}
1079	return -1
1080}
1081
1082func (m *mainModel) updateEmailBodyByUID(uid uint32, accountID string, mailbox tui.MailboxKind, body string, attachments []fetcher.Attachment) {
1083	for i := range m.emails {
1084		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1085			m.emails[i].Body = body
1086			m.emails[i].Attachments = attachments
1087			break
1088		}
1089	}
1090	if emails, ok := m.emailsByAcct[accountID]; ok {
1091		for i := range emails {
1092			if emails[i].UID == uid {
1093				emails[i].Body = body
1094				emails[i].Attachments = attachments
1095				break
1096			}
1097		}
1098	}
1099}
1100
1101func (m *mainModel) markEmailAsReadInStores(uid uint32, accountID string) {
1102	for i := range m.emails {
1103		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1104			m.emails[i].IsRead = true
1105			break
1106		}
1107	}
1108	if emails, ok := m.emailsByAcct[accountID]; ok {
1109		for i := range emails {
1110			if emails[i].UID == uid {
1111				emails[i].IsRead = true
1112				break
1113			}
1114		}
1115	}
1116	// Update folder email cache
1117	for folderName, folderEmails := range m.folderEmails {
1118		for i := range folderEmails {
1119			if folderEmails[i].UID == uid && folderEmails[i].AccountID == accountID {
1120				folderEmails[i].IsRead = true
1121				m.folderEmails[folderName] = folderEmails
1122				go saveFolderEmailsToCache(folderName, folderEmails)
1123				break
1124			}
1125		}
1126	}
1127	// Update the inbox UI
1128	if m.folderInbox != nil {
1129		m.folderInbox.GetInbox().MarkEmailAsRead(uid, accountID)
1130	}
1131}
1132
1133func (m *mainModel) removeEmailFromStores(uid uint32, accountID string) {
1134	var filtered []fetcher.Email
1135	for _, e := range m.emails {
1136		if !(e.UID == uid && e.AccountID == accountID) {
1137			filtered = append(filtered, e)
1138		}
1139	}
1140	m.emails = filtered
1141	if emails, ok := m.emailsByAcct[accountID]; ok {
1142		var filteredAcct []fetcher.Email
1143		for _, e := range emails {
1144			if e.UID != uid {
1145				filteredAcct = append(filteredAcct, e)
1146			}
1147		}
1148		m.emailsByAcct[accountID] = filteredAcct
1149	}
1150}
1151
1152// pluginNotifyCmd checks for a pending plugin notification and returns a command if one exists.
1153func (m *mainModel) pluginNotifyCmd() tea.Cmd {
1154	if m.plugins == nil {
1155		return nil
1156	}
1157	if n, ok := m.plugins.TakePendingNotification(); ok {
1158		return func() tea.Msg {
1159			return tui.PluginNotifyMsg{Message: n.Message, Duration: n.Duration}
1160		}
1161	}
1162	return nil
1163}
1164
1165func (m *mainModel) syncPluginStatus() {
1166	if m.plugins == nil {
1167		return
1168	}
1169	if m.folderInbox != nil {
1170		m.folderInbox.GetInbox().SetPluginStatus(m.plugins.StatusText(plugin.StatusInbox))
1171	}
1172	switch v := m.current.(type) {
1173	case *tui.Composer:
1174		v.SetPluginStatus(m.plugins.StatusText(plugin.StatusComposer))
1175	case *tui.EmailView:
1176		v.SetPluginStatus(m.plugins.StatusText(plugin.StatusEmailView))
1177	}
1178}
1179
1180func flattenAndSort(emailsByAccount map[string][]fetcher.Email) []fetcher.Email {
1181	var allEmails []fetcher.Email
1182	for _, emails := range emailsByAccount {
1183		allEmails = append(allEmails, emails...)
1184	}
1185	for i := 0; i < len(allEmails); i++ {
1186		for j := i + 1; j < len(allEmails); j++ {
1187			if allEmails[j].Date.After(allEmails[i].Date) {
1188				allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
1189			}
1190		}
1191	}
1192	return allEmails
1193}
1194
1195func fetchAllAccountsEmails(cfg *config.Config, mailbox tui.MailboxKind) tea.Cmd {
1196	return func() tea.Msg {
1197		emailsByAccount := make(map[string][]fetcher.Email)
1198		var mu sync.Mutex
1199		var wg sync.WaitGroup
1200
1201		for _, account := range cfg.Accounts {
1202			wg.Add(1)
1203			go func(acc config.Account) {
1204				defer wg.Done()
1205				var emails []fetcher.Email
1206				var err error
1207				switch mailbox {
1208				case tui.MailboxSent:
1209					emails, err = fetcher.FetchSentEmails(&acc, initialEmailLimit, 0)
1210				case tui.MailboxTrash:
1211					emails, err = fetcher.FetchTrashEmails(&acc, initialEmailLimit, 0)
1212				case tui.MailboxArchive:
1213					emails, err = fetcher.FetchArchiveEmails(&acc, initialEmailLimit, 0)
1214				default:
1215					emails, err = fetcher.FetchEmails(&acc, initialEmailLimit, 0)
1216				}
1217				if err != nil {
1218					log.Printf("Error fetching from %s: %v", acc.Email, err)
1219					return
1220				}
1221				mu.Lock()
1222				emailsByAccount[acc.ID] = emails
1223				mu.Unlock()
1224			}(account)
1225		}
1226
1227		wg.Wait()
1228		return tui.AllEmailsFetchedMsg{EmailsByAccount: emailsByAccount, Mailbox: mailbox}
1229	}
1230}
1231
1232func fetchEmails(account *config.Account, limit, offset uint32, mailbox tui.MailboxKind) tea.Cmd {
1233	return func() tea.Msg {
1234		var emails []fetcher.Email
1235		var err error
1236		if mailbox == tui.MailboxSent {
1237			emails, err = fetcher.FetchSentEmails(account, limit, offset)
1238		} else {
1239			emails, err = fetcher.FetchEmails(account, limit, offset)
1240		}
1241		if err != nil {
1242			return tui.FetchErr(err)
1243		}
1244		if offset == 0 {
1245			return tui.EmailsFetchedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1246		}
1247		return tui.EmailsAppendedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1248	}
1249}
1250
1251func fetchEmailsForMailbox(account *config.Account, limit, offset uint32, mailbox tui.MailboxKind) tea.Cmd {
1252	return func() tea.Msg {
1253		var emails []fetcher.Email
1254		var err error
1255		switch mailbox {
1256		case tui.MailboxSent:
1257			emails, err = fetcher.FetchSentEmails(account, limit, offset)
1258		case tui.MailboxTrash:
1259			emails, err = fetcher.FetchTrashEmails(account, limit, offset)
1260		case tui.MailboxArchive:
1261			emails, err = fetcher.FetchArchiveEmails(account, limit, offset)
1262		default:
1263			emails, err = fetcher.FetchEmails(account, limit, offset)
1264		}
1265		if err != nil {
1266			return tui.FetchErr(err)
1267		}
1268		if offset == 0 {
1269			return tui.EmailsFetchedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1270		}
1271		return tui.EmailsAppendedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1272	}
1273}
1274
1275func loadCachedEmails() tea.Cmd {
1276	return func() tea.Msg {
1277		cache, err := config.LoadEmailCache()
1278		if err != nil {
1279			return tui.CachedEmailsLoadedMsg{Cache: nil}
1280		}
1281		return tui.CachedEmailsLoadedMsg{Cache: cache}
1282	}
1283}
1284
1285func refreshEmails(cfg *config.Config, mailbox tui.MailboxKind, counts map[string]int) tea.Cmd {
1286	return func() tea.Msg {
1287		emailsByAccount := make(map[string][]fetcher.Email)
1288		var mu sync.Mutex
1289		var wg sync.WaitGroup
1290
1291		for _, account := range cfg.Accounts {
1292			wg.Add(1)
1293			go func(acc config.Account) {
1294				defer wg.Done()
1295				var emails []fetcher.Email
1296				var err error
1297
1298				limit := uint32(initialEmailLimit)
1299				if counts != nil {
1300					if c, ok := counts[acc.ID]; ok && c > 0 {
1301						limit = uint32(c)
1302					}
1303				}
1304
1305				if mailbox == tui.MailboxSent {
1306					emails, err = fetcher.FetchSentEmails(&acc, limit, 0)
1307				} else {
1308					emails, err = fetcher.FetchEmails(&acc, limit, 0)
1309				}
1310				if err != nil {
1311					log.Printf("Error fetching from %s: %v", acc.Email, err)
1312					return
1313				}
1314				mu.Lock()
1315				emailsByAccount[acc.ID] = emails
1316				mu.Unlock()
1317			}(account)
1318		}
1319
1320		wg.Wait()
1321		return tui.EmailsRefreshedMsg{EmailsByAccount: emailsByAccount, Mailbox: mailbox}
1322	}
1323}
1324
1325func emailsToCache(emails []fetcher.Email) []config.CachedEmail {
1326	var cached []config.CachedEmail
1327	for _, email := range emails {
1328		cached = append(cached, config.CachedEmail{
1329			UID:       email.UID,
1330			From:      email.From,
1331			To:        email.To,
1332			Subject:   email.Subject,
1333			Date:      email.Date,
1334			MessageID: email.MessageID,
1335			AccountID: email.AccountID,
1336			IsRead:    email.IsRead,
1337		})
1338	}
1339	return cached
1340}
1341
1342func cacheToEmails(cached []config.CachedEmail) []fetcher.Email {
1343	var emails []fetcher.Email
1344	for _, c := range cached {
1345		emails = append(emails, fetcher.Email{
1346			UID:       c.UID,
1347			From:      c.From,
1348			To:        c.To,
1349			Subject:   c.Subject,
1350			Date:      c.Date,
1351			MessageID: c.MessageID,
1352			AccountID: c.AccountID,
1353			IsRead:    c.IsRead,
1354		})
1355	}
1356	return emails
1357}
1358
1359func saveFolderEmailsToCache(folderName string, emails []fetcher.Email) {
1360	cached := emailsToCache(emails)
1361	if err := config.SaveFolderEmailCache(folderName, cached); err != nil {
1362		log.Printf("Error saving folder email cache for %s: %v", folderName, err)
1363	}
1364}
1365
1366func loadFolderEmailsFromCache(folderName string) []fetcher.Email {
1367	cached, err := config.LoadFolderEmailCache(folderName)
1368	if err != nil {
1369		return nil
1370	}
1371	return cacheToEmails(cached)
1372}
1373
1374func saveEmailsToCache(emails []fetcher.Email) {
1375	if len(emails) > maxCacheEmails {
1376		emails = emails[:maxCacheEmails]
1377	}
1378	var cachedEmails []config.CachedEmail
1379	for _, email := range emails {
1380		cachedEmails = append(cachedEmails, config.CachedEmail{
1381			UID:       email.UID,
1382			From:      email.From,
1383			To:        email.To,
1384			Subject:   email.Subject,
1385			Date:      email.Date,
1386			MessageID: email.MessageID,
1387			AccountID: email.AccountID,
1388			IsRead:    email.IsRead,
1389		})
1390
1391		// Save sender as a contact
1392		if email.From != "" {
1393			name, emailAddr := parseEmailAddress(email.From)
1394			if err := config.AddContact(name, emailAddr); err != nil {
1395				log.Printf("Error saving contact from email: %v", err)
1396			}
1397		}
1398	}
1399	cache := &config.EmailCache{Emails: cachedEmails}
1400	if err := config.SaveEmailCache(cache); err != nil {
1401		log.Printf("Error saving email cache: %v", err)
1402	}
1403}
1404
1405// parseEmailAddress parses "Name <email>" or just "email" format
1406func parseEmailAddress(addr string) (name, email string) {
1407	addr = strings.TrimSpace(addr)
1408	if idx := strings.Index(addr, "<"); idx != -1 {
1409		name = strings.TrimSpace(addr[:idx])
1410		endIdx := strings.Index(addr, ">")
1411		if endIdx > idx {
1412			email = strings.TrimSpace(addr[idx+1 : endIdx])
1413		} else {
1414			email = strings.TrimSpace(addr[idx+1:])
1415		}
1416	} else {
1417		email = addr
1418	}
1419	return name, email
1420}
1421
1422func fetchEmailBodyCmd(cfg *config.Config, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
1423	return func() tea.Msg {
1424		account := cfg.GetAccountByID(accountID)
1425		if account == nil {
1426			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: fmt.Errorf("account not found")}
1427		}
1428
1429		var (
1430			body        string
1431			attachments []fetcher.Attachment
1432			err         error
1433		)
1434		switch mailbox {
1435		case tui.MailboxSent:
1436			body, attachments, err = fetcher.FetchSentEmailBody(account, uid)
1437		case tui.MailboxTrash:
1438			body, attachments, err = fetcher.FetchTrashEmailBody(account, uid)
1439		case tui.MailboxArchive:
1440			body, attachments, err = fetcher.FetchArchiveEmailBody(account, uid)
1441		default:
1442			body, attachments, err = fetcher.FetchEmailBody(account, uid)
1443		}
1444		if err != nil {
1445			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1446		}
1447
1448		return tui.EmailBodyFetchedMsg{
1449			UID:         uid,
1450			Body:        body,
1451			Attachments: attachments,
1452			AccountID:   accountID,
1453			Mailbox:     mailbox,
1454		}
1455	}
1456}
1457
1458func markdownToHTML(md []byte) []byte {
1459	return clib.MarkdownToHTML(md)
1460}
1461
1462func splitEmails(s string) []string {
1463	if s == "" {
1464		return nil
1465	}
1466	parts := strings.Split(s, ",")
1467	var res []string
1468	for _, p := range parts {
1469		if trimmed := strings.TrimSpace(p); trimmed != "" {
1470			res = append(res, trimmed)
1471		}
1472	}
1473	return res
1474}
1475
1476func sendEmail(account *config.Account, msg tui.SendEmailMsg) tea.Cmd {
1477	return func() tea.Msg {
1478		if account == nil {
1479			return tui.EmailResultMsg{Err: fmt.Errorf("no account configured")}
1480		}
1481
1482		recipients := splitEmails(msg.To)
1483		cc := splitEmails(msg.Cc)
1484		bcc := splitEmails(msg.Bcc)
1485		body := msg.Body
1486		// Append signature if present
1487		if msg.Signature != "" {
1488			body = body + "\n\n" + msg.Signature
1489		}
1490		// Append quoted text if present (for replies)
1491		if msg.QuotedText != "" {
1492			body = body + msg.QuotedText
1493		}
1494		images := make(map[string][]byte)
1495		attachments := make(map[string][]byte)
1496
1497		re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
1498		matches := re.FindAllStringSubmatch(body, -1)
1499
1500		for _, match := range matches {
1501			imgPath := match[1]
1502			imgData, err := os.ReadFile(imgPath)
1503			if err != nil {
1504				log.Printf("Could not read image file %s: %v", imgPath, err)
1505				continue
1506			}
1507			cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "matcha")
1508			images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
1509			body = strings.Replace(body, imgPath, "cid:"+cid, 1)
1510		}
1511
1512		htmlBody := markdownToHTML([]byte(body))
1513
1514		for _, attachPath := range msg.AttachmentPaths {
1515			fileData, err := os.ReadFile(attachPath)
1516			if err != nil {
1517				log.Printf("Could not read attachment file %s: %v", attachPath, err)
1518				continue
1519			}
1520			_, filename := filepath.Split(attachPath)
1521			attachments[filename] = fileData
1522		}
1523
1524		err := sender.SendEmail(account, recipients, cc, bcc, msg.Subject, body, string(htmlBody), images, attachments, msg.InReplyTo, msg.References, msg.SignSMIME, msg.EncryptSMIME)
1525		if err != nil {
1526			log.Printf("Failed to send email: %v", err)
1527			return tui.EmailResultMsg{Err: err}
1528		}
1529		return tui.EmailResultMsg{}
1530	}
1531}
1532
1533func deleteEmailCmd(account *config.Account, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
1534	return func() tea.Msg {
1535		var err error
1536		switch mailbox {
1537		case tui.MailboxSent:
1538			err = fetcher.DeleteSentEmail(account, uid)
1539		case tui.MailboxTrash:
1540			err = fetcher.DeleteTrashEmail(account, uid)
1541		case tui.MailboxArchive:
1542			err = fetcher.DeleteArchiveEmail(account, uid)
1543		default:
1544			err = fetcher.DeleteEmail(account, uid)
1545		}
1546		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1547	}
1548}
1549
1550func archiveEmailCmd(account *config.Account, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
1551	return func() tea.Msg {
1552		var err error
1553		if mailbox == tui.MailboxSent {
1554			err = fetcher.ArchiveSentEmail(account, uid)
1555		} else {
1556			err = fetcher.ArchiveEmail(account, uid)
1557		}
1558		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1559	}
1560}
1561
1562// --- IDLE command ---
1563
1564// listenForIdleUpdates blocks until an IDLE update arrives, then returns it as a tea.Msg.
1565func listenForIdleUpdates(ch <-chan fetcher.IdleUpdate) tea.Cmd {
1566	return func() tea.Msg {
1567		update, ok := <-ch
1568		if !ok {
1569			return nil
1570		}
1571		return tui.IdleNewMailMsg{
1572			AccountID:  update.AccountID,
1573			FolderName: update.FolderName,
1574		}
1575	}
1576}
1577
1578// --- Folder-based command functions ---
1579
1580func fetchFoldersCmd(cfg *config.Config) tea.Cmd {
1581	return func() tea.Msg {
1582		if !cfg.HasAccounts() {
1583			return nil
1584		}
1585		foldersByAccount := make(map[string][]fetcher.Folder)
1586		seen := make(map[string]fetcher.Folder)
1587		var mu sync.Mutex
1588		var wg sync.WaitGroup
1589
1590		for _, account := range cfg.Accounts {
1591			wg.Add(1)
1592			go func(acc config.Account) {
1593				defer wg.Done()
1594				folders, err := fetcher.FetchFolders(&acc)
1595				if err != nil {
1596					return
1597				}
1598				mu.Lock()
1599				foldersByAccount[acc.ID] = folders
1600				for _, f := range folders {
1601					if _, ok := seen[f.Name]; !ok {
1602						seen[f.Name] = f
1603					}
1604				}
1605				mu.Unlock()
1606			}(account)
1607		}
1608		wg.Wait()
1609
1610		var merged []fetcher.Folder
1611		for _, f := range seen {
1612			merged = append(merged, f)
1613		}
1614
1615		return tui.FoldersFetchedMsg{
1616			FoldersByAccount: foldersByAccount,
1617			MergedFolders:    merged,
1618		}
1619	}
1620}
1621
1622func fetchFolderEmailsCmd(cfg *config.Config, folderName string) tea.Cmd {
1623	return func() tea.Msg {
1624		emailsByAccount := make(map[string][]fetcher.Email)
1625		var mu sync.Mutex
1626		var wg sync.WaitGroup
1627
1628		for _, account := range cfg.Accounts {
1629			wg.Add(1)
1630			go func(acc config.Account) {
1631				defer wg.Done()
1632				emails, err := fetcher.FetchFolderEmails(&acc, folderName, initialEmailLimit, 0)
1633				if err != nil {
1634					// Folder may not exist for this account — silently skip
1635					return
1636				}
1637				mu.Lock()
1638				emailsByAccount[acc.ID] = emails
1639				mu.Unlock()
1640			}(account)
1641		}
1642
1643		wg.Wait()
1644
1645		// Flatten all account emails
1646		var allEmails []fetcher.Email
1647		for _, emails := range emailsByAccount {
1648			allEmails = append(allEmails, emails...)
1649		}
1650		// Sort newest first
1651		for i := 0; i < len(allEmails); i++ {
1652			for j := i + 1; j < len(allEmails); j++ {
1653				if allEmails[j].Date.After(allEmails[i].Date) {
1654					allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
1655				}
1656			}
1657		}
1658
1659		return tui.FolderEmailsFetchedMsg{
1660			Emails:     allEmails,
1661			FolderName: folderName,
1662		}
1663	}
1664}
1665
1666func fetchFolderEmailsPaginatedCmd(account *config.Account, folderName string, limit, offset uint32) tea.Cmd {
1667	return func() tea.Msg {
1668		emails, err := fetcher.FetchFolderEmails(account, folderName, limit, offset)
1669		if err != nil {
1670			return tui.FetchErr(err)
1671		}
1672		return tui.FolderEmailsAppendedMsg{
1673			Emails:     emails,
1674			AccountID:  account.ID,
1675			FolderName: folderName,
1676		}
1677	}
1678}
1679
1680func fetchFolderEmailBodyCmd(cfg *config.Config, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
1681	return func() tea.Msg {
1682		account := cfg.GetAccountByID(accountID)
1683		if account == nil {
1684			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: fmt.Errorf("account not found")}
1685		}
1686
1687		body, attachments, err := fetcher.FetchFolderEmailBody(account, folderName, uid)
1688		if err != nil {
1689			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1690		}
1691
1692		return tui.EmailBodyFetchedMsg{
1693			UID:         uid,
1694			Body:        body,
1695			Attachments: attachments,
1696			AccountID:   accountID,
1697			Mailbox:     mailbox,
1698		}
1699	}
1700}
1701
1702func markEmailAsReadCmd(account *config.Account, uid uint32, accountID string, folderName string) tea.Cmd {
1703	return func() tea.Msg {
1704		err := fetcher.MarkEmailAsReadInMailbox(account, folderName, uid)
1705		return tui.EmailMarkedReadMsg{UID: uid, AccountID: accountID, Err: err}
1706	}
1707}
1708
1709func deleteFolderEmailCmd(account *config.Account, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
1710	return func() tea.Msg {
1711		err := fetcher.DeleteFolderEmail(account, folderName, uid)
1712		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1713	}
1714}
1715
1716func archiveFolderEmailCmd(account *config.Account, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
1717	return func() tea.Msg {
1718		err := fetcher.ArchiveFolderEmail(account, folderName, uid)
1719		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1720	}
1721}
1722
1723func moveEmailToFolderCmd(account *config.Account, uid uint32, accountID string, sourceFolder, destFolder string) tea.Cmd {
1724	return func() tea.Msg {
1725		err := fetcher.MoveEmailToFolder(account, uid, sourceFolder, destFolder)
1726		return tui.EmailMovedMsg{
1727			UID:          uid,
1728			AccountID:    accountID,
1729			SourceFolder: sourceFolder,
1730			DestFolder:   destFolder,
1731			Err:          err,
1732		}
1733	}
1734}
1735
1736func downloadAttachmentCmd(account *config.Account, uid uint32, msg tui.DownloadAttachmentMsg) tea.Cmd {
1737	return func() tea.Msg {
1738		// Download and decode the attachment using encoding provided in msg.Encoding.
1739		var data []byte
1740		var err error
1741		switch msg.Mailbox {
1742		case tui.MailboxSent:
1743			data, err = fetcher.FetchSentAttachment(account, uid, msg.PartID, msg.Encoding)
1744		case tui.MailboxTrash:
1745			data, err = fetcher.FetchTrashAttachment(account, uid, msg.PartID, msg.Encoding)
1746		case tui.MailboxArchive:
1747			data, err = fetcher.FetchArchiveAttachment(account, uid, msg.PartID, msg.Encoding)
1748		default:
1749			data, err = fetcher.FetchAttachment(account, uid, msg.PartID, msg.Encoding)
1750		}
1751		if err != nil {
1752			return tui.AttachmentDownloadedMsg{Err: err}
1753		}
1754
1755		homeDir, err := os.UserHomeDir()
1756		if err != nil {
1757			return tui.AttachmentDownloadedMsg{Err: err}
1758		}
1759		downloadsPath := filepath.Join(homeDir, "Downloads")
1760		if _, err := os.Stat(downloadsPath); os.IsNotExist(err) {
1761			if mkErr := os.MkdirAll(downloadsPath, 0755); mkErr != nil {
1762				return tui.AttachmentDownloadedMsg{Err: mkErr}
1763			}
1764		}
1765
1766		// Save the attachment using an exclusive create so we never overwrite an existing file.
1767		// If the filename already exists, append \" (n)\" before the extension.
1768		origName := msg.Filename
1769		ext := filepath.Ext(origName)
1770		base := strings.TrimSuffix(origName, ext)
1771		candidate := origName
1772		i := 1
1773		var filePath string
1774
1775		for {
1776			filePath = filepath.Join(downloadsPath, candidate)
1777
1778			// Try to create file exclusively. If it already exists, os.OpenFile will return an error
1779			// that satisfies os.IsExist(err), so we can increment the candidate.
1780			f, err := os.OpenFile(filePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
1781			if err != nil {
1782				if os.IsExist(err) {
1783					// file exists, try next candidate
1784					candidate = fmt.Sprintf("%s (%d)%s", base, i, ext)
1785					i++
1786					continue
1787				}
1788				// Some other error while attempting to create file
1789				log.Printf("error creating file %s: %v", filePath, err)
1790				return tui.AttachmentDownloadedMsg{Err: err}
1791			}
1792
1793			// Successfully created the file descriptor; write and close.
1794			if _, writeErr := f.Write(data); writeErr != nil {
1795				_ = f.Close()
1796				log.Printf("error writing to file %s: %v", filePath, writeErr)
1797				return tui.AttachmentDownloadedMsg{Err: writeErr}
1798			}
1799			if closeErr := f.Close(); closeErr != nil {
1800				log.Printf("warning: error closing file %s: %v", filePath, closeErr)
1801			}
1802
1803			// file saved successfully
1804			break
1805		}
1806
1807		log.Printf("attachment saved to %s", filePath)
1808
1809		// Try to open the file using a platform-specific opener asynchronously and log the outcome.
1810		go func(p string) {
1811			var cmd *exec.Cmd
1812			switch runtime.GOOS {
1813			case "darwin":
1814				cmd = exec.Command("open", p)
1815			case "linux":
1816				cmd = exec.Command("xdg-open", p)
1817			case "windows":
1818				// 'start' is a cmd builtin; provide an empty title argument to avoid interpreting the path as the title.
1819				cmd = exec.Command("cmd", "/c", "start", "", p)
1820			default:
1821				// Unsupported OS: nothing to do.
1822				return
1823			}
1824			if err := cmd.Start(); err != nil {
1825				log.Printf("failed to open file %s: %v", p, err)
1826			}
1827		}(filePath)
1828
1829		return tui.AttachmentDownloadedMsg{Path: filePath, Err: nil}
1830	}
1831}
1832
1833/*
1834detectInstalledVersion returns a best-effort installed version string.
1835Priority:
1836 1. If the build-in `version` variable is set to something other than "dev", return it.
1837 2. If Homebrew is present and reports a version for `matcha`, return that.
1838 3. If snap is present and lists `matcha`, return that.
1839 4. Fallback to the build `version` (likely "dev").
1840*/
1841func detectInstalledVersion() string {
1842	v := strings.TrimSpace(version)
1843	if v != "dev" && v != "" {
1844		return v
1845	}
1846
1847	// Try Homebrew (macOS)
1848	if runtime.GOOS == "darwin" {
1849		if _, err := exec.LookPath("brew"); err == nil {
1850			// `brew list --versions matcha` prints: matcha 1.2.3
1851			if out, err := exec.Command("brew", "list", "--versions", "matcha").Output(); err == nil {
1852				parts := strings.Fields(string(out))
1853				if len(parts) >= 2 {
1854					return parts[1]
1855				}
1856			}
1857		}
1858	}
1859
1860	// Try snap (Linux)
1861	if runtime.GOOS == "linux" {
1862		if _, err := exec.LookPath("snap"); err == nil {
1863			if out, err := exec.Command("snap", "list", "matcha").Output(); err == nil {
1864				lines := strings.Split(strings.TrimSpace(string(out)), "\n")
1865				if len(lines) >= 2 {
1866					fields := strings.Fields(lines[1])
1867					if len(fields) >= 2 {
1868						return fields[1]
1869					}
1870				}
1871			}
1872		}
1873
1874		if _, err := exec.LookPath("flatpak"); err == nil {
1875			if out, err := exec.Command("flatpak", "info", "com.floatpane.matcha").Output(); err == nil {
1876				lines := strings.Split(strings.TrimSpace(string(out)), "\n")
1877				for _, line := range lines {
1878					line = strings.TrimSpace(line)
1879					if strings.HasPrefix(line, "Version:") {
1880						fields := strings.Fields(line)
1881						if len(fields) >= 2 {
1882							return fields[1]
1883						}
1884					}
1885				}
1886			}
1887		}
1888	}
1889
1890	return v
1891}
1892
1893/*
1894checkForUpdatesCmd queries GitHub for the latest release tag and returns a
1895tea.Msg (UpdateAvailableMsg) if the latest version differs from the current
1896installed version. This runs in the background when the TUI initializes.
1897*/
1898func checkForUpdatesCmd() tea.Cmd {
1899	return func() tea.Msg {
1900		// Non-fatal: if anything goes wrong we just don't show the update message.
1901		const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
1902		resp, err := http.Get(api)
1903		if err != nil {
1904			return nil
1905		}
1906		defer resp.Body.Close()
1907
1908		var rel githubRelease
1909		if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
1910			return nil
1911		}
1912
1913		latest := strings.TrimPrefix(rel.TagName, "v")
1914		installed := strings.TrimPrefix(detectInstalledVersion(), "v")
1915		if latest != "" && installed != "" && latest != installed {
1916			return UpdateAvailableMsg{Latest: latest, Current: installed}
1917		}
1918		return nil
1919	}
1920}
1921
1922// runUpdateCLI implements the CLI entrypoint for `matcha update`.
1923// It detects the likely installation method and attempts the appropriate
1924// update path (Homebrew, Snap, or GitHub release binary extract).
1925func runUpdateCLI() error {
1926	const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
1927	resp, err := http.Get(api)
1928	if err != nil {
1929		return fmt.Errorf("could not query releases: %w", err)
1930	}
1931	defer resp.Body.Close()
1932
1933	var rel githubRelease
1934	if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
1935		return fmt.Errorf("could not parse release info: %w", err)
1936	}
1937
1938	latestTag := rel.TagName
1939	if strings.HasPrefix(latestTag, "v") {
1940		latestTag = latestTag[1:]
1941	}
1942
1943	fmt.Printf("Current version: %s\n", version)
1944	fmt.Printf("Latest version: %s\n", latestTag)
1945
1946	// Quick check: if already up-to-date, exit
1947	cur := version
1948	if strings.HasPrefix(cur, "v") {
1949		cur = cur[1:]
1950	}
1951	if latestTag == "" || cur == latestTag {
1952		fmt.Println("Already up to date.")
1953		return nil
1954	}
1955
1956	// Detect Homebrew
1957	if _, err := exec.LookPath("brew"); err == nil {
1958		fmt.Println("Detected Homebrew — updating taps and attempting to upgrade via brew.")
1959
1960		updateCmd := exec.Command("brew", "update")
1961		updateCmd.Stdout = os.Stdout
1962		updateCmd.Stderr = os.Stderr
1963		if err := updateCmd.Run(); err != nil {
1964			fmt.Printf("Homebrew update failed: %v\n", err)
1965			// continue to attempt upgrade even if update failed
1966		}
1967
1968		upgradeCmd := exec.Command("brew", "upgrade", "floatpane/matcha/matcha")
1969		upgradeCmd.Stdout = os.Stdout
1970		upgradeCmd.Stderr = os.Stderr
1971		if err := upgradeCmd.Run(); err == nil {
1972			fmt.Println("Successfully upgraded via Homebrew.")
1973			return nil
1974		}
1975		fmt.Printf("Homebrew upgrade failed: %v\n", err)
1976		// fallthrough to other methods
1977	}
1978
1979	// Detect snap
1980	if _, err := exec.LookPath("snap"); err == nil {
1981		// Check if matcha is installed as a snap
1982		cmdCheck := exec.Command("snap", "list", "matcha")
1983		if err := cmdCheck.Run(); err == nil {
1984			fmt.Println("Detected Snap package — attempting to refresh.")
1985			cmd := exec.Command("snap", "refresh", "matcha")
1986			cmd.Stdout = os.Stdout
1987			cmd.Stderr = os.Stderr
1988			if err := cmd.Run(); err == nil {
1989				fmt.Println("Successfully refreshed snap.")
1990				return nil
1991			}
1992			fmt.Printf("Snap refresh failed: %v\n", err)
1993			// fallthrough
1994		}
1995	}
1996	// Detect flatpak
1997	if _, err := exec.LookPath("flatpak"); err == nil {
1998		// Check if matcha is installed as a flatpak
1999		cmdCheck := exec.Command("flatpak", "info", "com.floatpane.matcha")
2000		if err := cmdCheck.Run(); err == nil {
2001			fmt.Println("Detected Flatpak package — attempting to update.")
2002			cmd := exec.Command("flatpak", "update", "-y", "com.floatpane.matcha")
2003			cmd.Stdout = os.Stdout
2004			cmd.Stderr = os.Stderr
2005			if err := cmd.Run(); err == nil {
2006				fmt.Println("Successfully updated flatpak.")
2007				return nil
2008			}
2009			fmt.Printf("Flatpak update failed: %v\n", err)
2010			// fallthrough
2011		}
2012	}
2013
2014	// Otherwise attempt to download the proper release asset and replace the binary.
2015	osName := runtime.GOOS
2016	arch := runtime.GOARCH
2017
2018	// Try to find a matching asset
2019	var assetURL, assetName string
2020	for _, a := range rel.Assets {
2021		n := strings.ToLower(a.Name)
2022		if strings.Contains(n, osName) && strings.Contains(n, arch) && (strings.HasSuffix(n, ".tar.gz") || strings.HasSuffix(n, ".tgz") || strings.HasSuffix(n, ".zip")) {
2023			assetURL = a.BrowserDownloadURL
2024			assetName = a.Name
2025			break
2026		}
2027	}
2028	if assetURL == "" {
2029		// Try any asset that contains 'matcha' and os/arch as a fallback
2030		for _, a := range rel.Assets {
2031			n := strings.ToLower(a.Name)
2032			if strings.Contains(n, "matcha") && (strings.Contains(n, osName) || strings.Contains(n, arch)) {
2033				assetURL = a.BrowserDownloadURL
2034				assetName = a.Name
2035				break
2036			}
2037		}
2038	}
2039
2040	if assetURL == "" {
2041		return fmt.Errorf("no suitable release artifact found for %s/%s", osName, arch)
2042	}
2043
2044	fmt.Printf("Found release asset: %s\n", assetName)
2045	fmt.Println("Downloading...")
2046
2047	// Download asset
2048	respAsset, err := http.Get(assetURL)
2049	if err != nil {
2050		return fmt.Errorf("download failed: %w", err)
2051	}
2052	defer respAsset.Body.Close()
2053
2054	// Create a temp file for the download
2055	tmpDir, err := os.MkdirTemp("", "matcha-update-*")
2056	if err != nil {
2057		return fmt.Errorf("could not create temp dir: %w", err)
2058	}
2059	defer os.RemoveAll(tmpDir)
2060
2061	assetPath := filepath.Join(tmpDir, assetName)
2062	outFile, err := os.Create(assetPath)
2063	if err != nil {
2064		return fmt.Errorf("could not create temp file: %w", err)
2065	}
2066	_, err = io.Copy(outFile, respAsset.Body)
2067	outFile.Close()
2068	if err != nil {
2069		return fmt.Errorf("could not write asset to disk: %w", err)
2070	}
2071
2072	// Determine the expected binary name based on the OS.
2073	binaryName := "matcha"
2074	if runtime.GOOS == "windows" {
2075		binaryName = "matcha.exe"
2076	}
2077
2078	// Extract the binary from the archive.
2079	var binPath string
2080	if strings.HasSuffix(assetName, ".tar.gz") || strings.HasSuffix(assetName, ".tgz") {
2081		f, err := os.Open(assetPath)
2082		if err != nil {
2083			return fmt.Errorf("could not open archive: %w", err)
2084		}
2085		defer f.Close()
2086		gzr, err := gzip.NewReader(f)
2087		if err != nil {
2088			return fmt.Errorf("could not create gzip reader: %w", err)
2089		}
2090		tr := tar.NewReader(gzr)
2091		for {
2092			hdr, err := tr.Next()
2093			if err == io.EOF {
2094				break
2095			}
2096			if err != nil {
2097				return fmt.Errorf("error reading tar: %w", err)
2098			}
2099			name := filepath.Base(hdr.Name)
2100			if name == binaryName || strings.Contains(strings.ToLower(name), "matcha") && (hdr.Typeflag == tar.TypeReg) {
2101				binPath = filepath.Join(tmpDir, binaryName)
2102				out, err := os.Create(binPath)
2103				if err != nil {
2104					return fmt.Errorf("could not create binary file: %w", err)
2105				}
2106				if _, err := io.Copy(out, tr); err != nil {
2107					out.Close()
2108					return fmt.Errorf("could not extract binary: %w", err)
2109				}
2110				out.Close()
2111				if err := os.Chmod(binPath, 0755); err != nil {
2112					return fmt.Errorf("could not make binary executable: %w", err)
2113				}
2114				break
2115			}
2116		}
2117	} else if strings.HasSuffix(assetName, ".zip") {
2118		zr, err := zip.OpenReader(assetPath)
2119		if err != nil {
2120			return fmt.Errorf("could not open zip archive: %w", err)
2121		}
2122		defer zr.Close()
2123		for _, zf := range zr.File {
2124			name := filepath.Base(zf.Name)
2125			if name == binaryName || strings.Contains(strings.ToLower(name), "matcha") && !zf.FileInfo().IsDir() {
2126				rc, err := zf.Open()
2127				if err != nil {
2128					return fmt.Errorf("could not open file in zip: %w", err)
2129				}
2130				binPath = filepath.Join(tmpDir, binaryName)
2131				out, err := os.Create(binPath)
2132				if err != nil {
2133					rc.Close()
2134					return fmt.Errorf("could not create binary file: %w", err)
2135				}
2136				if _, err := io.Copy(out, rc); err != nil {
2137					out.Close()
2138					rc.Close()
2139					return fmt.Errorf("could not extract binary: %w", err)
2140				}
2141				out.Close()
2142				rc.Close()
2143				if err := os.Chmod(binPath, 0755); err != nil {
2144					return fmt.Errorf("could not make binary executable: %w", err)
2145				}
2146				break
2147			}
2148		}
2149	} else {
2150		// For non-archive assets, assume the asset is the binary itself.
2151		binPath = assetPath
2152		if err := os.Chmod(binPath, 0755); err != nil {
2153			// ignore chmod errors but warn
2154			fmt.Printf("warning: could not chmod downloaded binary: %v\n", err)
2155		}
2156	}
2157
2158	if binPath == "" {
2159		return fmt.Errorf("could not locate matcha binary inside the release artifact")
2160	}
2161
2162	// Replace the running executable with the new binary
2163	execPath, err := os.Executable()
2164	if err != nil {
2165		return fmt.Errorf("could not determine executable path: %w", err)
2166	}
2167
2168	// Write the new binary to a temp file in same dir, then rename for atomic replacement.
2169	execDir := filepath.Dir(execPath)
2170	tmpNew := filepath.Join(execDir, fmt.Sprintf("matcha.new.%d", time.Now().Unix()))
2171	in, err := os.Open(binPath)
2172	if err != nil {
2173		return fmt.Errorf("could not open new binary: %w", err)
2174	}
2175	out, err := os.OpenFile(tmpNew, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
2176	if err != nil {
2177		in.Close()
2178		return fmt.Errorf("could not create temp binary in target dir: %w", err)
2179	}
2180	if _, err := io.Copy(out, in); err != nil {
2181		in.Close()
2182		out.Close()
2183		return fmt.Errorf("could not write new binary to disk: %w", err)
2184	}
2185	in.Close()
2186	out.Close()
2187
2188	// On Windows, a running executable cannot be overwritten directly.
2189	// Move the old binary out of the way first, then rename the new one in.
2190	if runtime.GOOS == "windows" {
2191		oldPath := execPath + ".old"
2192		_ = os.Remove(oldPath) // clean up any previous leftover
2193		if err := os.Rename(execPath, oldPath); err != nil {
2194			return fmt.Errorf("could not move old executable out of the way: %w", err)
2195		}
2196	}
2197
2198	if err := os.Rename(tmpNew, execPath); err != nil {
2199		return fmt.Errorf("could not replace executable: %w", err)
2200	}
2201
2202	fmt.Println("Successfully updated matcha to", latestTag)
2203	return nil
2204}
2205
2206func filterUnique(existing, incoming []fetcher.Email) []fetcher.Email {
2207	seen := make(map[uint32]struct{})
2208	for _, e := range existing {
2209		seen[e.UID] = struct{}{}
2210	}
2211	var unique []fetcher.Email
2212	for _, e := range incoming {
2213		if _, ok := seen[e.UID]; !ok {
2214			unique = append(unique, e)
2215		}
2216	}
2217	return unique
2218}
2219
2220func main() {
2221	// If invoked with version flag, print version and exit
2222	if len(os.Args) > 1 && (os.Args[1] == "-v" || os.Args[1] == "--version" || os.Args[1] == "version") {
2223		fmt.Printf("matcha version %s", version)
2224		if commit != "" {
2225			fmt.Printf(" (%s)", commit)
2226		}
2227		if date != "" {
2228			fmt.Printf(" built on %s", date)
2229		}
2230		fmt.Println()
2231		os.Exit(0)
2232	}
2233
2234	// If invoked as CLI update command, run updater and exit.
2235	if len(os.Args) > 1 && os.Args[1] == "update" {
2236		if err := runUpdateCLI(); err != nil {
2237			fmt.Fprintf(os.Stderr, "update failed: %v\n", err)
2238			os.Exit(1)
2239		}
2240		os.Exit(0)
2241	}
2242
2243	cfg, err := config.LoadConfig()
2244	if err == nil && cfg.Theme != "" {
2245		theme.SetTheme(cfg.Theme)
2246	}
2247	tui.RebuildStyles()
2248
2249	var initialModel *mainModel
2250	if err != nil {
2251		initialModel = newInitialModel(nil)
2252	} else {
2253		initialModel = newInitialModel(cfg)
2254	}
2255
2256	// Initialize plugin system
2257	plugins := plugin.NewManager()
2258	plugins.LoadPlugins()
2259	initialModel.plugins = plugins
2260	plugins.CallHook(plugin.HookStartup)
2261
2262	p := tea.NewProgram(initialModel)
2263
2264	if _, err := p.Run(); err != nil {
2265		plugins.Close()
2266		fmt.Printf("Alas, there's been an error: %v", err)
2267		os.Exit(1)
2268	}
2269
2270	plugins.CallHook(plugin.HookShutdown)
2271	plugins.Close()
2272}