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