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