main.go

  1package main
  2
  3import (
  4	"bytes"
  5	"encoding/base64"
  6	"fmt"
  7	"log"
  8	"os"
  9	"os/exec"
 10	"path/filepath"
 11	"regexp"
 12	"runtime"
 13	"strings"
 14	"sync"
 15	"time"
 16
 17	tea "github.com/charmbracelet/bubbletea"
 18	"github.com/floatpane/matcha/config"
 19	"github.com/floatpane/matcha/fetcher"
 20	"github.com/floatpane/matcha/sender"
 21	"github.com/floatpane/matcha/tui"
 22	"github.com/google/uuid"
 23	"github.com/yuin/goldmark"
 24	"github.com/yuin/goldmark/renderer/html"
 25)
 26
 27const (
 28	initialEmailLimit = 20
 29	paginationLimit   = 20
 30)
 31
 32type mainModel struct {
 33	current       tea.Model
 34	previousModel tea.Model
 35	config        *config.Config
 36	emails        []fetcher.Email
 37	emailsByAcct  map[string][]fetcher.Email
 38	inbox         *tui.Inbox
 39	width         int
 40	height        int
 41	err           error
 42}
 43
 44func newInitialModel(cfg *config.Config) *mainModel {
 45	initialModel := &mainModel{
 46		emailsByAcct: make(map[string][]fetcher.Email),
 47	}
 48
 49	if cfg == nil || !cfg.HasAccounts() {
 50		initialModel.current = tui.NewLogin()
 51	} else {
 52		initialModel.current = tui.NewChoice()
 53		initialModel.config = cfg
 54	}
 55	return initialModel
 56}
 57
 58func (m *mainModel) Init() tea.Cmd {
 59	return m.current.Init()
 60}
 61
 62func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 63	var cmd tea.Cmd
 64	var cmds []tea.Cmd
 65
 66	m.current, cmd = m.current.Update(msg)
 67	cmds = append(cmds, cmd)
 68
 69	switch msg := msg.(type) {
 70	case tea.WindowSizeMsg:
 71		m.width = msg.Width
 72		m.height = msg.Height
 73		return m, nil
 74
 75	case tea.KeyMsg:
 76		if msg.String() == "ctrl+c" {
 77			return m, tea.Quit
 78		}
 79		if msg.String() == "esc" {
 80			switch m.current.(type) {
 81			case *tui.FilePicker:
 82				return m, func() tea.Msg { return tui.CancelFilePickerMsg{} }
 83			case *tui.Inbox, *tui.Login:
 84				m.current = tui.NewChoice()
 85				return m, m.current.Init()
 86			}
 87		}
 88
 89	case tui.BackToInboxMsg:
 90		if m.inbox != nil {
 91			m.current = m.inbox
 92		} else {
 93			m.current = tui.NewChoice()
 94		}
 95		return m, nil
 96
 97	case tui.DiscardDraftMsg:
 98		// Save draft to disk
 99		if msg.ComposerState != nil {
100			draft := msg.ComposerState.ToDraft()
101			go func() {
102				if err := config.SaveDraft(draft); err != nil {
103					log.Printf("Error saving draft: %v", err)
104				}
105			}()
106		}
107		m.current = tui.NewChoice()
108		return m, m.current.Init()
109
110	case tui.Credentials:
111		// Add new account or update existing
112		account := config.Account{
113			ID:              uuid.New().String(),
114			Name:            msg.Name,
115			Email:           msg.Email,
116			Password:        msg.Password,
117			ServiceProvider: msg.Provider,
118		}
119
120		if msg.Provider == "custom" {
121			account.IMAPServer = msg.IMAPServer
122			account.IMAPPort = msg.IMAPPort
123			account.SMTPServer = msg.SMTPServer
124			account.SMTPPort = msg.SMTPPort
125		}
126
127		if m.config == nil {
128			m.config = &config.Config{}
129		}
130
131		// Check if we're editing an existing account
132		if login, ok := m.current.(*tui.Login); ok && login.IsEditMode() {
133			// Find and update the existing account
134			existingID := login.GetAccountID()
135			for i, acc := range m.config.Accounts {
136				if acc.ID == existingID {
137					account.ID = existingID
138					m.config.Accounts[i] = account
139					break
140				}
141			}
142		} else {
143			m.config.AddAccount(account)
144		}
145
146		if err := config.SaveConfig(m.config); err != nil {
147			log.Printf("could not save config: %v", err)
148			return m, tea.Quit
149		}
150
151		m.current = tui.NewChoice()
152		return m, m.current.Init()
153
154	case tui.GoToInboxMsg:
155		if m.config == nil || !m.config.HasAccounts() {
156			m.current = tui.NewLogin()
157			return m, m.current.Init()
158		}
159		// Try to load from cache first for instant display
160		if config.HasEmailCache() {
161			return m, loadCachedEmails()
162		}
163		// No cache, fetch normally
164		m.current = tui.NewStatus("Fetching emails from all accounts...")
165		return m, tea.Batch(m.current.Init(), fetchAllAccountsEmails(m.config))
166
167	case tui.CachedEmailsLoadedMsg:
168		if msg.Cache == nil {
169			// Cache load failed, fetch normally
170			m.current = tui.NewStatus("Fetching emails from all accounts...")
171			return m, tea.Batch(m.current.Init(), fetchAllAccountsEmails(m.config))
172		}
173
174		// Convert cached emails to fetcher.Email
175		var cachedEmails []fetcher.Email
176		emailsByAcct := make(map[string][]fetcher.Email)
177		for _, cached := range msg.Cache.Emails {
178			email := fetcher.Email{
179				UID:       cached.UID,
180				From:      cached.From,
181				To:        cached.To,
182				Subject:   cached.Subject,
183				Date:      cached.Date,
184				MessageID: cached.MessageID,
185				AccountID: cached.AccountID,
186			}
187			cachedEmails = append(cachedEmails, email)
188			emailsByAcct[cached.AccountID] = append(emailsByAcct[cached.AccountID], email)
189		}
190
191		m.emails = cachedEmails
192		m.emailsByAcct = emailsByAcct
193		m.inbox = tui.NewInbox(m.emails, m.config.Accounts)
194		m.current = m.inbox
195		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
196
197		// Start background refresh
198		return m, tea.Batch(
199			m.current.Init(),
200			func() tea.Msg { return tui.RefreshingEmailsMsg{} },
201			refreshEmails(m.config),
202		)
203
204	case tui.EmailsRefreshedMsg:
205		m.emailsByAcct = msg.EmailsByAccount
206
207		// Flatten all emails
208		var allEmails []fetcher.Email
209		for _, emails := range msg.EmailsByAccount {
210			allEmails = append(allEmails, emails...)
211		}
212
213		// Sort by date (newest first)
214		for i := 0; i < len(allEmails); i++ {
215			for j := i + 1; j < len(allEmails); j++ {
216				if allEmails[j].Date.After(allEmails[i].Date) {
217					allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
218				}
219			}
220		}
221
222		m.emails = allEmails
223
224		// Save to cache
225		go saveEmailsToCache(m.emails)
226
227		// Update inbox if it exists
228		if m.inbox != nil {
229			m.inbox.SetEmails(m.emails, m.config.Accounts)
230			// Forward the message to inbox to clear refreshing state
231			m.current, _ = m.current.Update(msg)
232		}
233		return m, nil
234
235	case tui.AllEmailsFetchedMsg:
236		m.emailsByAcct = msg.EmailsByAccount
237
238		// Flatten all emails
239		var allEmails []fetcher.Email
240		for _, emails := range msg.EmailsByAccount {
241			allEmails = append(allEmails, emails...)
242		}
243
244		// Sort by date (newest first)
245		for i := 0; i < len(allEmails); i++ {
246			for j := i + 1; j < len(allEmails); j++ {
247				if allEmails[j].Date.After(allEmails[i].Date) {
248					allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
249				}
250			}
251		}
252
253		m.emails = allEmails
254
255		// Save to cache
256		go saveEmailsToCache(m.emails)
257
258		m.inbox = tui.NewInbox(m.emails, m.config.Accounts)
259		m.current = m.inbox
260		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
261		return m, m.current.Init()
262
263	case tui.EmailsFetchedMsg:
264		// Single account fetch result
265		if m.emailsByAcct == nil {
266			m.emailsByAcct = make(map[string][]fetcher.Email)
267		}
268		m.emailsByAcct[msg.AccountID] = msg.Emails
269
270		// Rebuild all emails
271		var allEmails []fetcher.Email
272		for _, emails := range m.emailsByAcct {
273			allEmails = append(allEmails, emails...)
274		}
275
276		// Sort by date
277		for i := 0; i < len(allEmails); i++ {
278			for j := i + 1; j < len(allEmails); j++ {
279				if allEmails[j].Date.After(allEmails[i].Date) {
280					allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
281				}
282			}
283		}
284
285		m.emails = allEmails
286		if m.inbox == nil {
287			m.inbox = tui.NewInbox(m.emails, m.config.Accounts)
288		} else {
289			m.inbox.SetEmails(m.emails, m.config.Accounts)
290		}
291		m.current = m.inbox
292		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
293		return m, m.current.Init()
294
295	case tui.FetchMoreEmailsMsg:
296		if msg.AccountID == "" {
297			return m, nil // Don't fetch more for "ALL" view
298		}
299		account := m.config.GetAccountByID(msg.AccountID)
300		if account == nil {
301			return m, nil
302		}
303		return m, tea.Batch(
304			func() tea.Msg { return tui.FetchingMoreEmailsMsg{} },
305			fetchEmails(account, paginationLimit, msg.Offset),
306		)
307
308	case tui.EmailsAppendedMsg:
309		// Add new emails to the appropriate account
310		if m.emailsByAcct == nil {
311			m.emailsByAcct = make(map[string][]fetcher.Email)
312		}
313		m.emailsByAcct[msg.AccountID] = append(m.emailsByAcct[msg.AccountID], msg.Emails...)
314		m.emails = append(m.emails, msg.Emails...)
315		return m, nil
316
317	case tui.GoToSendMsg:
318		if m.config != nil && len(m.config.Accounts) > 0 {
319			firstAccount := m.config.GetFirstAccount()
320			composer := tui.NewComposerWithAccounts(m.config.Accounts, firstAccount.ID, msg.To, msg.Subject, msg.Body)
321			m.current = composer
322		} else {
323			m.current = tui.NewComposer("", msg.To, msg.Subject, msg.Body)
324		}
325		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
326		return m, m.current.Init()
327
328	case tui.GoToDraftsMsg:
329		drafts := config.GetAllDrafts()
330		m.current = tui.NewDrafts(drafts)
331		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
332		return m, m.current.Init()
333
334	case tui.OpenDraftMsg:
335		var accounts []config.Account
336		if m.config != nil {
337			accounts = m.config.Accounts
338		}
339		composer := tui.NewComposerFromDraft(msg.Draft, accounts)
340		m.current = composer
341		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
342		return m, m.current.Init()
343
344	case tui.DeleteSavedDraftMsg:
345		go func() {
346			if err := config.DeleteDraft(msg.DraftID); err != nil {
347				log.Printf("Error deleting draft: %v", err)
348			}
349		}()
350		// Send message back to drafts view
351		m.current, cmd = m.current.Update(tui.DraftDeletedMsg{DraftID: msg.DraftID})
352		return m, cmd
353
354	case tui.GoToSettingsMsg:
355		if m.config != nil {
356			m.current = tui.NewSettings(m.config.Accounts)
357		} else {
358			m.current = tui.NewSettings(nil)
359		}
360		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
361		return m, m.current.Init()
362
363	case tui.GoToAddAccountMsg:
364		m.current = tui.NewLogin()
365		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
366		return m, m.current.Init()
367
368	case tui.GoToChoiceMenuMsg:
369		m.current = tui.NewChoice()
370		return m, m.current.Init()
371
372	case tui.DeleteAccountMsg:
373		if m.config != nil {
374			m.config.RemoveAccount(msg.AccountID)
375			if err := config.SaveConfig(m.config); err != nil {
376				log.Printf("could not save config: %v", err)
377			}
378			// Remove emails for this account
379			delete(m.emailsByAcct, msg.AccountID)
380
381			// Rebuild all emails
382			var allEmails []fetcher.Email
383			for _, emails := range m.emailsByAcct {
384				allEmails = append(allEmails, emails...)
385			}
386			m.emails = allEmails
387
388			// Go back to settings
389			m.current = tui.NewSettings(m.config.Accounts)
390			m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
391		}
392		return m, m.current.Init()
393
394	case tui.ViewEmailMsg:
395		email := m.getEmailByUIDAndAccount(msg.UID, msg.AccountID)
396		if email == nil {
397			return m, nil
398		}
399		m.current = tui.NewStatus("Fetching email content...")
400		return m, tea.Batch(m.current.Init(), fetchEmailBodyCmd(m.config, *email, msg.UID, msg.AccountID))
401
402	case tui.EmailBodyFetchedMsg:
403		if msg.Err != nil {
404			log.Printf("could not fetch email body: %v", msg.Err)
405			m.current = m.inbox
406			return m, nil
407		}
408
409		// Update the email in our stores
410		m.updateEmailBodyByUID(msg.UID, msg.AccountID, msg.Body, msg.Attachments)
411
412		email := m.getEmailByUIDAndAccount(msg.UID, msg.AccountID)
413		if email == nil {
414			m.current = m.inbox
415			return m, nil
416		}
417
418		// Find the index for the email view (used for display purposes)
419		emailIndex := m.getEmailIndex(msg.UID, msg.AccountID)
420		emailView := tui.NewEmailView(*email, emailIndex, m.width, m.height)
421		m.current = emailView
422		return m, m.current.Init()
423
424	case tui.ReplyToEmailMsg:
425		to := msg.Email.From
426		subject := "Re: " + msg.Email.Subject
427		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> "))
428
429		if m.config != nil && len(m.config.Accounts) > 0 {
430			// Use the account that received the email
431			accountID := msg.Email.AccountID
432			if accountID == "" {
433				accountID = m.config.GetFirstAccount().ID
434			}
435			composer := tui.NewComposerWithAccounts(m.config.Accounts, accountID, to, subject, body)
436			m.current = composer
437		} else {
438			m.current = tui.NewComposer("", to, subject, body)
439		}
440		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
441		return m, m.current.Init()
442
443	case tui.GoToFilePickerMsg:
444		m.previousModel = m.current
445		wd, _ := os.Getwd()
446		m.current = tui.NewFilePicker(wd)
447		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
448		return m, m.current.Init()
449
450	case tui.FileSelectedMsg, tui.CancelFilePickerMsg:
451		if m.previousModel != nil {
452			m.current = m.previousModel
453			m.previousModel = nil
454		}
455		m.current, cmd = m.current.Update(msg)
456		cmds = append(cmds, cmd)
457
458	case tui.SendEmailMsg:
459		// Get draft ID before clearing composer (if it's a composer)
460		var draftID string
461		if composer, ok := m.current.(*tui.Composer); ok {
462			draftID = composer.GetDraftID()
463		}
464		m.current = tui.NewStatus("Sending email...")
465
466		// Get the account to send from
467		var account *config.Account
468		if msg.AccountID != "" && m.config != nil {
469			account = m.config.GetAccountByID(msg.AccountID)
470		}
471		if account == nil && m.config != nil {
472			account = m.config.GetFirstAccount()
473		}
474
475		// Save contact and delete draft in background
476		go func() {
477			// Save the recipient as a contact
478			if msg.To != "" {
479				// Parse "Name <email>" format
480				name, email := parseEmailAddress(msg.To)
481				if err := config.AddContact(name, email); err != nil {
482					log.Printf("Error saving contact: %v", err)
483				}
484			}
485			// Delete the draft since email is being sent
486			if draftID != "" {
487				if err := config.DeleteDraft(draftID); err != nil {
488					log.Printf("Error deleting draft after send: %v", err)
489				}
490			}
491		}()
492
493		return m, tea.Batch(m.current.Init(), sendEmail(account, msg))
494
495	case tui.EmailResultMsg:
496		m.current = tui.NewChoice()
497		return m, m.current.Init()
498
499	case tui.DeleteEmailMsg:
500		m.previousModel = m.current
501		m.current = tui.NewStatus("Deleting email...")
502
503		account := m.config.GetAccountByID(msg.AccountID)
504		if account == nil {
505			m.current = m.inbox
506			return m, nil
507		}
508
509		return m, tea.Batch(m.current.Init(), deleteEmailCmd(account, msg.UID, msg.AccountID))
510
511	case tui.ArchiveEmailMsg:
512		m.previousModel = m.current
513		m.current = tui.NewStatus("Archiving email...")
514
515		account := m.config.GetAccountByID(msg.AccountID)
516		if account == nil {
517			m.current = m.inbox
518			return m, nil
519		}
520
521		return m, tea.Batch(m.current.Init(), archiveEmailCmd(account, msg.UID, msg.AccountID))
522
523	case tui.EmailActionDoneMsg:
524		if msg.Err != nil {
525			log.Printf("Action failed: %v", msg.Err)
526			m.current = m.inbox
527			return m, nil
528		}
529
530		// Remove email from stores
531		m.removeEmail(msg.UID, msg.AccountID)
532
533		if m.inbox != nil {
534			m.inbox.RemoveEmail(msg.UID, msg.AccountID)
535		}
536		m.current = m.inbox
537		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
538		return m, m.current.Init()
539
540	case tui.DownloadAttachmentMsg:
541		m.previousModel = m.current
542		m.current = tui.NewStatus(fmt.Sprintf("Downloading %s...", msg.Filename))
543
544		account := m.config.GetAccountByID(msg.AccountID)
545		if account == nil {
546			m.current = m.previousModel
547			return m, nil
548		}
549
550		email := m.getEmailByIndex(msg.Index)
551		if email == nil {
552			m.current = m.previousModel
553			return m, nil
554		}
555
556		// Find the correct attachment to get encoding
557		var encoding string
558		for _, att := range email.Attachments {
559			if att.PartID == msg.PartID {
560				encoding = att.Encoding
561				break
562			}
563		}
564		newMsg := tui.DownloadAttachmentMsg{
565			Index:     msg.Index,
566			Filename:  msg.Filename,
567			PartID:    msg.PartID,
568			Data:      msg.Data,
569			AccountID: msg.AccountID,
570			Encoding:  encoding,
571		}
572		return m, tea.Batch(m.current.Init(), downloadAttachmentCmd(account, email.UID, newMsg))
573
574	case tui.AttachmentDownloadedMsg:
575		var statusMsg string
576		if msg.Err != nil {
577			statusMsg = fmt.Sprintf("Error downloading: %v", msg.Err)
578		} else {
579			statusMsg = fmt.Sprintf("Saved to %s", msg.Path)
580		}
581		m.current = tui.NewStatus(statusMsg)
582		return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
583			return tui.RestoreViewMsg{}
584		})
585
586	case tui.RestoreViewMsg:
587		if m.previousModel != nil {
588			m.current = m.previousModel
589			m.previousModel = nil
590		}
591		return m, nil
592	}
593
594	return m, tea.Batch(cmds...)
595}
596
597func (m *mainModel) getEmailByIndex(index int) *fetcher.Email {
598	if index >= 0 && index < len(m.emails) {
599		return &m.emails[index]
600	}
601	return nil
602}
603
604func (m *mainModel) getEmailByUIDAndAccount(uid uint32, accountID string) *fetcher.Email {
605	for i := range m.emails {
606		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
607			return &m.emails[i]
608		}
609	}
610	return nil
611}
612
613func (m *mainModel) getEmailIndex(uid uint32, accountID string) int {
614	for i := range m.emails {
615		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
616			return i
617		}
618	}
619	return -1
620}
621
622func (m *mainModel) updateEmailBodyByUID(uid uint32, accountID string, body string, attachments []fetcher.Attachment) {
623	// Update in all emails list
624	for i := range m.emails {
625		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
626			m.emails[i].Body = body
627			m.emails[i].Attachments = attachments
628			break
629		}
630	}
631
632	// Also update in account-specific store
633	if emails, ok := m.emailsByAcct[accountID]; ok {
634		for i := range emails {
635			if emails[i].UID == uid {
636				emails[i].Body = body
637				emails[i].Attachments = attachments
638				break
639			}
640		}
641	}
642}
643
644func (m *mainModel) removeEmail(uid uint32, accountID string) {
645	// Remove from all emails
646	var filtered []fetcher.Email
647	for _, e := range m.emails {
648		if !(e.UID == uid && e.AccountID == accountID) {
649			filtered = append(filtered, e)
650		}
651	}
652	m.emails = filtered
653
654	// Remove from account-specific store
655	if emails, ok := m.emailsByAcct[accountID]; ok {
656		var filteredAcct []fetcher.Email
657		for _, e := range emails {
658			if e.UID != uid {
659				filteredAcct = append(filteredAcct, e)
660			}
661		}
662		m.emailsByAcct[accountID] = filteredAcct
663	}
664}
665
666func (m *mainModel) View() string {
667	return m.current.View()
668}
669
670func fetchAllAccountsEmails(cfg *config.Config) tea.Cmd {
671	return func() tea.Msg {
672		emailsByAccount := make(map[string][]fetcher.Email)
673		var mu sync.Mutex
674		var wg sync.WaitGroup
675
676		for _, account := range cfg.Accounts {
677			wg.Add(1)
678			go func(acc config.Account) {
679				defer wg.Done()
680				emails, err := fetcher.FetchEmails(&acc, initialEmailLimit, 0)
681				if err != nil {
682					log.Printf("Error fetching from %s: %v", acc.Email, err)
683					return
684				}
685				mu.Lock()
686				emailsByAccount[acc.ID] = emails
687				mu.Unlock()
688			}(account)
689		}
690
691		wg.Wait()
692		return tui.AllEmailsFetchedMsg{EmailsByAccount: emailsByAccount}
693	}
694}
695
696func fetchEmails(account *config.Account, limit, offset uint32) tea.Cmd {
697	return func() tea.Msg {
698		emails, err := fetcher.FetchEmails(account, limit, offset)
699		if err != nil {
700			return tui.FetchErr(err)
701		}
702		if offset == 0 {
703			return tui.EmailsFetchedMsg{Emails: emails, AccountID: account.ID}
704		}
705		return tui.EmailsAppendedMsg{Emails: emails, AccountID: account.ID}
706	}
707}
708
709func loadCachedEmails() tea.Cmd {
710	return func() tea.Msg {
711		cache, err := config.LoadEmailCache()
712		if err != nil {
713			return tui.CachedEmailsLoadedMsg{Cache: nil}
714		}
715		return tui.CachedEmailsLoadedMsg{Cache: cache}
716	}
717}
718
719func refreshEmails(cfg *config.Config) tea.Cmd {
720	return func() tea.Msg {
721		emailsByAccount := make(map[string][]fetcher.Email)
722		var mu sync.Mutex
723		var wg sync.WaitGroup
724
725		for _, account := range cfg.Accounts {
726			wg.Add(1)
727			go func(acc config.Account) {
728				defer wg.Done()
729				emails, err := fetcher.FetchEmails(&acc, initialEmailLimit, 0)
730				if err != nil {
731					log.Printf("Error fetching from %s: %v", acc.Email, err)
732					return
733				}
734				mu.Lock()
735				emailsByAccount[acc.ID] = emails
736				mu.Unlock()
737			}(account)
738		}
739
740		wg.Wait()
741		return tui.EmailsRefreshedMsg{EmailsByAccount: emailsByAccount}
742	}
743}
744
745func saveEmailsToCache(emails []fetcher.Email) {
746	var cachedEmails []config.CachedEmail
747	for _, email := range emails {
748		cachedEmails = append(cachedEmails, config.CachedEmail{
749			UID:       email.UID,
750			From:      email.From,
751			To:        email.To,
752			Subject:   email.Subject,
753			Date:      email.Date,
754			MessageID: email.MessageID,
755			AccountID: email.AccountID,
756		})
757
758		// Save sender as a contact
759		if email.From != "" {
760			name, emailAddr := parseEmailAddress(email.From)
761			if err := config.AddContact(name, emailAddr); err != nil {
762				log.Printf("Error saving contact from email: %v", err)
763			}
764		}
765	}
766	cache := &config.EmailCache{Emails: cachedEmails}
767	if err := config.SaveEmailCache(cache); err != nil {
768		log.Printf("Error saving email cache: %v", err)
769	}
770}
771
772// parseEmailAddress parses "Name <email>" or just "email" format
773func parseEmailAddress(addr string) (name, email string) {
774	addr = strings.TrimSpace(addr)
775	if idx := strings.Index(addr, "<"); idx != -1 {
776		name = strings.TrimSpace(addr[:idx])
777		endIdx := strings.Index(addr, ">")
778		if endIdx > idx {
779			email = strings.TrimSpace(addr[idx+1 : endIdx])
780		} else {
781			email = strings.TrimSpace(addr[idx+1:])
782		}
783	} else {
784		email = addr
785	}
786	return name, email
787}
788
789func fetchEmailBodyCmd(cfg *config.Config, email fetcher.Email, uid uint32, accountID string) tea.Cmd {
790	return func() tea.Msg {
791		account := cfg.GetAccountByID(accountID)
792		if account == nil {
793			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Err: fmt.Errorf("account not found")}
794		}
795
796		body, attachments, err := fetcher.FetchEmailBody(account, uid)
797		if err != nil {
798			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Err: err}
799		}
800
801		return tui.EmailBodyFetchedMsg{
802			UID:         uid,
803			Body:        body,
804			Attachments: attachments,
805			AccountID:   accountID,
806		}
807	}
808}
809
810func markdownToHTML(md []byte) []byte {
811	var buf bytes.Buffer
812	p := goldmark.New(goldmark.WithRendererOptions(html.WithUnsafe()))
813	if err := p.Convert(md, &buf); err != nil {
814		return md
815	}
816	return buf.Bytes()
817}
818
819func sendEmail(account *config.Account, msg tui.SendEmailMsg) tea.Cmd {
820	return func() tea.Msg {
821		if account == nil {
822			return tui.EmailResultMsg{Err: fmt.Errorf("no account configured")}
823		}
824
825		recipients := []string{msg.To}
826		body := msg.Body
827		images := make(map[string][]byte)
828		attachments := make(map[string][]byte)
829
830		re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
831		matches := re.FindAllStringSubmatch(body, -1)
832
833		for _, match := range matches {
834			imgPath := match[1]
835			imgData, err := os.ReadFile(imgPath)
836			if err != nil {
837				log.Printf("Could not read image file %s: %v", imgPath, err)
838				continue
839			}
840			cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "matcha")
841			images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
842			body = strings.Replace(body, imgPath, "cid:"+cid, 1)
843		}
844
845		htmlBody := markdownToHTML([]byte(body))
846
847		if msg.AttachmentPath != "" {
848			fileData, err := os.ReadFile(msg.AttachmentPath)
849			if err != nil {
850				log.Printf("Could not read attachment file %s: %v", msg.AttachmentPath, err)
851			} else {
852				_, filename := filepath.Split(msg.AttachmentPath)
853				attachments[filename] = fileData
854			}
855		}
856
857		err := sender.SendEmail(account, recipients, msg.Subject, msg.Body, string(htmlBody), images, attachments, msg.InReplyTo, msg.References)
858		if err != nil {
859			log.Printf("Failed to send email: %v", err)
860			return tui.EmailResultMsg{Err: err}
861		}
862		return tui.EmailResultMsg{}
863	}
864}
865
866func deleteEmailCmd(account *config.Account, uid uint32, accountID string) tea.Cmd {
867	return func() tea.Msg {
868		err := fetcher.DeleteEmail(account, uid)
869		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Err: err}
870	}
871}
872
873func archiveEmailCmd(account *config.Account, uid uint32, accountID string) tea.Cmd {
874	return func() tea.Msg {
875		err := fetcher.ArchiveEmail(account, uid)
876		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Err: err}
877	}
878}
879
880func downloadAttachmentCmd(account *config.Account, uid uint32, msg tui.DownloadAttachmentMsg) tea.Cmd {
881	return func() tea.Msg {
882		// Download and decode the attachment using encoding provided in msg.Encoding.
883		data, err := fetcher.FetchAttachment(account, uid, msg.PartID, msg.Encoding)
884		if err != nil {
885			return tui.AttachmentDownloadedMsg{Err: err}
886		}
887
888		homeDir, err := os.UserHomeDir()
889		if err != nil {
890			return tui.AttachmentDownloadedMsg{Err: err}
891		}
892		downloadsPath := filepath.Join(homeDir, "Downloads")
893		if _, err := os.Stat(downloadsPath); os.IsNotExist(err) {
894			if mkErr := os.MkdirAll(downloadsPath, 0755); mkErr != nil {
895				return tui.AttachmentDownloadedMsg{Err: mkErr}
896			}
897		}
898
899		// Save the attachment using an exclusive create so we never overwrite an existing file.
900		// If the filename already exists, append \" (n)\" before the extension.
901		origName := msg.Filename
902		ext := filepath.Ext(origName)
903		base := strings.TrimSuffix(origName, ext)
904		candidate := origName
905		i := 1
906		var filePath string
907
908		for {
909			filePath = filepath.Join(downloadsPath, candidate)
910
911			// Try to create file exclusively. If it already exists, os.OpenFile will return an error
912			// that satisfies os.IsExist(err), so we can increment the candidate.
913			f, err := os.OpenFile(filePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
914			if err != nil {
915				if os.IsExist(err) {
916					// file exists, try next candidate
917					candidate = fmt.Sprintf("%s (%d)%s", base, i, ext)
918					i++
919					continue
920				}
921				// Some other error while attempting to create file
922				log.Printf("error creating file %s: %v", filePath, err)
923				return tui.AttachmentDownloadedMsg{Err: err}
924			}
925
926			// Successfully created the file descriptor; write and close.
927			if _, writeErr := f.Write(data); writeErr != nil {
928				_ = f.Close()
929				log.Printf("error writing to file %s: %v", filePath, writeErr)
930				return tui.AttachmentDownloadedMsg{Err: writeErr}
931			}
932			if closeErr := f.Close(); closeErr != nil {
933				log.Printf("warning: error closing file %s: %v", filePath, closeErr)
934			}
935
936			// file saved successfully
937			break
938		}
939
940		log.Printf("attachment saved to %s", filePath)
941
942		// Try to open the file using a platform-specific opener asynchronously and log the outcome.
943		go func(p string) {
944			var cmd *exec.Cmd
945			switch runtime.GOOS {
946			case "darwin":
947				cmd = exec.Command("open", p)
948			case "linux":
949				cmd = exec.Command("xdg-open", p)
950			case "windows":
951				// 'start' is a cmd builtin; provide an empty title argument to avoid interpreting the path as the title.
952				cmd = exec.Command("cmd", "/c", "start", "", p)
953			default:
954				// Unsupported OS: nothing to do.
955				return
956			}
957			if err := cmd.Start(); err != nil {
958				log.Printf("failed to open file %s: %v", p, err)
959			}
960		}(filePath)
961
962		return tui.AttachmentDownloadedMsg{Path: filePath, Err: nil}
963	}
964}
965
966func main() {
967	cfg, err := config.LoadConfig()
968	var initialModel *mainModel
969	if err != nil {
970		initialModel = newInitialModel(nil)
971	} else {
972		initialModel = newInitialModel(cfg)
973	}
974
975	p := tea.NewProgram(initialModel, tea.WithAltScreen())
976
977	if _, err := p.Run(); err != nil {
978		fmt.Printf("Alas, there's been an error: %v", err)
979		os.Exit(1)
980	}
981}