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