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