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