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}