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