1package main
2
3import (
4 "archive/tar"
5 "archive/zip"
6 "compress/gzip"
7 "context"
8 "encoding/base64"
9 "encoding/json"
10 "flag"
11 "fmt"
12 "io"
13 "log"
14 "net/http"
15 "os"
16 "os/exec"
17 "path/filepath"
18 "regexp"
19 "runtime"
20 "slices"
21 "strings"
22 "sync"
23 "time"
24
25 tea "charm.land/bubbletea/v2"
26 "github.com/floatpane/matcha/backend"
27 _ "github.com/floatpane/matcha/backend/imap"
28 _ "github.com/floatpane/matcha/backend/jmap"
29 _ "github.com/floatpane/matcha/backend/pop3"
30 matchaCli "github.com/floatpane/matcha/cli"
31 "github.com/floatpane/matcha/clib"
32 "github.com/floatpane/matcha/config"
33 "github.com/floatpane/matcha/fetcher"
34 "github.com/floatpane/matcha/notify"
35 "github.com/floatpane/matcha/plugin"
36 "github.com/floatpane/matcha/sender"
37 "github.com/floatpane/matcha/theme"
38 "github.com/floatpane/matcha/tui"
39 "github.com/google/uuid"
40 lua "github.com/yuin/gopher-lua"
41)
42
43const (
44 initialEmailLimit = 50
45 paginationLimit = 50
46 maxCacheEmails = 100
47)
48
49// Version variables are injected by the build (GoReleaser ldflags).
50// They default to "dev" when not set by the build system.
51var (
52 version = "dev"
53 commit = ""
54 date = ""
55)
56
57// UpdateAvailableMsg is sent into the TUI when a newer release is detected.
58type UpdateAvailableMsg struct {
59 Latest string
60 Current string
61}
62
63// internal struct for parsing GitHub release JSON.
64type githubRelease struct {
65 TagName string `json:"tag_name"`
66 Assets []struct {
67 Name string `json:"name"`
68 BrowserDownloadURL string `json:"browser_download_url"`
69 } `json:"assets"`
70}
71
72type mainModel struct {
73 current tea.Model
74 previousModel tea.Model
75 config *config.Config
76 plugins *plugin.Manager
77 // Folder-based email storage
78 folderEmails map[string][]fetcher.Email // key: folderName
79 folderInbox *tui.FolderInbox
80 // Legacy fields kept for email actions
81 emails []fetcher.Email
82 emailsByAcct map[string][]fetcher.Email
83 width int
84 height int
85 err error
86 // IMAP IDLE
87 idleWatcher *fetcher.IdleWatcher
88 idleUpdates chan fetcher.IdleUpdate
89 // Multi-protocol backend providers (keyed by account ID)
90 providers map[string]backend.Provider
91 // Plugin prompt waiting for user input
92 pendingPrompt *plugin.PendingPrompt
93}
94
95func newInitialModel(cfg *config.Config) *mainModel {
96 idleUpdates := make(chan fetcher.IdleUpdate, 16)
97 initialModel := &mainModel{
98 emailsByAcct: make(map[string][]fetcher.Email),
99 folderEmails: make(map[string][]fetcher.Email),
100 idleUpdates: idleUpdates,
101 idleWatcher: fetcher.NewIdleWatcher(idleUpdates),
102 providers: make(map[string]backend.Provider),
103 }
104
105 if cfg == nil || !cfg.HasAccounts() {
106 hideTips := false
107 if cfg != nil {
108 hideTips = cfg.HideTips
109 }
110 initialModel.current = tui.NewLogin(hideTips)
111 } else {
112 initialModel.current = tui.NewChoice()
113 initialModel.config = cfg
114 }
115 return initialModel
116}
117
118// ensureProviders creates backend providers for all configured accounts.
119func (m *mainModel) ensureProviders() {
120 if m.config == nil {
121 return
122 }
123 for _, acct := range m.config.Accounts {
124 if _, ok := m.providers[acct.ID]; ok {
125 continue
126 }
127 p, err := backend.New(&acct)
128 if err != nil {
129 log.Printf("backend: failed to create provider for %s: %v", acct.Email, err)
130 continue
131 }
132 m.providers[acct.ID] = p
133 }
134}
135
136// getProvider returns the backend provider for the given account.
137func (m *mainModel) getProvider(acct *config.Account) backend.Provider {
138 if acct == nil {
139 return nil
140 }
141 return m.providers[acct.ID]
142}
143
144func (m *mainModel) Init() tea.Cmd {
145 return tea.Batch(m.current.Init(), checkForUpdatesCmd())
146}
147
148func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
149 var cmd tea.Cmd
150 var cmds []tea.Cmd
151
152 m.current, cmd = m.current.Update(msg)
153 cmds = append(cmds, cmd)
154
155 // Fire composer_updated hook on key presses when the composer is active
156 if keyMsg, isKey := msg.(tea.KeyPressMsg); isKey {
157 if composer, ok := m.current.(*tui.Composer); ok && m.plugins != nil {
158 m.plugins.CallComposerHook(plugin.HookComposerUpdated, composer.GetBody(), composer.GetSubject(), composer.GetTo(), composer.GetCc(), composer.GetBcc())
159 m.syncPluginStatus()
160 m.applyPluginFields(composer)
161 }
162
163 // Check plugin key bindings for the current view
164 if m.plugins != nil {
165 m.handlePluginKeyBinding(keyMsg)
166 }
167 }
168
169 switch msg := msg.(type) {
170 case tea.WindowSizeMsg:
171 m.width = msg.Width
172 m.height = msg.Height
173 return m, nil
174
175 case tea.KeyPressMsg:
176 if msg.String() == "ctrl+c" {
177 m.idleWatcher.StopAll()
178 return m, tea.Quit
179 }
180 if msg.String() == "esc" {
181 switch m.current.(type) {
182 case *tui.FilePicker:
183 return m, func() tea.Msg { return tui.CancelFilePickerMsg{} }
184 case *tui.FolderInbox, *tui.Inbox, *tui.Login:
185 m.idleWatcher.StopAll()
186 m.current = tui.NewChoice()
187 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
188 return m, m.current.Init()
189 }
190 }
191
192 case tui.BackToInboxMsg:
193 if m.folderInbox != nil {
194 m.current = m.folderInbox
195 } else {
196 m.current = tui.NewChoice()
197 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
198 }
199 return m, nil
200
201 case tui.BackToMailboxMsg:
202 // Ensure kitty graphics are cleared when leaving email view
203 tui.ClearKittyGraphics()
204 if m.folderInbox != nil {
205 m.current = m.folderInbox
206 return m, nil
207 }
208 m.current = tui.NewChoice()
209 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
210 return m, nil
211
212 case tui.DiscardDraftMsg:
213 // Save draft to disk
214 if msg.ComposerState != nil {
215 draft := msg.ComposerState.ToDraft()
216
217 if err := config.SaveDraft(draft); err != nil {
218 log.Printf("Error saving draft: %v", err)
219 }
220
221 }
222 m.current = tui.NewChoice()
223 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
224 return m, m.current.Init()
225
226 case tui.OAuth2CompleteMsg:
227 if msg.Err != nil {
228 log.Printf("OAuth2 authorization failed: %v", msg.Err)
229 }
230 // After OAuth2 flow, go to the choice menu so user can proceed
231 m.current = tui.NewChoice()
232 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
233 return m, m.current.Init()
234
235 case tui.Credentials:
236 // Split FetchEmail by commas to support multiple fetch addresses.
237 // Each address creates a separate account sharing the same login credentials.
238 fetchEmails := []string{""}
239 if msg.FetchEmail != "" {
240 fetchEmails = fetchEmails[:0]
241 for _, fe := range strings.Split(msg.FetchEmail, ",") {
242 if trimmed := strings.TrimSpace(fe); trimmed != "" {
243 fetchEmails = append(fetchEmails, trimmed)
244 }
245 }
246 if len(fetchEmails) == 0 {
247 fetchEmails = []string{""}
248 }
249 }
250
251 if m.config == nil {
252 m.config = &config.Config{}
253 }
254
255 // Check if we're editing an existing account
256 isEdit := false
257 var lastAccount config.Account
258 if login, ok := m.current.(*tui.Login); ok && login.IsEditMode() {
259 isEdit = true
260 existingID := login.GetAccountID()
261
262 account := config.Account{
263 ID: existingID,
264 Name: msg.Name,
265 Email: msg.Host,
266 Password: msg.Password,
267 ServiceProvider: msg.Provider,
268 FetchEmail: fetchEmails[0],
269 SendAsEmail: msg.SendAsEmail,
270 AuthMethod: msg.AuthMethod,
271 Protocol: msg.Protocol,
272 JMAPEndpoint: msg.JMAPEndpoint,
273 POP3Server: msg.POP3Server,
274 POP3Port: msg.POP3Port,
275 }
276
277 if msg.Provider == "custom" || msg.Protocol == "pop3" {
278 account.IMAPServer = msg.IMAPServer
279 account.IMAPPort = msg.IMAPPort
280 account.SMTPServer = msg.SMTPServer
281 account.SMTPPort = msg.SMTPPort
282 }
283
284 if account.FetchEmail == "" && account.Email != "" {
285 account.FetchEmail = account.Email
286 }
287
288 // Find and update the existing account, preserving S/MIME settings
289 for i, acc := range m.config.Accounts {
290 if acc.ID == existingID {
291 account.SMIMECert = acc.SMIMECert
292 account.SMIMEKey = acc.SMIMEKey
293 account.SMIMESignByDefault = acc.SMIMESignByDefault
294 if account.Password == "" {
295 account.Password = acc.Password
296 }
297 m.config.Accounts[i] = account
298 break
299 }
300 }
301 lastAccount = account
302 } else {
303 // New account: create one account per fetch email address
304 for _, fe := range fetchEmails {
305 account := config.Account{
306 ID: uuid.New().String(),
307 Name: msg.Name,
308 Email: msg.Host,
309 Password: msg.Password,
310 ServiceProvider: msg.Provider,
311 FetchEmail: fe,
312 SendAsEmail: msg.SendAsEmail,
313 AuthMethod: msg.AuthMethod,
314 Protocol: msg.Protocol,
315 JMAPEndpoint: msg.JMAPEndpoint,
316 POP3Server: msg.POP3Server,
317 POP3Port: msg.POP3Port,
318 }
319
320 if msg.Provider == "custom" || msg.Protocol == "pop3" {
321 account.IMAPServer = msg.IMAPServer
322 account.IMAPPort = msg.IMAPPort
323 account.SMTPServer = msg.SMTPServer
324 account.SMTPPort = msg.SMTPPort
325 }
326
327 if account.FetchEmail == "" && account.Email != "" {
328 account.FetchEmail = account.Email
329 }
330
331 m.config.AddAccount(account)
332 lastAccount = account
333 }
334 }
335
336 if err := config.SaveConfig(m.config); err != nil {
337 log.Printf("could not save config: %v", err)
338 return m, tea.Quit
339 }
340
341 // If OAuth2, launch the authorization flow after saving the account
342 if lastAccount.IsOAuth2() {
343 email := lastAccount.Email
344 return m, func() tea.Msg {
345 err := config.RunOAuth2Flow(email, "", "")
346 return tui.OAuth2CompleteMsg{Email: email, Err: err}
347 }
348 }
349
350 if isEdit {
351 m.current = tui.NewSettings(m.config)
352 } else {
353 m.current = tui.NewChoice()
354 }
355 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
356 return m, m.current.Init()
357
358 case tui.GoToInboxMsg:
359 if m.config == nil || !m.config.HasAccounts() {
360 hideTips := false
361 if m.config != nil {
362 hideTips = m.config.HideTips
363 }
364 m.current = tui.NewLogin(hideTips)
365 return m, m.current.Init()
366 }
367 m.ensureProviders()
368 // Load cached folders from all accounts, merge unique names
369 seen := make(map[string]bool)
370 var cachedFolders []string
371 for _, acc := range m.config.Accounts {
372 for _, f := range config.GetCachedFolders(acc.ID) {
373 if !seen[f] {
374 seen[f] = true
375 cachedFolders = append(cachedFolders, f)
376 }
377 }
378 }
379 if len(cachedFolders) == 0 {
380 cachedFolders = []string{"INBOX"}
381 }
382 m.folderInbox = tui.NewFolderInbox(cachedFolders, m.config.Accounts)
383 // Use cached INBOX emails for instant display (memory first, then disk)
384 if cached, ok := m.folderEmails["INBOX"]; ok && len(cached) > 0 {
385 m.folderInbox.SetEmails(cached, m.config.Accounts)
386 } else if diskCached := loadFolderEmailsFromCache("INBOX"); len(diskCached) > 0 {
387 m.folderEmails["INBOX"] = diskCached
388 m.emails = diskCached
389 m.emailsByAcct = make(map[string][]fetcher.Email)
390 for _, email := range diskCached {
391 m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
392 }
393 m.folderInbox.SetEmails(diskCached, m.config.Accounts)
394 }
395 m.current = m.folderInbox
396 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
397 // Start IDLE watchers for all accounts on INBOX
398 for i := range m.config.Accounts {
399 m.idleWatcher.Watch(&m.config.Accounts[i], "INBOX")
400 }
401 // Fetch folders and INBOX emails in parallel (background refresh)
402 return m, tea.Batch(
403 m.current.Init(),
404 fetchFoldersCmd(m.config),
405 fetchFolderEmailsCmd(m.config, "INBOX"),
406 listenForIdleUpdates(m.idleUpdates),
407 )
408
409 case tui.FoldersFetchedMsg:
410 if m.folderInbox == nil {
411 return m, nil
412 }
413 var folderNames []string
414 for _, f := range msg.MergedFolders {
415 folderNames = append(folderNames, f.Name)
416 }
417 m.folderInbox.SetFolders(folderNames)
418 // Cache folder lists per account
419 for accID, folders := range msg.FoldersByAccount {
420 var names []string
421 for _, f := range folders {
422 names = append(names, f.Name)
423 }
424 go config.SaveAccountFolders(accID, names)
425 }
426 return m, nil
427
428 case tui.SwitchFolderMsg:
429 if m.config == nil {
430 return m, nil
431 }
432 // Update IDLE watchers to monitor the new folder
433 for i := range m.config.Accounts {
434 // Only start IDLE for accounts that actually have this folder
435 folders := config.GetCachedFolders(m.config.Accounts[i].ID)
436 if !slices.Contains(folders, msg.FolderName) {
437 m.idleWatcher.Stop(m.config.Accounts[i].ID)
438 continue
439 }
440 m.idleWatcher.Watch(&m.config.Accounts[i], msg.FolderName)
441 }
442 if m.plugins != nil {
443 m.plugins.CallFolderHook(plugin.HookFolderChanged, msg.FolderName)
444 m.syncPluginStatus()
445 m.syncPluginKeyBindings()
446 }
447 // Use in-memory cache if available
448 if cached, ok := m.folderEmails[msg.FolderName]; ok {
449 m.emails = cached
450 m.emailsByAcct = make(map[string][]fetcher.Email)
451 for _, email := range cached {
452 m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
453 }
454 if m.folderInbox != nil {
455 m.folderInbox.SetEmails(cached, m.config.Accounts)
456 m.folderInbox.GetInbox().SetFolderName(msg.FolderName)
457 m.folderInbox.SetLoadingEmails(false)
458 }
459 return m, m.pluginNotifyCmd()
460 }
461 // Fall back to disk cache for instant display, then fetch fresh in background
462 if diskCached := loadFolderEmailsFromCache(msg.FolderName); len(diskCached) > 0 {
463 m.folderEmails[msg.FolderName] = diskCached
464 m.emails = diskCached
465 m.emailsByAcct = make(map[string][]fetcher.Email)
466 for _, email := range diskCached {
467 m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
468 }
469 if m.folderInbox != nil {
470 m.folderInbox.SetEmails(diskCached, m.config.Accounts)
471 m.folderInbox.GetInbox().SetFolderName(msg.FolderName)
472 m.folderInbox.SetLoadingEmails(false)
473 }
474 // Still fetch fresh emails in background
475 return m, tea.Batch(fetchFolderEmailsCmd(m.config, msg.FolderName), m.pluginNotifyCmd())
476 }
477 if m.folderInbox != nil {
478 m.folderInbox.SetLoadingEmails(true)
479 }
480 return m, tea.Batch(fetchFolderEmailsCmd(m.config, msg.FolderName), m.pluginNotifyCmd())
481
482 case tui.PluginNotifyMsg:
483 m.previousModel = m.current
484 m.current = tui.NewStatus(msg.Message)
485 dur := time.Duration(msg.Duration * float64(time.Second))
486 if dur <= 0 {
487 dur = 2 * time.Second
488 }
489 return m, tea.Tick(dur, func(t time.Time) tea.Msg {
490 return tui.RestoreViewMsg{}
491 })
492
493 case tui.PluginPromptSubmitMsg:
494 if m.pendingPrompt != nil {
495 if composer, ok := m.current.(*tui.Composer); ok {
496 composer.HidePluginPrompt()
497 m.plugins.ResolvePrompt(m.pendingPrompt, msg.Value)
498 m.applyPluginFields(composer)
499 m.syncPluginStatus()
500 }
501 m.pendingPrompt = nil
502 }
503 return m, nil
504
505 case tui.PluginPromptCancelMsg:
506 if composer, ok := m.current.(*tui.Composer); ok {
507 composer.HidePluginPrompt()
508 }
509 m.pendingPrompt = nil
510 return m, nil
511
512 case tui.FolderEmailsFetchedMsg:
513 if m.folderInbox == nil {
514 return m, nil
515 }
516 // Call plugin hooks for received emails
517 if m.plugins != nil {
518 for _, email := range msg.Emails {
519 t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, msg.FolderName)
520 m.plugins.CallHook(plugin.HookEmailReceived, t)
521 }
522 }
523 // Always cache in memory and to disk
524 m.folderEmails[msg.FolderName] = msg.Emails
525 go saveFolderEmailsToCache(msg.FolderName, msg.Emails)
526 // Prune stale body cache entries
527 go func() {
528 validUIDs := make(map[uint32]string, len(msg.Emails))
529 for _, e := range msg.Emails {
530 validUIDs[e.UID] = e.AccountID
531 }
532 _ = config.PruneEmailBodyCache(msg.FolderName, validUIDs)
533 }()
534 // Only update the view if the user is still on this folder
535 if m.folderInbox.GetCurrentFolder() != msg.FolderName {
536 return m, nil
537 }
538 m.emails = msg.Emails
539 m.emailsByAcct = make(map[string][]fetcher.Email)
540 for _, email := range msg.Emails {
541 m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
542 }
543 m.folderInbox.SetEmails(msg.Emails, m.config.Accounts)
544 m.folderInbox.GetInbox().SetFolderName(msg.FolderName)
545 m.folderInbox.SetLoadingEmails(false)
546 m.syncPluginStatus()
547 m.syncPluginKeyBindings()
548 return m, m.pluginNotifyCmd()
549
550 case tui.FetchFolderMoreEmailsMsg:
551 if msg.AccountID == "" || m.config == nil {
552 return m, nil
553 }
554 account := m.config.GetAccountByID(msg.AccountID)
555 if account == nil {
556 return m, nil
557 }
558 limit := uint32(paginationLimit)
559 if msg.Limit > 0 {
560 limit = msg.Limit
561 }
562 return m, tea.Batch(
563 func() tea.Msg { return tui.FetchingMoreEmailsMsg{} },
564 fetchFolderEmailsPaginatedCmd(account, msg.FolderName, limit, msg.Offset),
565 )
566
567 case tui.FolderEmailsAppendedMsg:
568 // Ignore stale appends for a folder the user has moved away from
569 if m.folderInbox == nil || m.folderInbox.GetCurrentFolder() != msg.FolderName {
570 return m, nil
571 }
572 m.folderInbox.Update(msg)
573 // Update local stores and per-folder cache
574 for _, email := range msg.Emails {
575 m.emails = append(m.emails, email)
576 m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
577 }
578 m.folderEmails[msg.FolderName] = append(m.folderEmails[msg.FolderName], msg.Emails...)
579 go saveFolderEmailsToCache(msg.FolderName, m.folderEmails[msg.FolderName])
580 return m, nil
581
582 case tui.MoveEmailToFolderMsg:
583 if m.config == nil {
584 return m, nil
585 }
586 account := m.config.GetAccountByID(msg.AccountID)
587 if account == nil {
588 return m, nil
589 }
590 m.previousModel = m.current
591 m.current = tui.NewStatus("Moving email...")
592 return m, tea.Batch(m.current.Init(), moveEmailToFolderCmd(account, msg.UID, msg.AccountID, msg.SourceFolder, msg.DestFolder))
593
594 case tui.EmailMovedMsg:
595 if msg.Err != nil {
596 log.Printf("Move failed: %v", msg.Err)
597 if m.folderInbox != nil {
598 m.previousModel = m.folderInbox
599 }
600 m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
601 return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
602 return tui.RestoreViewMsg{}
603 })
604 }
605 // Remove email from current view
606 if m.folderInbox != nil {
607 m.folderInbox.RemoveEmail(msg.UID, msg.AccountID)
608 m.current = m.folderInbox
609 }
610 return m, nil
611
612 case tui.CachedEmailsLoadedMsg:
613 // Cache is no longer used for the folder-based inbox flow
614 // This handler is kept for backwards compatibility but simply fetches normally
615 if m.folderInbox == nil {
616 return m, nil
617 }
618 return m, fetchFolderEmailsCmd(m.config, m.folderInbox.GetCurrentFolder())
619
620 case tui.IdleNewMailMsg:
621 // Send desktop notification for new mail (if enabled)
622 if m.config == nil || !m.config.DisableNotifications {
623 accountName := msg.AccountID
624 if m.config != nil {
625 if acc := m.config.GetAccountByID(msg.AccountID); acc != nil {
626 accountName = acc.Email
627 }
628 }
629 go notify.Send("Matcha", fmt.Sprintf("New mail in %s (%s)", msg.FolderName, accountName))
630 }
631
632 // IDLE detected new mail — refetch the folder if we're viewing it
633 if m.folderInbox != nil && m.folderInbox.GetCurrentFolder() == msg.FolderName {
634 return m, tea.Batch(
635 fetchFolderEmailsCmd(m.config, msg.FolderName),
636 listenForIdleUpdates(m.idleUpdates),
637 )
638 }
639 // Re-subscribe even if not viewing the affected folder
640 return m, listenForIdleUpdates(m.idleUpdates)
641
642 case tui.RequestRefreshMsg:
643 // Folder-based refresh: clear folder cache and refetch
644 if msg.FolderName != "" && m.config != nil {
645 delete(m.folderEmails, msg.FolderName)
646 if m.folderInbox != nil {
647 m.folderInbox.SetRefreshing(true)
648 }
649 return m, fetchFolderEmailsCmd(m.config, msg.FolderName)
650 }
651 return m, tea.Batch(
652 func() tea.Msg { return tui.RefreshingEmailsMsg{Mailbox: msg.Mailbox} },
653 refreshEmails(m.config, msg.Mailbox, msg.Counts),
654 )
655
656 case tui.EmailsRefreshedMsg:
657 // Merge refreshed emails with any paginated emails already loaded.
658 for accID, refreshed := range msg.EmailsByAccount {
659 refreshedUIDs := make(map[uint32]struct{}, len(refreshed))
660 for _, e := range refreshed {
661 refreshedUIDs[e.UID] = struct{}{}
662 }
663 if existing, ok := m.emailsByAcct[accID]; ok {
664 for _, e := range existing {
665 if _, found := refreshedUIDs[e.UID]; !found {
666 refreshed = append(refreshed, e)
667 }
668 }
669 }
670 m.emailsByAcct[accID] = refreshed
671 }
672 m.emails = flattenAndSort(m.emailsByAcct)
673
674 // Update folder inbox if it exists
675 if m.folderInbox != nil {
676 m.folderInbox.SetEmails(m.emails, m.config.Accounts)
677 m.folderInbox.GetInbox().Update(msg)
678 }
679 return m, nil
680
681 case tui.AllEmailsFetchedMsg:
682 m.emailsByAcct = msg.EmailsByAccount
683 m.emails = flattenAndSort(msg.EmailsByAccount)
684
685 if m.folderInbox != nil {
686 m.folderInbox.SetEmails(m.emails, m.config.Accounts)
687 m.folderInbox.SetLoadingEmails(false)
688 }
689 return m, nil
690
691 case tui.EmailsFetchedMsg:
692 if m.emailsByAcct == nil {
693 m.emailsByAcct = make(map[string][]fetcher.Email)
694 }
695 m.emailsByAcct[msg.AccountID] = msg.Emails
696 m.emails = flattenAndSort(m.emailsByAcct)
697 if m.folderInbox != nil {
698 m.folderInbox.SetEmails(m.emails, m.config.Accounts)
699 }
700 return m, nil
701
702 case tui.FetchMoreEmailsMsg:
703 if msg.AccountID == "" {
704 return m, nil
705 }
706 account := m.config.GetAccountByID(msg.AccountID)
707 if account == nil {
708 return m, nil
709 }
710 limit := uint32(paginationLimit)
711 if msg.Limit > 0 {
712 limit = msg.Limit
713 }
714 folderName := "INBOX"
715 if m.folderInbox != nil {
716 folderName = m.folderInbox.GetCurrentFolder()
717 }
718 return m, tea.Batch(
719 func() tea.Msg { return tui.FetchingMoreEmailsMsg{} },
720 fetchFolderEmailsPaginatedCmd(account, folderName, limit, msg.Offset),
721 )
722
723 case tui.EmailsAppendedMsg:
724 if m.emailsByAcct == nil {
725 m.emailsByAcct = make(map[string][]fetcher.Email)
726 }
727 unique := filterUnique(m.emailsByAcct[msg.AccountID], msg.Emails)
728 m.emailsByAcct[msg.AccountID] = append(m.emailsByAcct[msg.AccountID], unique...)
729 m.emails = append(m.emails, unique...)
730 return m, nil
731
732 case tui.GoToSendMsg:
733 hideTips := false
734 if m.config != nil {
735 hideTips = m.config.HideTips
736 }
737 if m.config != nil && len(m.config.Accounts) > 0 {
738 firstAccount := m.config.GetFirstAccount()
739 composer := tui.NewComposerWithAccounts(m.config.Accounts, firstAccount.ID, msg.To, msg.Subject, msg.Body, hideTips)
740 m.current = composer
741 } else {
742 m.current = tui.NewComposer("", msg.To, msg.Subject, msg.Body, hideTips)
743 }
744 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
745 m.syncPluginKeyBindings()
746 return m, m.current.Init()
747
748 case tui.GoToDraftsMsg:
749 drafts := config.GetAllDrafts()
750 m.current = tui.NewDrafts(drafts)
751 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
752 return m, m.current.Init()
753
754 case tui.OpenDraftMsg:
755 var accounts []config.Account
756 hideTips := false
757 if m.config != nil {
758 accounts = m.config.Accounts
759 hideTips = m.config.HideTips
760 }
761 composer := tui.NewComposerFromDraft(msg.Draft, accounts, hideTips)
762 m.current = composer
763 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
764 m.syncPluginKeyBindings()
765 return m, m.current.Init()
766
767 case tui.DeleteSavedDraftMsg:
768 go func() {
769 if err := config.DeleteDraft(msg.DraftID); err != nil {
770 log.Printf("Error deleting draft: %v", err)
771 }
772 }()
773 // Send message back to drafts view
774 m.current, cmd = m.current.Update(tui.DraftDeletedMsg{DraftID: msg.DraftID})
775 return m, cmd
776
777 case tui.GoToMarketplaceMsg:
778 m.current = tui.NewMarketplace(false)
779 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
780 return m, m.current.Init()
781
782 case tui.GoToSettingsMsg:
783 m.current = tui.NewSettings(m.config)
784 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
785 return m, m.current.Init()
786
787 case tui.GoToAddAccountMsg:
788 hideTips := false
789 if m.config != nil {
790 hideTips = m.config.HideTips
791 }
792 m.current = tui.NewLogin(hideTips)
793 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
794 return m, m.current.Init()
795
796 case tui.GoToAddMailingListMsg:
797 m.current = tui.NewMailingListEditor()
798 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
799 return m, m.current.Init()
800
801 case tui.GoToEditAccountMsg:
802 hideTips := false
803 if m.config != nil {
804 hideTips = m.config.HideTips
805 }
806 login := tui.NewLogin(hideTips)
807 login.SetEditMode(msg.AccountID, msg.Protocol, msg.Provider, msg.Name, msg.Email, msg.FetchEmail, msg.SendAsEmail, msg.IMAPServer, msg.IMAPPort, msg.SMTPServer, msg.SMTPPort, msg.JMAPEndpoint, msg.POP3Server, msg.POP3Port)
808 m.current = login
809 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
810 return m, m.current.Init()
811
812 case tui.GoToEditMailingListMsg:
813 editor := tui.NewMailingListEditor()
814 editor.SetEditMode(msg.Index, msg.Name, msg.Addresses)
815 m.current = editor
816 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
817 return m, m.current.Init()
818
819 case tui.SaveMailingListMsg:
820 if m.config != nil {
821 var addrs []string
822 for _, part := range strings.Split(msg.Addresses, ",") {
823 if trimmed := strings.TrimSpace(part); trimmed != "" {
824 addrs = append(addrs, trimmed)
825 }
826 }
827 if msg.EditIndex >= 0 && msg.EditIndex < len(m.config.MailingLists) {
828 m.config.MailingLists[msg.EditIndex] = config.MailingList{
829 Name: msg.Name,
830 Addresses: addrs,
831 }
832 } else {
833 m.config.MailingLists = append(m.config.MailingLists, config.MailingList{
834 Name: msg.Name,
835 Addresses: addrs,
836 })
837 }
838 if err := config.SaveConfig(m.config); err != nil {
839 log.Printf("could not save config: %v", err)
840 }
841 }
842 // Return to settings
843 m.current = tui.NewSettings(m.config)
844 // Try to navigate to the mailing list view internally if possible, but NewSettings will go to SettingsMain by default.
845 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
846 return m, m.current.Init()
847
848 case tui.GoToSignatureEditorMsg:
849 m.current = tui.NewSignatureEditor()
850 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
851 return m, m.current.Init()
852
853 case tui.PasswordVerifiedMsg:
854 if msg.Err != nil {
855 // Error is handled inside PasswordPrompt itself
856 return m, nil
857 }
858 // Password verified — set session key and load config
859 config.SetSessionKey(msg.Key)
860 cfg, err := config.LoadConfig()
861 if err == nil && cfg.Theme != "" {
862 theme.SetTheme(cfg.Theme)
863 tui.RebuildStyles()
864 }
865 _ = config.EnsurePGPDir()
866 if err != nil {
867 m.config = nil
868 hideTips := false
869 m.current = tui.NewLogin(hideTips)
870 } else {
871 m.config = cfg
872 m.current = tui.NewChoice()
873 }
874 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
875 return m, m.current.Init()
876
877 case tui.SecureModeEnabledMsg:
878 if msg.Err != nil {
879 log.Printf("Failed to enable encryption: %v", msg.Err)
880 }
881 return m, nil
882
883 case tui.SecureModeDisabledMsg:
884 if msg.Err != nil {
885 log.Printf("Failed to disable encryption: %v", msg.Err)
886 }
887 return m, nil
888
889 case tui.GoToChoiceMenuMsg:
890 m.current = tui.NewChoice()
891 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
892 return m, m.current.Init()
893
894 case tui.DeleteAccountMsg:
895 if m.config != nil {
896 m.config.RemoveAccount(msg.AccountID)
897 if err := config.SaveConfig(m.config); err != nil {
898 log.Printf("could not save config: %v", err)
899 }
900 // Remove emails for this account
901 delete(m.emailsByAcct, msg.AccountID)
902
903 // Rebuild all emails
904 var allEmails []fetcher.Email
905 for _, emails := range m.emailsByAcct {
906 allEmails = append(allEmails, emails...)
907 }
908 m.emails = allEmails
909
910 // Go back to settings
911 m.current = tui.NewSettings(m.config)
912 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
913 }
914 return m, m.current.Init()
915
916 case tui.ViewEmailMsg:
917 email := m.getEmailByUIDAndAccount(msg.UID, msg.AccountID, msg.Mailbox)
918 if email == nil {
919 return m, nil
920 }
921 folderName := "INBOX"
922 if m.folderInbox != nil {
923 folderName = m.folderInbox.GetCurrentFolder()
924 }
925 if m.plugins != nil {
926 t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, folderName)
927 m.plugins.CallHook(plugin.HookEmailViewed, t)
928 }
929 // Check body cache first
930 if cached := config.GetCachedEmailBody(folderName, msg.UID, msg.AccountID); cached != nil {
931 // Convert cached attachments back to fetcher.Attachment
932 var attachments []fetcher.Attachment
933 for _, ca := range cached.Attachments {
934 attachments = append(attachments, fetcher.Attachment{
935 Filename: ca.Filename,
936 PartID: ca.PartID,
937 Encoding: ca.Encoding,
938 MIMEType: ca.MIMEType,
939 ContentID: ca.ContentID,
940 Inline: ca.Inline,
941 IsSMIMESignature: ca.IsSMIMESignature,
942 SMIMEVerified: ca.SMIMEVerified,
943 IsSMIMEEncrypted: ca.IsSMIMEEncrypted,
944 })
945 }
946 return m, func() tea.Msg {
947 return tui.EmailBodyFetchedMsg{
948 UID: msg.UID,
949 Body: cached.Body,
950 Attachments: attachments,
951 AccountID: msg.AccountID,
952 Mailbox: msg.Mailbox,
953 }
954 }
955 }
956 m.current = tui.NewStatus("Fetching email content...")
957 return m, tea.Batch(m.current.Init(), fetchFolderEmailBodyCmd(m.config, msg.UID, msg.AccountID, folderName, msg.Mailbox), m.pluginNotifyCmd())
958
959 case tui.EmailBodyFetchedMsg:
960 if msg.Err != nil {
961 log.Printf("could not fetch email body: %v", msg.Err)
962 if m.folderInbox != nil {
963 m.current = m.folderInbox
964 }
965 return m, nil
966 }
967
968 // Update the email in our stores
969 m.updateEmailBodyByUID(msg.UID, msg.AccountID, msg.Mailbox, msg.Body, msg.Attachments)
970
971 // Cache the body to disk
972 folderForCache := "INBOX"
973 if m.folderInbox != nil {
974 folderForCache = m.folderInbox.GetCurrentFolder()
975 }
976 var cachedAttachments []config.CachedAttachment
977 for _, a := range msg.Attachments {
978 cachedAttachments = append(cachedAttachments, config.CachedAttachment{
979 Filename: a.Filename,
980 PartID: a.PartID,
981 Encoding: a.Encoding,
982 MIMEType: a.MIMEType,
983 ContentID: a.ContentID,
984 Inline: a.Inline,
985 IsSMIMESignature: a.IsSMIMESignature,
986 SMIMEVerified: a.SMIMEVerified,
987 IsSMIMEEncrypted: a.IsSMIMEEncrypted,
988 })
989 }
990 _ = config.SaveEmailBody(folderForCache, config.CachedEmailBody{
991 UID: msg.UID,
992 AccountID: msg.AccountID,
993 Body: msg.Body,
994 Attachments: cachedAttachments,
995 })
996
997 email := m.getEmailByUIDAndAccount(msg.UID, msg.AccountID, msg.Mailbox)
998 if email == nil {
999 if m.folderInbox != nil {
1000 m.current = m.folderInbox
1001 }
1002 return m, nil
1003 }
1004
1005 // Mark as read in UI immediately and on the server
1006 var markReadCmd tea.Cmd
1007 if !email.IsRead {
1008 m.markEmailAsReadInStores(msg.UID, msg.AccountID)
1009
1010 folderName := "INBOX"
1011 if m.folderInbox != nil {
1012 folderName = m.folderInbox.GetCurrentFolder()
1013 }
1014 account := m.config.GetAccountByID(msg.AccountID)
1015 if account != nil {
1016 markReadCmd = markEmailAsReadCmd(account, msg.UID, msg.AccountID, folderName)
1017 }
1018 }
1019
1020 // Find the index for the email view (used for display purposes)
1021 emailIndex := m.getEmailIndex(msg.UID, msg.AccountID, msg.Mailbox)
1022 emailView := tui.NewEmailView(*email, emailIndex, m.width, m.height, msg.Mailbox, m.config.DisableImages)
1023 m.current = emailView
1024 m.syncPluginStatus()
1025 m.syncPluginKeyBindings()
1026 cmds := []tea.Cmd{m.current.Init()}
1027 if markReadCmd != nil {
1028 cmds = append(cmds, markReadCmd)
1029 }
1030 return m, tea.Batch(cmds...)
1031
1032 case tui.ReplyToEmailMsg:
1033 to := msg.Email.From
1034 subject := msg.Email.Subject
1035 normalizedSubject := strings.ToLower(strings.TrimSpace(subject))
1036 if !strings.HasPrefix(normalizedSubject, "re:") {
1037 subject = "Re: " + subject
1038 }
1039 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> "))
1040
1041 var composer *tui.Composer
1042 hideTips := false
1043 if m.config != nil {
1044 hideTips = m.config.HideTips
1045 }
1046 if m.config != nil && len(m.config.Accounts) > 0 {
1047 // Use the account that received the email
1048 accountID := msg.Email.AccountID
1049 if accountID == "" {
1050 accountID = m.config.GetFirstAccount().ID
1051 }
1052 composer = tui.NewComposerWithAccounts(m.config.Accounts, accountID, to, subject, "", hideTips)
1053 } else {
1054 composer = tui.NewComposer("", to, subject, "", hideTips)
1055 }
1056 composer.SetQuotedText(quotedText)
1057
1058 // Set reply headers
1059 inReplyTo := msg.Email.MessageID
1060 references := append(msg.Email.References, msg.Email.MessageID)
1061 composer.SetReplyContext(inReplyTo, references)
1062
1063 m.current = composer
1064 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1065 m.syncPluginKeyBindings()
1066 return m, m.current.Init()
1067
1068 case tui.ForwardEmailMsg:
1069 subject := msg.Email.Subject
1070 if !strings.HasPrefix(strings.ToLower(subject), "fwd:") {
1071 subject = "Fwd: " + subject
1072 }
1073
1074 forwardHeader := fmt.Sprintf("\n\n---------- Forwarded message ----------\nFrom: %s\nDate: %s\nSubject: %s\nTo: %s\n\n",
1075 msg.Email.From,
1076 msg.Email.Date.Format("Mon, Jan 2, 2006 at 3:04 PM"),
1077 msg.Email.Subject,
1078 msg.Email.To,
1079 )
1080
1081 body := forwardHeader + msg.Email.Body
1082
1083 var composer *tui.Composer
1084 hideTips := false
1085 if m.config != nil {
1086 hideTips = m.config.HideTips
1087 }
1088 if m.config != nil && len(m.config.Accounts) > 0 {
1089 // Use the account that received the email
1090 accountID := msg.Email.AccountID
1091 if accountID == "" {
1092 accountID = m.config.GetFirstAccount().ID
1093 }
1094 composer = tui.NewComposerWithAccounts(m.config.Accounts, accountID, "", subject, body, hideTips)
1095 } else {
1096 composer = tui.NewComposer("", "", subject, body, hideTips)
1097 }
1098
1099 m.current = composer
1100 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1101 m.syncPluginKeyBindings()
1102 return m, m.current.Init()
1103
1104 case tui.OpenEditorMsg:
1105 composer, ok := m.current.(*tui.Composer)
1106 if !ok {
1107 return m, nil
1108 }
1109 return m, openExternalEditor(composer.GetBody())
1110
1111 case tui.EditorFinishedMsg:
1112 if msg.Err != nil {
1113 log.Printf("Editor error: %v", msg.Err)
1114 return m, nil
1115 }
1116 if composer, ok := m.current.(*tui.Composer); ok {
1117 composer.SetBody(msg.Body)
1118 }
1119 return m, nil
1120
1121 case tui.GoToFilePickerMsg:
1122 m.previousModel = m.current
1123 wd, _ := os.Getwd()
1124 m.current = tui.NewFilePicker(wd)
1125 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1126 return m, m.current.Init()
1127
1128 case tui.FileSelectedMsg, tui.CancelFilePickerMsg:
1129 if m.previousModel != nil {
1130 m.current = m.previousModel
1131 m.previousModel = nil
1132 }
1133 m.current, cmd = m.current.Update(msg)
1134 cmds = append(cmds, cmd)
1135
1136 case tui.SendEmailMsg:
1137 if m.plugins != nil {
1138 m.plugins.CallSendHook(plugin.HookEmailSendBefore, msg.To, msg.Cc, msg.Subject, msg.AccountID)
1139 }
1140 // Get draft ID before clearing composer (if it's a composer)
1141 var draftID string
1142 if composer, ok := m.current.(*tui.Composer); ok {
1143 draftID = composer.GetDraftID()
1144 }
1145 // Get the account to send from
1146 var account *config.Account
1147 if msg.AccountID != "" && m.config != nil {
1148 account = m.config.GetAccountByID(msg.AccountID)
1149 }
1150 if account == nil && m.config != nil {
1151 account = m.config.GetFirstAccount()
1152 }
1153
1154 statusText := "Sending email..."
1155 if msg.SignPGP && account != nil && account.PGPKeySource == "yubikey" {
1156 statusText = "Touch your YubiKey to sign..."
1157 }
1158 m.current = tui.NewStatus(statusText)
1159
1160 // Save contact and delete draft in background
1161 go func() {
1162 // Save the recipient as a contact
1163 if msg.To != "" {
1164 recipients := strings.Split(msg.To, ",")
1165 for _, r := range recipients {
1166 r = strings.TrimSpace(r)
1167 if r == "" {
1168 continue
1169 }
1170 name, email := parseEmailAddress(r)
1171 if err := config.AddContact(name, email); err != nil {
1172 log.Printf("Error saving contact: %v", err)
1173 }
1174 }
1175 }
1176 // Delete the draft since email is being sent
1177 if draftID != "" {
1178 if err := config.DeleteDraft(draftID); err != nil {
1179 log.Printf("Error deleting draft after send: %v", err)
1180 }
1181 }
1182 }()
1183
1184 return m, tea.Batch(m.current.Init(), sendEmail(account, msg))
1185
1186 case tui.EmailResultMsg:
1187 if msg.Err != nil {
1188 log.Printf("Failed to send email: %v", msg.Err)
1189 m.previousModel = tui.NewChoice()
1190 m.previousModel, _ = m.previousModel.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1191 m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
1192 return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1193 return tui.RestoreViewMsg{}
1194 })
1195 }
1196 if m.plugins != nil {
1197 m.plugins.CallHook(plugin.HookEmailSendAfter)
1198 }
1199 m.current = tui.NewChoice()
1200 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1201 return m, m.current.Init()
1202
1203 case tui.DeleteEmailMsg:
1204 tui.ClearKittyGraphics()
1205 m.previousModel = m.current
1206 m.current = tui.NewStatus("Deleting email...")
1207
1208 account := m.config.GetAccountByID(msg.AccountID)
1209 if account == nil {
1210 if m.folderInbox != nil {
1211 m.current = m.folderInbox
1212 }
1213 return m, nil
1214 }
1215
1216 folderName := "INBOX"
1217 if m.folderInbox != nil {
1218 folderName = m.folderInbox.GetCurrentFolder()
1219 }
1220 return m, tea.Batch(m.current.Init(), deleteFolderEmailCmd(account, msg.UID, msg.AccountID, folderName, msg.Mailbox))
1221
1222 case tui.ArchiveEmailMsg:
1223 tui.ClearKittyGraphics()
1224 m.previousModel = m.current
1225 m.current = tui.NewStatus("Archiving email...")
1226
1227 account := m.config.GetAccountByID(msg.AccountID)
1228 if account == nil {
1229 if m.folderInbox != nil {
1230 m.current = m.folderInbox
1231 }
1232 return m, nil
1233 }
1234
1235 folderName := "INBOX"
1236 if m.folderInbox != nil {
1237 folderName = m.folderInbox.GetCurrentFolder()
1238 }
1239 return m, tea.Batch(m.current.Init(), archiveFolderEmailCmd(account, msg.UID, msg.AccountID, folderName, msg.Mailbox))
1240
1241 case tui.EmailMarkedReadMsg:
1242 if msg.Err != nil {
1243 log.Printf("Error marking email as read: %v", msg.Err)
1244 }
1245 return m, nil
1246
1247 case tui.EmailActionDoneMsg:
1248 if msg.Err != nil {
1249 log.Printf("Action failed: %v", msg.Err)
1250 if m.folderInbox != nil {
1251 m.previousModel = m.folderInbox
1252 }
1253 m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
1254 return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1255 return tui.RestoreViewMsg{}
1256 })
1257 }
1258
1259 // Remove email from stores
1260 m.removeEmailFromStores(msg.UID, msg.AccountID)
1261
1262 if m.folderInbox != nil {
1263 m.folderInbox.RemoveEmail(msg.UID, msg.AccountID)
1264 m.current = m.folderInbox
1265 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1266 return m, m.current.Init()
1267 }
1268 m.current = tui.NewChoice()
1269 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1270 return m, m.current.Init()
1271
1272 case tui.BatchDeleteEmailsMsg:
1273 tui.ClearKittyGraphics()
1274 m.previousModel = m.current
1275 count := len(msg.UIDs)
1276 m.current = tui.NewStatus(fmt.Sprintf("Deleting %d emails...", count))
1277
1278 account := m.config.GetAccountByID(msg.AccountID)
1279 if account == nil {
1280 if m.folderInbox != nil {
1281 m.current = m.folderInbox
1282 }
1283 return m, nil
1284 }
1285
1286 folderName := "INBOX"
1287 if m.folderInbox != nil {
1288 folderName = m.folderInbox.GetCurrentFolder()
1289 }
1290
1291 return m, tea.Batch(
1292 m.current.Init(),
1293 m.batchDeleteEmailsCmd(account, msg.UIDs, msg.AccountID, folderName, msg.Mailbox, count),
1294 )
1295
1296 case tui.BatchArchiveEmailsMsg:
1297 tui.ClearKittyGraphics()
1298 m.previousModel = m.current
1299 count := len(msg.UIDs)
1300 m.current = tui.NewStatus(fmt.Sprintf("Archiving %d emails...", count))
1301
1302 account := m.config.GetAccountByID(msg.AccountID)
1303 if account == nil {
1304 if m.folderInbox != nil {
1305 m.current = m.folderInbox
1306 }
1307 return m, nil
1308 }
1309
1310 folderName := "INBOX"
1311 if m.folderInbox != nil {
1312 folderName = m.folderInbox.GetCurrentFolder()
1313 }
1314
1315 return m, tea.Batch(
1316 m.current.Init(),
1317 m.batchArchiveEmailsCmd(account, msg.UIDs, msg.AccountID, folderName, msg.Mailbox, count),
1318 )
1319
1320 case tui.BatchMoveEmailsMsg:
1321 if m.config == nil {
1322 return m, nil
1323 }
1324 account := m.config.GetAccountByID(msg.AccountID)
1325 if account == nil {
1326 return m, nil
1327 }
1328
1329 count := len(msg.UIDs)
1330 m.previousModel = m.current
1331 m.current = tui.NewStatus(fmt.Sprintf("Moving %d emails...", count))
1332
1333 return m, tea.Batch(
1334 m.current.Init(),
1335 m.batchMoveEmailsCmd(account, msg.UIDs, msg.AccountID, msg.SourceFolder, msg.DestFolder, count),
1336 )
1337
1338 case tui.BatchEmailActionDoneMsg:
1339 if msg.Err != nil {
1340 log.Printf("Batch %s failed: %v", msg.Action, msg.Err)
1341 m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
1342 return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1343 return tui.RestoreViewMsg{}
1344 })
1345 }
1346
1347 // Success - show brief confirmation
1348 successMsg := fmt.Sprintf("%d emails %sd successfully", msg.SuccessCount, msg.Action)
1349 if msg.FailureCount > 0 {
1350 successMsg = fmt.Sprintf("%d of %d emails %sd (%d failed)",
1351 msg.SuccessCount, msg.Count, msg.Action, msg.FailureCount)
1352 }
1353
1354 m.current = tui.NewStatus(successMsg)
1355
1356 return m, tea.Tick(1500*time.Millisecond, func(t time.Time) tea.Msg {
1357 return tui.RestoreViewMsg{}
1358 })
1359
1360 case tui.DownloadAttachmentMsg:
1361 m.previousModel = m.current
1362 m.current = tui.NewStatus(fmt.Sprintf("Downloading %s...", msg.Filename))
1363
1364 account := m.config.GetAccountByID(msg.AccountID)
1365 if account == nil {
1366 m.current = m.previousModel
1367 return m, nil
1368 }
1369
1370 email := m.getEmailByIndex(msg.Index, msg.Mailbox)
1371 if email == nil {
1372 m.current = m.previousModel
1373 return m, nil
1374 }
1375
1376 // Find the correct attachment to get encoding
1377 var encoding string
1378 for _, att := range email.Attachments {
1379 if att.PartID == msg.PartID {
1380 encoding = att.Encoding
1381 break
1382 }
1383 }
1384 newMsg := tui.DownloadAttachmentMsg{
1385 Index: msg.Index,
1386 Filename: msg.Filename,
1387 PartID: msg.PartID,
1388 Data: msg.Data,
1389 AccountID: msg.AccountID,
1390 Encoding: encoding,
1391 Mailbox: msg.Mailbox,
1392 }
1393 return m, tea.Batch(m.current.Init(), downloadAttachmentCmd(account, email.UID, newMsg))
1394
1395 case tui.AttachmentDownloadedMsg:
1396 var statusMsg string
1397 if msg.Err != nil {
1398 statusMsg = fmt.Sprintf("Error downloading: %v", msg.Err)
1399 } else {
1400 statusMsg = fmt.Sprintf("Saved to %s", msg.Path)
1401 }
1402 m.current = tui.NewStatus(statusMsg)
1403 return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1404 return tui.RestoreViewMsg{}
1405 })
1406
1407 case tui.RestoreViewMsg:
1408 if m.previousModel != nil {
1409 m.current = m.previousModel
1410 m.previousModel = nil
1411 }
1412 return m, nil
1413 }
1414
1415 if cmd := m.pluginNotifyCmd(); cmd != nil {
1416 cmds = append(cmds, cmd)
1417 }
1418
1419 return m, tea.Batch(cmds...)
1420}
1421
1422func (m *mainModel) View() tea.View {
1423 v := m.current.View()
1424 v.AltScreen = true
1425 return v
1426}
1427
1428func (m *mainModel) getEmailByIndex(index int, mailbox tui.MailboxKind) *fetcher.Email {
1429 if index >= 0 && index < len(m.emails) {
1430 return &m.emails[index]
1431 }
1432 return nil
1433}
1434
1435func (m *mainModel) getEmailByUIDAndAccount(uid uint32, accountID string, mailbox tui.MailboxKind) *fetcher.Email {
1436 for i := range m.emails {
1437 if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1438 return &m.emails[i]
1439 }
1440 }
1441 return nil
1442}
1443
1444func (m *mainModel) getEmailIndex(uid uint32, accountID string, mailbox tui.MailboxKind) int {
1445 for i := range m.emails {
1446 if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1447 return i
1448 }
1449 }
1450 return -1
1451}
1452
1453func (m *mainModel) updateEmailBodyByUID(uid uint32, accountID string, mailbox tui.MailboxKind, body string, attachments []fetcher.Attachment) {
1454 for i := range m.emails {
1455 if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1456 m.emails[i].Body = body
1457 m.emails[i].Attachments = attachments
1458 break
1459 }
1460 }
1461 if emails, ok := m.emailsByAcct[accountID]; ok {
1462 for i := range emails {
1463 if emails[i].UID == uid {
1464 emails[i].Body = body
1465 emails[i].Attachments = attachments
1466 break
1467 }
1468 }
1469 }
1470}
1471
1472func (m *mainModel) markEmailAsReadInStores(uid uint32, accountID string) {
1473 for i := range m.emails {
1474 if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1475 m.emails[i].IsRead = true
1476 break
1477 }
1478 }
1479 if emails, ok := m.emailsByAcct[accountID]; ok {
1480 for i := range emails {
1481 if emails[i].UID == uid {
1482 emails[i].IsRead = true
1483 break
1484 }
1485 }
1486 }
1487 // Update folder email cache
1488 for folderName, folderEmails := range m.folderEmails {
1489 for i := range folderEmails {
1490 if folderEmails[i].UID == uid && folderEmails[i].AccountID == accountID {
1491 folderEmails[i].IsRead = true
1492 m.folderEmails[folderName] = folderEmails
1493 go saveFolderEmailsToCache(folderName, folderEmails)
1494 break
1495 }
1496 }
1497 }
1498 // Update the inbox UI
1499 if m.folderInbox != nil {
1500 m.folderInbox.GetInbox().MarkEmailAsRead(uid, accountID)
1501 }
1502}
1503
1504func (m *mainModel) removeEmailFromStores(uid uint32, accountID string) {
1505 var filtered []fetcher.Email
1506 for _, e := range m.emails {
1507 if !(e.UID == uid && e.AccountID == accountID) {
1508 filtered = append(filtered, e)
1509 }
1510 }
1511 m.emails = filtered
1512 if emails, ok := m.emailsByAcct[accountID]; ok {
1513 var filteredAcct []fetcher.Email
1514 for _, e := range emails {
1515 if e.UID != uid {
1516 filteredAcct = append(filteredAcct, e)
1517 }
1518 }
1519 m.emailsByAcct[accountID] = filteredAcct
1520 }
1521}
1522
1523// pluginNotifyCmd checks for a pending plugin notification and returns a command if one exists.
1524func (m *mainModel) pluginNotifyCmd() tea.Cmd {
1525 if m.plugins == nil {
1526 return nil
1527 }
1528 if n, ok := m.plugins.TakePendingNotification(); ok {
1529 return func() tea.Msg {
1530 return tui.PluginNotifyMsg{Message: n.Message, Duration: n.Duration}
1531 }
1532 }
1533 return nil
1534}
1535
1536func (m *mainModel) syncPluginStatus() {
1537 if m.plugins == nil {
1538 return
1539 }
1540 if m.folderInbox != nil {
1541 m.folderInbox.GetInbox().SetPluginStatus(m.plugins.StatusText(plugin.StatusInbox))
1542 }
1543 switch v := m.current.(type) {
1544 case *tui.Composer:
1545 v.SetPluginStatus(m.plugins.StatusText(plugin.StatusComposer))
1546 case *tui.EmailView:
1547 v.SetPluginStatus(m.plugins.StatusText(plugin.StatusEmailView))
1548 }
1549}
1550
1551func (m *mainModel) handlePluginKeyBinding(msg tea.KeyPressMsg) {
1552 keyStr := msg.String()
1553
1554 var area string
1555 switch m.current.(type) {
1556 case *tui.Inbox:
1557 area = plugin.StatusInbox
1558 case *tui.FolderInbox:
1559 area = plugin.StatusInbox
1560 case *tui.EmailView:
1561 area = plugin.StatusEmailView
1562 case *tui.Composer:
1563 area = plugin.StatusComposer
1564 default:
1565 return
1566 }
1567
1568 bindings := m.plugins.Bindings(area)
1569 for _, binding := range bindings {
1570 if binding.Key != keyStr {
1571 continue
1572 }
1573
1574 // Build context table based on the current view
1575 switch v := m.current.(type) {
1576 case *tui.Inbox:
1577 if email := v.GetSelectedEmail(); email != nil {
1578 t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, "")
1579 m.plugins.CallKeyBinding(binding, t)
1580 } else {
1581 m.plugins.CallKeyBinding(binding)
1582 }
1583 case *tui.FolderInbox:
1584 if email := v.GetInbox().GetSelectedEmail(); email != nil {
1585 t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, v.GetCurrentFolder())
1586 m.plugins.CallKeyBinding(binding, t)
1587 } else {
1588 m.plugins.CallKeyBinding(binding)
1589 }
1590 case *tui.EmailView:
1591 email := v.GetEmail()
1592 t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, "")
1593 m.plugins.CallKeyBinding(binding, t)
1594 case *tui.Composer:
1595 L := m.plugins.LuaState()
1596 t := L.NewTable()
1597 t.RawSetString("body", lua.LString(v.GetBody()))
1598 t.RawSetString("body_len", lua.LNumber(len(v.GetBody())))
1599 t.RawSetString("subject", lua.LString(v.GetSubject()))
1600 t.RawSetString("to", lua.LString(v.GetTo()))
1601 t.RawSetString("cc", lua.LString(v.GetCc()))
1602 t.RawSetString("bcc", lua.LString(v.GetBcc()))
1603 m.plugins.CallKeyBinding(binding, t)
1604 m.applyPluginFields(v)
1605
1606 // Check if the plugin requested a prompt overlay
1607 if p, ok := m.plugins.TakePendingPrompt(); ok {
1608 m.pendingPrompt = p
1609 v.ShowPluginPrompt(p.Placeholder)
1610 }
1611 }
1612
1613 m.syncPluginStatus()
1614 return
1615 }
1616}
1617
1618func (m *mainModel) syncPluginKeyBindings() {
1619 if m.plugins == nil {
1620 return
1621 }
1622
1623 toPluginKeyBindings := func(bindings []plugin.KeyBinding) []tui.PluginKeyBinding {
1624 result := make([]tui.PluginKeyBinding, len(bindings))
1625 for i, b := range bindings {
1626 result[i] = tui.PluginKeyBinding{Key: b.Key, Description: b.Description}
1627 }
1628 return result
1629 }
1630
1631 if m.folderInbox != nil {
1632 m.folderInbox.GetInbox().SetPluginKeyBindings(toPluginKeyBindings(m.plugins.Bindings(plugin.StatusInbox)))
1633 }
1634 switch v := m.current.(type) {
1635 case *tui.Composer:
1636 v.SetPluginKeyBindings(toPluginKeyBindings(m.plugins.Bindings(plugin.StatusComposer)))
1637 case *tui.EmailView:
1638 v.SetPluginKeyBindings(toPluginKeyBindings(m.plugins.Bindings(plugin.StatusEmailView)))
1639 }
1640}
1641
1642func (m *mainModel) applyPluginFields(composer *tui.Composer) {
1643 fields := m.plugins.TakePendingFields()
1644 if fields == nil {
1645 return
1646 }
1647 for field, value := range fields {
1648 switch field {
1649 case "to":
1650 composer.SetTo(value)
1651 case "cc":
1652 composer.SetCc(value)
1653 case "bcc":
1654 composer.SetBcc(value)
1655 case "subject":
1656 composer.SetSubject(value)
1657 case "body":
1658 composer.SetBody(value)
1659 }
1660 }
1661}
1662
1663func flattenAndSort(emailsByAccount map[string][]fetcher.Email) []fetcher.Email {
1664 var allEmails []fetcher.Email
1665 for _, emails := range emailsByAccount {
1666 allEmails = append(allEmails, emails...)
1667 }
1668 for i := 0; i < len(allEmails); i++ {
1669 for j := i + 1; j < len(allEmails); j++ {
1670 if allEmails[j].Date.After(allEmails[i].Date) {
1671 allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
1672 }
1673 }
1674 }
1675 return allEmails
1676}
1677
1678func fetchAllAccountsEmails(cfg *config.Config, mailbox tui.MailboxKind) tea.Cmd {
1679 return func() tea.Msg {
1680 emailsByAccount := make(map[string][]fetcher.Email)
1681 var mu sync.Mutex
1682 var wg sync.WaitGroup
1683
1684 for _, account := range cfg.Accounts {
1685 wg.Add(1)
1686 go func(acc config.Account) {
1687 defer wg.Done()
1688 var emails []fetcher.Email
1689 var err error
1690 switch mailbox {
1691 case tui.MailboxSent:
1692 emails, err = fetcher.FetchSentEmails(&acc, initialEmailLimit, 0)
1693 case tui.MailboxTrash:
1694 emails, err = fetcher.FetchTrashEmails(&acc, initialEmailLimit, 0)
1695 case tui.MailboxArchive:
1696 emails, err = fetcher.FetchArchiveEmails(&acc, initialEmailLimit, 0)
1697 default:
1698 emails, err = fetcher.FetchEmails(&acc, initialEmailLimit, 0)
1699 }
1700 if err != nil {
1701 log.Printf("Error fetching from %s: %v", acc.Email, err)
1702 return
1703 }
1704 mu.Lock()
1705 emailsByAccount[acc.ID] = emails
1706 mu.Unlock()
1707 }(account)
1708 }
1709
1710 wg.Wait()
1711 return tui.AllEmailsFetchedMsg{EmailsByAccount: emailsByAccount, Mailbox: mailbox}
1712 }
1713}
1714
1715func fetchEmails(account *config.Account, limit, offset uint32, mailbox tui.MailboxKind) tea.Cmd {
1716 return func() tea.Msg {
1717 var emails []fetcher.Email
1718 var err error
1719 if mailbox == tui.MailboxSent {
1720 emails, err = fetcher.FetchSentEmails(account, limit, offset)
1721 } else {
1722 emails, err = fetcher.FetchEmails(account, limit, offset)
1723 }
1724 if err != nil {
1725 return tui.FetchErr(err)
1726 }
1727 if offset == 0 {
1728 return tui.EmailsFetchedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1729 }
1730 return tui.EmailsAppendedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1731 }
1732}
1733
1734func fetchEmailsForMailbox(account *config.Account, limit, offset uint32, mailbox tui.MailboxKind) tea.Cmd {
1735 return func() tea.Msg {
1736 var emails []fetcher.Email
1737 var err error
1738 switch mailbox {
1739 case tui.MailboxSent:
1740 emails, err = fetcher.FetchSentEmails(account, limit, offset)
1741 case tui.MailboxTrash:
1742 emails, err = fetcher.FetchTrashEmails(account, limit, offset)
1743 case tui.MailboxArchive:
1744 emails, err = fetcher.FetchArchiveEmails(account, limit, offset)
1745 default:
1746 emails, err = fetcher.FetchEmails(account, limit, offset)
1747 }
1748 if err != nil {
1749 return tui.FetchErr(err)
1750 }
1751 if offset == 0 {
1752 return tui.EmailsFetchedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1753 }
1754 return tui.EmailsAppendedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1755 }
1756}
1757
1758func loadCachedEmails() tea.Cmd {
1759 return func() tea.Msg {
1760 cache, err := config.LoadEmailCache()
1761 if err != nil {
1762 return tui.CachedEmailsLoadedMsg{Cache: nil}
1763 }
1764 return tui.CachedEmailsLoadedMsg{Cache: cache}
1765 }
1766}
1767
1768func refreshEmails(cfg *config.Config, mailbox tui.MailboxKind, counts map[string]int) tea.Cmd {
1769 return func() tea.Msg {
1770 emailsByAccount := make(map[string][]fetcher.Email)
1771 var mu sync.Mutex
1772 var wg sync.WaitGroup
1773
1774 for _, account := range cfg.Accounts {
1775 wg.Add(1)
1776 go func(acc config.Account) {
1777 defer wg.Done()
1778 var emails []fetcher.Email
1779 var err error
1780
1781 limit := uint32(initialEmailLimit)
1782 if counts != nil {
1783 if c, ok := counts[acc.ID]; ok && c > 0 {
1784 limit = uint32(c)
1785 }
1786 }
1787
1788 if mailbox == tui.MailboxSent {
1789 emails, err = fetcher.FetchSentEmails(&acc, limit, 0)
1790 } else {
1791 emails, err = fetcher.FetchEmails(&acc, limit, 0)
1792 }
1793 if err != nil {
1794 log.Printf("Error fetching from %s: %v", acc.Email, err)
1795 return
1796 }
1797 mu.Lock()
1798 emailsByAccount[acc.ID] = emails
1799 mu.Unlock()
1800 }(account)
1801 }
1802
1803 wg.Wait()
1804 return tui.EmailsRefreshedMsg{EmailsByAccount: emailsByAccount, Mailbox: mailbox}
1805 }
1806}
1807
1808func emailsToCache(emails []fetcher.Email) []config.CachedEmail {
1809 var cached []config.CachedEmail
1810 for _, email := range emails {
1811 cached = append(cached, config.CachedEmail{
1812 UID: email.UID,
1813 From: email.From,
1814 To: email.To,
1815 Subject: email.Subject,
1816 Date: email.Date,
1817 MessageID: email.MessageID,
1818 AccountID: email.AccountID,
1819 IsRead: email.IsRead,
1820 })
1821 }
1822 return cached
1823}
1824
1825func cacheToEmails(cached []config.CachedEmail) []fetcher.Email {
1826 var emails []fetcher.Email
1827 for _, c := range cached {
1828 emails = append(emails, fetcher.Email{
1829 UID: c.UID,
1830 From: c.From,
1831 To: c.To,
1832 Subject: c.Subject,
1833 Date: c.Date,
1834 MessageID: c.MessageID,
1835 AccountID: c.AccountID,
1836 IsRead: c.IsRead,
1837 })
1838 }
1839 return emails
1840}
1841
1842func saveFolderEmailsToCache(folderName string, emails []fetcher.Email) {
1843 cached := emailsToCache(emails)
1844 if err := config.SaveFolderEmailCache(folderName, cached); err != nil {
1845 log.Printf("Error saving folder email cache for %s: %v", folderName, err)
1846 }
1847}
1848
1849func loadFolderEmailsFromCache(folderName string) []fetcher.Email {
1850 cached, err := config.LoadFolderEmailCache(folderName)
1851 if err != nil {
1852 return nil
1853 }
1854 return cacheToEmails(cached)
1855}
1856
1857func saveEmailsToCache(emails []fetcher.Email) {
1858 if len(emails) > maxCacheEmails {
1859 emails = emails[:maxCacheEmails]
1860 }
1861 var cachedEmails []config.CachedEmail
1862 for _, email := range emails {
1863 cachedEmails = append(cachedEmails, config.CachedEmail{
1864 UID: email.UID,
1865 From: email.From,
1866 To: email.To,
1867 Subject: email.Subject,
1868 Date: email.Date,
1869 MessageID: email.MessageID,
1870 AccountID: email.AccountID,
1871 IsRead: email.IsRead,
1872 })
1873
1874 // Save sender as a contact
1875 if email.From != "" {
1876 name, emailAddr := parseEmailAddress(email.From)
1877 if err := config.AddContact(name, emailAddr); err != nil {
1878 log.Printf("Error saving contact from email: %v", err)
1879 }
1880 }
1881 }
1882 cache := &config.EmailCache{Emails: cachedEmails}
1883 if err := config.SaveEmailCache(cache); err != nil {
1884 log.Printf("Error saving email cache: %v", err)
1885 }
1886}
1887
1888// parseEmailAddress parses "Name <email>" or just "email" format
1889func parseEmailAddress(addr string) (name, email string) {
1890 addr = strings.TrimSpace(addr)
1891 if idx := strings.Index(addr, "<"); idx != -1 {
1892 name = strings.TrimSpace(addr[:idx])
1893 endIdx := strings.Index(addr, ">")
1894 if endIdx > idx {
1895 email = strings.TrimSpace(addr[idx+1 : endIdx])
1896 } else {
1897 email = strings.TrimSpace(addr[idx+1:])
1898 }
1899 } else {
1900 email = addr
1901 }
1902 return name, email
1903}
1904
1905func fetchEmailBodyCmd(cfg *config.Config, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
1906 return func() tea.Msg {
1907 account := cfg.GetAccountByID(accountID)
1908 if account == nil {
1909 return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: fmt.Errorf("account not found")}
1910 }
1911
1912 var (
1913 body string
1914 attachments []fetcher.Attachment
1915 err error
1916 )
1917 switch mailbox {
1918 case tui.MailboxSent:
1919 body, attachments, err = fetcher.FetchSentEmailBody(account, uid)
1920 case tui.MailboxTrash:
1921 body, attachments, err = fetcher.FetchTrashEmailBody(account, uid)
1922 case tui.MailboxArchive:
1923 body, attachments, err = fetcher.FetchArchiveEmailBody(account, uid)
1924 default:
1925 body, attachments, err = fetcher.FetchEmailBody(account, uid)
1926 }
1927 if err != nil {
1928 return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1929 }
1930
1931 return tui.EmailBodyFetchedMsg{
1932 UID: uid,
1933 Body: body,
1934 Attachments: attachments,
1935 AccountID: accountID,
1936 Mailbox: mailbox,
1937 }
1938 }
1939}
1940
1941func markdownToHTML(md []byte) []byte {
1942 return clib.MarkdownToHTML(md)
1943}
1944
1945func splitEmails(s string) []string {
1946 if s == "" {
1947 return nil
1948 }
1949 parts := strings.Split(s, ",")
1950 var res []string
1951 for _, p := range parts {
1952 if trimmed := strings.TrimSpace(p); trimmed != "" {
1953 res = append(res, trimmed)
1954 }
1955 }
1956 return res
1957}
1958
1959func sendEmail(account *config.Account, msg tui.SendEmailMsg) tea.Cmd {
1960 return func() tea.Msg {
1961 if account == nil {
1962 return tui.EmailResultMsg{Err: fmt.Errorf("no account configured")}
1963 }
1964
1965 recipients := splitEmails(msg.To)
1966 cc := splitEmails(msg.Cc)
1967 bcc := splitEmails(msg.Bcc)
1968 body := msg.Body
1969 // Append signature if present
1970 if msg.Signature != "" {
1971 body = body + "\n\n" + msg.Signature
1972 }
1973 // Append quoted text if present (for replies)
1974 if msg.QuotedText != "" {
1975 body = body + msg.QuotedText
1976 }
1977 images := make(map[string][]byte)
1978 attachments := make(map[string][]byte)
1979
1980 re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
1981 matches := re.FindAllStringSubmatch(body, -1)
1982
1983 for _, match := range matches {
1984 imgPath := match[1]
1985 imgData, err := os.ReadFile(imgPath)
1986 if err != nil {
1987 log.Printf("Could not read image file %s: %v", imgPath, err)
1988 continue
1989 }
1990 cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "matcha")
1991 images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
1992 body = strings.Replace(body, imgPath, "cid:"+cid, 1)
1993 }
1994
1995 htmlBody := markdownToHTML([]byte(body))
1996
1997 for _, attachPath := range msg.AttachmentPaths {
1998 fileData, err := os.ReadFile(attachPath)
1999 if err != nil {
2000 log.Printf("Could not read attachment file %s: %v", attachPath, err)
2001 continue
2002 }
2003 _, filename := filepath.Split(attachPath)
2004 attachments[filename] = fileData
2005 }
2006
2007 rawMsg, err := sender.SendEmail(account, recipients, cc, bcc, msg.Subject, body, string(htmlBody), images, attachments, msg.InReplyTo, msg.References, msg.SignSMIME, msg.EncryptSMIME, msg.SignPGP, false)
2008 if err != nil {
2009 log.Printf("Failed to send email: %v", err)
2010 return tui.EmailResultMsg{Err: err}
2011 }
2012
2013 // Append to Sent folder via IMAP (Gmail auto-saves, so skip it)
2014 if account.ServiceProvider != "gmail" {
2015 if err := fetcher.AppendToSentMailbox(account, rawMsg); err != nil {
2016 log.Printf("Failed to append sent message to Sent folder: %v", err)
2017 }
2018 }
2019
2020 return tui.EmailResultMsg{}
2021 }
2022}
2023
2024func deleteEmailCmd(account *config.Account, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
2025 return func() tea.Msg {
2026 var err error
2027 switch mailbox {
2028 case tui.MailboxSent:
2029 err = fetcher.DeleteSentEmail(account, uid)
2030 case tui.MailboxTrash:
2031 err = fetcher.DeleteTrashEmail(account, uid)
2032 case tui.MailboxArchive:
2033 err = fetcher.DeleteArchiveEmail(account, uid)
2034 default:
2035 err = fetcher.DeleteEmail(account, uid)
2036 }
2037 return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
2038 }
2039}
2040
2041func archiveEmailCmd(account *config.Account, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
2042 return func() tea.Msg {
2043 var err error
2044 if mailbox == tui.MailboxSent {
2045 err = fetcher.ArchiveSentEmail(account, uid)
2046 } else {
2047 err = fetcher.ArchiveEmail(account, uid)
2048 }
2049 return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
2050 }
2051}
2052
2053// --- External editor command ---
2054
2055// openExternalEditor writes the body to a temp file, opens $EDITOR, and reads back the result.
2056func openExternalEditor(body string) tea.Cmd {
2057 editor := os.Getenv("EDITOR")
2058 if editor == "" {
2059 editor = os.Getenv("VISUAL")
2060 }
2061 if editor == "" {
2062 editor = "vi"
2063 }
2064
2065 tmpFile, err := os.CreateTemp("", "matcha-*.md")
2066 if err != nil {
2067 return func() tea.Msg {
2068 return tui.EditorFinishedMsg{Err: fmt.Errorf("creating temp file: %w", err)}
2069 }
2070 }
2071 tmpPath := tmpFile.Name()
2072
2073 if _, err := tmpFile.WriteString(body); err != nil {
2074 tmpFile.Close()
2075 os.Remove(tmpPath)
2076 return func() tea.Msg {
2077 return tui.EditorFinishedMsg{Err: fmt.Errorf("writing temp file: %w", err)}
2078 }
2079 }
2080 tmpFile.Close()
2081
2082 parts := strings.Fields(editor)
2083 args := append(parts[1:], tmpPath)
2084 c := exec.Command(parts[0], args...)
2085 return tea.ExecProcess(c, func(err error) tea.Msg {
2086 defer os.Remove(tmpPath)
2087 if err != nil {
2088 return tui.EditorFinishedMsg{Err: err}
2089 }
2090 content, readErr := os.ReadFile(tmpPath)
2091 if readErr != nil {
2092 return tui.EditorFinishedMsg{Err: readErr}
2093 }
2094 return tui.EditorFinishedMsg{Body: string(content)}
2095 })
2096}
2097
2098// --- IDLE command ---
2099
2100// listenForIdleUpdates blocks until an IDLE update arrives, then returns it as a tea.Msg.
2101func listenForIdleUpdates(ch <-chan fetcher.IdleUpdate) tea.Cmd {
2102 return func() tea.Msg {
2103 update, ok := <-ch
2104 if !ok {
2105 return nil
2106 }
2107 return tui.IdleNewMailMsg{
2108 AccountID: update.AccountID,
2109 FolderName: update.FolderName,
2110 }
2111 }
2112}
2113
2114// --- Folder-based command functions ---
2115
2116func fetchFoldersCmd(cfg *config.Config) tea.Cmd {
2117 return func() tea.Msg {
2118 if !cfg.HasAccounts() {
2119 return nil
2120 }
2121 foldersByAccount := make(map[string][]fetcher.Folder)
2122 seen := make(map[string]fetcher.Folder)
2123 var mu sync.Mutex
2124 var wg sync.WaitGroup
2125
2126 for _, account := range cfg.Accounts {
2127 wg.Add(1)
2128 go func(acc config.Account) {
2129 defer wg.Done()
2130 folders, err := fetcher.FetchFolders(&acc)
2131 if err != nil {
2132 return
2133 }
2134 mu.Lock()
2135 foldersByAccount[acc.ID] = folders
2136 for _, f := range folders {
2137 if _, ok := seen[f.Name]; !ok {
2138 seen[f.Name] = f
2139 }
2140 }
2141 mu.Unlock()
2142 }(account)
2143 }
2144 wg.Wait()
2145
2146 var merged []fetcher.Folder
2147 for _, f := range seen {
2148 merged = append(merged, f)
2149 }
2150
2151 return tui.FoldersFetchedMsg{
2152 FoldersByAccount: foldersByAccount,
2153 MergedFolders: merged,
2154 }
2155 }
2156}
2157
2158func fetchFolderEmailsCmd(cfg *config.Config, folderName string) tea.Cmd {
2159 return func() tea.Msg {
2160 emailsByAccount := make(map[string][]fetcher.Email)
2161 var mu sync.Mutex
2162 var wg sync.WaitGroup
2163
2164 for _, account := range cfg.Accounts {
2165 wg.Add(1)
2166 go func(acc config.Account) {
2167 defer wg.Done()
2168 emails, err := fetcher.FetchFolderEmails(&acc, folderName, initialEmailLimit, 0)
2169 if err != nil {
2170 // Folder may not exist for this account — silently skip
2171 return
2172 }
2173 mu.Lock()
2174 emailsByAccount[acc.ID] = emails
2175 mu.Unlock()
2176 }(account)
2177 }
2178
2179 wg.Wait()
2180
2181 // Flatten all account emails
2182 var allEmails []fetcher.Email
2183 for _, emails := range emailsByAccount {
2184 allEmails = append(allEmails, emails...)
2185 }
2186 // Sort newest first
2187 for i := 0; i < len(allEmails); i++ {
2188 for j := i + 1; j < len(allEmails); j++ {
2189 if allEmails[j].Date.After(allEmails[i].Date) {
2190 allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
2191 }
2192 }
2193 }
2194
2195 return tui.FolderEmailsFetchedMsg{
2196 Emails: allEmails,
2197 FolderName: folderName,
2198 }
2199 }
2200}
2201
2202func fetchFolderEmailsPaginatedCmd(account *config.Account, folderName string, limit, offset uint32) tea.Cmd {
2203 return func() tea.Msg {
2204 emails, err := fetcher.FetchFolderEmails(account, folderName, limit, offset)
2205 if err != nil {
2206 return tui.FetchErr(err)
2207 }
2208 return tui.FolderEmailsAppendedMsg{
2209 Emails: emails,
2210 AccountID: account.ID,
2211 FolderName: folderName,
2212 }
2213 }
2214}
2215
2216func fetchFolderEmailBodyCmd(cfg *config.Config, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
2217 return func() tea.Msg {
2218 account := cfg.GetAccountByID(accountID)
2219 if account == nil {
2220 return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: fmt.Errorf("account not found")}
2221 }
2222
2223 body, attachments, err := fetcher.FetchFolderEmailBody(account, folderName, uid)
2224 if err != nil {
2225 return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
2226 }
2227
2228 return tui.EmailBodyFetchedMsg{
2229 UID: uid,
2230 Body: body,
2231 Attachments: attachments,
2232 AccountID: accountID,
2233 Mailbox: mailbox,
2234 }
2235 }
2236}
2237
2238func markEmailAsReadCmd(account *config.Account, uid uint32, accountID string, folderName string) tea.Cmd {
2239 return func() tea.Msg {
2240 err := fetcher.MarkEmailAsReadInMailbox(account, folderName, uid)
2241 return tui.EmailMarkedReadMsg{UID: uid, AccountID: accountID, Err: err}
2242 }
2243}
2244
2245func deleteFolderEmailCmd(account *config.Account, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
2246 return func() tea.Msg {
2247 err := fetcher.DeleteFolderEmail(account, folderName, uid)
2248 return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
2249 }
2250}
2251
2252func archiveFolderEmailCmd(account *config.Account, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
2253 return func() tea.Msg {
2254 err := fetcher.ArchiveFolderEmail(account, folderName, uid)
2255 return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
2256 }
2257}
2258
2259func (m *mainModel) batchDeleteEmailsCmd(account *config.Account, uids []uint32, accountID, folderName string, mailbox tui.MailboxKind, count int) tea.Cmd {
2260 return func() tea.Msg {
2261 ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
2262 defer cancel()
2263
2264 p := m.getProvider(account)
2265 if p == nil {
2266 return tui.BatchEmailActionDoneMsg{
2267 Count: count,
2268 Action: "delete",
2269 Err: fmt.Errorf("provider not found"),
2270 }
2271 }
2272
2273 err := p.DeleteEmails(ctx, folderName, uids)
2274
2275 // Remove emails from local state on success
2276 if err == nil && m.folderInbox != nil {
2277 m.folderInbox.GetInbox().RemoveEmails(uids, accountID)
2278 }
2279
2280 successCount := count
2281 failureCount := 0
2282 if err != nil {
2283 failureCount = count
2284 successCount = 0
2285 }
2286
2287 return tui.BatchEmailActionDoneMsg{
2288 Count: count,
2289 SuccessCount: successCount,
2290 FailureCount: failureCount,
2291 Action: "delete",
2292 Mailbox: mailbox,
2293 Err: err,
2294 }
2295 }
2296}
2297
2298func (m *mainModel) batchArchiveEmailsCmd(account *config.Account, uids []uint32, accountID, folderName string, mailbox tui.MailboxKind, count int) tea.Cmd {
2299 return func() tea.Msg {
2300 ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
2301 defer cancel()
2302
2303 p := m.getProvider(account)
2304 if p == nil {
2305 return tui.BatchEmailActionDoneMsg{
2306 Count: count,
2307 Action: "archive",
2308 Err: fmt.Errorf("provider not found"),
2309 }
2310 }
2311
2312 err := p.ArchiveEmails(ctx, folderName, uids)
2313
2314 if err == nil && m.folderInbox != nil {
2315 m.folderInbox.GetInbox().RemoveEmails(uids, accountID)
2316 }
2317
2318 successCount := count
2319 failureCount := 0
2320 if err != nil {
2321 failureCount = count
2322 successCount = 0
2323 }
2324
2325 return tui.BatchEmailActionDoneMsg{
2326 Count: count,
2327 SuccessCount: successCount,
2328 FailureCount: failureCount,
2329 Action: "archive",
2330 Mailbox: mailbox,
2331 Err: err,
2332 }
2333 }
2334}
2335
2336func (m *mainModel) batchMoveEmailsCmd(account *config.Account, uids []uint32, accountID, sourceFolder, destFolder string, count int) tea.Cmd {
2337 return func() tea.Msg {
2338 ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
2339 defer cancel()
2340
2341 p := m.getProvider(account)
2342 if p == nil {
2343 return tui.BatchEmailActionDoneMsg{
2344 Count: count,
2345 Action: "move",
2346 Err: fmt.Errorf("provider not found"),
2347 }
2348 }
2349
2350 err := p.MoveEmails(ctx, uids, sourceFolder, destFolder)
2351
2352 if err == nil && m.folderInbox != nil {
2353 m.folderInbox.GetInbox().RemoveEmails(uids, accountID)
2354 }
2355
2356 successCount := count
2357 failureCount := 0
2358 if err != nil {
2359 failureCount = count
2360 successCount = 0
2361 }
2362
2363 return tui.BatchEmailActionDoneMsg{
2364 Count: count,
2365 SuccessCount: successCount,
2366 FailureCount: failureCount,
2367 Action: "move",
2368 Err: err,
2369 }
2370 }
2371}
2372
2373func moveEmailToFolderCmd(account *config.Account, uid uint32, accountID string, sourceFolder, destFolder string) tea.Cmd {
2374 return func() tea.Msg {
2375 err := fetcher.MoveEmailToFolder(account, uid, sourceFolder, destFolder)
2376 return tui.EmailMovedMsg{
2377 UID: uid,
2378 AccountID: accountID,
2379 SourceFolder: sourceFolder,
2380 DestFolder: destFolder,
2381 Err: err,
2382 }
2383 }
2384}
2385
2386func downloadAttachmentCmd(account *config.Account, uid uint32, msg tui.DownloadAttachmentMsg) tea.Cmd {
2387 return func() tea.Msg {
2388 // Download and decode the attachment using encoding provided in msg.Encoding.
2389 var data []byte
2390 var err error
2391 switch msg.Mailbox {
2392 case tui.MailboxSent:
2393 data, err = fetcher.FetchSentAttachment(account, uid, msg.PartID, msg.Encoding)
2394 case tui.MailboxTrash:
2395 data, err = fetcher.FetchTrashAttachment(account, uid, msg.PartID, msg.Encoding)
2396 case tui.MailboxArchive:
2397 data, err = fetcher.FetchArchiveAttachment(account, uid, msg.PartID, msg.Encoding)
2398 default:
2399 data, err = fetcher.FetchAttachment(account, uid, msg.PartID, msg.Encoding)
2400 }
2401 if err != nil {
2402 return tui.AttachmentDownloadedMsg{Err: err}
2403 }
2404
2405 homeDir, err := os.UserHomeDir()
2406 if err != nil {
2407 return tui.AttachmentDownloadedMsg{Err: err}
2408 }
2409 downloadsPath := filepath.Join(homeDir, "Downloads")
2410 if _, err := os.Stat(downloadsPath); os.IsNotExist(err) {
2411 if mkErr := os.MkdirAll(downloadsPath, 0755); mkErr != nil {
2412 return tui.AttachmentDownloadedMsg{Err: mkErr}
2413 }
2414 }
2415
2416 // Save the attachment using an exclusive create so we never overwrite an existing file.
2417 // If the filename already exists, append \" (n)\" before the extension.
2418 origName := msg.Filename
2419 ext := filepath.Ext(origName)
2420 base := strings.TrimSuffix(origName, ext)
2421 candidate := origName
2422 i := 1
2423 var filePath string
2424
2425 for {
2426 filePath = filepath.Join(downloadsPath, candidate)
2427
2428 // Try to create file exclusively. If it already exists, os.OpenFile will return an error
2429 // that satisfies os.IsExist(err), so we can increment the candidate.
2430 f, err := os.OpenFile(filePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
2431 if err != nil {
2432 if os.IsExist(err) {
2433 // file exists, try next candidate
2434 candidate = fmt.Sprintf("%s (%d)%s", base, i, ext)
2435 i++
2436 continue
2437 }
2438 // Some other error while attempting to create file
2439 log.Printf("error creating file %s: %v", filePath, err)
2440 return tui.AttachmentDownloadedMsg{Err: err}
2441 }
2442
2443 // Successfully created the file descriptor; write and close.
2444 if _, writeErr := f.Write(data); writeErr != nil {
2445 _ = f.Close()
2446 log.Printf("error writing to file %s: %v", filePath, writeErr)
2447 return tui.AttachmentDownloadedMsg{Err: writeErr}
2448 }
2449 if closeErr := f.Close(); closeErr != nil {
2450 log.Printf("warning: error closing file %s: %v", filePath, closeErr)
2451 }
2452
2453 // file saved successfully
2454 break
2455 }
2456
2457 log.Printf("attachment saved to %s", filePath)
2458
2459 // Try to open the file using a platform-specific opener asynchronously and log the outcome.
2460 go func(p string) {
2461 var cmd *exec.Cmd
2462 switch runtime.GOOS {
2463 case "darwin":
2464 cmd = exec.Command("open", p)
2465 case "linux":
2466 cmd = exec.Command("xdg-open", p)
2467 case "windows":
2468 // 'start' is a cmd builtin; provide an empty title argument to avoid interpreting the path as the title.
2469 cmd = exec.Command("cmd", "/c", "start", "", p)
2470 default:
2471 // Unsupported OS: nothing to do.
2472 return
2473 }
2474 if err := cmd.Start(); err != nil {
2475 log.Printf("failed to open file %s: %v", p, err)
2476 }
2477 }(filePath)
2478
2479 return tui.AttachmentDownloadedMsg{Path: filePath, Err: nil}
2480 }
2481}
2482
2483/*
2484detectInstalledVersion returns a best-effort installed version string.
2485Priority:
2486 1. If the build-in `version` variable is set to something other than "dev", return it.
2487 2. If Homebrew is present and reports a version for `matcha`, return that.
2488 3. If snap is present and lists `matcha`, return that.
2489 4. Fallback to the build `version` (likely "dev").
2490*/
2491func detectInstalledVersion() string {
2492 v := strings.TrimSpace(version)
2493 if v != "dev" && v != "" {
2494 return v
2495 }
2496
2497 // Try Homebrew (macOS)
2498 if runtime.GOOS == "darwin" {
2499 if _, err := exec.LookPath("brew"); err == nil {
2500 // `brew list --versions matcha` prints: matcha 1.2.3
2501 if out, err := exec.Command("brew", "list", "--versions", "matcha").Output(); err == nil {
2502 parts := strings.Fields(string(out))
2503 if len(parts) >= 2 {
2504 return parts[1]
2505 }
2506 }
2507 }
2508 }
2509
2510 // Try WinGet (Windows)
2511 if runtime.GOOS == "windows" {
2512 if _, err := exec.LookPath("winget"); err == nil {
2513 if out, err := exec.Command("winget", "list", "--id", "floatpane.matcha", "--disable-interactivity").Output(); err == nil {
2514 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2515 for _, line := range lines {
2516 if strings.Contains(strings.ToLower(line), "floatpane.matcha") {
2517 fields := strings.Fields(line)
2518 for _, f := range fields {
2519 if len(f) > 0 && f[0] >= '0' && f[0] <= '9' && strings.Contains(f, ".") {
2520 return f
2521 }
2522 }
2523 }
2524 }
2525 }
2526 }
2527 }
2528
2529 // Try snap (Linux)
2530 if runtime.GOOS == "linux" {
2531 if _, err := exec.LookPath("snap"); err == nil {
2532 if out, err := exec.Command("snap", "list", "matcha").Output(); err == nil {
2533 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2534 if len(lines) >= 2 {
2535 fields := strings.Fields(lines[1])
2536 if len(fields) >= 2 {
2537 return fields[1]
2538 }
2539 }
2540 }
2541 }
2542
2543 if _, err := exec.LookPath("flatpak"); err == nil {
2544 if out, err := exec.Command("flatpak", "info", "com.floatpane.matcha").Output(); err == nil {
2545 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2546 for _, line := range lines {
2547 line = strings.TrimSpace(line)
2548 if strings.HasPrefix(line, "Version:") {
2549 fields := strings.Fields(line)
2550 if len(fields) >= 2 {
2551 return fields[1]
2552 }
2553 }
2554 }
2555 }
2556 }
2557 }
2558
2559 return v
2560}
2561
2562/*
2563checkForUpdatesCmd queries GitHub for the latest release tag and returns a
2564tea.Msg (UpdateAvailableMsg) if the latest version differs from the current
2565installed version. This runs in the background when the TUI initializes.
2566*/
2567func checkForUpdatesCmd() tea.Cmd {
2568 return func() tea.Msg {
2569 // Non-fatal: if anything goes wrong we just don't show the update message.
2570 const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
2571 resp, err := http.Get(api)
2572 if err != nil {
2573 return nil
2574 }
2575 defer resp.Body.Close()
2576
2577 var rel githubRelease
2578 if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
2579 return nil
2580 }
2581
2582 latest := strings.TrimPrefix(rel.TagName, "v")
2583 installed := strings.TrimPrefix(detectInstalledVersion(), "v")
2584 if latest != "" && installed != "" && latest != installed {
2585 return UpdateAvailableMsg{Latest: latest, Current: installed}
2586 }
2587 return nil
2588 }
2589}
2590
2591// runUpdateCLI implements the CLI entrypoint for `matcha update`.
2592// It detects the likely installation method and attempts the appropriate
2593// update path (Homebrew, Snap, or GitHub release binary extract).
2594// runGmailOAuthCLI handles the "matcha gmail" subcommand for OAuth2 management.
2595// Usage:
2596//
2597// matcha gmail auth <email> [--client-id ID --client-secret SECRET]
2598// matcha gmail token <email>
2599// matcha gmail revoke <email>
2600func runGmailOAuthCLI(args []string) {
2601 if len(args) < 1 {
2602 fmt.Fprintln(os.Stderr, "Usage: matcha gmail <auth|token|revoke> <email> [flags]")
2603 fmt.Fprintln(os.Stderr, "")
2604 fmt.Fprintln(os.Stderr, "Commands:")
2605 fmt.Fprintln(os.Stderr, " auth <email> Authorize a Gmail account via OAuth2 (opens browser)")
2606 fmt.Fprintln(os.Stderr, " token <email> Print a fresh access token (refreshes automatically)")
2607 fmt.Fprintln(os.Stderr, " revoke <email> Revoke and delete stored OAuth2 tokens")
2608 fmt.Fprintln(os.Stderr, "")
2609 fmt.Fprintln(os.Stderr, "Before using OAuth2, create ~/.config/matcha/oauth_client.json with:")
2610 fmt.Fprintln(os.Stderr, ` {"client_id": "YOUR_ID", "client_secret": "YOUR_SECRET"}`)
2611 fmt.Fprintln(os.Stderr, "")
2612 fmt.Fprintln(os.Stderr, "Get credentials at: https://console.cloud.google.com/apis/credentials")
2613 os.Exit(1)
2614 }
2615
2616 // Find the Python script and pass through to it
2617 script, err := config.OAuthScriptPath()
2618 if err != nil {
2619 fmt.Fprintf(os.Stderr, "Error: %v\n", err)
2620 os.Exit(1)
2621 }
2622
2623 cmdArgs := append([]string{script}, args...)
2624 cmd := exec.Command("python3", cmdArgs...)
2625 cmd.Stdin = os.Stdin
2626 cmd.Stdout = os.Stdout
2627 cmd.Stderr = os.Stderr
2628
2629 if err := cmd.Run(); err != nil {
2630 if exitErr, ok := err.(*exec.ExitError); ok {
2631 os.Exit(exitErr.ExitCode())
2632 }
2633 fmt.Fprintf(os.Stderr, "Error: %v\n", err)
2634 os.Exit(1)
2635 }
2636}
2637
2638// stringSliceFlag implements flag.Value to allow repeated --attach flags.
2639type stringSliceFlag []string
2640
2641func (s *stringSliceFlag) String() string { return strings.Join(*s, ", ") }
2642func (s *stringSliceFlag) Set(val string) error {
2643 *s = append(*s, val)
2644 return nil
2645}
2646
2647// runSendCLI implements the CLI entrypoint for `matcha send`.
2648// It sends an email non-interactively using configured accounts.
2649func runSendCLI(args []string) {
2650 fs := flag.NewFlagSet("send", flag.ExitOnError)
2651
2652 to := fs.String("to", "", "Recipient(s), comma-separated (required)")
2653 cc := fs.String("cc", "", "CC recipient(s), comma-separated")
2654 bcc := fs.String("bcc", "", "BCC recipient(s), comma-separated")
2655 subject := fs.String("subject", "", "Email subject (required)")
2656 body := fs.String("body", "", `Email body (Markdown supported). Use "-" to read from stdin`)
2657 from := fs.String("from", "", "Sender account email (defaults to first configured account)")
2658 withSignature := fs.Bool("signature", true, "Append default signature")
2659 signSMIME := fs.Bool("sign-smime", false, "Sign with S/MIME")
2660 encryptSMIME := fs.Bool("encrypt-smime", false, "Encrypt with S/MIME")
2661 signPGP := fs.Bool("sign-pgp", false, "Sign with PGP")
2662
2663 var attachments stringSliceFlag
2664 fs.Var(&attachments, "attach", "Attachment file path (can be repeated)")
2665
2666 fs.Usage = func() {
2667 fmt.Fprintln(os.Stderr, "Usage: matcha send [flags]")
2668 fmt.Fprintln(os.Stderr, "")
2669 fmt.Fprintln(os.Stderr, "Send an email non-interactively using a configured account.")
2670 fmt.Fprintln(os.Stderr, "")
2671 fmt.Fprintln(os.Stderr, "Flags:")
2672 fs.PrintDefaults()
2673 fmt.Fprintln(os.Stderr, "")
2674 fmt.Fprintln(os.Stderr, "Examples:")
2675 fmt.Fprintln(os.Stderr, ` matcha send --to user@example.com --subject "Hello" --body "Hi there"`)
2676 fmt.Fprintln(os.Stderr, ` echo "Body text" | matcha send --to user@example.com --subject "Hello" --body -`)
2677 fmt.Fprintln(os.Stderr, ` matcha send --to user@example.com --subject "Report" --body "See attached" --attach report.pdf`)
2678 }
2679
2680 if err := fs.Parse(args); err != nil {
2681 os.Exit(1)
2682 }
2683
2684 if *to == "" || *subject == "" {
2685 fmt.Fprintln(os.Stderr, "Error: --to and --subject are required")
2686 fs.Usage()
2687 os.Exit(1)
2688 }
2689
2690 // Read body from stdin if "-"
2691 emailBody := *body
2692 if emailBody == "-" {
2693 data, err := io.ReadAll(os.Stdin)
2694 if err != nil {
2695 fmt.Fprintf(os.Stderr, "Error reading stdin: %v\n", err)
2696 os.Exit(1)
2697 }
2698 emailBody = string(data)
2699 }
2700
2701 // Load config
2702 cfg, err := config.LoadConfig()
2703 if err != nil {
2704 fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
2705 os.Exit(1)
2706 }
2707 if !cfg.HasAccounts() {
2708 fmt.Fprintln(os.Stderr, "Error: no accounts configured. Run matcha to set up an account first.")
2709 os.Exit(1)
2710 }
2711
2712 // Resolve account
2713 var account *config.Account
2714 if *from != "" {
2715 account = cfg.GetAccountByEmail(*from)
2716 if account == nil {
2717 // Also try matching against FetchEmail
2718 for i := range cfg.Accounts {
2719 if strings.EqualFold(cfg.Accounts[i].FetchEmail, *from) {
2720 account = &cfg.Accounts[i]
2721 break
2722 }
2723 }
2724 }
2725 if account == nil {
2726 fmt.Fprintf(os.Stderr, "Error: no account found matching %q\n", *from)
2727 os.Exit(1)
2728 }
2729 } else {
2730 account = cfg.GetFirstAccount()
2731 }
2732
2733 // Use account S/MIME/PGP defaults unless explicitly set
2734 if !isFlagSet(fs, "sign-smime") {
2735 *signSMIME = account.SMIMESignByDefault
2736 }
2737 if !isFlagSet(fs, "sign-pgp") {
2738 *signPGP = account.PGPSignByDefault
2739 }
2740
2741 // Append signature
2742 if *withSignature {
2743 if sig, err := config.LoadSignature(); err == nil && sig != "" {
2744 emailBody = emailBody + "\n\n" + sig
2745 }
2746 }
2747
2748 // Process inline images (same logic as TUI sendEmail)
2749 images := make(map[string][]byte)
2750 re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
2751 matches := re.FindAllStringSubmatch(emailBody, -1)
2752 for _, match := range matches {
2753 imgPath := match[1]
2754 imgData, err := os.ReadFile(imgPath)
2755 if err != nil {
2756 log.Printf("Could not read image file %s: %v", imgPath, err)
2757 continue
2758 }
2759 cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "matcha")
2760 images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
2761 emailBody = strings.Replace(emailBody, imgPath, "cid:"+cid, 1)
2762 }
2763
2764 htmlBody := markdownToHTML([]byte(emailBody))
2765
2766 // Process attachments
2767 attachMap := make(map[string][]byte)
2768 for _, attachPath := range attachments {
2769 fileData, err := os.ReadFile(attachPath)
2770 if err != nil {
2771 fmt.Fprintf(os.Stderr, "Error reading attachment %s: %v\n", attachPath, err)
2772 os.Exit(1)
2773 }
2774 attachMap[filepath.Base(attachPath)] = fileData
2775 }
2776
2777 // Send
2778 recipients := splitEmails(*to)
2779 ccList := splitEmails(*cc)
2780 bccList := splitEmails(*bcc)
2781
2782 rawMsg, sendErr := sender.SendEmail(account, recipients, ccList, bccList, *subject, emailBody, string(htmlBody), images, attachMap, "", nil, *signSMIME, *encryptSMIME, *signPGP, false)
2783 if sendErr != nil {
2784 fmt.Fprintf(os.Stderr, "Error: %v\n", sendErr)
2785 os.Exit(1)
2786 }
2787
2788 // Append to Sent folder via IMAP (Gmail auto-saves, so skip it)
2789 if account.ServiceProvider != "gmail" {
2790 if err := fetcher.AppendToSentMailbox(account, rawMsg); err != nil {
2791 log.Printf("Failed to append sent message to Sent folder: %v", err)
2792 }
2793 }
2794
2795 fmt.Println("Email sent successfully.")
2796}
2797
2798// isFlagSet returns true if the named flag was explicitly provided on the command line.
2799func isFlagSet(fs *flag.FlagSet, name string) bool {
2800 found := false
2801 fs.Visit(func(f *flag.Flag) {
2802 if f.Name == name {
2803 found = true
2804 }
2805 })
2806 return found
2807}
2808
2809func runUpdateCLI() error {
2810 const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
2811 resp, err := http.Get(api)
2812 if err != nil {
2813 return fmt.Errorf("could not query releases: %w", err)
2814 }
2815 defer resp.Body.Close()
2816
2817 var rel githubRelease
2818 if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
2819 return fmt.Errorf("could not parse release info: %w", err)
2820 }
2821
2822 latestTag := rel.TagName
2823 if strings.HasPrefix(latestTag, "v") {
2824 latestTag = latestTag[1:]
2825 }
2826
2827 fmt.Printf("Current version: %s\n", version)
2828 fmt.Printf("Latest version: %s\n", latestTag)
2829
2830 // Quick check: if already up-to-date, exit
2831 cur := version
2832 if strings.HasPrefix(cur, "v") {
2833 cur = cur[1:]
2834 }
2835 if latestTag == "" || cur == latestTag {
2836 fmt.Println("Already up to date.")
2837 return nil
2838 }
2839
2840 // Detect Homebrew
2841 if _, err := exec.LookPath("brew"); err == nil {
2842 fmt.Println("Detected Homebrew — updating taps and attempting to upgrade via brew.")
2843
2844 updateCmd := exec.Command("brew", "update")
2845 updateCmd.Stdout = os.Stdout
2846 updateCmd.Stderr = os.Stderr
2847 if err := updateCmd.Run(); err != nil {
2848 fmt.Printf("Homebrew update failed: %v\n", err)
2849 // continue to attempt upgrade even if update failed
2850 }
2851
2852 upgradeCmd := exec.Command("brew", "upgrade", "floatpane/matcha/matcha")
2853 upgradeCmd.Stdout = os.Stdout
2854 upgradeCmd.Stderr = os.Stderr
2855 if err := upgradeCmd.Run(); err == nil {
2856 fmt.Println("Successfully upgraded via Homebrew.")
2857 return nil
2858 }
2859 fmt.Printf("Homebrew upgrade failed: %v\n", err)
2860 // fallthrough to other methods
2861 }
2862
2863 // Detect snap
2864 if _, err := exec.LookPath("snap"); err == nil {
2865 // Check if matcha is installed as a snap
2866 cmdCheck := exec.Command("snap", "list", "matcha")
2867 if err := cmdCheck.Run(); err == nil {
2868 fmt.Println("Detected Snap package — attempting to refresh.")
2869 cmd := exec.Command("snap", "refresh", "matcha")
2870 cmd.Stdout = os.Stdout
2871 cmd.Stderr = os.Stderr
2872 if err := cmd.Run(); err == nil {
2873 fmt.Println("Successfully refreshed snap.")
2874 return nil
2875 }
2876 fmt.Printf("Snap refresh failed: %v\n", err)
2877 // fallthrough
2878 }
2879 }
2880 // Detect flatpak
2881 if _, err := exec.LookPath("flatpak"); err == nil {
2882 // Check if matcha is installed as a flatpak
2883 cmdCheck := exec.Command("flatpak", "info", "com.floatpane.matcha")
2884 if err := cmdCheck.Run(); err == nil {
2885 fmt.Println("Detected Flatpak package — attempting to update.")
2886 cmd := exec.Command("flatpak", "update", "-y", "com.floatpane.matcha")
2887 cmd.Stdout = os.Stdout
2888 cmd.Stderr = os.Stderr
2889 if err := cmd.Run(); err == nil {
2890 fmt.Println("Successfully updated flatpak.")
2891 return nil
2892 }
2893 fmt.Printf("Flatpak update failed: %v\n", err)
2894 // fallthrough
2895 }
2896 }
2897
2898 // Detect WinGet
2899 if _, err := exec.LookPath("winget"); err == nil {
2900 cmdCheck := exec.Command("winget", "list", "--id", "floatpane.matcha", "--disable-interactivity")
2901 if err := cmdCheck.Run(); err == nil {
2902 fmt.Println("Detected WinGet package — attempting to upgrade.")
2903 cmd := exec.Command("winget", "upgrade", "--id", "floatpane.matcha", "--disable-interactivity")
2904 cmd.Stdout = os.Stdout
2905 cmd.Stderr = os.Stderr
2906 if err := cmd.Run(); err == nil {
2907 fmt.Println("Successfully upgraded via WinGet.")
2908 return nil
2909 }
2910 fmt.Printf("WinGet upgrade failed: %v\n", err)
2911 // fallthrough
2912 }
2913 }
2914
2915 // Otherwise attempt to download the proper release asset and replace the binary.
2916 osName := runtime.GOOS
2917 arch := runtime.GOARCH
2918
2919 // Try to find a matching asset
2920 var assetURL, assetName string
2921 for _, a := range rel.Assets {
2922 n := strings.ToLower(a.Name)
2923 if strings.Contains(n, osName) && strings.Contains(n, arch) && (strings.HasSuffix(n, ".tar.gz") || strings.HasSuffix(n, ".tgz") || strings.HasSuffix(n, ".zip")) {
2924 assetURL = a.BrowserDownloadURL
2925 assetName = a.Name
2926 break
2927 }
2928 }
2929 if assetURL == "" {
2930 // Try any asset that contains 'matcha' and os/arch as a fallback
2931 for _, a := range rel.Assets {
2932 n := strings.ToLower(a.Name)
2933 if strings.Contains(n, "matcha") && (strings.Contains(n, osName) || strings.Contains(n, arch)) {
2934 assetURL = a.BrowserDownloadURL
2935 assetName = a.Name
2936 break
2937 }
2938 }
2939 }
2940
2941 if assetURL == "" {
2942 return fmt.Errorf("no suitable release artifact found for %s/%s", osName, arch)
2943 }
2944
2945 fmt.Printf("Found release asset: %s\n", assetName)
2946 fmt.Println("Downloading...")
2947
2948 // Download asset
2949 respAsset, err := http.Get(assetURL)
2950 if err != nil {
2951 return fmt.Errorf("download failed: %w", err)
2952 }
2953 defer respAsset.Body.Close()
2954
2955 // Create a temp file for the download
2956 tmpDir, err := os.MkdirTemp("", "matcha-update-*")
2957 if err != nil {
2958 return fmt.Errorf("could not create temp dir: %w", err)
2959 }
2960 defer os.RemoveAll(tmpDir)
2961
2962 assetPath := filepath.Join(tmpDir, assetName)
2963 outFile, err := os.Create(assetPath)
2964 if err != nil {
2965 return fmt.Errorf("could not create temp file: %w", err)
2966 }
2967 _, err = io.Copy(outFile, respAsset.Body)
2968 outFile.Close()
2969 if err != nil {
2970 return fmt.Errorf("could not write asset to disk: %w", err)
2971 }
2972
2973 // Determine the expected binary name based on the OS.
2974 binaryName := "matcha"
2975 if runtime.GOOS == "windows" {
2976 binaryName = "matcha.exe"
2977 }
2978
2979 // Extract the binary from the archive.
2980 var binPath string
2981 if strings.HasSuffix(assetName, ".tar.gz") || strings.HasSuffix(assetName, ".tgz") {
2982 f, err := os.Open(assetPath)
2983 if err != nil {
2984 return fmt.Errorf("could not open archive: %w", err)
2985 }
2986 defer f.Close()
2987 gzr, err := gzip.NewReader(f)
2988 if err != nil {
2989 return fmt.Errorf("could not create gzip reader: %w", err)
2990 }
2991 tr := tar.NewReader(gzr)
2992 for {
2993 hdr, err := tr.Next()
2994 if err == io.EOF {
2995 break
2996 }
2997 if err != nil {
2998 return fmt.Errorf("error reading tar: %w", err)
2999 }
3000 name := filepath.Base(hdr.Name)
3001 if name == binaryName || strings.Contains(strings.ToLower(name), "matcha") && (hdr.Typeflag == tar.TypeReg) {
3002 binPath = filepath.Join(tmpDir, binaryName)
3003 out, err := os.Create(binPath)
3004 if err != nil {
3005 return fmt.Errorf("could not create binary file: %w", err)
3006 }
3007 if _, err := io.Copy(out, tr); err != nil {
3008 out.Close()
3009 return fmt.Errorf("could not extract binary: %w", err)
3010 }
3011 out.Close()
3012 if err := os.Chmod(binPath, 0755); err != nil {
3013 return fmt.Errorf("could not make binary executable: %w", err)
3014 }
3015 break
3016 }
3017 }
3018 } else if strings.HasSuffix(assetName, ".zip") {
3019 zr, err := zip.OpenReader(assetPath)
3020 if err != nil {
3021 return fmt.Errorf("could not open zip archive: %w", err)
3022 }
3023 defer zr.Close()
3024 for _, zf := range zr.File {
3025 name := filepath.Base(zf.Name)
3026 if name == binaryName || strings.Contains(strings.ToLower(name), "matcha") && !zf.FileInfo().IsDir() {
3027 rc, err := zf.Open()
3028 if err != nil {
3029 return fmt.Errorf("could not open file in zip: %w", err)
3030 }
3031 binPath = filepath.Join(tmpDir, binaryName)
3032 out, err := os.Create(binPath)
3033 if err != nil {
3034 rc.Close()
3035 return fmt.Errorf("could not create binary file: %w", err)
3036 }
3037 if _, err := io.Copy(out, rc); err != nil {
3038 out.Close()
3039 rc.Close()
3040 return fmt.Errorf("could not extract binary: %w", err)
3041 }
3042 out.Close()
3043 rc.Close()
3044 if err := os.Chmod(binPath, 0755); err != nil {
3045 return fmt.Errorf("could not make binary executable: %w", err)
3046 }
3047 break
3048 }
3049 }
3050 } else {
3051 // For non-archive assets, assume the asset is the binary itself.
3052 binPath = assetPath
3053 if err := os.Chmod(binPath, 0755); err != nil {
3054 // ignore chmod errors but warn
3055 fmt.Printf("warning: could not chmod downloaded binary: %v\n", err)
3056 }
3057 }
3058
3059 if binPath == "" {
3060 return fmt.Errorf("could not locate matcha binary inside the release artifact")
3061 }
3062
3063 // Replace the running executable with the new binary
3064 execPath, err := os.Executable()
3065 if err != nil {
3066 return fmt.Errorf("could not determine executable path: %w", err)
3067 }
3068
3069 // Write the new binary to a temp file in same dir, then rename for atomic replacement.
3070 execDir := filepath.Dir(execPath)
3071 tmpNew := filepath.Join(execDir, fmt.Sprintf("matcha.new.%d", time.Now().Unix()))
3072 in, err := os.Open(binPath)
3073 if err != nil {
3074 return fmt.Errorf("could not open new binary: %w", err)
3075 }
3076 out, err := os.OpenFile(tmpNew, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
3077 if err != nil {
3078 in.Close()
3079 return fmt.Errorf("could not create temp binary in target dir: %w", err)
3080 }
3081 if _, err := io.Copy(out, in); err != nil {
3082 in.Close()
3083 out.Close()
3084 return fmt.Errorf("could not write new binary to disk: %w", err)
3085 }
3086 in.Close()
3087 out.Close()
3088
3089 // On Windows, a running executable cannot be overwritten directly.
3090 // Move the old binary out of the way first, then rename the new one in.
3091 if runtime.GOOS == "windows" {
3092 oldPath := execPath + ".old"
3093 _ = os.Remove(oldPath) // clean up any previous leftover
3094 if err := os.Rename(execPath, oldPath); err != nil {
3095 return fmt.Errorf("could not move old executable out of the way: %w", err)
3096 }
3097 }
3098
3099 if err := os.Rename(tmpNew, execPath); err != nil {
3100 return fmt.Errorf("could not replace executable: %w", err)
3101 }
3102
3103 fmt.Println("Successfully updated matcha to", latestTag)
3104 return nil
3105}
3106
3107func filterUnique(existing, incoming []fetcher.Email) []fetcher.Email {
3108 seen := make(map[uint32]struct{})
3109 for _, e := range existing {
3110 seen[e.UID] = struct{}{}
3111 }
3112 var unique []fetcher.Email
3113 for _, e := range incoming {
3114 if _, ok := seen[e.UID]; !ok {
3115 unique = append(unique, e)
3116 }
3117 }
3118 return unique
3119}
3120
3121func main() {
3122 // If invoked with version flag, print version and exit
3123 if len(os.Args) > 1 && (os.Args[1] == "-v" || os.Args[1] == "--version" || os.Args[1] == "version") {
3124 fmt.Printf("matcha version %s", version)
3125 if commit != "" {
3126 fmt.Printf(" (%s)", commit)
3127 }
3128 if date != "" {
3129 fmt.Printf(" built on %s", date)
3130 }
3131 fmt.Println()
3132 os.Exit(0)
3133 }
3134
3135 // If invoked as CLI update command, run updater and exit.
3136 if len(os.Args) > 1 && os.Args[1] == "update" {
3137 if err := runUpdateCLI(); err != nil {
3138 fmt.Fprintf(os.Stderr, "update failed: %v\n", err)
3139 os.Exit(1)
3140 }
3141 os.Exit(0)
3142 }
3143
3144 // Gmail OAuth2 CLI subcommand: matcha gmail <auth|token|revoke> <email> [flags]
3145 if len(os.Args) > 1 && os.Args[1] == "gmail" {
3146 runGmailOAuthCLI(os.Args[2:])
3147 os.Exit(0)
3148 }
3149
3150 // Send email CLI subcommand: matcha send --to <email> --subject <subject> [flags]
3151 if len(os.Args) > 1 && os.Args[1] == "send" {
3152 runSendCLI(os.Args[2:])
3153 os.Exit(0)
3154 }
3155
3156 // Install plugin CLI subcommand: matcha install <url_or_file>
3157 if len(os.Args) > 1 && os.Args[1] == "install" {
3158 if err := matchaCli.RunInstall(os.Args[2:]); err != nil {
3159 fmt.Fprintf(os.Stderr, "install failed: %v\n", err)
3160 os.Exit(1)
3161 }
3162 os.Exit(0)
3163 }
3164
3165 // Config CLI subcommand: matcha config [plugin_name]
3166 if len(os.Args) > 1 && os.Args[1] == "config" {
3167 if err := matchaCli.RunConfig(os.Args[2:]); err != nil {
3168 fmt.Fprintf(os.Stderr, "config failed: %v\n", err)
3169 os.Exit(1)
3170 }
3171 os.Exit(0)
3172 }
3173
3174 // Marketplace TUI subcommand: matcha marketplace
3175 if len(os.Args) > 1 && os.Args[1] == "marketplace" {
3176 mp := tui.NewMarketplace(true)
3177 p := tea.NewProgram(mp)
3178 if _, err := p.Run(); err != nil {
3179 fmt.Fprintf(os.Stderr, "marketplace failed: %v\n", err)
3180 os.Exit(1)
3181 }
3182 os.Exit(0)
3183 }
3184
3185 // Migrate cache files from ~/.config/matcha/ to ~/.cache/matcha/ if needed
3186 _ = config.MigrateCacheFiles()
3187
3188 var initialModel *mainModel
3189
3190 if config.IsSecureModeEnabled() {
3191 // Secure mode: show password prompt before loading config
3192 tui.RebuildStyles()
3193 initialModel = newInitialModel(nil)
3194 initialModel.current = tui.NewPasswordPrompt()
3195 } else {
3196 cfg, err := config.LoadConfig()
3197 if err == nil && cfg.Theme != "" {
3198 theme.SetTheme(cfg.Theme)
3199 }
3200 tui.RebuildStyles()
3201
3202 // Ensure PGP keys directory exists
3203 _ = config.EnsurePGPDir()
3204
3205 if err != nil {
3206 initialModel = newInitialModel(nil)
3207 } else {
3208 initialModel = newInitialModel(cfg)
3209 }
3210 }
3211
3212 // Initialize plugin system
3213 plugins := plugin.NewManager()
3214 plugins.LoadPlugins()
3215 initialModel.plugins = plugins
3216 plugins.CallHook(plugin.HookStartup)
3217
3218 p := tea.NewProgram(initialModel)
3219
3220 if _, err := p.Run(); err != nil {
3221 plugins.Close()
3222 fmt.Printf("Alas, there's been an error: %v", err)
3223 os.Exit(1)
3224 }
3225
3226 plugins.CallHook(plugin.HookShutdown)
3227 plugins.Close()
3228}