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