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