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