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