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