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