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