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