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