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