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