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