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