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