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