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	inbox         *tui.Inbox
  67	width         int
  68	height        int
  69	err           error
  70}
  71
  72func newInitialModel(cfg *config.Config) *mainModel {
  73	initialModel := &mainModel{
  74		emailsByAcct: make(map[string][]fetcher.Email),
  75	}
  76
  77	if cfg == nil || !cfg.HasAccounts() {
  78		initialModel.current = tui.NewLogin()
  79	} else {
  80		initialModel.current = tui.NewChoice()
  81		initialModel.config = cfg
  82	}
  83	return initialModel
  84}
  85
  86func (m *mainModel) Init() tea.Cmd {
  87	return tea.Batch(m.current.Init(), checkForUpdatesCmd())
  88}
  89
  90func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  91	var cmd tea.Cmd
  92	var cmds []tea.Cmd
  93
  94	m.current, cmd = m.current.Update(msg)
  95	cmds = append(cmds, cmd)
  96
  97	switch msg := msg.(type) {
  98	case tea.WindowSizeMsg:
  99		m.width = msg.Width
 100		m.height = msg.Height
 101		return m, nil
 102
 103	case tea.KeyMsg:
 104		if msg.String() == "ctrl+c" {
 105			return m, tea.Quit
 106		}
 107		if msg.String() == "esc" {
 108			switch m.current.(type) {
 109			case *tui.FilePicker:
 110				return m, func() tea.Msg { return tui.CancelFilePickerMsg{} }
 111			case *tui.Inbox, *tui.Login:
 112				m.current = tui.NewChoice()
 113				return m, m.current.Init()
 114			}
 115		}
 116
 117	case tui.BackToInboxMsg:
 118		if m.inbox != nil {
 119			m.current = m.inbox
 120		} else {
 121			m.current = tui.NewChoice()
 122		}
 123		return m, nil
 124
 125	case tui.DiscardDraftMsg:
 126		// Save draft to disk
 127		if msg.ComposerState != nil {
 128			draft := msg.ComposerState.ToDraft()
 129			go func() {
 130				if err := config.SaveDraft(draft); err != nil {
 131					log.Printf("Error saving draft: %v", err)
 132				}
 133			}()
 134		}
 135		m.current = tui.NewChoice()
 136		return m, m.current.Init()
 137
 138	case tui.Credentials:
 139		// Add new account or update existing
 140		account := config.Account{
 141			ID:              uuid.New().String(),
 142			Name:            msg.Name,
 143			Email:           msg.Email,
 144			Password:        msg.Password,
 145			ServiceProvider: msg.Provider,
 146		}
 147
 148		if msg.Provider == "custom" {
 149			account.IMAPServer = msg.IMAPServer
 150			account.IMAPPort = msg.IMAPPort
 151			account.SMTPServer = msg.SMTPServer
 152			account.SMTPPort = msg.SMTPPort
 153		}
 154
 155		if m.config == nil {
 156			m.config = &config.Config{}
 157		}
 158
 159		// Check if we're editing an existing account
 160		if login, ok := m.current.(*tui.Login); ok && login.IsEditMode() {
 161			// Find and update the existing account
 162			existingID := login.GetAccountID()
 163			for i, acc := range m.config.Accounts {
 164				if acc.ID == existingID {
 165					account.ID = existingID
 166					m.config.Accounts[i] = account
 167					break
 168				}
 169			}
 170		} else {
 171			m.config.AddAccount(account)
 172		}
 173
 174		if err := config.SaveConfig(m.config); err != nil {
 175			log.Printf("could not save config: %v", err)
 176			return m, tea.Quit
 177		}
 178
 179		m.current = tui.NewChoice()
 180		return m, m.current.Init()
 181
 182	case tui.GoToInboxMsg:
 183		if m.config == nil || !m.config.HasAccounts() {
 184			m.current = tui.NewLogin()
 185			return m, m.current.Init()
 186		}
 187		// Try to load from cache first for instant display
 188		if config.HasEmailCache() {
 189			return m, loadCachedEmails()
 190		}
 191		// No cache, fetch normally
 192		m.current = tui.NewStatus("Fetching emails from all accounts...")
 193		return m, tea.Batch(m.current.Init(), fetchAllAccountsEmails(m.config))
 194
 195	case tui.CachedEmailsLoadedMsg:
 196		if msg.Cache == nil {
 197			// Cache load failed, fetch normally
 198			m.current = tui.NewStatus("Fetching emails from all accounts...")
 199			return m, tea.Batch(m.current.Init(), fetchAllAccountsEmails(m.config))
 200		}
 201
 202		// Convert cached emails to fetcher.Email
 203		var cachedEmails []fetcher.Email
 204		emailsByAcct := make(map[string][]fetcher.Email)
 205		for _, cached := range msg.Cache.Emails {
 206			email := fetcher.Email{
 207				UID:       cached.UID,
 208				From:      cached.From,
 209				To:        cached.To,
 210				Subject:   cached.Subject,
 211				Date:      cached.Date,
 212				MessageID: cached.MessageID,
 213				AccountID: cached.AccountID,
 214			}
 215			cachedEmails = append(cachedEmails, email)
 216			emailsByAcct[cached.AccountID] = append(emailsByAcct[cached.AccountID], email)
 217		}
 218
 219		m.emails = cachedEmails
 220		m.emailsByAcct = emailsByAcct
 221		m.inbox = tui.NewInbox(m.emails, m.config.Accounts)
 222		m.current = m.inbox
 223		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 224
 225		// Start background refresh
 226		return m, tea.Batch(
 227			m.current.Init(),
 228			func() tea.Msg { return tui.RefreshingEmailsMsg{} },
 229			refreshEmails(m.config),
 230		)
 231
 232	case tui.EmailsRefreshedMsg:
 233		m.emailsByAcct = msg.EmailsByAccount
 234
 235		// Flatten all emails
 236		var allEmails []fetcher.Email
 237		for _, emails := range msg.EmailsByAccount {
 238			allEmails = append(allEmails, emails...)
 239		}
 240
 241		// Sort by date (newest first)
 242		for i := 0; i < len(allEmails); i++ {
 243			for j := i + 1; j < len(allEmails); j++ {
 244				if allEmails[j].Date.After(allEmails[i].Date) {
 245					allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
 246				}
 247			}
 248		}
 249
 250		m.emails = allEmails
 251
 252		// Save to cache
 253		go saveEmailsToCache(m.emails)
 254
 255		// Update inbox if it exists
 256		if m.inbox != nil {
 257			m.inbox.SetEmails(m.emails, m.config.Accounts)
 258			// Forward the message to inbox to clear refreshing state
 259			m.current, _ = m.current.Update(msg)
 260		}
 261		return m, nil
 262
 263	case tui.AllEmailsFetchedMsg:
 264		m.emailsByAcct = msg.EmailsByAccount
 265
 266		// Flatten all emails
 267		var allEmails []fetcher.Email
 268		for _, emails := range msg.EmailsByAccount {
 269			allEmails = append(allEmails, emails...)
 270		}
 271
 272		// Sort by date (newest first)
 273		for i := 0; i < len(allEmails); i++ {
 274			for j := i + 1; j < len(allEmails); j++ {
 275				if allEmails[j].Date.After(allEmails[i].Date) {
 276					allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
 277				}
 278			}
 279		}
 280
 281		m.emails = allEmails
 282
 283		// Save to cache
 284		go saveEmailsToCache(m.emails)
 285
 286		m.inbox = tui.NewInbox(m.emails, m.config.Accounts)
 287		m.current = m.inbox
 288		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 289		return m, m.current.Init()
 290
 291	case tui.EmailsFetchedMsg:
 292		// Single account fetch result
 293		if m.emailsByAcct == nil {
 294			m.emailsByAcct = make(map[string][]fetcher.Email)
 295		}
 296		m.emailsByAcct[msg.AccountID] = msg.Emails
 297
 298		// Rebuild all emails
 299		var allEmails []fetcher.Email
 300		for _, emails := range m.emailsByAcct {
 301			allEmails = append(allEmails, emails...)
 302		}
 303
 304		// Sort by date
 305		for i := 0; i < len(allEmails); i++ {
 306			for j := i + 1; j < len(allEmails); j++ {
 307				if allEmails[j].Date.After(allEmails[i].Date) {
 308					allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
 309				}
 310			}
 311		}
 312
 313		m.emails = allEmails
 314		if m.inbox == nil {
 315			m.inbox = tui.NewInbox(m.emails, m.config.Accounts)
 316		} else {
 317			m.inbox.SetEmails(m.emails, m.config.Accounts)
 318		}
 319		m.current = m.inbox
 320		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 321		return m, m.current.Init()
 322
 323	case tui.FetchMoreEmailsMsg:
 324		if msg.AccountID == "" {
 325			return m, nil // Don't fetch more for "ALL" view
 326		}
 327		account := m.config.GetAccountByID(msg.AccountID)
 328		if account == nil {
 329			return m, nil
 330		}
 331		return m, tea.Batch(
 332			func() tea.Msg { return tui.FetchingMoreEmailsMsg{} },
 333			fetchEmails(account, paginationLimit, msg.Offset),
 334		)
 335
 336	case tui.EmailsAppendedMsg:
 337		// Add new emails to the appropriate account
 338		if m.emailsByAcct == nil {
 339			m.emailsByAcct = make(map[string][]fetcher.Email)
 340		}
 341		m.emailsByAcct[msg.AccountID] = append(m.emailsByAcct[msg.AccountID], msg.Emails...)
 342		m.emails = append(m.emails, msg.Emails...)
 343		return m, nil
 344
 345	case tui.GoToSendMsg:
 346		if m.config != nil && len(m.config.Accounts) > 0 {
 347			firstAccount := m.config.GetFirstAccount()
 348			composer := tui.NewComposerWithAccounts(m.config.Accounts, firstAccount.ID, msg.To, msg.Subject, msg.Body)
 349			m.current = composer
 350		} else {
 351			m.current = tui.NewComposer("", msg.To, msg.Subject, msg.Body)
 352		}
 353		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 354		return m, m.current.Init()
 355
 356	case tui.GoToDraftsMsg:
 357		drafts := config.GetAllDrafts()
 358		m.current = tui.NewDrafts(drafts)
 359		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 360		return m, m.current.Init()
 361
 362	case tui.OpenDraftMsg:
 363		var accounts []config.Account
 364		if m.config != nil {
 365			accounts = m.config.Accounts
 366		}
 367		composer := tui.NewComposerFromDraft(msg.Draft, accounts)
 368		m.current = composer
 369		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 370		return m, m.current.Init()
 371
 372	case tui.DeleteSavedDraftMsg:
 373		go func() {
 374			if err := config.DeleteDraft(msg.DraftID); err != nil {
 375				log.Printf("Error deleting draft: %v", err)
 376			}
 377		}()
 378		// Send message back to drafts view
 379		m.current, cmd = m.current.Update(tui.DraftDeletedMsg{DraftID: msg.DraftID})
 380		return m, cmd
 381
 382	case tui.GoToSettingsMsg:
 383		if m.config != nil {
 384			m.current = tui.NewSettings(m.config.Accounts)
 385		} else {
 386			m.current = tui.NewSettings(nil)
 387		}
 388		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 389		return m, m.current.Init()
 390
 391	case tui.GoToAddAccountMsg:
 392		m.current = tui.NewLogin()
 393		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 394		return m, m.current.Init()
 395
 396	case tui.GoToChoiceMenuMsg:
 397		m.current = tui.NewChoice()
 398		return m, m.current.Init()
 399
 400	case tui.DeleteAccountMsg:
 401		if m.config != nil {
 402			m.config.RemoveAccount(msg.AccountID)
 403			if err := config.SaveConfig(m.config); err != nil {
 404				log.Printf("could not save config: %v", err)
 405			}
 406			// Remove emails for this account
 407			delete(m.emailsByAcct, msg.AccountID)
 408
 409			// Rebuild all emails
 410			var allEmails []fetcher.Email
 411			for _, emails := range m.emailsByAcct {
 412				allEmails = append(allEmails, emails...)
 413			}
 414			m.emails = allEmails
 415
 416			// Go back to settings
 417			m.current = tui.NewSettings(m.config.Accounts)
 418			m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 419		}
 420		return m, m.current.Init()
 421
 422	case tui.ViewEmailMsg:
 423		email := m.getEmailByUIDAndAccount(msg.UID, msg.AccountID)
 424		if email == nil {
 425			return m, nil
 426		}
 427		m.current = tui.NewStatus("Fetching email content...")
 428		return m, tea.Batch(m.current.Init(), fetchEmailBodyCmd(m.config, *email, msg.UID, msg.AccountID))
 429
 430	case tui.EmailBodyFetchedMsg:
 431		if msg.Err != nil {
 432			log.Printf("could not fetch email body: %v", msg.Err)
 433			m.current = m.inbox
 434			return m, nil
 435		}
 436
 437		// Update the email in our stores
 438		m.updateEmailBodyByUID(msg.UID, msg.AccountID, msg.Body, msg.Attachments)
 439
 440		email := m.getEmailByUIDAndAccount(msg.UID, msg.AccountID)
 441		if email == nil {
 442			m.current = m.inbox
 443			return m, nil
 444		}
 445
 446		// Find the index for the email view (used for display purposes)
 447		emailIndex := m.getEmailIndex(msg.UID, msg.AccountID)
 448		emailView := tui.NewEmailView(*email, emailIndex, m.width, m.height)
 449		m.current = emailView
 450		return m, m.current.Init()
 451
 452	case tui.ReplyToEmailMsg:
 453		to := msg.Email.From
 454		subject := "Re: " + msg.Email.Subject
 455		body := 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> "))
 456
 457		if m.config != nil && len(m.config.Accounts) > 0 {
 458			// Use the account that received the email
 459			accountID := msg.Email.AccountID
 460			if accountID == "" {
 461				accountID = m.config.GetFirstAccount().ID
 462			}
 463			composer := tui.NewComposerWithAccounts(m.config.Accounts, accountID, to, subject, body)
 464			m.current = composer
 465		} else {
 466			m.current = tui.NewComposer("", to, subject, body)
 467		}
 468		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 469		return m, m.current.Init()
 470
 471	case tui.GoToFilePickerMsg:
 472		m.previousModel = m.current
 473		wd, _ := os.Getwd()
 474		m.current = tui.NewFilePicker(wd)
 475		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 476		return m, m.current.Init()
 477
 478	case tui.FileSelectedMsg, tui.CancelFilePickerMsg:
 479		if m.previousModel != nil {
 480			m.current = m.previousModel
 481			m.previousModel = nil
 482		}
 483		m.current, cmd = m.current.Update(msg)
 484		cmds = append(cmds, cmd)
 485
 486	case tui.SendEmailMsg:
 487		// Get draft ID before clearing composer (if it's a composer)
 488		var draftID string
 489		if composer, ok := m.current.(*tui.Composer); ok {
 490			draftID = composer.GetDraftID()
 491		}
 492		m.current = tui.NewStatus("Sending email...")
 493
 494		// Get the account to send from
 495		var account *config.Account
 496		if msg.AccountID != "" && m.config != nil {
 497			account = m.config.GetAccountByID(msg.AccountID)
 498		}
 499		if account == nil && m.config != nil {
 500			account = m.config.GetFirstAccount()
 501		}
 502
 503		// Save contact and delete draft in background
 504		go func() {
 505			// Save the recipient as a contact
 506			if msg.To != "" {
 507				// Parse "Name <email>" format
 508				name, email := parseEmailAddress(msg.To)
 509				if err := config.AddContact(name, email); err != nil {
 510					log.Printf("Error saving contact: %v", err)
 511				}
 512			}
 513			// Delete the draft since email is being sent
 514			if draftID != "" {
 515				if err := config.DeleteDraft(draftID); err != nil {
 516					log.Printf("Error deleting draft after send: %v", err)
 517				}
 518			}
 519		}()
 520
 521		return m, tea.Batch(m.current.Init(), sendEmail(account, msg))
 522
 523	case tui.EmailResultMsg:
 524		m.current = tui.NewChoice()
 525		return m, m.current.Init()
 526
 527	case tui.DeleteEmailMsg:
 528		m.previousModel = m.current
 529		m.current = tui.NewStatus("Deleting email...")
 530
 531		account := m.config.GetAccountByID(msg.AccountID)
 532		if account == nil {
 533			m.current = m.inbox
 534			return m, nil
 535		}
 536
 537		return m, tea.Batch(m.current.Init(), deleteEmailCmd(account, msg.UID, msg.AccountID))
 538
 539	case tui.ArchiveEmailMsg:
 540		m.previousModel = m.current
 541		m.current = tui.NewStatus("Archiving email...")
 542
 543		account := m.config.GetAccountByID(msg.AccountID)
 544		if account == nil {
 545			m.current = m.inbox
 546			return m, nil
 547		}
 548
 549		return m, tea.Batch(m.current.Init(), archiveEmailCmd(account, msg.UID, msg.AccountID))
 550
 551	case tui.EmailActionDoneMsg:
 552		if msg.Err != nil {
 553			log.Printf("Action failed: %v", msg.Err)
 554			m.current = m.inbox
 555			return m, nil
 556		}
 557
 558		// Remove email from stores
 559		m.removeEmail(msg.UID, msg.AccountID)
 560
 561		if m.inbox != nil {
 562			m.inbox.RemoveEmail(msg.UID, msg.AccountID)
 563		}
 564		m.current = m.inbox
 565		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 566		return m, m.current.Init()
 567
 568	case tui.DownloadAttachmentMsg:
 569		m.previousModel = m.current
 570		m.current = tui.NewStatus(fmt.Sprintf("Downloading %s...", msg.Filename))
 571
 572		account := m.config.GetAccountByID(msg.AccountID)
 573		if account == nil {
 574			m.current = m.previousModel
 575			return m, nil
 576		}
 577
 578		email := m.getEmailByIndex(msg.Index)
 579		if email == nil {
 580			m.current = m.previousModel
 581			return m, nil
 582		}
 583
 584		// Find the correct attachment to get encoding
 585		var encoding string
 586		for _, att := range email.Attachments {
 587			if att.PartID == msg.PartID {
 588				encoding = att.Encoding
 589				break
 590			}
 591		}
 592		newMsg := tui.DownloadAttachmentMsg{
 593			Index:     msg.Index,
 594			Filename:  msg.Filename,
 595			PartID:    msg.PartID,
 596			Data:      msg.Data,
 597			AccountID: msg.AccountID,
 598			Encoding:  encoding,
 599		}
 600		return m, tea.Batch(m.current.Init(), downloadAttachmentCmd(account, email.UID, newMsg))
 601
 602	case tui.AttachmentDownloadedMsg:
 603		var statusMsg string
 604		if msg.Err != nil {
 605			statusMsg = fmt.Sprintf("Error downloading: %v", msg.Err)
 606		} else {
 607			statusMsg = fmt.Sprintf("Saved to %s", msg.Path)
 608		}
 609		m.current = tui.NewStatus(statusMsg)
 610		return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
 611			return tui.RestoreViewMsg{}
 612		})
 613
 614	case tui.RestoreViewMsg:
 615		if m.previousModel != nil {
 616			m.current = m.previousModel
 617			m.previousModel = nil
 618		}
 619		return m, nil
 620	}
 621
 622	return m, tea.Batch(cmds...)
 623}
 624
 625func (m *mainModel) getEmailByIndex(index int) *fetcher.Email {
 626	if index >= 0 && index < len(m.emails) {
 627		return &m.emails[index]
 628	}
 629	return nil
 630}
 631
 632func (m *mainModel) getEmailByUIDAndAccount(uid uint32, accountID string) *fetcher.Email {
 633	for i := range m.emails {
 634		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
 635			return &m.emails[i]
 636		}
 637	}
 638	return nil
 639}
 640
 641func (m *mainModel) getEmailIndex(uid uint32, accountID string) int {
 642	for i := range m.emails {
 643		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
 644			return i
 645		}
 646	}
 647	return -1
 648}
 649
 650func (m *mainModel) updateEmailBodyByUID(uid uint32, accountID string, body string, attachments []fetcher.Attachment) {
 651	// Update in all emails list
 652	for i := range m.emails {
 653		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
 654			m.emails[i].Body = body
 655			m.emails[i].Attachments = attachments
 656			break
 657		}
 658	}
 659
 660	// Also update in account-specific store
 661	if emails, ok := m.emailsByAcct[accountID]; ok {
 662		for i := range emails {
 663			if emails[i].UID == uid {
 664				emails[i].Body = body
 665				emails[i].Attachments = attachments
 666				break
 667			}
 668		}
 669	}
 670}
 671
 672func (m *mainModel) removeEmail(uid uint32, accountID string) {
 673	// Remove from all emails
 674	var filtered []fetcher.Email
 675	for _, e := range m.emails {
 676		if !(e.UID == uid && e.AccountID == accountID) {
 677			filtered = append(filtered, e)
 678		}
 679	}
 680	m.emails = filtered
 681
 682	// Remove from account-specific store
 683	if emails, ok := m.emailsByAcct[accountID]; ok {
 684		var filteredAcct []fetcher.Email
 685		for _, e := range emails {
 686			if e.UID != uid {
 687				filteredAcct = append(filteredAcct, e)
 688			}
 689		}
 690		m.emailsByAcct[accountID] = filteredAcct
 691	}
 692}
 693
 694func (m *mainModel) View() string {
 695	return m.current.View()
 696}
 697
 698func fetchAllAccountsEmails(cfg *config.Config) tea.Cmd {
 699	return func() tea.Msg {
 700		emailsByAccount := make(map[string][]fetcher.Email)
 701		var mu sync.Mutex
 702		var wg sync.WaitGroup
 703
 704		for _, account := range cfg.Accounts {
 705			wg.Add(1)
 706			go func(acc config.Account) {
 707				defer wg.Done()
 708				emails, err := fetcher.FetchEmails(&acc, initialEmailLimit, 0)
 709				if err != nil {
 710					log.Printf("Error fetching from %s: %v", acc.Email, err)
 711					return
 712				}
 713				mu.Lock()
 714				emailsByAccount[acc.ID] = emails
 715				mu.Unlock()
 716			}(account)
 717		}
 718
 719		wg.Wait()
 720		return tui.AllEmailsFetchedMsg{EmailsByAccount: emailsByAccount}
 721	}
 722}
 723
 724func fetchEmails(account *config.Account, limit, offset uint32) tea.Cmd {
 725	return func() tea.Msg {
 726		emails, err := fetcher.FetchEmails(account, limit, offset)
 727		if err != nil {
 728			return tui.FetchErr(err)
 729		}
 730		if offset == 0 {
 731			return tui.EmailsFetchedMsg{Emails: emails, AccountID: account.ID}
 732		}
 733		return tui.EmailsAppendedMsg{Emails: emails, AccountID: account.ID}
 734	}
 735}
 736
 737func loadCachedEmails() tea.Cmd {
 738	return func() tea.Msg {
 739		cache, err := config.LoadEmailCache()
 740		if err != nil {
 741			return tui.CachedEmailsLoadedMsg{Cache: nil}
 742		}
 743		return tui.CachedEmailsLoadedMsg{Cache: cache}
 744	}
 745}
 746
 747func refreshEmails(cfg *config.Config) tea.Cmd {
 748	return func() tea.Msg {
 749		emailsByAccount := make(map[string][]fetcher.Email)
 750		var mu sync.Mutex
 751		var wg sync.WaitGroup
 752
 753		for _, account := range cfg.Accounts {
 754			wg.Add(1)
 755			go func(acc config.Account) {
 756				defer wg.Done()
 757				emails, err := fetcher.FetchEmails(&acc, initialEmailLimit, 0)
 758				if err != nil {
 759					log.Printf("Error fetching from %s: %v", acc.Email, err)
 760					return
 761				}
 762				mu.Lock()
 763				emailsByAccount[acc.ID] = emails
 764				mu.Unlock()
 765			}(account)
 766		}
 767
 768		wg.Wait()
 769		return tui.EmailsRefreshedMsg{EmailsByAccount: emailsByAccount}
 770	}
 771}
 772
 773func saveEmailsToCache(emails []fetcher.Email) {
 774	var cachedEmails []config.CachedEmail
 775	for _, email := range emails {
 776		cachedEmails = append(cachedEmails, config.CachedEmail{
 777			UID:       email.UID,
 778			From:      email.From,
 779			To:        email.To,
 780			Subject:   email.Subject,
 781			Date:      email.Date,
 782			MessageID: email.MessageID,
 783			AccountID: email.AccountID,
 784		})
 785
 786		// Save sender as a contact
 787		if email.From != "" {
 788			name, emailAddr := parseEmailAddress(email.From)
 789			if err := config.AddContact(name, emailAddr); err != nil {
 790				log.Printf("Error saving contact from email: %v", err)
 791			}
 792		}
 793	}
 794	cache := &config.EmailCache{Emails: cachedEmails}
 795	if err := config.SaveEmailCache(cache); err != nil {
 796		log.Printf("Error saving email cache: %v", err)
 797	}
 798}
 799
 800// parseEmailAddress parses "Name <email>" or just "email" format
 801func parseEmailAddress(addr string) (name, email string) {
 802	addr = strings.TrimSpace(addr)
 803	if idx := strings.Index(addr, "<"); idx != -1 {
 804		name = strings.TrimSpace(addr[:idx])
 805		endIdx := strings.Index(addr, ">")
 806		if endIdx > idx {
 807			email = strings.TrimSpace(addr[idx+1 : endIdx])
 808		} else {
 809			email = strings.TrimSpace(addr[idx+1:])
 810		}
 811	} else {
 812		email = addr
 813	}
 814	return name, email
 815}
 816
 817func fetchEmailBodyCmd(cfg *config.Config, email fetcher.Email, uid uint32, accountID string) tea.Cmd {
 818	return func() tea.Msg {
 819		account := cfg.GetAccountByID(accountID)
 820		if account == nil {
 821			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Err: fmt.Errorf("account not found")}
 822		}
 823
 824		body, attachments, err := fetcher.FetchEmailBody(account, uid)
 825		if err != nil {
 826			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Err: err}
 827		}
 828
 829		return tui.EmailBodyFetchedMsg{
 830			UID:         uid,
 831			Body:        body,
 832			Attachments: attachments,
 833			AccountID:   accountID,
 834		}
 835	}
 836}
 837
 838func markdownToHTML(md []byte) []byte {
 839	var buf bytes.Buffer
 840	p := goldmark.New(goldmark.WithRendererOptions(html.WithUnsafe()))
 841	if err := p.Convert(md, &buf); err != nil {
 842		return md
 843	}
 844	return buf.Bytes()
 845}
 846
 847func sendEmail(account *config.Account, msg tui.SendEmailMsg) tea.Cmd {
 848	return func() tea.Msg {
 849		if account == nil {
 850			return tui.EmailResultMsg{Err: fmt.Errorf("no account configured")}
 851		}
 852
 853		recipients := []string{msg.To}
 854		body := msg.Body
 855		images := make(map[string][]byte)
 856		attachments := make(map[string][]byte)
 857
 858		re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
 859		matches := re.FindAllStringSubmatch(body, -1)
 860
 861		for _, match := range matches {
 862			imgPath := match[1]
 863			imgData, err := os.ReadFile(imgPath)
 864			if err != nil {
 865				log.Printf("Could not read image file %s: %v", imgPath, err)
 866				continue
 867			}
 868			cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "matcha")
 869			images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
 870			body = strings.Replace(body, imgPath, "cid:"+cid, 1)
 871		}
 872
 873		htmlBody := markdownToHTML([]byte(body))
 874
 875		if msg.AttachmentPath != "" {
 876			fileData, err := os.ReadFile(msg.AttachmentPath)
 877			if err != nil {
 878				log.Printf("Could not read attachment file %s: %v", msg.AttachmentPath, err)
 879			} else {
 880				_, filename := filepath.Split(msg.AttachmentPath)
 881				attachments[filename] = fileData
 882			}
 883		}
 884
 885		err := sender.SendEmail(account, recipients, msg.Subject, msg.Body, string(htmlBody), images, attachments, msg.InReplyTo, msg.References)
 886		if err != nil {
 887			log.Printf("Failed to send email: %v", err)
 888			return tui.EmailResultMsg{Err: err}
 889		}
 890		return tui.EmailResultMsg{}
 891	}
 892}
 893
 894func deleteEmailCmd(account *config.Account, uid uint32, accountID string) tea.Cmd {
 895	return func() tea.Msg {
 896		err := fetcher.DeleteEmail(account, uid)
 897		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Err: err}
 898	}
 899}
 900
 901func archiveEmailCmd(account *config.Account, uid uint32, accountID string) tea.Cmd {
 902	return func() tea.Msg {
 903		err := fetcher.ArchiveEmail(account, uid)
 904		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Err: err}
 905	}
 906}
 907
 908func downloadAttachmentCmd(account *config.Account, uid uint32, msg tui.DownloadAttachmentMsg) tea.Cmd {
 909	return func() tea.Msg {
 910		// Download and decode the attachment using encoding provided in msg.Encoding.
 911		data, err := fetcher.FetchAttachment(account, uid, msg.PartID, msg.Encoding)
 912		if err != nil {
 913			return tui.AttachmentDownloadedMsg{Err: err}
 914		}
 915
 916		homeDir, err := os.UserHomeDir()
 917		if err != nil {
 918			return tui.AttachmentDownloadedMsg{Err: err}
 919		}
 920		downloadsPath := filepath.Join(homeDir, "Downloads")
 921		if _, err := os.Stat(downloadsPath); os.IsNotExist(err) {
 922			if mkErr := os.MkdirAll(downloadsPath, 0755); mkErr != nil {
 923				return tui.AttachmentDownloadedMsg{Err: mkErr}
 924			}
 925		}
 926
 927		// Save the attachment using an exclusive create so we never overwrite an existing file.
 928		// If the filename already exists, append \" (n)\" before the extension.
 929		origName := msg.Filename
 930		ext := filepath.Ext(origName)
 931		base := strings.TrimSuffix(origName, ext)
 932		candidate := origName
 933		i := 1
 934		var filePath string
 935
 936		for {
 937			filePath = filepath.Join(downloadsPath, candidate)
 938
 939			// Try to create file exclusively. If it already exists, os.OpenFile will return an error
 940			// that satisfies os.IsExist(err), so we can increment the candidate.
 941			f, err := os.OpenFile(filePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
 942			if err != nil {
 943				if os.IsExist(err) {
 944					// file exists, try next candidate
 945					candidate = fmt.Sprintf("%s (%d)%s", base, i, ext)
 946					i++
 947					continue
 948				}
 949				// Some other error while attempting to create file
 950				log.Printf("error creating file %s: %v", filePath, err)
 951				return tui.AttachmentDownloadedMsg{Err: err}
 952			}
 953
 954			// Successfully created the file descriptor; write and close.
 955			if _, writeErr := f.Write(data); writeErr != nil {
 956				_ = f.Close()
 957				log.Printf("error writing to file %s: %v", filePath, writeErr)
 958				return tui.AttachmentDownloadedMsg{Err: writeErr}
 959			}
 960			if closeErr := f.Close(); closeErr != nil {
 961				log.Printf("warning: error closing file %s: %v", filePath, closeErr)
 962			}
 963
 964			// file saved successfully
 965			break
 966		}
 967
 968		log.Printf("attachment saved to %s", filePath)
 969
 970		// Try to open the file using a platform-specific opener asynchronously and log the outcome.
 971		go func(p string) {
 972			var cmd *exec.Cmd
 973			switch runtime.GOOS {
 974			case "darwin":
 975				cmd = exec.Command("open", p)
 976			case "linux":
 977				cmd = exec.Command("xdg-open", p)
 978			case "windows":
 979				// 'start' is a cmd builtin; provide an empty title argument to avoid interpreting the path as the title.
 980				cmd = exec.Command("cmd", "/c", "start", "", p)
 981			default:
 982				// Unsupported OS: nothing to do.
 983				return
 984			}
 985			if err := cmd.Start(); err != nil {
 986				log.Printf("failed to open file %s: %v", p, err)
 987			}
 988		}(filePath)
 989
 990		return tui.AttachmentDownloadedMsg{Path: filePath, Err: nil}
 991	}
 992}
 993
 994/*
 995detectInstalledVersion returns a best-effort installed version string.
 996Priority:
 997 1. If the build-in `version` variable is set to something other than "dev", return it.
 998 2. If Homebrew is present and reports a version for `matcha`, return that.
 999 3. If snap is present and lists `matcha`, return that.
1000 4. Fallback to the build `version` (likely "dev").
1001*/
1002func detectInstalledVersion() string {
1003	v := strings.TrimSpace(version)
1004	if v != "dev" && v != "" {
1005		return v
1006	}
1007
1008	// Try Homebrew (macOS)
1009	if runtime.GOOS == "darwin" {
1010		if _, err := exec.LookPath("brew"); err == nil {
1011			// `brew list --versions matcha` prints: matcha 1.2.3
1012			if out, err := exec.Command("brew", "list", "--versions", "matcha").Output(); err == nil {
1013				parts := strings.Fields(string(out))
1014				if len(parts) >= 2 {
1015					return parts[1]
1016				}
1017			}
1018		}
1019	}
1020
1021	// Try snap (Linux)
1022	if runtime.GOOS == "linux" {
1023		if _, err := exec.LookPath("snap"); err == nil {
1024			if out, err := exec.Command("snap", "list", "matcha").Output(); err == nil {
1025				lines := strings.Split(strings.TrimSpace(string(out)), "\n")
1026				if len(lines) >= 2 {
1027					fields := strings.Fields(lines[1])
1028					if len(fields) >= 2 {
1029						return fields[1]
1030					}
1031				}
1032			}
1033		}
1034	}
1035
1036	return v
1037}
1038
1039/*
1040checkForUpdatesCmd queries GitHub for the latest release tag and returns a
1041tea.Msg (UpdateAvailableMsg) if the latest version differs from the current
1042installed version. This runs in the background when the TUI initializes.
1043*/
1044func checkForUpdatesCmd() tea.Cmd {
1045	return func() tea.Msg {
1046		// Non-fatal: if anything goes wrong we just don't show the update message.
1047		const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
1048		resp, err := http.Get(api)
1049		if err != nil {
1050			return nil
1051		}
1052		defer resp.Body.Close()
1053
1054		var rel githubRelease
1055		if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
1056			return nil
1057		}
1058
1059		latest := strings.TrimPrefix(rel.TagName, "v")
1060		installed := strings.TrimPrefix(detectInstalledVersion(), "v")
1061		if latest != "" && installed != "" && latest != installed {
1062			return UpdateAvailableMsg{Latest: latest, Current: installed}
1063		}
1064		return nil
1065	}
1066}
1067
1068// runUpdateCLI implements the CLI entrypoint for `matcha update`.
1069// It detects the likely installation method and attempts the appropriate
1070// update path (Homebrew, Snap, or GitHub release binary extract).
1071func runUpdateCLI() error {
1072	const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
1073	resp, err := http.Get(api)
1074	if err != nil {
1075		return fmt.Errorf("could not query releases: %w", err)
1076	}
1077	defer resp.Body.Close()
1078
1079	var rel githubRelease
1080	if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
1081		return fmt.Errorf("could not parse release info: %w", err)
1082	}
1083
1084	latestTag := rel.TagName
1085	if strings.HasPrefix(latestTag, "v") {
1086		latestTag = latestTag[1:]
1087	}
1088
1089	fmt.Printf("Current version: %s\n", version)
1090	fmt.Printf("Latest version: %s\n", latestTag)
1091
1092	// Quick check: if already up-to-date, exit
1093	cur := version
1094	if strings.HasPrefix(cur, "v") {
1095		cur = cur[1:]
1096	}
1097	if latestTag == "" || cur == latestTag {
1098		fmt.Println("Already up to date.")
1099		return nil
1100	}
1101
1102	// Detect Homebrew
1103	if _, err := exec.LookPath("brew"); err == nil {
1104		fmt.Println("Detected Homebrew — attempting to upgrade via brew.")
1105		cmd := exec.Command("brew", "upgrade", "matcha")
1106		cmd.Stdout = os.Stdout
1107		cmd.Stderr = os.Stderr
1108		if err := cmd.Run(); err == nil {
1109			fmt.Println("Successfully upgraded via Homebrew.")
1110			return nil
1111		}
1112		fmt.Printf("Homebrew upgrade failed: %v\n", err)
1113		// fallthrough to other methods
1114	}
1115
1116	// Detect snap
1117	if _, err := exec.LookPath("snap"); err == nil {
1118		// Check if matcha is installed as a snap
1119		cmdCheck := exec.Command("snap", "list", "matcha")
1120		if err := cmdCheck.Run(); err == nil {
1121			fmt.Println("Detected Snap package — attempting to refresh.")
1122			cmd := exec.Command("snap", "refresh", "matcha")
1123			cmd.Stdout = os.Stdout
1124			cmd.Stderr = os.Stderr
1125			if err := cmd.Run(); err == nil {
1126				fmt.Println("Successfully refreshed snap.")
1127				return nil
1128			}
1129			fmt.Printf("Snap refresh failed: %v\n", err)
1130			// fallthrough
1131		}
1132	}
1133
1134	// Otherwise attempt to download the proper release asset and replace the binary.
1135	osName := runtime.GOOS
1136	arch := runtime.GOARCH
1137
1138	// Try to find a matching asset
1139	var assetURL, assetName string
1140	for _, a := range rel.Assets {
1141		n := strings.ToLower(a.Name)
1142		if strings.Contains(n, osName) && strings.Contains(n, arch) && (strings.HasSuffix(n, ".tar.gz") || strings.HasSuffix(n, ".tgz") || strings.HasSuffix(n, ".zip")) {
1143			assetURL = a.BrowserDownloadURL
1144			assetName = a.Name
1145			break
1146		}
1147	}
1148	if assetURL == "" {
1149		// Try any asset that contains 'matcha' and os/arch as a fallback
1150		for _, a := range rel.Assets {
1151			n := strings.ToLower(a.Name)
1152			if strings.Contains(n, "matcha") && (strings.Contains(n, osName) || strings.Contains(n, arch)) {
1153				assetURL = a.BrowserDownloadURL
1154				assetName = a.Name
1155				break
1156			}
1157		}
1158	}
1159
1160	if assetURL == "" {
1161		return fmt.Errorf("no suitable release artifact found for %s/%s", osName, arch)
1162	}
1163
1164	fmt.Printf("Found release asset: %s\n", assetName)
1165	fmt.Println("Downloading...")
1166
1167	// Download asset
1168	respAsset, err := http.Get(assetURL)
1169	if err != nil {
1170		return fmt.Errorf("download failed: %w", err)
1171	}
1172	defer respAsset.Body.Close()
1173
1174	// Create a temp file for the download
1175	tmpDir, err := os.MkdirTemp("", "matcha-update-*")
1176	if err != nil {
1177		return fmt.Errorf("could not create temp dir: %w", err)
1178	}
1179	defer os.RemoveAll(tmpDir)
1180
1181	assetPath := filepath.Join(tmpDir, assetName)
1182	outFile, err := os.Create(assetPath)
1183	if err != nil {
1184		return fmt.Errorf("could not create temp file: %w", err)
1185	}
1186	_, err = io.Copy(outFile, respAsset.Body)
1187	outFile.Close()
1188	if err != nil {
1189		return fmt.Errorf("could not write asset to disk: %w", err)
1190	}
1191
1192	// If it's a tar.gz, extract and find the `matcha` binary
1193	var binPath string
1194	if strings.HasSuffix(assetName, ".tar.gz") || strings.HasSuffix(assetName, ".tgz") {
1195		f, err := os.Open(assetPath)
1196		if err != nil {
1197			return fmt.Errorf("could not open archive: %w", err)
1198		}
1199		defer f.Close()
1200		gzr, err := gzip.NewReader(f)
1201		if err != nil {
1202			return fmt.Errorf("could not create gzip reader: %w", err)
1203		}
1204		tr := tar.NewReader(gzr)
1205		for {
1206			hdr, err := tr.Next()
1207			if err == io.EOF {
1208				break
1209			}
1210			if err != nil {
1211				return fmt.Errorf("error reading tar: %w", err)
1212			}
1213			name := filepath.Base(hdr.Name)
1214			if name == "matcha" || strings.Contains(strings.ToLower(name), "matcha") && (hdr.Typeflag == tar.TypeReg) {
1215				// write out the file
1216				binPath = filepath.Join(tmpDir, "matcha")
1217				out, err := os.Create(binPath)
1218				if err != nil {
1219					return fmt.Errorf("could not create binary file: %w", err)
1220				}
1221				if _, err := io.Copy(out, tr); err != nil {
1222					out.Close()
1223					return fmt.Errorf("could not extract binary: %w", err)
1224				}
1225				out.Close()
1226				if err := os.Chmod(binPath, 0755); err != nil {
1227					return fmt.Errorf("could not make binary executable: %w", err)
1228				}
1229				break
1230			}
1231		}
1232	} else {
1233		// For non-archive assets, assume the asset is the binary itself.
1234		binPath = assetPath
1235		if err := os.Chmod(binPath, 0755); err != nil {
1236			// ignore chmod errors but warn
1237			fmt.Printf("warning: could not chmod downloaded binary: %v\n", err)
1238		}
1239	}
1240
1241	if binPath == "" {
1242		return fmt.Errorf("could not locate matcha binary inside the release artifact")
1243	}
1244
1245	// Replace the running executable with the new binary
1246	execPath, err := os.Executable()
1247	if err != nil {
1248		return fmt.Errorf("could not determine executable path: %w", err)
1249	}
1250
1251	// Write the new binary to a temp file in same dir, then rename for atomic replacement.
1252	execDir := filepath.Dir(execPath)
1253	tmpNew := filepath.Join(execDir, fmt.Sprintf("matcha.new.%d", time.Now().Unix()))
1254	in, err := os.Open(binPath)
1255	if err != nil {
1256		return fmt.Errorf("could not open new binary: %w", err)
1257	}
1258	out, err := os.OpenFile(tmpNew, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
1259	if err != nil {
1260		in.Close()
1261		return fmt.Errorf("could not create temp binary in target dir: %w", err)
1262	}
1263	if _, err := io.Copy(out, in); err != nil {
1264		in.Close()
1265		out.Close()
1266		return fmt.Errorf("could not write new binary to disk: %w", err)
1267	}
1268	in.Close()
1269	out.Close()
1270
1271	// Attempt to atomically replace
1272	if err := os.Rename(tmpNew, execPath); err != nil {
1273		return fmt.Errorf("could not replace executable: %w", err)
1274	}
1275
1276	fmt.Println("Successfully updated matcha to", latestTag)
1277	return nil
1278}
1279
1280func main() {
1281	// If invoked as CLI update command, run updater and exit.
1282	if len(os.Args) > 1 && os.Args[1] == "update" {
1283		if err := runUpdateCLI(); err != nil {
1284			fmt.Fprintf(os.Stderr, "update failed: %v\n", err)
1285			os.Exit(1)
1286		}
1287		os.Exit(0)
1288	}
1289
1290	cfg, err := config.LoadConfig()
1291	var initialModel *mainModel
1292	if err != nil {
1293		initialModel = newInitialModel(nil)
1294	} else {
1295		initialModel = newInitialModel(cfg)
1296	}
1297
1298	p := tea.NewProgram(initialModel, tea.WithAltScreen())
1299
1300	if _, err := p.Run(); err != nil {
1301		fmt.Printf("Alas, there's been an error: %v", err)
1302		os.Exit(1)
1303	}
1304}