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