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