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