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