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