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