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