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