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