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		return m, tea.Batch(m.current.Init(), downloadAttachmentCmd(account, email.UID, msg))
555
556	case tui.AttachmentDownloadedMsg:
557		var statusMsg string
558		if msg.Err != nil {
559			statusMsg = fmt.Sprintf("Error downloading: %v", msg.Err)
560		} else {
561			statusMsg = fmt.Sprintf("Saved to %s", msg.Path)
562		}
563		m.current = tui.NewStatus(statusMsg)
564		return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
565			return tui.RestoreViewMsg{}
566		})
567
568	case tui.RestoreViewMsg:
569		if m.previousModel != nil {
570			m.current = m.previousModel
571			m.previousModel = nil
572		}
573		return m, nil
574	}
575
576	return m, tea.Batch(cmds...)
577}
578
579func (m *mainModel) getEmailByIndex(index int) *fetcher.Email {
580	if index >= 0 && index < len(m.emails) {
581		return &m.emails[index]
582	}
583	return nil
584}
585
586func (m *mainModel) getEmailByUIDAndAccount(uid uint32, accountID string) *fetcher.Email {
587	for i := range m.emails {
588		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
589			return &m.emails[i]
590		}
591	}
592	return nil
593}
594
595func (m *mainModel) getEmailIndex(uid uint32, accountID string) int {
596	for i := range m.emails {
597		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
598			return i
599		}
600	}
601	return -1
602}
603
604func (m *mainModel) updateEmailBodyByUID(uid uint32, accountID string, body string, attachments []fetcher.Attachment) {
605	// Update in all emails list
606	for i := range m.emails {
607		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
608			m.emails[i].Body = body
609			m.emails[i].Attachments = attachments
610			break
611		}
612	}
613
614	// Also update in account-specific store
615	if emails, ok := m.emailsByAcct[accountID]; ok {
616		for i := range emails {
617			if emails[i].UID == uid {
618				emails[i].Body = body
619				emails[i].Attachments = attachments
620				break
621			}
622		}
623	}
624}
625
626func (m *mainModel) removeEmail(uid uint32, accountID string) {
627	// Remove from all emails
628	var filtered []fetcher.Email
629	for _, e := range m.emails {
630		if !(e.UID == uid && e.AccountID == accountID) {
631			filtered = append(filtered, e)
632		}
633	}
634	m.emails = filtered
635
636	// Remove from account-specific store
637	if emails, ok := m.emailsByAcct[accountID]; ok {
638		var filteredAcct []fetcher.Email
639		for _, e := range emails {
640			if e.UID != uid {
641				filteredAcct = append(filteredAcct, e)
642			}
643		}
644		m.emailsByAcct[accountID] = filteredAcct
645	}
646}
647
648func (m *mainModel) View() string {
649	return m.current.View()
650}
651
652func fetchAllAccountsEmails(cfg *config.Config) tea.Cmd {
653	return func() tea.Msg {
654		emailsByAccount := make(map[string][]fetcher.Email)
655		var mu sync.Mutex
656		var wg sync.WaitGroup
657
658		for _, account := range cfg.Accounts {
659			wg.Add(1)
660			go func(acc config.Account) {
661				defer wg.Done()
662				emails, err := fetcher.FetchEmails(&acc, initialEmailLimit, 0)
663				if err != nil {
664					log.Printf("Error fetching from %s: %v", acc.Email, err)
665					return
666				}
667				mu.Lock()
668				emailsByAccount[acc.ID] = emails
669				mu.Unlock()
670			}(account)
671		}
672
673		wg.Wait()
674		return tui.AllEmailsFetchedMsg{EmailsByAccount: emailsByAccount}
675	}
676}
677
678func fetchEmails(account *config.Account, limit, offset uint32) tea.Cmd {
679	return func() tea.Msg {
680		emails, err := fetcher.FetchEmails(account, limit, offset)
681		if err != nil {
682			return tui.FetchErr(err)
683		}
684		if offset == 0 {
685			return tui.EmailsFetchedMsg{Emails: emails, AccountID: account.ID}
686		}
687		return tui.EmailsAppendedMsg{Emails: emails, AccountID: account.ID}
688	}
689}
690
691func loadCachedEmails() tea.Cmd {
692	return func() tea.Msg {
693		cache, err := config.LoadEmailCache()
694		if err != nil {
695			return tui.CachedEmailsLoadedMsg{Cache: nil}
696		}
697		return tui.CachedEmailsLoadedMsg{Cache: cache}
698	}
699}
700
701func refreshEmails(cfg *config.Config) tea.Cmd {
702	return func() tea.Msg {
703		emailsByAccount := make(map[string][]fetcher.Email)
704		var mu sync.Mutex
705		var wg sync.WaitGroup
706
707		for _, account := range cfg.Accounts {
708			wg.Add(1)
709			go func(acc config.Account) {
710				defer wg.Done()
711				emails, err := fetcher.FetchEmails(&acc, initialEmailLimit, 0)
712				if err != nil {
713					log.Printf("Error fetching from %s: %v", acc.Email, err)
714					return
715				}
716				mu.Lock()
717				emailsByAccount[acc.ID] = emails
718				mu.Unlock()
719			}(account)
720		}
721
722		wg.Wait()
723		return tui.EmailsRefreshedMsg{EmailsByAccount: emailsByAccount}
724	}
725}
726
727func saveEmailsToCache(emails []fetcher.Email) {
728	var cachedEmails []config.CachedEmail
729	for _, email := range emails {
730		cachedEmails = append(cachedEmails, config.CachedEmail{
731			UID:       email.UID,
732			From:      email.From,
733			To:        email.To,
734			Subject:   email.Subject,
735			Date:      email.Date,
736			MessageID: email.MessageID,
737			AccountID: email.AccountID,
738		})
739
740		// Save sender as a contact
741		if email.From != "" {
742			name, emailAddr := parseEmailAddress(email.From)
743			if err := config.AddContact(name, emailAddr); err != nil {
744				log.Printf("Error saving contact from email: %v", err)
745			}
746		}
747	}
748	cache := &config.EmailCache{Emails: cachedEmails}
749	if err := config.SaveEmailCache(cache); err != nil {
750		log.Printf("Error saving email cache: %v", err)
751	}
752}
753
754// parseEmailAddress parses "Name <email>" or just "email" format
755func parseEmailAddress(addr string) (name, email string) {
756	addr = strings.TrimSpace(addr)
757	if idx := strings.Index(addr, "<"); idx != -1 {
758		name = strings.TrimSpace(addr[:idx])
759		endIdx := strings.Index(addr, ">")
760		if endIdx > idx {
761			email = strings.TrimSpace(addr[idx+1 : endIdx])
762		} else {
763			email = strings.TrimSpace(addr[idx+1:])
764		}
765	} else {
766		email = addr
767	}
768	return name, email
769}
770
771func fetchEmailBodyCmd(cfg *config.Config, email fetcher.Email, uid uint32, accountID string) tea.Cmd {
772	return func() tea.Msg {
773		account := cfg.GetAccountByID(accountID)
774		if account == nil {
775			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Err: fmt.Errorf("account not found")}
776		}
777
778		body, attachments, err := fetcher.FetchEmailBody(account, uid)
779		if err != nil {
780			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Err: err}
781		}
782
783		return tui.EmailBodyFetchedMsg{
784			UID:         uid,
785			Body:        body,
786			Attachments: attachments,
787			AccountID:   accountID,
788		}
789	}
790}
791
792func markdownToHTML(md []byte) []byte {
793	var buf bytes.Buffer
794	p := goldmark.New(goldmark.WithRendererOptions(html.WithUnsafe()))
795	if err := p.Convert(md, &buf); err != nil {
796		return md
797	}
798	return buf.Bytes()
799}
800
801func sendEmail(account *config.Account, msg tui.SendEmailMsg) tea.Cmd {
802	return func() tea.Msg {
803		if account == nil {
804			return tui.EmailResultMsg{Err: fmt.Errorf("no account configured")}
805		}
806
807		recipients := []string{msg.To}
808		body := msg.Body
809		images := make(map[string][]byte)
810		attachments := make(map[string][]byte)
811
812		re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
813		matches := re.FindAllStringSubmatch(body, -1)
814
815		for _, match := range matches {
816			imgPath := match[1]
817			imgData, err := os.ReadFile(imgPath)
818			if err != nil {
819				log.Printf("Could not read image file %s: %v", imgPath, err)
820				continue
821			}
822			cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "matcha")
823			images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
824			body = strings.Replace(body, imgPath, "cid:"+cid, 1)
825		}
826
827		htmlBody := markdownToHTML([]byte(body))
828
829		if msg.AttachmentPath != "" {
830			fileData, err := os.ReadFile(msg.AttachmentPath)
831			if err != nil {
832				log.Printf("Could not read attachment file %s: %v", msg.AttachmentPath, err)
833			} else {
834				_, filename := filepath.Split(msg.AttachmentPath)
835				attachments[filename] = fileData
836			}
837		}
838
839		err := sender.SendEmail(account, recipients, msg.Subject, msg.Body, string(htmlBody), images, attachments, msg.InReplyTo, msg.References)
840		if err != nil {
841			log.Printf("Failed to send email: %v", err)
842			return tui.EmailResultMsg{Err: err}
843		}
844		return tui.EmailResultMsg{}
845	}
846}
847
848func deleteEmailCmd(account *config.Account, uid uint32, accountID string) tea.Cmd {
849	return func() tea.Msg {
850		err := fetcher.DeleteEmail(account, uid)
851		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Err: err}
852	}
853}
854
855func archiveEmailCmd(account *config.Account, uid uint32, accountID string) tea.Cmd {
856	return func() tea.Msg {
857		err := fetcher.ArchiveEmail(account, uid)
858		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Err: err}
859	}
860}
861
862func downloadAttachmentCmd(account *config.Account, uid uint32, msg tui.DownloadAttachmentMsg) tea.Cmd {
863	return func() tea.Msg {
864		data, err := fetcher.FetchAttachment(account, uid, msg.PartID)
865		if err != nil {
866			return tui.AttachmentDownloadedMsg{Err: err}
867		}
868
869		homeDir, err := os.UserHomeDir()
870		if err != nil {
871			return tui.AttachmentDownloadedMsg{Err: err}
872		}
873		downloadsPath := filepath.Join(homeDir, "Downloads")
874		if _, err := os.Stat(downloadsPath); os.IsNotExist(err) {
875			if mkErr := os.MkdirAll(downloadsPath, 0755); mkErr != nil {
876				return tui.AttachmentDownloadedMsg{Err: mkErr}
877			}
878		}
879		filePath := filepath.Join(downloadsPath, msg.Filename)
880		err = os.WriteFile(filePath, data, 0644)
881		return tui.AttachmentDownloadedMsg{Path: filePath, Err: err}
882	}
883}
884
885func main() {
886	cfg, err := config.LoadConfig()
887	var initialModel *mainModel
888	if err != nil {
889		initialModel = newInitialModel(nil)
890	} else {
891		initialModel = newInitialModel(cfg)
892	}
893
894	p := tea.NewProgram(initialModel, tea.WithAltScreen())
895
896	if _, err := p.Run(); err != nil {
897		fmt.Printf("Alas, there's been an error: %v", err)
898		os.Exit(1)
899	}
900}