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