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