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