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