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 := false
 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		m.current = tui.NewChoice(true)
100		return m, m.current.Init()
101
102	case tui.RestoreDraftMsg:
103		if m.cachedComposer != nil {
104			m.current = m.cachedComposer
105			m.cachedComposer.ResetConfirmation()
106			m.cachedComposer = nil
107			return m, m.current.Init()
108		}
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(m.cachedComposer != nil)
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		m.current = tui.NewStatus("Fetching emails from all accounts...")
160		return m, tea.Batch(m.current.Init(), fetchAllAccountsEmails(m.config))
161
162	case tui.AllEmailsFetchedMsg:
163		m.emailsByAcct = msg.EmailsByAccount
164
165		// Flatten all emails
166		var allEmails []fetcher.Email
167		for _, emails := range msg.EmailsByAccount {
168			allEmails = append(allEmails, emails...)
169		}
170
171		// Sort by date (newest first)
172		for i := 0; i < len(allEmails); i++ {
173			for j := i + 1; j < len(allEmails); j++ {
174				if allEmails[j].Date.After(allEmails[i].Date) {
175					allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
176				}
177			}
178		}
179
180		m.emails = allEmails
181		m.inbox = tui.NewInbox(m.emails, m.config.Accounts)
182		m.current = m.inbox
183		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
184		return m, m.current.Init()
185
186	case tui.EmailsFetchedMsg:
187		// Single account fetch result
188		if m.emailsByAcct == nil {
189			m.emailsByAcct = make(map[string][]fetcher.Email)
190		}
191		m.emailsByAcct[msg.AccountID] = msg.Emails
192
193		// Rebuild all emails
194		var allEmails []fetcher.Email
195		for _, emails := range m.emailsByAcct {
196			allEmails = append(allEmails, emails...)
197		}
198
199		// Sort by date
200		for i := 0; i < len(allEmails); i++ {
201			for j := i + 1; j < len(allEmails); j++ {
202				if allEmails[j].Date.After(allEmails[i].Date) {
203					allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
204				}
205			}
206		}
207
208		m.emails = allEmails
209		if m.inbox == nil {
210			m.inbox = tui.NewInbox(m.emails, m.config.Accounts)
211		} else {
212			m.inbox.SetEmails(m.emails, m.config.Accounts)
213		}
214		m.current = m.inbox
215		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
216		return m, m.current.Init()
217
218	case tui.FetchMoreEmailsMsg:
219		if msg.AccountID == "" {
220			return m, nil // Don't fetch more for "ALL" view
221		}
222		account := m.config.GetAccountByID(msg.AccountID)
223		if account == nil {
224			return m, nil
225		}
226		return m, tea.Batch(
227			func() tea.Msg { return tui.FetchingMoreEmailsMsg{} },
228			fetchEmails(account, paginationLimit, msg.Offset),
229		)
230
231	case tui.EmailsAppendedMsg:
232		// Add new emails to the appropriate account
233		if m.emailsByAcct == nil {
234			m.emailsByAcct = make(map[string][]fetcher.Email)
235		}
236		m.emailsByAcct[msg.AccountID] = append(m.emailsByAcct[msg.AccountID], msg.Emails...)
237		m.emails = append(m.emails, msg.Emails...)
238		return m, nil
239
240	case tui.GoToSendMsg:
241		m.cachedComposer = nil
242		if m.config != nil && len(m.config.Accounts) > 0 {
243			firstAccount := m.config.GetFirstAccount()
244			composer := tui.NewComposerWithAccounts(m.config.Accounts, firstAccount.ID, msg.To, msg.Subject, msg.Body)
245			m.current = composer
246		} else {
247			m.current = tui.NewComposer("", msg.To, msg.Subject, msg.Body)
248		}
249		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
250		return m, m.current.Init()
251
252	case tui.GoToSettingsMsg:
253		if m.config != nil {
254			m.current = tui.NewSettings(m.config.Accounts)
255		} else {
256			m.current = tui.NewSettings(nil)
257		}
258		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
259		return m, m.current.Init()
260
261	case tui.GoToAddAccountMsg:
262		m.current = tui.NewLogin()
263		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
264		return m, m.current.Init()
265
266	case tui.GoToChoiceMenuMsg:
267		m.current = tui.NewChoice(m.cachedComposer != nil)
268		return m, m.current.Init()
269
270	case tui.DeleteAccountMsg:
271		if m.config != nil {
272			m.config.RemoveAccount(msg.AccountID)
273			if err := config.SaveConfig(m.config); err != nil {
274				log.Printf("could not save config: %v", err)
275			}
276			// Remove emails for this account
277			delete(m.emailsByAcct, msg.AccountID)
278
279			// Rebuild all emails
280			var allEmails []fetcher.Email
281			for _, emails := range m.emailsByAcct {
282				allEmails = append(allEmails, emails...)
283			}
284			m.emails = allEmails
285
286			// Go back to settings
287			m.current = tui.NewSettings(m.config.Accounts)
288			m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
289		}
290		return m, m.current.Init()
291
292	case tui.ViewEmailMsg:
293		email := m.getEmailByUIDAndAccount(msg.UID, msg.AccountID)
294		if email == nil {
295			return m, nil
296		}
297		m.current = tui.NewStatus("Fetching email content...")
298		return m, tea.Batch(m.current.Init(), fetchEmailBodyCmd(m.config, *email, msg.UID, msg.AccountID))
299
300	case tui.EmailBodyFetchedMsg:
301		if msg.Err != nil {
302			log.Printf("could not fetch email body: %v", msg.Err)
303			m.current = m.inbox
304			return m, nil
305		}
306
307		// Update the email in our stores
308		m.updateEmailBodyByUID(msg.UID, msg.AccountID, msg.Body, msg.Attachments)
309
310		email := m.getEmailByUIDAndAccount(msg.UID, msg.AccountID)
311		if email == nil {
312			m.current = m.inbox
313			return m, nil
314		}
315
316		// Find the index for the email view (used for display purposes)
317		emailIndex := m.getEmailIndex(msg.UID, msg.AccountID)
318		emailView := tui.NewEmailView(*email, emailIndex, m.width, m.height)
319		m.current = emailView
320		return m, m.current.Init()
321
322	case tui.ReplyToEmailMsg:
323		to := msg.Email.From
324		subject := "Re: " + msg.Email.Subject
325		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> "))
326
327		if m.config != nil && len(m.config.Accounts) > 0 {
328			// Use the account that received the email
329			accountID := msg.Email.AccountID
330			if accountID == "" {
331				accountID = m.config.GetFirstAccount().ID
332			}
333			composer := tui.NewComposerWithAccounts(m.config.Accounts, accountID, to, subject, body)
334			m.current = composer
335		} else {
336			m.current = tui.NewComposer("", to, subject, body)
337		}
338		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
339		return m, m.current.Init()
340
341	case tui.GoToFilePickerMsg:
342		m.previousModel = m.current
343		wd, _ := os.Getwd()
344		m.current = tui.NewFilePicker(wd)
345		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
346		return m, m.current.Init()
347
348	case tui.FileSelectedMsg, tui.CancelFilePickerMsg:
349		if m.previousModel != nil {
350			m.current = m.previousModel
351			m.previousModel = nil
352		}
353		m.current, cmd = m.current.Update(msg)
354		cmds = append(cmds, cmd)
355
356	case tui.SendEmailMsg:
357		m.cachedComposer = nil
358		m.current = tui.NewStatus("Sending email...")
359
360		// Get the account to send from
361		var account *config.Account
362		if msg.AccountID != "" && m.config != nil {
363			account = m.config.GetAccountByID(msg.AccountID)
364		}
365		if account == nil && m.config != nil {
366			account = m.config.GetFirstAccount()
367		}
368
369		return m, tea.Batch(m.current.Init(), sendEmail(account, msg))
370
371	case tui.EmailResultMsg:
372		m.current = tui.NewChoice(m.cachedComposer != nil)
373		return m, m.current.Init()
374
375	case tui.DeleteEmailMsg:
376		m.previousModel = m.current
377		m.current = tui.NewStatus("Deleting email...")
378
379		account := m.config.GetAccountByID(msg.AccountID)
380		if account == nil {
381			m.current = m.inbox
382			return m, nil
383		}
384
385		return m, tea.Batch(m.current.Init(), deleteEmailCmd(account, msg.UID, msg.AccountID))
386
387	case tui.ArchiveEmailMsg:
388		m.previousModel = m.current
389		m.current = tui.NewStatus("Archiving email...")
390
391		account := m.config.GetAccountByID(msg.AccountID)
392		if account == nil {
393			m.current = m.inbox
394			return m, nil
395		}
396
397		return m, tea.Batch(m.current.Init(), archiveEmailCmd(account, msg.UID, msg.AccountID))
398
399	case tui.EmailActionDoneMsg:
400		if msg.Err != nil {
401			log.Printf("Action failed: %v", msg.Err)
402			m.current = m.inbox
403			return m, nil
404		}
405
406		// Remove email from stores
407		m.removeEmail(msg.UID, msg.AccountID)
408
409		if m.inbox != nil {
410			m.inbox.RemoveEmail(msg.UID, msg.AccountID)
411		}
412		m.current = m.inbox
413		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
414		return m, m.current.Init()
415
416	case tui.DownloadAttachmentMsg:
417		m.previousModel = m.current
418		m.current = tui.NewStatus(fmt.Sprintf("Downloading %s...", msg.Filename))
419
420		account := m.config.GetAccountByID(msg.AccountID)
421		if account == nil {
422			m.current = m.previousModel
423			return m, nil
424		}
425
426		email := m.getEmailByIndex(msg.Index)
427		if email == nil {
428			m.current = m.previousModel
429			return m, nil
430		}
431
432		return m, tea.Batch(m.current.Init(), downloadAttachmentCmd(account, email.UID, msg))
433
434	case tui.AttachmentDownloadedMsg:
435		var statusMsg string
436		if msg.Err != nil {
437			statusMsg = fmt.Sprintf("Error downloading: %v", msg.Err)
438		} else {
439			statusMsg = fmt.Sprintf("Saved to %s", msg.Path)
440		}
441		m.current = tui.NewStatus(statusMsg)
442		return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
443			return tui.RestoreViewMsg{}
444		})
445
446	case tui.RestoreViewMsg:
447		if m.previousModel != nil {
448			m.current = m.previousModel
449			m.previousModel = nil
450		}
451		return m, nil
452	}
453
454	return m, tea.Batch(cmds...)
455}
456
457func (m *mainModel) getEmailByIndex(index int) *fetcher.Email {
458	if index >= 0 && index < len(m.emails) {
459		return &m.emails[index]
460	}
461	return nil
462}
463
464func (m *mainModel) getEmailByUIDAndAccount(uid uint32, accountID string) *fetcher.Email {
465	for i := range m.emails {
466		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
467			return &m.emails[i]
468		}
469	}
470	return nil
471}
472
473func (m *mainModel) getEmailIndex(uid uint32, accountID string) int {
474	for i := range m.emails {
475		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
476			return i
477		}
478	}
479	return -1
480}
481
482func (m *mainModel) updateEmailBodyByUID(uid uint32, accountID string, body string, attachments []fetcher.Attachment) {
483	// Update in all emails list
484	for i := range m.emails {
485		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
486			m.emails[i].Body = body
487			m.emails[i].Attachments = attachments
488			break
489		}
490	}
491
492	// Also update in account-specific store
493	if emails, ok := m.emailsByAcct[accountID]; ok {
494		for i := range emails {
495			if emails[i].UID == uid {
496				emails[i].Body = body
497				emails[i].Attachments = attachments
498				break
499			}
500		}
501	}
502}
503
504func (m *mainModel) removeEmail(uid uint32, accountID string) {
505	// Remove from all emails
506	var filtered []fetcher.Email
507	for _, e := range m.emails {
508		if !(e.UID == uid && e.AccountID == accountID) {
509			filtered = append(filtered, e)
510		}
511	}
512	m.emails = filtered
513
514	// Remove from account-specific store
515	if emails, ok := m.emailsByAcct[accountID]; ok {
516		var filteredAcct []fetcher.Email
517		for _, e := range emails {
518			if e.UID != uid {
519				filteredAcct = append(filteredAcct, e)
520			}
521		}
522		m.emailsByAcct[accountID] = filteredAcct
523	}
524}
525
526func (m *mainModel) View() string {
527	return m.current.View()
528}
529
530func fetchAllAccountsEmails(cfg *config.Config) tea.Cmd {
531	return func() tea.Msg {
532		emailsByAccount := make(map[string][]fetcher.Email)
533		var mu sync.Mutex
534		var wg sync.WaitGroup
535
536		for _, account := range cfg.Accounts {
537			wg.Add(1)
538			go func(acc config.Account) {
539				defer wg.Done()
540				emails, err := fetcher.FetchEmails(&acc, initialEmailLimit, 0)
541				if err != nil {
542					log.Printf("Error fetching from %s: %v", acc.Email, err)
543					return
544				}
545				mu.Lock()
546				emailsByAccount[acc.ID] = emails
547				mu.Unlock()
548			}(account)
549		}
550
551		wg.Wait()
552		return tui.AllEmailsFetchedMsg{EmailsByAccount: emailsByAccount}
553	}
554}
555
556func fetchEmails(account *config.Account, limit, offset uint32) tea.Cmd {
557	return func() tea.Msg {
558		emails, err := fetcher.FetchEmails(account, limit, offset)
559		if err != nil {
560			return tui.FetchErr(err)
561		}
562		if offset == 0 {
563			return tui.EmailsFetchedMsg{Emails: emails, AccountID: account.ID}
564		}
565		return tui.EmailsAppendedMsg{Emails: emails, AccountID: account.ID}
566	}
567}
568
569func fetchEmailBodyCmd(cfg *config.Config, email fetcher.Email, uid uint32, accountID string) tea.Cmd {
570	return func() tea.Msg {
571		account := cfg.GetAccountByID(accountID)
572		if account == nil {
573			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Err: fmt.Errorf("account not found")}
574		}
575
576		body, attachments, err := fetcher.FetchEmailBody(account, uid)
577		if err != nil {
578			return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Err: err}
579		}
580
581		return tui.EmailBodyFetchedMsg{
582			UID:         uid,
583			Body:        body,
584			Attachments: attachments,
585			AccountID:   accountID,
586		}
587	}
588}
589
590func markdownToHTML(md []byte) []byte {
591	var buf bytes.Buffer
592	p := goldmark.New(goldmark.WithRendererOptions(html.WithUnsafe()))
593	if err := p.Convert(md, &buf); err != nil {
594		return md
595	}
596	return buf.Bytes()
597}
598
599func sendEmail(account *config.Account, msg tui.SendEmailMsg) tea.Cmd {
600	return func() tea.Msg {
601		if account == nil {
602			return tui.EmailResultMsg{Err: fmt.Errorf("no account configured")}
603		}
604
605		recipients := []string{msg.To}
606		body := msg.Body
607		images := make(map[string][]byte)
608		attachments := make(map[string][]byte)
609
610		re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
611		matches := re.FindAllStringSubmatch(body, -1)
612
613		for _, match := range matches {
614			imgPath := match[1]
615			imgData, err := os.ReadFile(imgPath)
616			if err != nil {
617				log.Printf("Could not read image file %s: %v", imgPath, err)
618				continue
619			}
620			cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "matcha")
621			images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
622			body = strings.Replace(body, imgPath, "cid:"+cid, 1)
623		}
624
625		htmlBody := markdownToHTML([]byte(body))
626
627		if msg.AttachmentPath != "" {
628			fileData, err := os.ReadFile(msg.AttachmentPath)
629			if err != nil {
630				log.Printf("Could not read attachment file %s: %v", msg.AttachmentPath, err)
631			} else {
632				_, filename := filepath.Split(msg.AttachmentPath)
633				attachments[filename] = fileData
634			}
635		}
636
637		err := sender.SendEmail(account, recipients, msg.Subject, msg.Body, string(htmlBody), images, attachments, msg.InReplyTo, msg.References)
638		if err != nil {
639			log.Printf("Failed to send email: %v", err)
640			return tui.EmailResultMsg{Err: err}
641		}
642		return tui.EmailResultMsg{}
643	}
644}
645
646func deleteEmailCmd(account *config.Account, uid uint32, accountID string) tea.Cmd {
647	return func() tea.Msg {
648		err := fetcher.DeleteEmail(account, uid)
649		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Err: err}
650	}
651}
652
653func archiveEmailCmd(account *config.Account, uid uint32, accountID string) tea.Cmd {
654	return func() tea.Msg {
655		err := fetcher.ArchiveEmail(account, uid)
656		return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Err: err}
657	}
658}
659
660func downloadAttachmentCmd(account *config.Account, uid uint32, msg tui.DownloadAttachmentMsg) tea.Cmd {
661	return func() tea.Msg {
662		data, err := fetcher.FetchAttachment(account, uid, msg.PartID)
663		if err != nil {
664			return tui.AttachmentDownloadedMsg{Err: err}
665		}
666
667		homeDir, err := os.UserHomeDir()
668		if err != nil {
669			return tui.AttachmentDownloadedMsg{Err: err}
670		}
671		downloadsPath := filepath.Join(homeDir, "Downloads")
672		if _, err := os.Stat(downloadsPath); os.IsNotExist(err) {
673			if mkErr := os.MkdirAll(downloadsPath, 0755); mkErr != nil {
674				return tui.AttachmentDownloadedMsg{Err: mkErr}
675			}
676		}
677		filePath := filepath.Join(downloadsPath, msg.Filename)
678		err = os.WriteFile(filePath, data, 0644)
679		return tui.AttachmentDownloadedMsg{Path: filePath, Err: err}
680	}
681}
682
683func main() {
684	cfg, err := config.LoadConfig()
685	var initialModel *mainModel
686	if err != nil {
687		initialModel = newInitialModel(nil)
688	} else {
689		initialModel = newInitialModel(cfg)
690	}
691
692	p := tea.NewProgram(initialModel, tea.WithAltScreen())
693
694	if _, err := p.Run(); err != nil {
695		fmt.Printf("Alas, there's been an error: %v", err)
696		os.Exit(1)
697	}
698}