main.go

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