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