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