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