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 "charm.land/bubbletea/v2"
23 "github.com/floatpane/matcha/config"
24 "github.com/floatpane/matcha/fetcher"
25 "github.com/floatpane/matcha/sender"
26 "github.com/floatpane/matcha/theme"
27 "github.com/floatpane/matcha/tui"
28 "github.com/google/uuid"
29 "github.com/yuin/goldmark"
30 "github.com/yuin/goldmark/renderer/html"
31)
32
33const (
34 initialEmailLimit = 50
35 paginationLimit = 50
36 maxCacheEmails = 100
37)
38
39// Version variables are injected by the build (GoReleaser ldflags).
40// They default to "dev" when not set by the build system.
41var (
42 version = "dev"
43 commit = ""
44 date = ""
45)
46
47// UpdateAvailableMsg is sent into the TUI when a newer release is detected.
48type UpdateAvailableMsg struct {
49 Latest string
50 Current string
51}
52
53// internal struct for parsing GitHub release JSON.
54type githubRelease struct {
55 TagName string `json:"tag_name"`
56 Assets []struct {
57 Name string `json:"name"`
58 BrowserDownloadURL string `json:"browser_download_url"`
59 } `json:"assets"`
60}
61
62type mainModel struct {
63 current tea.Model
64 previousModel tea.Model
65 config *config.Config
66 // Folder-based email storage
67 folderEmails map[string][]fetcher.Email // key: folderName
68 folderInbox *tui.FolderInbox
69 // Legacy fields kept for email actions
70 emails []fetcher.Email
71 emailsByAcct map[string][]fetcher.Email
72 width int
73 height int
74 err error
75}
76
77func newInitialModel(cfg *config.Config) *mainModel {
78 initialModel := &mainModel{
79 emailsByAcct: make(map[string][]fetcher.Email),
80 folderEmails: make(map[string][]fetcher.Email),
81 }
82
83 if cfg == nil || !cfg.HasAccounts() {
84 hideTips := false
85 if cfg != nil {
86 hideTips = cfg.HideTips
87 }
88 initialModel.current = tui.NewLogin(hideTips)
89 } else {
90 initialModel.current = tui.NewChoice()
91 initialModel.config = cfg
92 }
93 return initialModel
94}
95
96func (m *mainModel) Init() tea.Cmd {
97 return tea.Batch(m.current.Init(), checkForUpdatesCmd())
98}
99
100func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
101 var cmd tea.Cmd
102 var cmds []tea.Cmd
103
104 m.current, cmd = m.current.Update(msg)
105 cmds = append(cmds, cmd)
106
107 switch msg := msg.(type) {
108 case tea.WindowSizeMsg:
109 m.width = msg.Width
110 m.height = msg.Height
111 return m, nil
112
113 case tea.KeyPressMsg:
114 if msg.String() == "ctrl+c" {
115 return m, tea.Quit
116 }
117 if msg.String() == "esc" {
118 switch m.current.(type) {
119 case *tui.FilePicker:
120 return m, func() tea.Msg { return tui.CancelFilePickerMsg{} }
121 case *tui.FolderInbox, *tui.Inbox, *tui.Login:
122 m.current = tui.NewChoice()
123 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
124 return m, m.current.Init()
125 }
126 }
127
128 case tui.BackToInboxMsg:
129 if m.folderInbox != nil {
130 m.current = m.folderInbox
131 } else {
132 m.current = tui.NewChoice()
133 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
134 }
135 return m, nil
136
137 case tui.BackToMailboxMsg:
138 // Ensure kitty graphics are cleared when leaving email view
139 tui.ClearKittyGraphics()
140 if m.folderInbox != nil {
141 m.current = m.folderInbox
142 return m, nil
143 }
144 m.current = tui.NewChoice()
145 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
146 return m, nil
147
148 case tui.DiscardDraftMsg:
149 // Save draft to disk
150 if msg.ComposerState != nil {
151 draft := msg.ComposerState.ToDraft()
152 go func() {
153 if err := config.SaveDraft(draft); err != nil {
154 log.Printf("Error saving draft: %v", err)
155 }
156 }()
157 }
158 m.current = tui.NewChoice()
159 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
160 return m, m.current.Init()
161
162 case tui.Credentials:
163 // Add new account or update existing
164 account := config.Account{
165 ID: uuid.New().String(),
166 Name: msg.Name,
167 Email: msg.Host, // login/email used for authentication comes from Host field in the form
168 Password: msg.Password,
169 ServiceProvider: msg.Provider,
170 FetchEmail: msg.FetchEmail,
171 }
172
173 if msg.Provider == "custom" {
174 account.IMAPServer = msg.IMAPServer
175 account.IMAPPort = msg.IMAPPort
176 account.SMTPServer = msg.SMTPServer
177 account.SMTPPort = msg.SMTPPort
178 }
179
180 // Ensure FetchEmail defaults to the login Email (Host) if not explicitly set
181 if account.FetchEmail == "" && account.Email != "" {
182 account.FetchEmail = account.Email
183 }
184
185 if m.config == nil {
186 m.config = &config.Config{}
187 }
188
189 // Check if we're editing an existing account
190 isEdit := false
191 if login, ok := m.current.(*tui.Login); ok && login.IsEditMode() {
192 isEdit = true
193 // Find and update the existing account, preserving S/MIME settings
194 existingID := login.GetAccountID()
195 for i, acc := range m.config.Accounts {
196 if acc.ID == existingID {
197 account.ID = existingID
198 account.SMIMECert = acc.SMIMECert
199 account.SMIMEKey = acc.SMIMEKey
200 account.SMIMESignByDefault = acc.SMIMESignByDefault
201 if account.Password == "" {
202 account.Password = acc.Password
203 }
204 m.config.Accounts[i] = account
205 break
206 }
207 }
208 } else {
209 m.config.AddAccount(account)
210 }
211
212 if err := config.SaveConfig(m.config); err != nil {
213 log.Printf("could not save config: %v", err)
214 return m, tea.Quit
215 }
216
217 if isEdit {
218 m.current = tui.NewSettings(m.config)
219 } else {
220 m.current = tui.NewChoice()
221 }
222 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
223 return m, m.current.Init()
224
225 case tui.GoToInboxMsg:
226 if m.config == nil || !m.config.HasAccounts() {
227 hideTips := false
228 if m.config != nil {
229 hideTips = m.config.HideTips
230 }
231 m.current = tui.NewLogin(hideTips)
232 return m, m.current.Init()
233 }
234 // Load cached folders from all accounts, merge unique names
235 seen := make(map[string]bool)
236 var cachedFolders []string
237 for _, acc := range m.config.Accounts {
238 for _, f := range config.GetCachedFolders(acc.ID) {
239 if !seen[f] {
240 seen[f] = true
241 cachedFolders = append(cachedFolders, f)
242 }
243 }
244 }
245 if len(cachedFolders) == 0 {
246 cachedFolders = []string{"INBOX"}
247 }
248 m.folderInbox = tui.NewFolderInbox(cachedFolders, m.config.Accounts)
249 // Use cached INBOX emails for instant display (memory first, then disk)
250 if cached, ok := m.folderEmails["INBOX"]; ok && len(cached) > 0 {
251 m.folderInbox.SetEmails(cached, m.config.Accounts)
252 } else if diskCached := loadFolderEmailsFromCache("INBOX"); len(diskCached) > 0 {
253 m.folderEmails["INBOX"] = diskCached
254 m.emails = diskCached
255 m.emailsByAcct = make(map[string][]fetcher.Email)
256 for _, email := range diskCached {
257 m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
258 }
259 m.folderInbox.SetEmails(diskCached, m.config.Accounts)
260 }
261 m.current = m.folderInbox
262 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
263 // Fetch folders and INBOX emails in parallel (background refresh)
264 return m, tea.Batch(
265 m.current.Init(),
266 fetchFoldersCmd(m.config),
267 fetchFolderEmailsCmd(m.config, "INBOX"),
268 )
269
270 case tui.FoldersFetchedMsg:
271 if m.folderInbox == nil {
272 return m, nil
273 }
274 var folderNames []string
275 for _, f := range msg.MergedFolders {
276 folderNames = append(folderNames, f.Name)
277 }
278 m.folderInbox.SetFolders(folderNames)
279 // Cache folder lists per account
280 for accID, folders := range msg.FoldersByAccount {
281 var names []string
282 for _, f := range folders {
283 names = append(names, f.Name)
284 }
285 go config.SaveAccountFolders(accID, names)
286 }
287 return m, nil
288
289 case tui.SwitchFolderMsg:
290 if m.config == nil {
291 return m, nil
292 }
293 // Use in-memory cache if available
294 if cached, ok := m.folderEmails[msg.FolderName]; ok {
295 m.emails = cached
296 m.emailsByAcct = make(map[string][]fetcher.Email)
297 for _, email := range cached {
298 m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
299 }
300 if m.folderInbox != nil {
301 m.folderInbox.SetEmails(cached, m.config.Accounts)
302 m.folderInbox.GetInbox().SetFolderName(msg.FolderName)
303 m.folderInbox.SetLoadingEmails(false)
304 }
305 return m, nil
306 }
307 // Fall back to disk cache for instant display, then fetch fresh in background
308 if diskCached := loadFolderEmailsFromCache(msg.FolderName); len(diskCached) > 0 {
309 m.folderEmails[msg.FolderName] = diskCached
310 m.emails = diskCached
311 m.emailsByAcct = make(map[string][]fetcher.Email)
312 for _, email := range diskCached {
313 m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
314 }
315 if m.folderInbox != nil {
316 m.folderInbox.SetEmails(diskCached, m.config.Accounts)
317 m.folderInbox.GetInbox().SetFolderName(msg.FolderName)
318 m.folderInbox.SetLoadingEmails(false)
319 }
320 // Still fetch fresh emails in background
321 return m, fetchFolderEmailsCmd(m.config, msg.FolderName)
322 }
323 if m.folderInbox != nil {
324 m.folderInbox.SetLoadingEmails(true)
325 }
326 return m, fetchFolderEmailsCmd(m.config, msg.FolderName)
327
328 case tui.FolderEmailsFetchedMsg:
329 if m.folderInbox == nil {
330 return m, nil
331 }
332 // Always cache in memory and to disk
333 m.folderEmails[msg.FolderName] = msg.Emails
334 go saveFolderEmailsToCache(msg.FolderName, msg.Emails)
335 // Only update the view if the user is still on this folder
336 if m.folderInbox.GetCurrentFolder() != msg.FolderName {
337 return m, nil
338 }
339 m.emails = msg.Emails
340 m.emailsByAcct = make(map[string][]fetcher.Email)
341 for _, email := range msg.Emails {
342 m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
343 }
344 m.folderInbox.SetEmails(msg.Emails, m.config.Accounts)
345 m.folderInbox.GetInbox().SetFolderName(msg.FolderName)
346 m.folderInbox.SetLoadingEmails(false)
347 return m, nil
348
349 case tui.FetchFolderMoreEmailsMsg:
350 if msg.AccountID == "" || m.config == nil {
351 return m, nil
352 }
353 account := m.config.GetAccountByID(msg.AccountID)
354 if account == nil {
355 return m, nil
356 }
357 limit := uint32(paginationLimit)
358 if msg.Limit > 0 {
359 limit = msg.Limit
360 }
361 return m, tea.Batch(
362 func() tea.Msg { return tui.FetchingMoreEmailsMsg{} },
363 fetchFolderEmailsPaginatedCmd(account, msg.FolderName, limit, msg.Offset),
364 )
365
366 case tui.FolderEmailsAppendedMsg:
367 // Ignore stale appends for a folder the user has moved away from
368 if m.folderInbox == nil || m.folderInbox.GetCurrentFolder() != msg.FolderName {
369 return m, nil
370 }
371 m.folderInbox.Update(msg)
372 // Update local stores and per-folder cache
373 for _, email := range msg.Emails {
374 m.emails = append(m.emails, email)
375 m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
376 }
377 m.folderEmails[msg.FolderName] = append(m.folderEmails[msg.FolderName], msg.Emails...)
378 go saveFolderEmailsToCache(msg.FolderName, m.folderEmails[msg.FolderName])
379 return m, nil
380
381 case tui.MoveEmailToFolderMsg:
382 if m.config == nil {
383 return m, nil
384 }
385 account := m.config.GetAccountByID(msg.AccountID)
386 if account == nil {
387 return m, nil
388 }
389 m.previousModel = m.current
390 m.current = tui.NewStatus("Moving email...")
391 return m, tea.Batch(m.current.Init(), moveEmailToFolderCmd(account, msg.UID, msg.AccountID, msg.SourceFolder, msg.DestFolder))
392
393 case tui.EmailMovedMsg:
394 if msg.Err != nil {
395 log.Printf("Move failed: %v", msg.Err)
396 if m.folderInbox != nil {
397 m.previousModel = m.folderInbox
398 }
399 m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
400 return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
401 return tui.RestoreViewMsg{}
402 })
403 }
404 // Remove email from current view
405 if m.folderInbox != nil {
406 m.folderInbox.RemoveEmail(msg.UID, msg.AccountID)
407 m.current = m.folderInbox
408 }
409 return m, nil
410
411 case tui.CachedEmailsLoadedMsg:
412 // Cache is no longer used for the folder-based inbox flow
413 // This handler is kept for backwards compatibility but simply fetches normally
414 if m.folderInbox == nil {
415 return m, nil
416 }
417 return m, fetchFolderEmailsCmd(m.config, m.folderInbox.GetCurrentFolder())
418
419 case tui.RequestRefreshMsg:
420 // Folder-based refresh: clear folder cache and refetch
421 if msg.FolderName != "" && m.config != nil {
422 delete(m.folderEmails, msg.FolderName)
423 if m.folderInbox != nil {
424 m.folderInbox.SetRefreshing(true)
425 }
426 return m, fetchFolderEmailsCmd(m.config, msg.FolderName)
427 }
428 return m, tea.Batch(
429 func() tea.Msg { return tui.RefreshingEmailsMsg{Mailbox: msg.Mailbox} },
430 refreshEmails(m.config, msg.Mailbox, msg.Counts),
431 )
432
433 case tui.EmailsRefreshedMsg:
434 // Merge refreshed emails with any paginated emails already loaded.
435 for accID, refreshed := range msg.EmailsByAccount {
436 refreshedUIDs := make(map[uint32]struct{}, len(refreshed))
437 for _, e := range refreshed {
438 refreshedUIDs[e.UID] = struct{}{}
439 }
440 if existing, ok := m.emailsByAcct[accID]; ok {
441 for _, e := range existing {
442 if _, found := refreshedUIDs[e.UID]; !found {
443 refreshed = append(refreshed, e)
444 }
445 }
446 }
447 m.emailsByAcct[accID] = refreshed
448 }
449 m.emails = flattenAndSort(m.emailsByAcct)
450
451 // Update folder inbox if it exists
452 if m.folderInbox != nil {
453 m.folderInbox.SetEmails(m.emails, m.config.Accounts)
454 m.folderInbox.GetInbox().Update(msg)
455 }
456 return m, nil
457
458 case tui.AllEmailsFetchedMsg:
459 m.emailsByAcct = msg.EmailsByAccount
460 m.emails = flattenAndSort(msg.EmailsByAccount)
461
462 if m.folderInbox != nil {
463 m.folderInbox.SetEmails(m.emails, m.config.Accounts)
464 m.folderInbox.SetLoadingEmails(false)
465 }
466 return m, nil
467
468 case tui.EmailsFetchedMsg:
469 if m.emailsByAcct == nil {
470 m.emailsByAcct = make(map[string][]fetcher.Email)
471 }
472 m.emailsByAcct[msg.AccountID] = msg.Emails
473 m.emails = flattenAndSort(m.emailsByAcct)
474 if m.folderInbox != nil {
475 m.folderInbox.SetEmails(m.emails, m.config.Accounts)
476 }
477 return m, nil
478
479 case tui.FetchMoreEmailsMsg:
480 if msg.AccountID == "" {
481 return m, nil
482 }
483 account := m.config.GetAccountByID(msg.AccountID)
484 if account == nil {
485 return m, nil
486 }
487 limit := uint32(paginationLimit)
488 if msg.Limit > 0 {
489 limit = msg.Limit
490 }
491 folderName := "INBOX"
492 if m.folderInbox != nil {
493 folderName = m.folderInbox.GetCurrentFolder()
494 }
495 return m, tea.Batch(
496 func() tea.Msg { return tui.FetchingMoreEmailsMsg{} },
497 fetchFolderEmailsPaginatedCmd(account, folderName, limit, msg.Offset),
498 )
499
500 case tui.EmailsAppendedMsg:
501 if m.emailsByAcct == nil {
502 m.emailsByAcct = make(map[string][]fetcher.Email)
503 }
504 unique := filterUnique(m.emailsByAcct[msg.AccountID], msg.Emails)
505 m.emailsByAcct[msg.AccountID] = append(m.emailsByAcct[msg.AccountID], unique...)
506 m.emails = append(m.emails, unique...)
507 return m, nil
508
509 case tui.GoToSendMsg:
510 hideTips := false
511 if m.config != nil {
512 hideTips = m.config.HideTips
513 }
514 if m.config != nil && len(m.config.Accounts) > 0 {
515 firstAccount := m.config.GetFirstAccount()
516 composer := tui.NewComposerWithAccounts(m.config.Accounts, firstAccount.ID, msg.To, msg.Subject, msg.Body, hideTips)
517 m.current = composer
518 } else {
519 m.current = tui.NewComposer("", msg.To, msg.Subject, msg.Body, hideTips)
520 }
521 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
522 return m, m.current.Init()
523
524 case tui.GoToDraftsMsg:
525 drafts := config.GetAllDrafts()
526 m.current = tui.NewDrafts(drafts)
527 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
528 return m, m.current.Init()
529
530 case tui.OpenDraftMsg:
531 var accounts []config.Account
532 hideTips := false
533 if m.config != nil {
534 accounts = m.config.Accounts
535 hideTips = m.config.HideTips
536 }
537 composer := tui.NewComposerFromDraft(msg.Draft, accounts, hideTips)
538 m.current = composer
539 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
540 return m, m.current.Init()
541
542 case tui.DeleteSavedDraftMsg:
543 go func() {
544 if err := config.DeleteDraft(msg.DraftID); err != nil {
545 log.Printf("Error deleting draft: %v", err)
546 }
547 }()
548 // Send message back to drafts view
549 m.current, cmd = m.current.Update(tui.DraftDeletedMsg{DraftID: msg.DraftID})
550 return m, cmd
551
552 case tui.GoToSettingsMsg:
553 m.current = tui.NewSettings(m.config)
554 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
555 return m, m.current.Init()
556
557 case tui.GoToAddAccountMsg:
558 hideTips := false
559 if m.config != nil {
560 hideTips = m.config.HideTips
561 }
562 m.current = tui.NewLogin(hideTips)
563 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
564 return m, m.current.Init()
565
566 case tui.GoToAddMailingListMsg:
567 m.current = tui.NewMailingListEditor()
568 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
569 return m, m.current.Init()
570
571 case tui.GoToEditAccountMsg:
572 hideTips := false
573 if m.config != nil {
574 hideTips = m.config.HideTips
575 }
576 login := tui.NewLogin(hideTips)
577 login.SetEditMode(msg.AccountID, msg.Provider, msg.Name, msg.Email, msg.FetchEmail, msg.IMAPServer, msg.IMAPPort, msg.SMTPServer, msg.SMTPPort)
578 m.current = login
579 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
580 return m, m.current.Init()
581
582 case tui.GoToEditMailingListMsg:
583 editor := tui.NewMailingListEditor()
584 editor.SetEditMode(msg.Index, msg.Name, msg.Addresses)
585 m.current = editor
586 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
587 return m, m.current.Init()
588
589 case tui.SaveMailingListMsg:
590 if m.config != nil {
591 var addrs []string
592 for _, part := range strings.Split(msg.Addresses, ",") {
593 if trimmed := strings.TrimSpace(part); trimmed != "" {
594 addrs = append(addrs, trimmed)
595 }
596 }
597 if msg.EditIndex >= 0 && msg.EditIndex < len(m.config.MailingLists) {
598 m.config.MailingLists[msg.EditIndex] = config.MailingList{
599 Name: msg.Name,
600 Addresses: addrs,
601 }
602 } else {
603 m.config.MailingLists = append(m.config.MailingLists, config.MailingList{
604 Name: msg.Name,
605 Addresses: addrs,
606 })
607 }
608 if err := config.SaveConfig(m.config); err != nil {
609 log.Printf("could not save config: %v", err)
610 }
611 }
612 // Return to settings
613 m.current = tui.NewSettings(m.config)
614 // Try to navigate to the mailing list view internally if possible, but NewSettings will go to SettingsMain by default.
615 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
616 return m, m.current.Init()
617
618 case tui.GoToSignatureEditorMsg:
619 m.current = tui.NewSignatureEditor()
620 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
621 return m, m.current.Init()
622
623 case tui.GoToChoiceMenuMsg:
624 m.current = tui.NewChoice()
625 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
626 return m, m.current.Init()
627
628 case tui.DeleteAccountMsg:
629 if m.config != nil {
630 m.config.RemoveAccount(msg.AccountID)
631 if err := config.SaveConfig(m.config); err != nil {
632 log.Printf("could not save config: %v", err)
633 }
634 // Remove emails for this account
635 delete(m.emailsByAcct, msg.AccountID)
636
637 // Rebuild all emails
638 var allEmails []fetcher.Email
639 for _, emails := range m.emailsByAcct {
640 allEmails = append(allEmails, emails...)
641 }
642 m.emails = allEmails
643
644 // Go back to settings
645 m.current = tui.NewSettings(m.config)
646 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
647 }
648 return m, m.current.Init()
649
650 case tui.ViewEmailMsg:
651 if m.getEmailByUIDAndAccount(msg.UID, msg.AccountID, msg.Mailbox) == nil {
652 return m, nil
653 }
654 folderName := "INBOX"
655 if m.folderInbox != nil {
656 folderName = m.folderInbox.GetCurrentFolder()
657 }
658 m.current = tui.NewStatus("Fetching email content...")
659 return m, tea.Batch(m.current.Init(), fetchFolderEmailBodyCmd(m.config, msg.UID, msg.AccountID, folderName, msg.Mailbox))
660
661 case tui.EmailBodyFetchedMsg:
662 if msg.Err != nil {
663 log.Printf("could not fetch email body: %v", msg.Err)
664 if m.folderInbox != nil {
665 m.current = m.folderInbox
666 }
667 return m, nil
668 }
669
670 // Update the email in our stores
671 m.updateEmailBodyByUID(msg.UID, msg.AccountID, msg.Mailbox, msg.Body, msg.Attachments)
672
673 email := m.getEmailByUIDAndAccount(msg.UID, msg.AccountID, msg.Mailbox)
674 if email == nil {
675 if m.folderInbox != nil {
676 m.current = m.folderInbox
677 }
678 return m, nil
679 }
680
681 // Mark as read in UI immediately and on the server
682 var markReadCmd tea.Cmd
683 if !email.IsRead {
684 m.markEmailAsReadInStores(msg.UID, msg.AccountID)
685
686 folderName := "INBOX"
687 if m.folderInbox != nil {
688 folderName = m.folderInbox.GetCurrentFolder()
689 }
690 account := m.config.GetAccountByID(msg.AccountID)
691 if account != nil {
692 markReadCmd = markEmailAsReadCmd(account, msg.UID, msg.AccountID, folderName)
693 }
694 }
695
696 // Find the index for the email view (used for display purposes)
697 emailIndex := m.getEmailIndex(msg.UID, msg.AccountID, msg.Mailbox)
698 emailView := tui.NewEmailView(*email, emailIndex, m.width, m.height, msg.Mailbox, m.config.DisableImages)
699 m.current = emailView
700 cmds := []tea.Cmd{m.current.Init()}
701 if markReadCmd != nil {
702 cmds = append(cmds, markReadCmd)
703 }
704 return m, tea.Batch(cmds...)
705
706 case tui.ReplyToEmailMsg:
707 to := msg.Email.From
708 subject := msg.Email.Subject
709 normalizedSubject := strings.ToLower(strings.TrimSpace(subject))
710 if !strings.HasPrefix(normalizedSubject, "re:") {
711 subject = "Re: " + subject
712 }
713 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> "))
714
715 var composer *tui.Composer
716 hideTips := false
717 if m.config != nil {
718 hideTips = m.config.HideTips
719 }
720 if m.config != nil && len(m.config.Accounts) > 0 {
721 // Use the account that received the email
722 accountID := msg.Email.AccountID
723 if accountID == "" {
724 accountID = m.config.GetFirstAccount().ID
725 }
726 composer = tui.NewComposerWithAccounts(m.config.Accounts, accountID, to, subject, "", hideTips)
727 } else {
728 composer = tui.NewComposer("", to, subject, "", hideTips)
729 }
730 composer.SetQuotedText(quotedText)
731
732 // Set reply headers
733 inReplyTo := msg.Email.MessageID
734 references := append(msg.Email.References, msg.Email.MessageID)
735 composer.SetReplyContext(inReplyTo, references)
736
737 m.current = composer
738 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
739 return m, m.current.Init()
740
741 case tui.ForwardEmailMsg:
742 subject := msg.Email.Subject
743 if !strings.HasPrefix(strings.ToLower(subject), "fwd:") {
744 subject = "Fwd: " + subject
745 }
746
747 forwardHeader := fmt.Sprintf("\n\n---------- Forwarded message ----------\nFrom: %s\nDate: %s\nSubject: %s\nTo: %s\n\n",
748 msg.Email.From,
749 msg.Email.Date.Format("Mon, Jan 2, 2006 at 3:04 PM"),
750 msg.Email.Subject,
751 msg.Email.To,
752 )
753
754 body := forwardHeader + msg.Email.Body
755
756 var composer *tui.Composer
757 hideTips := false
758 if m.config != nil {
759 hideTips = m.config.HideTips
760 }
761 if m.config != nil && len(m.config.Accounts) > 0 {
762 // Use the account that received the email
763 accountID := msg.Email.AccountID
764 if accountID == "" {
765 accountID = m.config.GetFirstAccount().ID
766 }
767 composer = tui.NewComposerWithAccounts(m.config.Accounts, accountID, "", subject, body, hideTips)
768 } else {
769 composer = tui.NewComposer("", "", subject, body, hideTips)
770 }
771
772 m.current = composer
773 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
774 return m, m.current.Init()
775
776 case tui.GoToFilePickerMsg:
777 m.previousModel = m.current
778 wd, _ := os.Getwd()
779 m.current = tui.NewFilePicker(wd)
780 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
781 return m, m.current.Init()
782
783 case tui.FileSelectedMsg, tui.CancelFilePickerMsg:
784 if m.previousModel != nil {
785 m.current = m.previousModel
786 m.previousModel = nil
787 }
788 m.current, cmd = m.current.Update(msg)
789 cmds = append(cmds, cmd)
790
791 case tui.SendEmailMsg:
792 // Get draft ID before clearing composer (if it's a composer)
793 var draftID string
794 if composer, ok := m.current.(*tui.Composer); ok {
795 draftID = composer.GetDraftID()
796 }
797 m.current = tui.NewStatus("Sending email...")
798
799 // Get the account to send from
800 var account *config.Account
801 if msg.AccountID != "" && m.config != nil {
802 account = m.config.GetAccountByID(msg.AccountID)
803 }
804 if account == nil && m.config != nil {
805 account = m.config.GetFirstAccount()
806 }
807
808 // Save contact and delete draft in background
809 go func() {
810 // Save the recipient as a contact
811 if msg.To != "" {
812 recipients := strings.Split(msg.To, ",")
813 for _, r := range recipients {
814 r = strings.TrimSpace(r)
815 if r == "" {
816 continue
817 }
818 name, email := parseEmailAddress(r)
819 if err := config.AddContact(name, email); err != nil {
820 log.Printf("Error saving contact: %v", err)
821 }
822 }
823 }
824 // Delete the draft since email is being sent
825 if draftID != "" {
826 if err := config.DeleteDraft(draftID); err != nil {
827 log.Printf("Error deleting draft after send: %v", err)
828 }
829 }
830 }()
831
832 return m, tea.Batch(m.current.Init(), sendEmail(account, msg))
833
834 case tui.EmailResultMsg:
835 if msg.Err != nil {
836 log.Printf("Failed to send email: %v", msg.Err)
837 m.previousModel = tui.NewChoice()
838 m.previousModel, _ = m.previousModel.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
839 m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
840 return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
841 return tui.RestoreViewMsg{}
842 })
843 }
844 m.current = tui.NewChoice()
845 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
846 return m, m.current.Init()
847
848 case tui.DeleteEmailMsg:
849 tui.ClearKittyGraphics()
850 m.previousModel = m.current
851 m.current = tui.NewStatus("Deleting email...")
852
853 account := m.config.GetAccountByID(msg.AccountID)
854 if account == nil {
855 if m.folderInbox != nil {
856 m.current = m.folderInbox
857 }
858 return m, nil
859 }
860
861 folderName := "INBOX"
862 if m.folderInbox != nil {
863 folderName = m.folderInbox.GetCurrentFolder()
864 }
865 return m, tea.Batch(m.current.Init(), deleteFolderEmailCmd(account, msg.UID, msg.AccountID, folderName, msg.Mailbox))
866
867 case tui.ArchiveEmailMsg:
868 tui.ClearKittyGraphics()
869 m.previousModel = m.current
870 m.current = tui.NewStatus("Archiving email...")
871
872 account := m.config.GetAccountByID(msg.AccountID)
873 if account == nil {
874 if m.folderInbox != nil {
875 m.current = m.folderInbox
876 }
877 return m, nil
878 }
879
880 folderName := "INBOX"
881 if m.folderInbox != nil {
882 folderName = m.folderInbox.GetCurrentFolder()
883 }
884 return m, tea.Batch(m.current.Init(), archiveFolderEmailCmd(account, msg.UID, msg.AccountID, folderName, msg.Mailbox))
885
886 case tui.EmailMarkedReadMsg:
887 if msg.Err != nil {
888 log.Printf("Error marking email as read: %v", msg.Err)
889 }
890 return m, nil
891
892 case tui.EmailActionDoneMsg:
893 if msg.Err != nil {
894 log.Printf("Action failed: %v", msg.Err)
895 if m.folderInbox != nil {
896 m.previousModel = m.folderInbox
897 }
898 m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
899 return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
900 return tui.RestoreViewMsg{}
901 })
902 }
903
904 // Remove email from stores
905 m.removeEmailFromStores(msg.UID, msg.AccountID)
906
907 if m.folderInbox != nil {
908 m.folderInbox.RemoveEmail(msg.UID, msg.AccountID)
909 m.current = m.folderInbox
910 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
911 return m, m.current.Init()
912 }
913 m.current = tui.NewChoice()
914 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
915 return m, m.current.Init()
916
917 case tui.DownloadAttachmentMsg:
918 m.previousModel = m.current
919 m.current = tui.NewStatus(fmt.Sprintf("Downloading %s...", msg.Filename))
920
921 account := m.config.GetAccountByID(msg.AccountID)
922 if account == nil {
923 m.current = m.previousModel
924 return m, nil
925 }
926
927 email := m.getEmailByIndex(msg.Index, msg.Mailbox)
928 if email == nil {
929 m.current = m.previousModel
930 return m, nil
931 }
932
933 // Find the correct attachment to get encoding
934 var encoding string
935 for _, att := range email.Attachments {
936 if att.PartID == msg.PartID {
937 encoding = att.Encoding
938 break
939 }
940 }
941 newMsg := tui.DownloadAttachmentMsg{
942 Index: msg.Index,
943 Filename: msg.Filename,
944 PartID: msg.PartID,
945 Data: msg.Data,
946 AccountID: msg.AccountID,
947 Encoding: encoding,
948 Mailbox: msg.Mailbox,
949 }
950 return m, tea.Batch(m.current.Init(), downloadAttachmentCmd(account, email.UID, newMsg))
951
952 case tui.AttachmentDownloadedMsg:
953 var statusMsg string
954 if msg.Err != nil {
955 statusMsg = fmt.Sprintf("Error downloading: %v", msg.Err)
956 } else {
957 statusMsg = fmt.Sprintf("Saved to %s", msg.Path)
958 }
959 m.current = tui.NewStatus(statusMsg)
960 return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
961 return tui.RestoreViewMsg{}
962 })
963
964 case tui.RestoreViewMsg:
965 if m.previousModel != nil {
966 m.current = m.previousModel
967 m.previousModel = nil
968 }
969 return m, nil
970 }
971
972 return m, tea.Batch(cmds...)
973}
974
975func (m *mainModel) View() tea.View {
976 v := m.current.View()
977 v.AltScreen = true
978 return v
979}
980
981func (m *mainModel) getEmailByIndex(index int, mailbox tui.MailboxKind) *fetcher.Email {
982 if index >= 0 && index < len(m.emails) {
983 return &m.emails[index]
984 }
985 return nil
986}
987
988func (m *mainModel) getEmailByUIDAndAccount(uid uint32, accountID string, mailbox tui.MailboxKind) *fetcher.Email {
989 for i := range m.emails {
990 if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
991 return &m.emails[i]
992 }
993 }
994 return nil
995}
996
997func (m *mainModel) getEmailIndex(uid uint32, accountID string, mailbox tui.MailboxKind) int {
998 for i := range m.emails {
999 if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1000 return i
1001 }
1002 }
1003 return -1
1004}
1005
1006func (m *mainModel) updateEmailBodyByUID(uid uint32, accountID string, mailbox tui.MailboxKind, body string, attachments []fetcher.Attachment) {
1007 for i := range m.emails {
1008 if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1009 m.emails[i].Body = body
1010 m.emails[i].Attachments = attachments
1011 break
1012 }
1013 }
1014 if emails, ok := m.emailsByAcct[accountID]; ok {
1015 for i := range emails {
1016 if emails[i].UID == uid {
1017 emails[i].Body = body
1018 emails[i].Attachments = attachments
1019 break
1020 }
1021 }
1022 }
1023}
1024
1025func (m *mainModel) markEmailAsReadInStores(uid uint32, accountID string) {
1026 for i := range m.emails {
1027 if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1028 m.emails[i].IsRead = true
1029 break
1030 }
1031 }
1032 if emails, ok := m.emailsByAcct[accountID]; ok {
1033 for i := range emails {
1034 if emails[i].UID == uid {
1035 emails[i].IsRead = true
1036 break
1037 }
1038 }
1039 }
1040 // Update folder email cache
1041 for folderName, folderEmails := range m.folderEmails {
1042 for i := range folderEmails {
1043 if folderEmails[i].UID == uid && folderEmails[i].AccountID == accountID {
1044 folderEmails[i].IsRead = true
1045 m.folderEmails[folderName] = folderEmails
1046 go saveFolderEmailsToCache(folderName, folderEmails)
1047 break
1048 }
1049 }
1050 }
1051 // Update the inbox UI
1052 if m.folderInbox != nil {
1053 m.folderInbox.GetInbox().MarkEmailAsRead(uid, accountID)
1054 }
1055}
1056
1057func (m *mainModel) removeEmailFromStores(uid uint32, accountID string) {
1058 var filtered []fetcher.Email
1059 for _, e := range m.emails {
1060 if !(e.UID == uid && e.AccountID == accountID) {
1061 filtered = append(filtered, e)
1062 }
1063 }
1064 m.emails = filtered
1065 if emails, ok := m.emailsByAcct[accountID]; ok {
1066 var filteredAcct []fetcher.Email
1067 for _, e := range emails {
1068 if e.UID != uid {
1069 filteredAcct = append(filteredAcct, e)
1070 }
1071 }
1072 m.emailsByAcct[accountID] = filteredAcct
1073 }
1074}
1075
1076func flattenAndSort(emailsByAccount map[string][]fetcher.Email) []fetcher.Email {
1077 var allEmails []fetcher.Email
1078 for _, emails := range emailsByAccount {
1079 allEmails = append(allEmails, emails...)
1080 }
1081 for i := 0; i < len(allEmails); i++ {
1082 for j := i + 1; j < len(allEmails); j++ {
1083 if allEmails[j].Date.After(allEmails[i].Date) {
1084 allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
1085 }
1086 }
1087 }
1088 return allEmails
1089}
1090
1091func fetchAllAccountsEmails(cfg *config.Config, mailbox tui.MailboxKind) tea.Cmd {
1092 return func() tea.Msg {
1093 emailsByAccount := make(map[string][]fetcher.Email)
1094 var mu sync.Mutex
1095 var wg sync.WaitGroup
1096
1097 for _, account := range cfg.Accounts {
1098 wg.Add(1)
1099 go func(acc config.Account) {
1100 defer wg.Done()
1101 var emails []fetcher.Email
1102 var err error
1103 switch mailbox {
1104 case tui.MailboxSent:
1105 emails, err = fetcher.FetchSentEmails(&acc, initialEmailLimit, 0)
1106 case tui.MailboxTrash:
1107 emails, err = fetcher.FetchTrashEmails(&acc, initialEmailLimit, 0)
1108 case tui.MailboxArchive:
1109 emails, err = fetcher.FetchArchiveEmails(&acc, initialEmailLimit, 0)
1110 default:
1111 emails, err = fetcher.FetchEmails(&acc, initialEmailLimit, 0)
1112 }
1113 if err != nil {
1114 log.Printf("Error fetching from %s: %v", acc.Email, err)
1115 return
1116 }
1117 mu.Lock()
1118 emailsByAccount[acc.ID] = emails
1119 mu.Unlock()
1120 }(account)
1121 }
1122
1123 wg.Wait()
1124 return tui.AllEmailsFetchedMsg{EmailsByAccount: emailsByAccount, Mailbox: mailbox}
1125 }
1126}
1127
1128func fetchEmails(account *config.Account, limit, offset uint32, mailbox tui.MailboxKind) tea.Cmd {
1129 return func() tea.Msg {
1130 var emails []fetcher.Email
1131 var err error
1132 if mailbox == tui.MailboxSent {
1133 emails, err = fetcher.FetchSentEmails(account, limit, offset)
1134 } else {
1135 emails, err = fetcher.FetchEmails(account, limit, offset)
1136 }
1137 if err != nil {
1138 return tui.FetchErr(err)
1139 }
1140 if offset == 0 {
1141 return tui.EmailsFetchedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1142 }
1143 return tui.EmailsAppendedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1144 }
1145}
1146
1147func fetchEmailsForMailbox(account *config.Account, limit, offset uint32, mailbox tui.MailboxKind) tea.Cmd {
1148 return func() tea.Msg {
1149 var emails []fetcher.Email
1150 var err error
1151 switch mailbox {
1152 case tui.MailboxSent:
1153 emails, err = fetcher.FetchSentEmails(account, limit, offset)
1154 case tui.MailboxTrash:
1155 emails, err = fetcher.FetchTrashEmails(account, limit, offset)
1156 case tui.MailboxArchive:
1157 emails, err = fetcher.FetchArchiveEmails(account, limit, offset)
1158 default:
1159 emails, err = fetcher.FetchEmails(account, limit, offset)
1160 }
1161 if err != nil {
1162 return tui.FetchErr(err)
1163 }
1164 if offset == 0 {
1165 return tui.EmailsFetchedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1166 }
1167 return tui.EmailsAppendedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1168 }
1169}
1170
1171func loadCachedEmails() tea.Cmd {
1172 return func() tea.Msg {
1173 cache, err := config.LoadEmailCache()
1174 if err != nil {
1175 return tui.CachedEmailsLoadedMsg{Cache: nil}
1176 }
1177 return tui.CachedEmailsLoadedMsg{Cache: cache}
1178 }
1179}
1180
1181func refreshEmails(cfg *config.Config, mailbox tui.MailboxKind, counts map[string]int) tea.Cmd {
1182 return func() tea.Msg {
1183 emailsByAccount := make(map[string][]fetcher.Email)
1184 var mu sync.Mutex
1185 var wg sync.WaitGroup
1186
1187 for _, account := range cfg.Accounts {
1188 wg.Add(1)
1189 go func(acc config.Account) {
1190 defer wg.Done()
1191 var emails []fetcher.Email
1192 var err error
1193
1194 limit := uint32(initialEmailLimit)
1195 if counts != nil {
1196 if c, ok := counts[acc.ID]; ok && c > 0 {
1197 limit = uint32(c)
1198 }
1199 }
1200
1201 if mailbox == tui.MailboxSent {
1202 emails, err = fetcher.FetchSentEmails(&acc, limit, 0)
1203 } else {
1204 emails, err = fetcher.FetchEmails(&acc, limit, 0)
1205 }
1206 if err != nil {
1207 log.Printf("Error fetching from %s: %v", acc.Email, err)
1208 return
1209 }
1210 mu.Lock()
1211 emailsByAccount[acc.ID] = emails
1212 mu.Unlock()
1213 }(account)
1214 }
1215
1216 wg.Wait()
1217 return tui.EmailsRefreshedMsg{EmailsByAccount: emailsByAccount, Mailbox: mailbox}
1218 }
1219}
1220
1221func emailsToCache(emails []fetcher.Email) []config.CachedEmail {
1222 var cached []config.CachedEmail
1223 for _, email := range emails {
1224 cached = append(cached, config.CachedEmail{
1225 UID: email.UID,
1226 From: email.From,
1227 To: email.To,
1228 Subject: email.Subject,
1229 Date: email.Date,
1230 MessageID: email.MessageID,
1231 AccountID: email.AccountID,
1232 IsRead: email.IsRead,
1233 })
1234 }
1235 return cached
1236}
1237
1238func cacheToEmails(cached []config.CachedEmail) []fetcher.Email {
1239 var emails []fetcher.Email
1240 for _, c := range cached {
1241 emails = append(emails, fetcher.Email{
1242 UID: c.UID,
1243 From: c.From,
1244 To: c.To,
1245 Subject: c.Subject,
1246 Date: c.Date,
1247 MessageID: c.MessageID,
1248 AccountID: c.AccountID,
1249 IsRead: c.IsRead,
1250 })
1251 }
1252 return emails
1253}
1254
1255func saveFolderEmailsToCache(folderName string, emails []fetcher.Email) {
1256 cached := emailsToCache(emails)
1257 if err := config.SaveFolderEmailCache(folderName, cached); err != nil {
1258 log.Printf("Error saving folder email cache for %s: %v", folderName, err)
1259 }
1260}
1261
1262func loadFolderEmailsFromCache(folderName string) []fetcher.Email {
1263 cached, err := config.LoadFolderEmailCache(folderName)
1264 if err != nil {
1265 return nil
1266 }
1267 return cacheToEmails(cached)
1268}
1269
1270func saveEmailsToCache(emails []fetcher.Email) {
1271 if len(emails) > maxCacheEmails {
1272 emails = emails[:maxCacheEmails]
1273 }
1274 var cachedEmails []config.CachedEmail
1275 for _, email := range emails {
1276 cachedEmails = append(cachedEmails, config.CachedEmail{
1277 UID: email.UID,
1278 From: email.From,
1279 To: email.To,
1280 Subject: email.Subject,
1281 Date: email.Date,
1282 MessageID: email.MessageID,
1283 AccountID: email.AccountID,
1284 IsRead: email.IsRead,
1285 })
1286
1287 // Save sender as a contact
1288 if email.From != "" {
1289 name, emailAddr := parseEmailAddress(email.From)
1290 if err := config.AddContact(name, emailAddr); err != nil {
1291 log.Printf("Error saving contact from email: %v", err)
1292 }
1293 }
1294 }
1295 cache := &config.EmailCache{Emails: cachedEmails}
1296 if err := config.SaveEmailCache(cache); err != nil {
1297 log.Printf("Error saving email cache: %v", err)
1298 }
1299}
1300
1301// parseEmailAddress parses "Name <email>" or just "email" format
1302func parseEmailAddress(addr string) (name, email string) {
1303 addr = strings.TrimSpace(addr)
1304 if idx := strings.Index(addr, "<"); idx != -1 {
1305 name = strings.TrimSpace(addr[:idx])
1306 endIdx := strings.Index(addr, ">")
1307 if endIdx > idx {
1308 email = strings.TrimSpace(addr[idx+1 : endIdx])
1309 } else {
1310 email = strings.TrimSpace(addr[idx+1:])
1311 }
1312 } else {
1313 email = addr
1314 }
1315 return name, email
1316}
1317
1318func fetchEmailBodyCmd(cfg *config.Config, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
1319 return func() tea.Msg {
1320 account := cfg.GetAccountByID(accountID)
1321 if account == nil {
1322 return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: fmt.Errorf("account not found")}
1323 }
1324
1325 var (
1326 body string
1327 attachments []fetcher.Attachment
1328 err error
1329 )
1330 switch mailbox {
1331 case tui.MailboxSent:
1332 body, attachments, err = fetcher.FetchSentEmailBody(account, uid)
1333 case tui.MailboxTrash:
1334 body, attachments, err = fetcher.FetchTrashEmailBody(account, uid)
1335 case tui.MailboxArchive:
1336 body, attachments, err = fetcher.FetchArchiveEmailBody(account, uid)
1337 default:
1338 body, attachments, err = fetcher.FetchEmailBody(account, uid)
1339 }
1340 if err != nil {
1341 return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1342 }
1343
1344 return tui.EmailBodyFetchedMsg{
1345 UID: uid,
1346 Body: body,
1347 Attachments: attachments,
1348 AccountID: accountID,
1349 Mailbox: mailbox,
1350 }
1351 }
1352}
1353
1354func markdownToHTML(md []byte) []byte {
1355 var buf bytes.Buffer
1356 p := goldmark.New(goldmark.WithRendererOptions(html.WithUnsafe()))
1357 if err := p.Convert(md, &buf); err != nil {
1358 return md
1359 }
1360 return buf.Bytes()
1361}
1362
1363func splitEmails(s string) []string {
1364 if s == "" {
1365 return nil
1366 }
1367 parts := strings.Split(s, ",")
1368 var res []string
1369 for _, p := range parts {
1370 if trimmed := strings.TrimSpace(p); trimmed != "" {
1371 res = append(res, trimmed)
1372 }
1373 }
1374 return res
1375}
1376
1377func sendEmail(account *config.Account, msg tui.SendEmailMsg) tea.Cmd {
1378 return func() tea.Msg {
1379 if account == nil {
1380 return tui.EmailResultMsg{Err: fmt.Errorf("no account configured")}
1381 }
1382
1383 recipients := splitEmails(msg.To)
1384 cc := splitEmails(msg.Cc)
1385 bcc := splitEmails(msg.Bcc)
1386 body := msg.Body
1387 // Append signature if present
1388 if msg.Signature != "" {
1389 body = body + "\n\n" + msg.Signature
1390 }
1391 // Append quoted text if present (for replies)
1392 if msg.QuotedText != "" {
1393 body = body + msg.QuotedText
1394 }
1395 images := make(map[string][]byte)
1396 attachments := make(map[string][]byte)
1397
1398 re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
1399 matches := re.FindAllStringSubmatch(body, -1)
1400
1401 for _, match := range matches {
1402 imgPath := match[1]
1403 imgData, err := os.ReadFile(imgPath)
1404 if err != nil {
1405 log.Printf("Could not read image file %s: %v", imgPath, err)
1406 continue
1407 }
1408 cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "matcha")
1409 images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
1410 body = strings.Replace(body, imgPath, "cid:"+cid, 1)
1411 }
1412
1413 htmlBody := markdownToHTML([]byte(body))
1414
1415 for _, attachPath := range msg.AttachmentPaths {
1416 fileData, err := os.ReadFile(attachPath)
1417 if err != nil {
1418 log.Printf("Could not read attachment file %s: %v", attachPath, err)
1419 continue
1420 }
1421 _, filename := filepath.Split(attachPath)
1422 attachments[filename] = fileData
1423 }
1424
1425 err := sender.SendEmail(account, recipients, cc, bcc, msg.Subject, body, string(htmlBody), images, attachments, msg.InReplyTo, msg.References, msg.SignSMIME, msg.EncryptSMIME)
1426 if err != nil {
1427 log.Printf("Failed to send email: %v", err)
1428 return tui.EmailResultMsg{Err: err}
1429 }
1430 return tui.EmailResultMsg{}
1431 }
1432}
1433
1434func deleteEmailCmd(account *config.Account, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
1435 return func() tea.Msg {
1436 var err error
1437 switch mailbox {
1438 case tui.MailboxSent:
1439 err = fetcher.DeleteSentEmail(account, uid)
1440 case tui.MailboxTrash:
1441 err = fetcher.DeleteTrashEmail(account, uid)
1442 case tui.MailboxArchive:
1443 err = fetcher.DeleteArchiveEmail(account, uid)
1444 default:
1445 err = fetcher.DeleteEmail(account, uid)
1446 }
1447 return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1448 }
1449}
1450
1451func archiveEmailCmd(account *config.Account, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
1452 return func() tea.Msg {
1453 var err error
1454 if mailbox == tui.MailboxSent {
1455 err = fetcher.ArchiveSentEmail(account, uid)
1456 } else {
1457 err = fetcher.ArchiveEmail(account, uid)
1458 }
1459 return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1460 }
1461}
1462
1463// --- Folder-based command functions ---
1464
1465func fetchFoldersCmd(cfg *config.Config) tea.Cmd {
1466 return func() tea.Msg {
1467 if !cfg.HasAccounts() {
1468 return nil
1469 }
1470 foldersByAccount := make(map[string][]fetcher.Folder)
1471 seen := make(map[string]fetcher.Folder)
1472 var mu sync.Mutex
1473 var wg sync.WaitGroup
1474
1475 for _, account := range cfg.Accounts {
1476 wg.Add(1)
1477 go func(acc config.Account) {
1478 defer wg.Done()
1479 folders, err := fetcher.FetchFolders(&acc)
1480 if err != nil {
1481 return
1482 }
1483 mu.Lock()
1484 foldersByAccount[acc.ID] = folders
1485 for _, f := range folders {
1486 if _, ok := seen[f.Name]; !ok {
1487 seen[f.Name] = f
1488 }
1489 }
1490 mu.Unlock()
1491 }(account)
1492 }
1493 wg.Wait()
1494
1495 var merged []fetcher.Folder
1496 for _, f := range seen {
1497 merged = append(merged, f)
1498 }
1499
1500 return tui.FoldersFetchedMsg{
1501 FoldersByAccount: foldersByAccount,
1502 MergedFolders: merged,
1503 }
1504 }
1505}
1506
1507func fetchFolderEmailsCmd(cfg *config.Config, folderName string) tea.Cmd {
1508 return func() tea.Msg {
1509 emailsByAccount := make(map[string][]fetcher.Email)
1510 var mu sync.Mutex
1511 var wg sync.WaitGroup
1512
1513 for _, account := range cfg.Accounts {
1514 wg.Add(1)
1515 go func(acc config.Account) {
1516 defer wg.Done()
1517 emails, err := fetcher.FetchFolderEmails(&acc, folderName, initialEmailLimit, 0)
1518 if err != nil {
1519 // Folder may not exist for this account — silently skip
1520 return
1521 }
1522 mu.Lock()
1523 emailsByAccount[acc.ID] = emails
1524 mu.Unlock()
1525 }(account)
1526 }
1527
1528 wg.Wait()
1529
1530 // Flatten all account emails
1531 var allEmails []fetcher.Email
1532 for _, emails := range emailsByAccount {
1533 allEmails = append(allEmails, emails...)
1534 }
1535 // Sort newest first
1536 for i := 0; i < len(allEmails); i++ {
1537 for j := i + 1; j < len(allEmails); j++ {
1538 if allEmails[j].Date.After(allEmails[i].Date) {
1539 allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
1540 }
1541 }
1542 }
1543
1544 return tui.FolderEmailsFetchedMsg{
1545 Emails: allEmails,
1546 FolderName: folderName,
1547 }
1548 }
1549}
1550
1551func fetchFolderEmailsPaginatedCmd(account *config.Account, folderName string, limit, offset uint32) tea.Cmd {
1552 return func() tea.Msg {
1553 emails, err := fetcher.FetchFolderEmails(account, folderName, limit, offset)
1554 if err != nil {
1555 return tui.FetchErr(err)
1556 }
1557 return tui.FolderEmailsAppendedMsg{
1558 Emails: emails,
1559 AccountID: account.ID,
1560 FolderName: folderName,
1561 }
1562 }
1563}
1564
1565func fetchFolderEmailBodyCmd(cfg *config.Config, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
1566 return func() tea.Msg {
1567 account := cfg.GetAccountByID(accountID)
1568 if account == nil {
1569 return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: fmt.Errorf("account not found")}
1570 }
1571
1572 body, attachments, err := fetcher.FetchFolderEmailBody(account, folderName, uid)
1573 if err != nil {
1574 return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1575 }
1576
1577 return tui.EmailBodyFetchedMsg{
1578 UID: uid,
1579 Body: body,
1580 Attachments: attachments,
1581 AccountID: accountID,
1582 Mailbox: mailbox,
1583 }
1584 }
1585}
1586
1587func markEmailAsReadCmd(account *config.Account, uid uint32, accountID string, folderName string) tea.Cmd {
1588 return func() tea.Msg {
1589 err := fetcher.MarkEmailAsReadInMailbox(account, folderName, uid)
1590 return tui.EmailMarkedReadMsg{UID: uid, AccountID: accountID, Err: err}
1591 }
1592}
1593
1594func deleteFolderEmailCmd(account *config.Account, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
1595 return func() tea.Msg {
1596 err := fetcher.DeleteFolderEmail(account, folderName, uid)
1597 return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1598 }
1599}
1600
1601func archiveFolderEmailCmd(account *config.Account, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
1602 return func() tea.Msg {
1603 err := fetcher.ArchiveFolderEmail(account, folderName, uid)
1604 return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1605 }
1606}
1607
1608func moveEmailToFolderCmd(account *config.Account, uid uint32, accountID string, sourceFolder, destFolder string) tea.Cmd {
1609 return func() tea.Msg {
1610 err := fetcher.MoveEmailToFolder(account, uid, sourceFolder, destFolder)
1611 return tui.EmailMovedMsg{
1612 UID: uid,
1613 AccountID: accountID,
1614 SourceFolder: sourceFolder,
1615 DestFolder: destFolder,
1616 Err: err,
1617 }
1618 }
1619}
1620
1621func downloadAttachmentCmd(account *config.Account, uid uint32, msg tui.DownloadAttachmentMsg) tea.Cmd {
1622 return func() tea.Msg {
1623 // Download and decode the attachment using encoding provided in msg.Encoding.
1624 var data []byte
1625 var err error
1626 switch msg.Mailbox {
1627 case tui.MailboxSent:
1628 data, err = fetcher.FetchSentAttachment(account, uid, msg.PartID, msg.Encoding)
1629 case tui.MailboxTrash:
1630 data, err = fetcher.FetchTrashAttachment(account, uid, msg.PartID, msg.Encoding)
1631 case tui.MailboxArchive:
1632 data, err = fetcher.FetchArchiveAttachment(account, uid, msg.PartID, msg.Encoding)
1633 default:
1634 data, err = fetcher.FetchAttachment(account, uid, msg.PartID, msg.Encoding)
1635 }
1636 if err != nil {
1637 return tui.AttachmentDownloadedMsg{Err: err}
1638 }
1639
1640 homeDir, err := os.UserHomeDir()
1641 if err != nil {
1642 return tui.AttachmentDownloadedMsg{Err: err}
1643 }
1644 downloadsPath := filepath.Join(homeDir, "Downloads")
1645 if _, err := os.Stat(downloadsPath); os.IsNotExist(err) {
1646 if mkErr := os.MkdirAll(downloadsPath, 0755); mkErr != nil {
1647 return tui.AttachmentDownloadedMsg{Err: mkErr}
1648 }
1649 }
1650
1651 // Save the attachment using an exclusive create so we never overwrite an existing file.
1652 // If the filename already exists, append \" (n)\" before the extension.
1653 origName := msg.Filename
1654 ext := filepath.Ext(origName)
1655 base := strings.TrimSuffix(origName, ext)
1656 candidate := origName
1657 i := 1
1658 var filePath string
1659
1660 for {
1661 filePath = filepath.Join(downloadsPath, candidate)
1662
1663 // Try to create file exclusively. If it already exists, os.OpenFile will return an error
1664 // that satisfies os.IsExist(err), so we can increment the candidate.
1665 f, err := os.OpenFile(filePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
1666 if err != nil {
1667 if os.IsExist(err) {
1668 // file exists, try next candidate
1669 candidate = fmt.Sprintf("%s (%d)%s", base, i, ext)
1670 i++
1671 continue
1672 }
1673 // Some other error while attempting to create file
1674 log.Printf("error creating file %s: %v", filePath, err)
1675 return tui.AttachmentDownloadedMsg{Err: err}
1676 }
1677
1678 // Successfully created the file descriptor; write and close.
1679 if _, writeErr := f.Write(data); writeErr != nil {
1680 _ = f.Close()
1681 log.Printf("error writing to file %s: %v", filePath, writeErr)
1682 return tui.AttachmentDownloadedMsg{Err: writeErr}
1683 }
1684 if closeErr := f.Close(); closeErr != nil {
1685 log.Printf("warning: error closing file %s: %v", filePath, closeErr)
1686 }
1687
1688 // file saved successfully
1689 break
1690 }
1691
1692 log.Printf("attachment saved to %s", filePath)
1693
1694 // Try to open the file using a platform-specific opener asynchronously and log the outcome.
1695 go func(p string) {
1696 var cmd *exec.Cmd
1697 switch runtime.GOOS {
1698 case "darwin":
1699 cmd = exec.Command("open", p)
1700 case "linux":
1701 cmd = exec.Command("xdg-open", p)
1702 case "windows":
1703 // 'start' is a cmd builtin; provide an empty title argument to avoid interpreting the path as the title.
1704 cmd = exec.Command("cmd", "/c", "start", "", p)
1705 default:
1706 // Unsupported OS: nothing to do.
1707 return
1708 }
1709 if err := cmd.Start(); err != nil {
1710 log.Printf("failed to open file %s: %v", p, err)
1711 }
1712 }(filePath)
1713
1714 return tui.AttachmentDownloadedMsg{Path: filePath, Err: nil}
1715 }
1716}
1717
1718/*
1719detectInstalledVersion returns a best-effort installed version string.
1720Priority:
1721 1. If the build-in `version` variable is set to something other than "dev", return it.
1722 2. If Homebrew is present and reports a version for `matcha`, return that.
1723 3. If snap is present and lists `matcha`, return that.
1724 4. Fallback to the build `version` (likely "dev").
1725*/
1726func detectInstalledVersion() string {
1727 v := strings.TrimSpace(version)
1728 if v != "dev" && v != "" {
1729 return v
1730 }
1731
1732 // Try Homebrew (macOS)
1733 if runtime.GOOS == "darwin" {
1734 if _, err := exec.LookPath("brew"); err == nil {
1735 // `brew list --versions matcha` prints: matcha 1.2.3
1736 if out, err := exec.Command("brew", "list", "--versions", "matcha").Output(); err == nil {
1737 parts := strings.Fields(string(out))
1738 if len(parts) >= 2 {
1739 return parts[1]
1740 }
1741 }
1742 }
1743 }
1744
1745 // Try snap (Linux)
1746 if runtime.GOOS == "linux" {
1747 if _, err := exec.LookPath("snap"); err == nil {
1748 if out, err := exec.Command("snap", "list", "matcha").Output(); err == nil {
1749 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
1750 if len(lines) >= 2 {
1751 fields := strings.Fields(lines[1])
1752 if len(fields) >= 2 {
1753 return fields[1]
1754 }
1755 }
1756 }
1757 }
1758
1759 if _, err := exec.LookPath("flatpak"); err == nil {
1760 if out, err := exec.Command("flatpak", "info", "com.floatpane.matcha").Output(); err == nil {
1761 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
1762 for _, line := range lines {
1763 line = strings.TrimSpace(line)
1764 if strings.HasPrefix(line, "Version:") {
1765 fields := strings.Fields(line)
1766 if len(fields) >= 2 {
1767 return fields[1]
1768 }
1769 }
1770 }
1771 }
1772 }
1773 }
1774
1775 return v
1776}
1777
1778/*
1779checkForUpdatesCmd queries GitHub for the latest release tag and returns a
1780tea.Msg (UpdateAvailableMsg) if the latest version differs from the current
1781installed version. This runs in the background when the TUI initializes.
1782*/
1783func checkForUpdatesCmd() tea.Cmd {
1784 return func() tea.Msg {
1785 // Non-fatal: if anything goes wrong we just don't show the update message.
1786 const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
1787 resp, err := http.Get(api)
1788 if err != nil {
1789 return nil
1790 }
1791 defer resp.Body.Close()
1792
1793 var rel githubRelease
1794 if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
1795 return nil
1796 }
1797
1798 latest := strings.TrimPrefix(rel.TagName, "v")
1799 installed := strings.TrimPrefix(detectInstalledVersion(), "v")
1800 if latest != "" && installed != "" && latest != installed {
1801 return UpdateAvailableMsg{Latest: latest, Current: installed}
1802 }
1803 return nil
1804 }
1805}
1806
1807// runUpdateCLI implements the CLI entrypoint for `matcha update`.
1808// It detects the likely installation method and attempts the appropriate
1809// update path (Homebrew, Snap, or GitHub release binary extract).
1810func runUpdateCLI() error {
1811 const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
1812 resp, err := http.Get(api)
1813 if err != nil {
1814 return fmt.Errorf("could not query releases: %w", err)
1815 }
1816 defer resp.Body.Close()
1817
1818 var rel githubRelease
1819 if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
1820 return fmt.Errorf("could not parse release info: %w", err)
1821 }
1822
1823 latestTag := rel.TagName
1824 if strings.HasPrefix(latestTag, "v") {
1825 latestTag = latestTag[1:]
1826 }
1827
1828 fmt.Printf("Current version: %s\n", version)
1829 fmt.Printf("Latest version: %s\n", latestTag)
1830
1831 // Quick check: if already up-to-date, exit
1832 cur := version
1833 if strings.HasPrefix(cur, "v") {
1834 cur = cur[1:]
1835 }
1836 if latestTag == "" || cur == latestTag {
1837 fmt.Println("Already up to date.")
1838 return nil
1839 }
1840
1841 // Detect Homebrew
1842 if _, err := exec.LookPath("brew"); err == nil {
1843 fmt.Println("Detected Homebrew — updating taps and attempting to upgrade via brew.")
1844
1845 updateCmd := exec.Command("brew", "update")
1846 updateCmd.Stdout = os.Stdout
1847 updateCmd.Stderr = os.Stderr
1848 if err := updateCmd.Run(); err != nil {
1849 fmt.Printf("Homebrew update failed: %v\n", err)
1850 // continue to attempt upgrade even if update failed
1851 }
1852
1853 upgradeCmd := exec.Command("brew", "upgrade", "floatpane/matcha/matcha")
1854 upgradeCmd.Stdout = os.Stdout
1855 upgradeCmd.Stderr = os.Stderr
1856 if err := upgradeCmd.Run(); err == nil {
1857 fmt.Println("Successfully upgraded via Homebrew.")
1858 return nil
1859 }
1860 fmt.Printf("Homebrew upgrade failed: %v\n", err)
1861 // fallthrough to other methods
1862 }
1863
1864 // Detect snap
1865 if _, err := exec.LookPath("snap"); err == nil {
1866 // Check if matcha is installed as a snap
1867 cmdCheck := exec.Command("snap", "list", "matcha")
1868 if err := cmdCheck.Run(); err == nil {
1869 fmt.Println("Detected Snap package — attempting to refresh.")
1870 cmd := exec.Command("snap", "refresh", "matcha")
1871 cmd.Stdout = os.Stdout
1872 cmd.Stderr = os.Stderr
1873 if err := cmd.Run(); err == nil {
1874 fmt.Println("Successfully refreshed snap.")
1875 return nil
1876 }
1877 fmt.Printf("Snap refresh failed: %v\n", err)
1878 // fallthrough
1879 }
1880 }
1881 // Detect flatpak
1882 if _, err := exec.LookPath("flatpak"); err == nil {
1883 // Check if matcha is installed as a flatpak
1884 cmdCheck := exec.Command("flatpak", "info", "com.floatpane.matcha")
1885 if err := cmdCheck.Run(); err == nil {
1886 fmt.Println("Detected Flatpak package — attempting to update.")
1887 cmd := exec.Command("flatpak", "update", "-y", "com.floatpane.matcha")
1888 cmd.Stdout = os.Stdout
1889 cmd.Stderr = os.Stderr
1890 if err := cmd.Run(); err == nil {
1891 fmt.Println("Successfully updated flatpak.")
1892 return nil
1893 }
1894 fmt.Printf("Flatpak update failed: %v\n", err)
1895 // fallthrough
1896 }
1897 }
1898
1899 // Otherwise attempt to download the proper release asset and replace the binary.
1900 osName := runtime.GOOS
1901 arch := runtime.GOARCH
1902
1903 // Try to find a matching asset
1904 var assetURL, assetName string
1905 for _, a := range rel.Assets {
1906 n := strings.ToLower(a.Name)
1907 if strings.Contains(n, osName) && strings.Contains(n, arch) && (strings.HasSuffix(n, ".tar.gz") || strings.HasSuffix(n, ".tgz") || strings.HasSuffix(n, ".zip")) {
1908 assetURL = a.BrowserDownloadURL
1909 assetName = a.Name
1910 break
1911 }
1912 }
1913 if assetURL == "" {
1914 // Try any asset that contains 'matcha' and os/arch as a fallback
1915 for _, a := range rel.Assets {
1916 n := strings.ToLower(a.Name)
1917 if strings.Contains(n, "matcha") && (strings.Contains(n, osName) || strings.Contains(n, arch)) {
1918 assetURL = a.BrowserDownloadURL
1919 assetName = a.Name
1920 break
1921 }
1922 }
1923 }
1924
1925 if assetURL == "" {
1926 return fmt.Errorf("no suitable release artifact found for %s/%s", osName, arch)
1927 }
1928
1929 fmt.Printf("Found release asset: %s\n", assetName)
1930 fmt.Println("Downloading...")
1931
1932 // Download asset
1933 respAsset, err := http.Get(assetURL)
1934 if err != nil {
1935 return fmt.Errorf("download failed: %w", err)
1936 }
1937 defer respAsset.Body.Close()
1938
1939 // Create a temp file for the download
1940 tmpDir, err := os.MkdirTemp("", "matcha-update-*")
1941 if err != nil {
1942 return fmt.Errorf("could not create temp dir: %w", err)
1943 }
1944 defer os.RemoveAll(tmpDir)
1945
1946 assetPath := filepath.Join(tmpDir, assetName)
1947 outFile, err := os.Create(assetPath)
1948 if err != nil {
1949 return fmt.Errorf("could not create temp file: %w", err)
1950 }
1951 _, err = io.Copy(outFile, respAsset.Body)
1952 outFile.Close()
1953 if err != nil {
1954 return fmt.Errorf("could not write asset to disk: %w", err)
1955 }
1956
1957 // If it's a tar.gz, extract and find the `matcha` binary
1958 var binPath string
1959 if strings.HasSuffix(assetName, ".tar.gz") || strings.HasSuffix(assetName, ".tgz") {
1960 f, err := os.Open(assetPath)
1961 if err != nil {
1962 return fmt.Errorf("could not open archive: %w", err)
1963 }
1964 defer f.Close()
1965 gzr, err := gzip.NewReader(f)
1966 if err != nil {
1967 return fmt.Errorf("could not create gzip reader: %w", err)
1968 }
1969 tr := tar.NewReader(gzr)
1970 for {
1971 hdr, err := tr.Next()
1972 if err == io.EOF {
1973 break
1974 }
1975 if err != nil {
1976 return fmt.Errorf("error reading tar: %w", err)
1977 }
1978 name := filepath.Base(hdr.Name)
1979 if name == "matcha" || strings.Contains(strings.ToLower(name), "matcha") && (hdr.Typeflag == tar.TypeReg) {
1980 // write out the file
1981 binPath = filepath.Join(tmpDir, "matcha")
1982 out, err := os.Create(binPath)
1983 if err != nil {
1984 return fmt.Errorf("could not create binary file: %w", err)
1985 }
1986 if _, err := io.Copy(out, tr); err != nil {
1987 out.Close()
1988 return fmt.Errorf("could not extract binary: %w", err)
1989 }
1990 out.Close()
1991 if err := os.Chmod(binPath, 0755); err != nil {
1992 return fmt.Errorf("could not make binary executable: %w", err)
1993 }
1994 break
1995 }
1996 }
1997 } else {
1998 // For non-archive assets, assume the asset is the binary itself.
1999 binPath = assetPath
2000 if err := os.Chmod(binPath, 0755); err != nil {
2001 // ignore chmod errors but warn
2002 fmt.Printf("warning: could not chmod downloaded binary: %v\n", err)
2003 }
2004 }
2005
2006 if binPath == "" {
2007 return fmt.Errorf("could not locate matcha binary inside the release artifact")
2008 }
2009
2010 // Replace the running executable with the new binary
2011 execPath, err := os.Executable()
2012 if err != nil {
2013 return fmt.Errorf("could not determine executable path: %w", err)
2014 }
2015
2016 // Write the new binary to a temp file in same dir, then rename for atomic replacement.
2017 execDir := filepath.Dir(execPath)
2018 tmpNew := filepath.Join(execDir, fmt.Sprintf("matcha.new.%d", time.Now().Unix()))
2019 in, err := os.Open(binPath)
2020 if err != nil {
2021 return fmt.Errorf("could not open new binary: %w", err)
2022 }
2023 out, err := os.OpenFile(tmpNew, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
2024 if err != nil {
2025 in.Close()
2026 return fmt.Errorf("could not create temp binary in target dir: %w", err)
2027 }
2028 if _, err := io.Copy(out, in); err != nil {
2029 in.Close()
2030 out.Close()
2031 return fmt.Errorf("could not write new binary to disk: %w", err)
2032 }
2033 in.Close()
2034 out.Close()
2035
2036 // Attempt to atomically replace
2037 if err := os.Rename(tmpNew, execPath); err != nil {
2038 return fmt.Errorf("could not replace executable: %w", err)
2039 }
2040
2041 fmt.Println("Successfully updated matcha to", latestTag)
2042 return nil
2043}
2044
2045func filterUnique(existing, incoming []fetcher.Email) []fetcher.Email {
2046 seen := make(map[uint32]struct{})
2047 for _, e := range existing {
2048 seen[e.UID] = struct{}{}
2049 }
2050 var unique []fetcher.Email
2051 for _, e := range incoming {
2052 if _, ok := seen[e.UID]; !ok {
2053 unique = append(unique, e)
2054 }
2055 }
2056 return unique
2057}
2058
2059func main() {
2060 // If invoked with version flag, print version and exit
2061 if len(os.Args) > 1 && (os.Args[1] == "-v" || os.Args[1] == "--version" || os.Args[1] == "version") {
2062 fmt.Printf("matcha version %s", version)
2063 if commit != "" {
2064 fmt.Printf(" (%s)", commit)
2065 }
2066 if date != "" {
2067 fmt.Printf(" built on %s", date)
2068 }
2069 fmt.Println()
2070 os.Exit(0)
2071 }
2072
2073 // If invoked as CLI update command, run updater and exit.
2074 if len(os.Args) > 1 && os.Args[1] == "update" {
2075 if err := runUpdateCLI(); err != nil {
2076 fmt.Fprintf(os.Stderr, "update failed: %v\n", err)
2077 os.Exit(1)
2078 }
2079 os.Exit(0)
2080 }
2081
2082 cfg, err := config.LoadConfig()
2083 if err == nil && cfg.Theme != "" {
2084 theme.SetTheme(cfg.Theme)
2085 }
2086 tui.RebuildStyles()
2087
2088 var initialModel *mainModel
2089 if err != nil {
2090 initialModel = newInitialModel(nil)
2091 } else {
2092 initialModel = newInitialModel(cfg)
2093 }
2094
2095 p := tea.NewProgram(initialModel)
2096
2097 if _, err := p.Run(); err != nil {
2098 fmt.Printf("Alas, there's been an error: %v", err)
2099 os.Exit(1)
2100 }
2101}