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