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