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