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