1package main
2
3import (
4 "archive/tar"
5 "bytes"
6 "compress/gzip"
7 "encoding/base64"
8 "encoding/json"
9 "fmt"
10 "io"
11 "log"
12 "net/http"
13 "os"
14 "os/exec"
15 "path/filepath"
16 "regexp"
17 "runtime"
18 "strings"
19 "sync"
20 "time"
21
22 tea "github.com/charmbracelet/bubbletea"
23 "github.com/floatpane/matcha/config"
24 "github.com/floatpane/matcha/fetcher"
25 "github.com/floatpane/matcha/sender"
26 "github.com/floatpane/matcha/tui"
27 "github.com/google/uuid"
28 "github.com/yuin/goldmark"
29 "github.com/yuin/goldmark/renderer/html"
30)
31
32const (
33 initialEmailLimit = 20
34 paginationLimit = 20
35)
36
37// Version variables are injected by the build (GoReleaser ldflags).
38// They default to "dev" when not set by the build system.
39var (
40 version = "dev"
41 commit = ""
42 date = ""
43)
44
45// UpdateAvailableMsg is sent into the TUI when a newer release is detected.
46type UpdateAvailableMsg struct {
47 Latest string
48 Current string
49}
50
51// internal struct for parsing GitHub release JSON.
52type githubRelease struct {
53 TagName string `json:"tag_name"`
54 Assets []struct {
55 Name string `json:"name"`
56 BrowserDownloadURL string `json:"browser_download_url"`
57 } `json:"assets"`
58}
59
60type mainModel struct {
61 current tea.Model
62 previousModel tea.Model
63 config *config.Config
64 emails []fetcher.Email
65 emailsByAcct map[string][]fetcher.Email
66 inbox *tui.Inbox
67 width int
68 height int
69 err error
70}
71
72func newInitialModel(cfg *config.Config) *mainModel {
73 initialModel := &mainModel{
74 emailsByAcct: make(map[string][]fetcher.Email),
75 }
76
77 if cfg == nil || !cfg.HasAccounts() {
78 initialModel.current = tui.NewLogin()
79 } else {
80 initialModel.current = tui.NewChoice()
81 initialModel.config = cfg
82 }
83 return initialModel
84}
85
86func (m *mainModel) Init() tea.Cmd {
87 return tea.Batch(m.current.Init(), checkForUpdatesCmd())
88}
89
90func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
91 var cmd tea.Cmd
92 var cmds []tea.Cmd
93
94 m.current, cmd = m.current.Update(msg)
95 cmds = append(cmds, cmd)
96
97 switch msg := msg.(type) {
98 case tea.WindowSizeMsg:
99 m.width = msg.Width
100 m.height = msg.Height
101 return m, nil
102
103 case tea.KeyMsg:
104 if msg.String() == "ctrl+c" {
105 return m, tea.Quit
106 }
107 if msg.String() == "esc" {
108 switch m.current.(type) {
109 case *tui.FilePicker:
110 return m, func() tea.Msg { return tui.CancelFilePickerMsg{} }
111 case *tui.Inbox, *tui.Login:
112 m.current = tui.NewChoice()
113 return m, m.current.Init()
114 }
115 }
116
117 case tui.BackToInboxMsg:
118 if m.inbox != nil {
119 m.current = m.inbox
120 } else {
121 m.current = tui.NewChoice()
122 }
123 return m, nil
124
125 case tui.DiscardDraftMsg:
126 // Save draft to disk
127 if msg.ComposerState != nil {
128 draft := msg.ComposerState.ToDraft()
129 go func() {
130 if err := config.SaveDraft(draft); err != nil {
131 log.Printf("Error saving draft: %v", err)
132 }
133 }()
134 }
135 m.current = tui.NewChoice()
136 return m, m.current.Init()
137
138 case tui.Credentials:
139 // Add new account or update existing
140 account := config.Account{
141 ID: uuid.New().String(),
142 Name: msg.Name,
143 Email: msg.Email,
144 Password: msg.Password,
145 ServiceProvider: msg.Provider,
146 }
147
148 if msg.Provider == "custom" {
149 account.IMAPServer = msg.IMAPServer
150 account.IMAPPort = msg.IMAPPort
151 account.SMTPServer = msg.SMTPServer
152 account.SMTPPort = msg.SMTPPort
153 }
154
155 if m.config == nil {
156 m.config = &config.Config{}
157 }
158
159 // Check if we're editing an existing account
160 if login, ok := m.current.(*tui.Login); ok && login.IsEditMode() {
161 // Find and update the existing account
162 existingID := login.GetAccountID()
163 for i, acc := range m.config.Accounts {
164 if acc.ID == existingID {
165 account.ID = existingID
166 m.config.Accounts[i] = account
167 break
168 }
169 }
170 } else {
171 m.config.AddAccount(account)
172 }
173
174 if err := config.SaveConfig(m.config); err != nil {
175 log.Printf("could not save config: %v", err)
176 return m, tea.Quit
177 }
178
179 m.current = tui.NewChoice()
180 return m, m.current.Init()
181
182 case tui.GoToInboxMsg:
183 if m.config == nil || !m.config.HasAccounts() {
184 m.current = tui.NewLogin()
185 return m, m.current.Init()
186 }
187 // Try to load from cache first for instant display
188 if config.HasEmailCache() {
189 return m, loadCachedEmails()
190 }
191 // No cache, fetch normally
192 m.current = tui.NewStatus("Fetching emails from all accounts...")
193 return m, tea.Batch(m.current.Init(), fetchAllAccountsEmails(m.config))
194
195 case tui.CachedEmailsLoadedMsg:
196 if msg.Cache == nil {
197 // Cache load failed, fetch normally
198 m.current = tui.NewStatus("Fetching emails from all accounts...")
199 return m, tea.Batch(m.current.Init(), fetchAllAccountsEmails(m.config))
200 }
201
202 // Convert cached emails to fetcher.Email
203 var cachedEmails []fetcher.Email
204 emailsByAcct := make(map[string][]fetcher.Email)
205 for _, cached := range msg.Cache.Emails {
206 email := fetcher.Email{
207 UID: cached.UID,
208 From: cached.From,
209 To: cached.To,
210 Subject: cached.Subject,
211 Date: cached.Date,
212 MessageID: cached.MessageID,
213 AccountID: cached.AccountID,
214 }
215 cachedEmails = append(cachedEmails, email)
216 emailsByAcct[cached.AccountID] = append(emailsByAcct[cached.AccountID], email)
217 }
218
219 m.emails = cachedEmails
220 m.emailsByAcct = emailsByAcct
221 m.inbox = tui.NewInbox(m.emails, m.config.Accounts)
222 m.current = m.inbox
223 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
224
225 // Start background refresh
226 return m, tea.Batch(
227 m.current.Init(),
228 func() tea.Msg { return tui.RefreshingEmailsMsg{} },
229 refreshEmails(m.config),
230 )
231
232 case tui.EmailsRefreshedMsg:
233 m.emailsByAcct = msg.EmailsByAccount
234
235 // Flatten all emails
236 var allEmails []fetcher.Email
237 for _, emails := range msg.EmailsByAccount {
238 allEmails = append(allEmails, emails...)
239 }
240
241 // Sort by date (newest first)
242 for i := 0; i < len(allEmails); i++ {
243 for j := i + 1; j < len(allEmails); j++ {
244 if allEmails[j].Date.After(allEmails[i].Date) {
245 allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
246 }
247 }
248 }
249
250 m.emails = allEmails
251
252 // Save to cache
253 go saveEmailsToCache(m.emails)
254
255 // Update inbox if it exists
256 if m.inbox != nil {
257 m.inbox.SetEmails(m.emails, m.config.Accounts)
258 // Forward the message to inbox to clear refreshing state
259 m.current, _ = m.current.Update(msg)
260 }
261 return m, nil
262
263 case tui.AllEmailsFetchedMsg:
264 m.emailsByAcct = msg.EmailsByAccount
265
266 // Flatten all emails
267 var allEmails []fetcher.Email
268 for _, emails := range msg.EmailsByAccount {
269 allEmails = append(allEmails, emails...)
270 }
271
272 // Sort by date (newest first)
273 for i := 0; i < len(allEmails); i++ {
274 for j := i + 1; j < len(allEmails); j++ {
275 if allEmails[j].Date.After(allEmails[i].Date) {
276 allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
277 }
278 }
279 }
280
281 m.emails = allEmails
282
283 // Save to cache
284 go saveEmailsToCache(m.emails)
285
286 m.inbox = tui.NewInbox(m.emails, m.config.Accounts)
287 m.current = m.inbox
288 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
289 return m, m.current.Init()
290
291 case tui.EmailsFetchedMsg:
292 // Single account fetch result
293 if m.emailsByAcct == nil {
294 m.emailsByAcct = make(map[string][]fetcher.Email)
295 }
296 m.emailsByAcct[msg.AccountID] = msg.Emails
297
298 // Rebuild all emails
299 var allEmails []fetcher.Email
300 for _, emails := range m.emailsByAcct {
301 allEmails = append(allEmails, emails...)
302 }
303
304 // Sort by date
305 for i := 0; i < len(allEmails); i++ {
306 for j := i + 1; j < len(allEmails); j++ {
307 if allEmails[j].Date.After(allEmails[i].Date) {
308 allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
309 }
310 }
311 }
312
313 m.emails = allEmails
314 if m.inbox == nil {
315 m.inbox = tui.NewInbox(m.emails, m.config.Accounts)
316 } else {
317 m.inbox.SetEmails(m.emails, m.config.Accounts)
318 }
319 m.current = m.inbox
320 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
321 return m, m.current.Init()
322
323 case tui.FetchMoreEmailsMsg:
324 if msg.AccountID == "" {
325 return m, nil // Don't fetch more for "ALL" view
326 }
327 account := m.config.GetAccountByID(msg.AccountID)
328 if account == nil {
329 return m, nil
330 }
331 return m, tea.Batch(
332 func() tea.Msg { return tui.FetchingMoreEmailsMsg{} },
333 fetchEmails(account, paginationLimit, msg.Offset),
334 )
335
336 case tui.EmailsAppendedMsg:
337 // Add new emails to the appropriate account
338 if m.emailsByAcct == nil {
339 m.emailsByAcct = make(map[string][]fetcher.Email)
340 }
341 m.emailsByAcct[msg.AccountID] = append(m.emailsByAcct[msg.AccountID], msg.Emails...)
342 m.emails = append(m.emails, msg.Emails...)
343 return m, nil
344
345 case tui.GoToSendMsg:
346 if m.config != nil && len(m.config.Accounts) > 0 {
347 firstAccount := m.config.GetFirstAccount()
348 composer := tui.NewComposerWithAccounts(m.config.Accounts, firstAccount.ID, msg.To, msg.Subject, msg.Body)
349 m.current = composer
350 } else {
351 m.current = tui.NewComposer("", msg.To, msg.Subject, msg.Body)
352 }
353 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
354 return m, m.current.Init()
355
356 case tui.GoToDraftsMsg:
357 drafts := config.GetAllDrafts()
358 m.current = tui.NewDrafts(drafts)
359 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
360 return m, m.current.Init()
361
362 case tui.OpenDraftMsg:
363 var accounts []config.Account
364 if m.config != nil {
365 accounts = m.config.Accounts
366 }
367 composer := tui.NewComposerFromDraft(msg.Draft, accounts)
368 m.current = composer
369 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
370 return m, m.current.Init()
371
372 case tui.DeleteSavedDraftMsg:
373 go func() {
374 if err := config.DeleteDraft(msg.DraftID); err != nil {
375 log.Printf("Error deleting draft: %v", err)
376 }
377 }()
378 // Send message back to drafts view
379 m.current, cmd = m.current.Update(tui.DraftDeletedMsg{DraftID: msg.DraftID})
380 return m, cmd
381
382 case tui.GoToSettingsMsg:
383 if m.config != nil {
384 m.current = tui.NewSettings(m.config.Accounts)
385 } else {
386 m.current = tui.NewSettings(nil)
387 }
388 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
389 return m, m.current.Init()
390
391 case tui.GoToAddAccountMsg:
392 m.current = tui.NewLogin()
393 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
394 return m, m.current.Init()
395
396 case tui.GoToChoiceMenuMsg:
397 m.current = tui.NewChoice()
398 return m, m.current.Init()
399
400 case tui.DeleteAccountMsg:
401 if m.config != nil {
402 m.config.RemoveAccount(msg.AccountID)
403 if err := config.SaveConfig(m.config); err != nil {
404 log.Printf("could not save config: %v", err)
405 }
406 // Remove emails for this account
407 delete(m.emailsByAcct, msg.AccountID)
408
409 // Rebuild all emails
410 var allEmails []fetcher.Email
411 for _, emails := range m.emailsByAcct {
412 allEmails = append(allEmails, emails...)
413 }
414 m.emails = allEmails
415
416 // Go back to settings
417 m.current = tui.NewSettings(m.config.Accounts)
418 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
419 }
420 return m, m.current.Init()
421
422 case tui.ViewEmailMsg:
423 email := m.getEmailByUIDAndAccount(msg.UID, msg.AccountID)
424 if email == nil {
425 return m, nil
426 }
427 m.current = tui.NewStatus("Fetching email content...")
428 return m, tea.Batch(m.current.Init(), fetchEmailBodyCmd(m.config, *email, msg.UID, msg.AccountID))
429
430 case tui.EmailBodyFetchedMsg:
431 if msg.Err != nil {
432 log.Printf("could not fetch email body: %v", msg.Err)
433 m.current = m.inbox
434 return m, nil
435 }
436
437 // Update the email in our stores
438 m.updateEmailBodyByUID(msg.UID, msg.AccountID, msg.Body, msg.Attachments)
439
440 email := m.getEmailByUIDAndAccount(msg.UID, msg.AccountID)
441 if email == nil {
442 m.current = m.inbox
443 return m, nil
444 }
445
446 // Find the index for the email view (used for display purposes)
447 emailIndex := m.getEmailIndex(msg.UID, msg.AccountID)
448 emailView := tui.NewEmailView(*email, emailIndex, m.width, m.height)
449 m.current = emailView
450 return m, m.current.Init()
451
452 case tui.ReplyToEmailMsg:
453 to := msg.Email.From
454 subject := "Re: " + msg.Email.Subject
455 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> "))
456
457 if m.config != nil && len(m.config.Accounts) > 0 {
458 // Use the account that received the email
459 accountID := msg.Email.AccountID
460 if accountID == "" {
461 accountID = m.config.GetFirstAccount().ID
462 }
463 composer := tui.NewComposerWithAccounts(m.config.Accounts, accountID, to, subject, body)
464 m.current = composer
465 } else {
466 m.current = tui.NewComposer("", to, subject, body)
467 }
468 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
469 return m, m.current.Init()
470
471 case tui.GoToFilePickerMsg:
472 m.previousModel = m.current
473 wd, _ := os.Getwd()
474 m.current = tui.NewFilePicker(wd)
475 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
476 return m, m.current.Init()
477
478 case tui.FileSelectedMsg, tui.CancelFilePickerMsg:
479 if m.previousModel != nil {
480 m.current = m.previousModel
481 m.previousModel = nil
482 }
483 m.current, cmd = m.current.Update(msg)
484 cmds = append(cmds, cmd)
485
486 case tui.SendEmailMsg:
487 // Get draft ID before clearing composer (if it's a composer)
488 var draftID string
489 if composer, ok := m.current.(*tui.Composer); ok {
490 draftID = composer.GetDraftID()
491 }
492 m.current = tui.NewStatus("Sending email...")
493
494 // Get the account to send from
495 var account *config.Account
496 if msg.AccountID != "" && m.config != nil {
497 account = m.config.GetAccountByID(msg.AccountID)
498 }
499 if account == nil && m.config != nil {
500 account = m.config.GetFirstAccount()
501 }
502
503 // Save contact and delete draft in background
504 go func() {
505 // Save the recipient as a contact
506 if msg.To != "" {
507 // Parse "Name <email>" format
508 name, email := parseEmailAddress(msg.To)
509 if err := config.AddContact(name, email); err != nil {
510 log.Printf("Error saving contact: %v", err)
511 }
512 }
513 // Delete the draft since email is being sent
514 if draftID != "" {
515 if err := config.DeleteDraft(draftID); err != nil {
516 log.Printf("Error deleting draft after send: %v", err)
517 }
518 }
519 }()
520
521 return m, tea.Batch(m.current.Init(), sendEmail(account, msg))
522
523 case tui.EmailResultMsg:
524 m.current = tui.NewChoice()
525 return m, m.current.Init()
526
527 case tui.DeleteEmailMsg:
528 m.previousModel = m.current
529 m.current = tui.NewStatus("Deleting email...")
530
531 account := m.config.GetAccountByID(msg.AccountID)
532 if account == nil {
533 m.current = m.inbox
534 return m, nil
535 }
536
537 return m, tea.Batch(m.current.Init(), deleteEmailCmd(account, msg.UID, msg.AccountID))
538
539 case tui.ArchiveEmailMsg:
540 m.previousModel = m.current
541 m.current = tui.NewStatus("Archiving email...")
542
543 account := m.config.GetAccountByID(msg.AccountID)
544 if account == nil {
545 m.current = m.inbox
546 return m, nil
547 }
548
549 return m, tea.Batch(m.current.Init(), archiveEmailCmd(account, msg.UID, msg.AccountID))
550
551 case tui.EmailActionDoneMsg:
552 if msg.Err != nil {
553 log.Printf("Action failed: %v", msg.Err)
554 m.current = m.inbox
555 return m, nil
556 }
557
558 // Remove email from stores
559 m.removeEmail(msg.UID, msg.AccountID)
560
561 if m.inbox != nil {
562 m.inbox.RemoveEmail(msg.UID, msg.AccountID)
563 }
564 m.current = m.inbox
565 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
566 return m, m.current.Init()
567
568 case tui.DownloadAttachmentMsg:
569 m.previousModel = m.current
570 m.current = tui.NewStatus(fmt.Sprintf("Downloading %s...", msg.Filename))
571
572 account := m.config.GetAccountByID(msg.AccountID)
573 if account == nil {
574 m.current = m.previousModel
575 return m, nil
576 }
577
578 email := m.getEmailByIndex(msg.Index)
579 if email == nil {
580 m.current = m.previousModel
581 return m, nil
582 }
583
584 // Find the correct attachment to get encoding
585 var encoding string
586 for _, att := range email.Attachments {
587 if att.PartID == msg.PartID {
588 encoding = att.Encoding
589 break
590 }
591 }
592 newMsg := tui.DownloadAttachmentMsg{
593 Index: msg.Index,
594 Filename: msg.Filename,
595 PartID: msg.PartID,
596 Data: msg.Data,
597 AccountID: msg.AccountID,
598 Encoding: encoding,
599 }
600 return m, tea.Batch(m.current.Init(), downloadAttachmentCmd(account, email.UID, newMsg))
601
602 case tui.AttachmentDownloadedMsg:
603 var statusMsg string
604 if msg.Err != nil {
605 statusMsg = fmt.Sprintf("Error downloading: %v", msg.Err)
606 } else {
607 statusMsg = fmt.Sprintf("Saved to %s", msg.Path)
608 }
609 m.current = tui.NewStatus(statusMsg)
610 return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
611 return tui.RestoreViewMsg{}
612 })
613
614 case tui.RestoreViewMsg:
615 if m.previousModel != nil {
616 m.current = m.previousModel
617 m.previousModel = nil
618 }
619 return m, nil
620 }
621
622 return m, tea.Batch(cmds...)
623}
624
625func (m *mainModel) getEmailByIndex(index int) *fetcher.Email {
626 if index >= 0 && index < len(m.emails) {
627 return &m.emails[index]
628 }
629 return nil
630}
631
632func (m *mainModel) getEmailByUIDAndAccount(uid uint32, accountID string) *fetcher.Email {
633 for i := range m.emails {
634 if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
635 return &m.emails[i]
636 }
637 }
638 return nil
639}
640
641func (m *mainModel) getEmailIndex(uid uint32, accountID string) int {
642 for i := range m.emails {
643 if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
644 return i
645 }
646 }
647 return -1
648}
649
650func (m *mainModel) updateEmailBodyByUID(uid uint32, accountID string, body string, attachments []fetcher.Attachment) {
651 // Update in all emails list
652 for i := range m.emails {
653 if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
654 m.emails[i].Body = body
655 m.emails[i].Attachments = attachments
656 break
657 }
658 }
659
660 // Also update in account-specific store
661 if emails, ok := m.emailsByAcct[accountID]; ok {
662 for i := range emails {
663 if emails[i].UID == uid {
664 emails[i].Body = body
665 emails[i].Attachments = attachments
666 break
667 }
668 }
669 }
670}
671
672func (m *mainModel) removeEmail(uid uint32, accountID string) {
673 // Remove from all emails
674 var filtered []fetcher.Email
675 for _, e := range m.emails {
676 if !(e.UID == uid && e.AccountID == accountID) {
677 filtered = append(filtered, e)
678 }
679 }
680 m.emails = filtered
681
682 // Remove from account-specific store
683 if emails, ok := m.emailsByAcct[accountID]; ok {
684 var filteredAcct []fetcher.Email
685 for _, e := range emails {
686 if e.UID != uid {
687 filteredAcct = append(filteredAcct, e)
688 }
689 }
690 m.emailsByAcct[accountID] = filteredAcct
691 }
692}
693
694func (m *mainModel) View() string {
695 return m.current.View()
696}
697
698func fetchAllAccountsEmails(cfg *config.Config) tea.Cmd {
699 return func() tea.Msg {
700 emailsByAccount := make(map[string][]fetcher.Email)
701 var mu sync.Mutex
702 var wg sync.WaitGroup
703
704 for _, account := range cfg.Accounts {
705 wg.Add(1)
706 go func(acc config.Account) {
707 defer wg.Done()
708 emails, err := fetcher.FetchEmails(&acc, initialEmailLimit, 0)
709 if err != nil {
710 log.Printf("Error fetching from %s: %v", acc.Email, err)
711 return
712 }
713 mu.Lock()
714 emailsByAccount[acc.ID] = emails
715 mu.Unlock()
716 }(account)
717 }
718
719 wg.Wait()
720 return tui.AllEmailsFetchedMsg{EmailsByAccount: emailsByAccount}
721 }
722}
723
724func fetchEmails(account *config.Account, limit, offset uint32) tea.Cmd {
725 return func() tea.Msg {
726 emails, err := fetcher.FetchEmails(account, limit, offset)
727 if err != nil {
728 return tui.FetchErr(err)
729 }
730 if offset == 0 {
731 return tui.EmailsFetchedMsg{Emails: emails, AccountID: account.ID}
732 }
733 return tui.EmailsAppendedMsg{Emails: emails, AccountID: account.ID}
734 }
735}
736
737func loadCachedEmails() tea.Cmd {
738 return func() tea.Msg {
739 cache, err := config.LoadEmailCache()
740 if err != nil {
741 return tui.CachedEmailsLoadedMsg{Cache: nil}
742 }
743 return tui.CachedEmailsLoadedMsg{Cache: cache}
744 }
745}
746
747func refreshEmails(cfg *config.Config) tea.Cmd {
748 return func() tea.Msg {
749 emailsByAccount := make(map[string][]fetcher.Email)
750 var mu sync.Mutex
751 var wg sync.WaitGroup
752
753 for _, account := range cfg.Accounts {
754 wg.Add(1)
755 go func(acc config.Account) {
756 defer wg.Done()
757 emails, err := fetcher.FetchEmails(&acc, initialEmailLimit, 0)
758 if err != nil {
759 log.Printf("Error fetching from %s: %v", acc.Email, err)
760 return
761 }
762 mu.Lock()
763 emailsByAccount[acc.ID] = emails
764 mu.Unlock()
765 }(account)
766 }
767
768 wg.Wait()
769 return tui.EmailsRefreshedMsg{EmailsByAccount: emailsByAccount}
770 }
771}
772
773func saveEmailsToCache(emails []fetcher.Email) {
774 var cachedEmails []config.CachedEmail
775 for _, email := range emails {
776 cachedEmails = append(cachedEmails, config.CachedEmail{
777 UID: email.UID,
778 From: email.From,
779 To: email.To,
780 Subject: email.Subject,
781 Date: email.Date,
782 MessageID: email.MessageID,
783 AccountID: email.AccountID,
784 })
785
786 // Save sender as a contact
787 if email.From != "" {
788 name, emailAddr := parseEmailAddress(email.From)
789 if err := config.AddContact(name, emailAddr); err != nil {
790 log.Printf("Error saving contact from email: %v", err)
791 }
792 }
793 }
794 cache := &config.EmailCache{Emails: cachedEmails}
795 if err := config.SaveEmailCache(cache); err != nil {
796 log.Printf("Error saving email cache: %v", err)
797 }
798}
799
800// parseEmailAddress parses "Name <email>" or just "email" format
801func parseEmailAddress(addr string) (name, email string) {
802 addr = strings.TrimSpace(addr)
803 if idx := strings.Index(addr, "<"); idx != -1 {
804 name = strings.TrimSpace(addr[:idx])
805 endIdx := strings.Index(addr, ">")
806 if endIdx > idx {
807 email = strings.TrimSpace(addr[idx+1 : endIdx])
808 } else {
809 email = strings.TrimSpace(addr[idx+1:])
810 }
811 } else {
812 email = addr
813 }
814 return name, email
815}
816
817func fetchEmailBodyCmd(cfg *config.Config, email fetcher.Email, uid uint32, accountID string) tea.Cmd {
818 return func() tea.Msg {
819 account := cfg.GetAccountByID(accountID)
820 if account == nil {
821 return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Err: fmt.Errorf("account not found")}
822 }
823
824 body, attachments, err := fetcher.FetchEmailBody(account, uid)
825 if err != nil {
826 return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Err: err}
827 }
828
829 return tui.EmailBodyFetchedMsg{
830 UID: uid,
831 Body: body,
832 Attachments: attachments,
833 AccountID: accountID,
834 }
835 }
836}
837
838func markdownToHTML(md []byte) []byte {
839 var buf bytes.Buffer
840 p := goldmark.New(goldmark.WithRendererOptions(html.WithUnsafe()))
841 if err := p.Convert(md, &buf); err != nil {
842 return md
843 }
844 return buf.Bytes()
845}
846
847func sendEmail(account *config.Account, msg tui.SendEmailMsg) tea.Cmd {
848 return func() tea.Msg {
849 if account == nil {
850 return tui.EmailResultMsg{Err: fmt.Errorf("no account configured")}
851 }
852
853 recipients := []string{msg.To}
854 body := msg.Body
855 images := make(map[string][]byte)
856 attachments := make(map[string][]byte)
857
858 re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
859 matches := re.FindAllStringSubmatch(body, -1)
860
861 for _, match := range matches {
862 imgPath := match[1]
863 imgData, err := os.ReadFile(imgPath)
864 if err != nil {
865 log.Printf("Could not read image file %s: %v", imgPath, err)
866 continue
867 }
868 cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "matcha")
869 images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
870 body = strings.Replace(body, imgPath, "cid:"+cid, 1)
871 }
872
873 htmlBody := markdownToHTML([]byte(body))
874
875 if msg.AttachmentPath != "" {
876 fileData, err := os.ReadFile(msg.AttachmentPath)
877 if err != nil {
878 log.Printf("Could not read attachment file %s: %v", msg.AttachmentPath, err)
879 } else {
880 _, filename := filepath.Split(msg.AttachmentPath)
881 attachments[filename] = fileData
882 }
883 }
884
885 err := sender.SendEmail(account, recipients, msg.Subject, msg.Body, string(htmlBody), images, attachments, msg.InReplyTo, msg.References)
886 if err != nil {
887 log.Printf("Failed to send email: %v", err)
888 return tui.EmailResultMsg{Err: err}
889 }
890 return tui.EmailResultMsg{}
891 }
892}
893
894func deleteEmailCmd(account *config.Account, uid uint32, accountID string) tea.Cmd {
895 return func() tea.Msg {
896 err := fetcher.DeleteEmail(account, uid)
897 return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Err: err}
898 }
899}
900
901func archiveEmailCmd(account *config.Account, uid uint32, accountID string) tea.Cmd {
902 return func() tea.Msg {
903 err := fetcher.ArchiveEmail(account, uid)
904 return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Err: err}
905 }
906}
907
908func downloadAttachmentCmd(account *config.Account, uid uint32, msg tui.DownloadAttachmentMsg) tea.Cmd {
909 return func() tea.Msg {
910 // Download and decode the attachment using encoding provided in msg.Encoding.
911 data, err := fetcher.FetchAttachment(account, uid, msg.PartID, msg.Encoding)
912 if err != nil {
913 return tui.AttachmentDownloadedMsg{Err: err}
914 }
915
916 homeDir, err := os.UserHomeDir()
917 if err != nil {
918 return tui.AttachmentDownloadedMsg{Err: err}
919 }
920 downloadsPath := filepath.Join(homeDir, "Downloads")
921 if _, err := os.Stat(downloadsPath); os.IsNotExist(err) {
922 if mkErr := os.MkdirAll(downloadsPath, 0755); mkErr != nil {
923 return tui.AttachmentDownloadedMsg{Err: mkErr}
924 }
925 }
926
927 // Save the attachment using an exclusive create so we never overwrite an existing file.
928 // If the filename already exists, append \" (n)\" before the extension.
929 origName := msg.Filename
930 ext := filepath.Ext(origName)
931 base := strings.TrimSuffix(origName, ext)
932 candidate := origName
933 i := 1
934 var filePath string
935
936 for {
937 filePath = filepath.Join(downloadsPath, candidate)
938
939 // Try to create file exclusively. If it already exists, os.OpenFile will return an error
940 // that satisfies os.IsExist(err), so we can increment the candidate.
941 f, err := os.OpenFile(filePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
942 if err != nil {
943 if os.IsExist(err) {
944 // file exists, try next candidate
945 candidate = fmt.Sprintf("%s (%d)%s", base, i, ext)
946 i++
947 continue
948 }
949 // Some other error while attempting to create file
950 log.Printf("error creating file %s: %v", filePath, err)
951 return tui.AttachmentDownloadedMsg{Err: err}
952 }
953
954 // Successfully created the file descriptor; write and close.
955 if _, writeErr := f.Write(data); writeErr != nil {
956 _ = f.Close()
957 log.Printf("error writing to file %s: %v", filePath, writeErr)
958 return tui.AttachmentDownloadedMsg{Err: writeErr}
959 }
960 if closeErr := f.Close(); closeErr != nil {
961 log.Printf("warning: error closing file %s: %v", filePath, closeErr)
962 }
963
964 // file saved successfully
965 break
966 }
967
968 log.Printf("attachment saved to %s", filePath)
969
970 // Try to open the file using a platform-specific opener asynchronously and log the outcome.
971 go func(p string) {
972 var cmd *exec.Cmd
973 switch runtime.GOOS {
974 case "darwin":
975 cmd = exec.Command("open", p)
976 case "linux":
977 cmd = exec.Command("xdg-open", p)
978 case "windows":
979 // 'start' is a cmd builtin; provide an empty title argument to avoid interpreting the path as the title.
980 cmd = exec.Command("cmd", "/c", "start", "", p)
981 default:
982 // Unsupported OS: nothing to do.
983 return
984 }
985 if err := cmd.Start(); err != nil {
986 log.Printf("failed to open file %s: %v", p, err)
987 }
988 }(filePath)
989
990 return tui.AttachmentDownloadedMsg{Path: filePath, Err: nil}
991 }
992}
993
994/*
995detectInstalledVersion returns a best-effort installed version string.
996Priority:
997 1. If the build-in `version` variable is set to something other than "dev", return it.
998 2. If Homebrew is present and reports a version for `matcha`, return that.
999 3. If snap is present and lists `matcha`, return that.
1000 4. Fallback to the build `version` (likely "dev").
1001*/
1002func detectInstalledVersion() string {
1003 v := strings.TrimSpace(version)
1004 if v != "dev" && v != "" {
1005 return v
1006 }
1007
1008 // Try Homebrew (macOS)
1009 if runtime.GOOS == "darwin" {
1010 if _, err := exec.LookPath("brew"); err == nil {
1011 // `brew list --versions matcha` prints: matcha 1.2.3
1012 if out, err := exec.Command("brew", "list", "--versions", "matcha").Output(); err == nil {
1013 parts := strings.Fields(string(out))
1014 if len(parts) >= 2 {
1015 return parts[1]
1016 }
1017 }
1018 }
1019 }
1020
1021 // Try snap (Linux)
1022 if runtime.GOOS == "linux" {
1023 if _, err := exec.LookPath("snap"); err == nil {
1024 if out, err := exec.Command("snap", "list", "matcha").Output(); err == nil {
1025 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
1026 if len(lines) >= 2 {
1027 fields := strings.Fields(lines[1])
1028 if len(fields) >= 2 {
1029 return fields[1]
1030 }
1031 }
1032 }
1033 }
1034 }
1035
1036 return v
1037}
1038
1039/*
1040checkForUpdatesCmd queries GitHub for the latest release tag and returns a
1041tea.Msg (UpdateAvailableMsg) if the latest version differs from the current
1042installed version. This runs in the background when the TUI initializes.
1043*/
1044func checkForUpdatesCmd() tea.Cmd {
1045 return func() tea.Msg {
1046 // Non-fatal: if anything goes wrong we just don't show the update message.
1047 const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
1048 resp, err := http.Get(api)
1049 if err != nil {
1050 return nil
1051 }
1052 defer resp.Body.Close()
1053
1054 var rel githubRelease
1055 if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
1056 return nil
1057 }
1058
1059 latest := strings.TrimPrefix(rel.TagName, "v")
1060 installed := strings.TrimPrefix(detectInstalledVersion(), "v")
1061 if latest != "" && installed != "" && latest != installed {
1062 return UpdateAvailableMsg{Latest: latest, Current: installed}
1063 }
1064 return nil
1065 }
1066}
1067
1068// runUpdateCLI implements the CLI entrypoint for `matcha update`.
1069// It detects the likely installation method and attempts the appropriate
1070// update path (Homebrew, Snap, or GitHub release binary extract).
1071func runUpdateCLI() error {
1072 const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
1073 resp, err := http.Get(api)
1074 if err != nil {
1075 return fmt.Errorf("could not query releases: %w", err)
1076 }
1077 defer resp.Body.Close()
1078
1079 var rel githubRelease
1080 if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
1081 return fmt.Errorf("could not parse release info: %w", err)
1082 }
1083
1084 latestTag := rel.TagName
1085 if strings.HasPrefix(latestTag, "v") {
1086 latestTag = latestTag[1:]
1087 }
1088
1089 fmt.Printf("Current version: %s\n", version)
1090 fmt.Printf("Latest version: %s\n", latestTag)
1091
1092 // Quick check: if already up-to-date, exit
1093 cur := version
1094 if strings.HasPrefix(cur, "v") {
1095 cur = cur[1:]
1096 }
1097 if latestTag == "" || cur == latestTag {
1098 fmt.Println("Already up to date.")
1099 return nil
1100 }
1101
1102 // Detect Homebrew
1103 if _, err := exec.LookPath("brew"); err == nil {
1104 fmt.Println("Detected Homebrew — attempting to upgrade via brew.")
1105 cmd := exec.Command("brew", "upgrade", "matcha")
1106 cmd.Stdout = os.Stdout
1107 cmd.Stderr = os.Stderr
1108 if err := cmd.Run(); err == nil {
1109 fmt.Println("Successfully upgraded via Homebrew.")
1110 return nil
1111 }
1112 fmt.Printf("Homebrew upgrade failed: %v\n", err)
1113 // fallthrough to other methods
1114 }
1115
1116 // Detect snap
1117 if _, err := exec.LookPath("snap"); err == nil {
1118 // Check if matcha is installed as a snap
1119 cmdCheck := exec.Command("snap", "list", "matcha")
1120 if err := cmdCheck.Run(); err == nil {
1121 fmt.Println("Detected Snap package — attempting to refresh.")
1122 cmd := exec.Command("snap", "refresh", "matcha")
1123 cmd.Stdout = os.Stdout
1124 cmd.Stderr = os.Stderr
1125 if err := cmd.Run(); err == nil {
1126 fmt.Println("Successfully refreshed snap.")
1127 return nil
1128 }
1129 fmt.Printf("Snap refresh failed: %v\n", err)
1130 // fallthrough
1131 }
1132 }
1133
1134 // Otherwise attempt to download the proper release asset and replace the binary.
1135 osName := runtime.GOOS
1136 arch := runtime.GOARCH
1137
1138 // Try to find a matching asset
1139 var assetURL, assetName string
1140 for _, a := range rel.Assets {
1141 n := strings.ToLower(a.Name)
1142 if strings.Contains(n, osName) && strings.Contains(n, arch) && (strings.HasSuffix(n, ".tar.gz") || strings.HasSuffix(n, ".tgz") || strings.HasSuffix(n, ".zip")) {
1143 assetURL = a.BrowserDownloadURL
1144 assetName = a.Name
1145 break
1146 }
1147 }
1148 if assetURL == "" {
1149 // Try any asset that contains 'matcha' and os/arch as a fallback
1150 for _, a := range rel.Assets {
1151 n := strings.ToLower(a.Name)
1152 if strings.Contains(n, "matcha") && (strings.Contains(n, osName) || strings.Contains(n, arch)) {
1153 assetURL = a.BrowserDownloadURL
1154 assetName = a.Name
1155 break
1156 }
1157 }
1158 }
1159
1160 if assetURL == "" {
1161 return fmt.Errorf("no suitable release artifact found for %s/%s", osName, arch)
1162 }
1163
1164 fmt.Printf("Found release asset: %s\n", assetName)
1165 fmt.Println("Downloading...")
1166
1167 // Download asset
1168 respAsset, err := http.Get(assetURL)
1169 if err != nil {
1170 return fmt.Errorf("download failed: %w", err)
1171 }
1172 defer respAsset.Body.Close()
1173
1174 // Create a temp file for the download
1175 tmpDir, err := os.MkdirTemp("", "matcha-update-*")
1176 if err != nil {
1177 return fmt.Errorf("could not create temp dir: %w", err)
1178 }
1179 defer os.RemoveAll(tmpDir)
1180
1181 assetPath := filepath.Join(tmpDir, assetName)
1182 outFile, err := os.Create(assetPath)
1183 if err != nil {
1184 return fmt.Errorf("could not create temp file: %w", err)
1185 }
1186 _, err = io.Copy(outFile, respAsset.Body)
1187 outFile.Close()
1188 if err != nil {
1189 return fmt.Errorf("could not write asset to disk: %w", err)
1190 }
1191
1192 // If it's a tar.gz, extract and find the `matcha` binary
1193 var binPath string
1194 if strings.HasSuffix(assetName, ".tar.gz") || strings.HasSuffix(assetName, ".tgz") {
1195 f, err := os.Open(assetPath)
1196 if err != nil {
1197 return fmt.Errorf("could not open archive: %w", err)
1198 }
1199 defer f.Close()
1200 gzr, err := gzip.NewReader(f)
1201 if err != nil {
1202 return fmt.Errorf("could not create gzip reader: %w", err)
1203 }
1204 tr := tar.NewReader(gzr)
1205 for {
1206 hdr, err := tr.Next()
1207 if err == io.EOF {
1208 break
1209 }
1210 if err != nil {
1211 return fmt.Errorf("error reading tar: %w", err)
1212 }
1213 name := filepath.Base(hdr.Name)
1214 if name == "matcha" || strings.Contains(strings.ToLower(name), "matcha") && (hdr.Typeflag == tar.TypeReg) {
1215 // write out the file
1216 binPath = filepath.Join(tmpDir, "matcha")
1217 out, err := os.Create(binPath)
1218 if err != nil {
1219 return fmt.Errorf("could not create binary file: %w", err)
1220 }
1221 if _, err := io.Copy(out, tr); err != nil {
1222 out.Close()
1223 return fmt.Errorf("could not extract binary: %w", err)
1224 }
1225 out.Close()
1226 if err := os.Chmod(binPath, 0755); err != nil {
1227 return fmt.Errorf("could not make binary executable: %w", err)
1228 }
1229 break
1230 }
1231 }
1232 } else {
1233 // For non-archive assets, assume the asset is the binary itself.
1234 binPath = assetPath
1235 if err := os.Chmod(binPath, 0755); err != nil {
1236 // ignore chmod errors but warn
1237 fmt.Printf("warning: could not chmod downloaded binary: %v\n", err)
1238 }
1239 }
1240
1241 if binPath == "" {
1242 return fmt.Errorf("could not locate matcha binary inside the release artifact")
1243 }
1244
1245 // Replace the running executable with the new binary
1246 execPath, err := os.Executable()
1247 if err != nil {
1248 return fmt.Errorf("could not determine executable path: %w", err)
1249 }
1250
1251 // Write the new binary to a temp file in same dir, then rename for atomic replacement.
1252 execDir := filepath.Dir(execPath)
1253 tmpNew := filepath.Join(execDir, fmt.Sprintf("matcha.new.%d", time.Now().Unix()))
1254 in, err := os.Open(binPath)
1255 if err != nil {
1256 return fmt.Errorf("could not open new binary: %w", err)
1257 }
1258 out, err := os.OpenFile(tmpNew, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
1259 if err != nil {
1260 in.Close()
1261 return fmt.Errorf("could not create temp binary in target dir: %w", err)
1262 }
1263 if _, err := io.Copy(out, in); err != nil {
1264 in.Close()
1265 out.Close()
1266 return fmt.Errorf("could not write new binary to disk: %w", err)
1267 }
1268 in.Close()
1269 out.Close()
1270
1271 // Attempt to atomically replace
1272 if err := os.Rename(tmpNew, execPath); err != nil {
1273 return fmt.Errorf("could not replace executable: %w", err)
1274 }
1275
1276 fmt.Println("Successfully updated matcha to", latestTag)
1277 return nil
1278}
1279
1280func main() {
1281 // If invoked as CLI update command, run updater and exit.
1282 if len(os.Args) > 1 && os.Args[1] == "update" {
1283 if err := runUpdateCLI(); err != nil {
1284 fmt.Fprintf(os.Stderr, "update failed: %v\n", err)
1285 os.Exit(1)
1286 }
1287 os.Exit(0)
1288 }
1289
1290 cfg, err := config.LoadConfig()
1291 var initialModel *mainModel
1292 if err != nil {
1293 initialModel = newInitialModel(nil)
1294 } else {
1295 initialModel = newInitialModel(cfg)
1296 }
1297
1298 p := tea.NewProgram(initialModel, tea.WithAltScreen())
1299
1300 if _, err := p.Run(); err != nil {
1301 fmt.Printf("Alas, there's been an error: %v", err)
1302 os.Exit(1)
1303 }
1304}