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