main.go

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