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