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