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