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