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