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