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