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