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