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}