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