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