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 // Sanitize filename: enforce length limit to prevent filesystem errors
2588 // with extremely long names from untrusted email headers.
2589 const maxFilenameLen = 255
2590 if len(name) > maxFilenameLen {
2591 ext := filepath.Ext(name)
2592 if len(ext) > maxFilenameLen {
2593 ext = ext[:maxFilenameLen]
2594 }
2595 name = name[:maxFilenameLen-len(ext)] + ext
2596 }
2597 return name
2598}
2599
2600func downloadAttachmentCmd(account *config.Account, uid uint32, msg tui.DownloadAttachmentMsg) tea.Cmd {
2601 return func() tea.Msg {
2602 // Download and decode the attachment using encoding provided in msg.Encoding.
2603 var data []byte
2604 var err error
2605 switch msg.Mailbox {
2606 case tui.MailboxSent:
2607 data, err = fetcher.FetchSentAttachment(account, uid, msg.PartID, msg.Encoding)
2608 case tui.MailboxTrash:
2609 data, err = fetcher.FetchTrashAttachment(account, uid, msg.PartID, msg.Encoding)
2610 case tui.MailboxArchive:
2611 data, err = fetcher.FetchArchiveAttachment(account, uid, msg.PartID, msg.Encoding)
2612 default:
2613 data, err = fetcher.FetchAttachment(account, uid, msg.PartID, msg.Encoding)
2614 }
2615
2616 if err != nil {
2617 return tui.AttachmentDownloadedMsg{Err: err}
2618 }
2619
2620 homeDir, err := os.UserHomeDir()
2621 if err != nil {
2622 return tui.AttachmentDownloadedMsg{Err: err}
2623 }
2624 downloadsPath := filepath.Join(homeDir, "Downloads")
2625 if _, err := os.Stat(downloadsPath); os.IsNotExist(err) {
2626 if mkErr := os.MkdirAll(downloadsPath, 0755); mkErr != nil {
2627 return tui.AttachmentDownloadedMsg{Err: mkErr}
2628 }
2629 }
2630
2631 // Save the attachment using an exclusive create so we never overwrite an existing file.
2632 // If the filename already exists, append \" (n)\" before the extension.
2633 origName := sanitizeFilename(msg.Filename)
2634 ext := filepath.Ext(origName)
2635 base := strings.TrimSuffix(origName, ext)
2636 candidate := origName
2637 i := 1
2638 var filePath string
2639
2640 for {
2641 filePath = filepath.Join(downloadsPath, candidate)
2642
2643 // Try to create file exclusively. If it already exists, os.OpenFile will return an error
2644 // that satisfies os.IsExist(err), so we can increment the candidate.
2645 f, err := os.OpenFile(filePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
2646 if err != nil {
2647 if os.IsExist(err) {
2648 // file exists, try next candidate
2649 candidate = fmt.Sprintf("%s (%d)%s", base, i, ext)
2650 i++
2651 continue
2652 }
2653 // Some other error while attempting to create file
2654 log.Printf("error creating file %s: %v", filePath, err)
2655 return tui.AttachmentDownloadedMsg{Err: err}
2656 }
2657
2658 // Successfully created the file descriptor; write and close.
2659 if _, writeErr := f.Write(data); writeErr != nil {
2660 _ = f.Close()
2661 log.Printf("error writing to file %s: %v", filePath, writeErr)
2662 return tui.AttachmentDownloadedMsg{Err: writeErr}
2663 }
2664 if closeErr := f.Close(); closeErr != nil {
2665 log.Printf("warning: error closing file %s: %v", filePath, closeErr)
2666 }
2667
2668 // file saved successfully
2669 break
2670 }
2671
2672 log.Printf("attachment saved to %s", filePath)
2673
2674 // Try to open the file using a platform-specific opener asynchronously and log the outcome.
2675 go func(p string) {
2676 var cmd *exec.Cmd
2677 switch runtime.GOOS {
2678 case "darwin":
2679 cmd = exec.Command("open", p)
2680 case "linux":
2681 cmd = exec.Command("xdg-open", p)
2682 case "windows":
2683 // 'start' is a cmd builtin; provide an empty title argument to avoid interpreting the path as the title.
2684 cmd = exec.Command("cmd", "/c", "start", "", p)
2685 default:
2686 // Unsupported OS: nothing to do.
2687 return
2688 }
2689 if err := cmd.Start(); err != nil {
2690 log.Printf("failed to open file %s: %v", p, err)
2691 }
2692 }(filePath)
2693
2694 return tui.AttachmentDownloadedMsg{Path: filePath, Err: nil}
2695 }
2696}
2697
2698/*
2699detectInstalledVersion returns a best-effort installed version string.
2700Priority:
2701 1. If the build-in `version` variable is set to something other than "dev", return it.
2702 2. If Homebrew is present and reports a version for `matcha`, return that.
2703 3. If snap is present and lists `matcha`, return that.
2704 4. Fallback to the build `version` (likely "dev").
2705*/
2706func detectInstalledVersion() string {
2707 v := strings.TrimSpace(version)
2708 if v != "dev" && v != "" {
2709 return v
2710 }
2711
2712 // Try Homebrew (macOS)
2713 if runtime.GOOS == "darwin" {
2714 if _, err := exec.LookPath("brew"); err == nil {
2715 // `brew list --versions matcha` prints: matcha 1.2.3
2716 if out, err := exec.Command("brew", "list", "--versions", "matcha").Output(); err == nil {
2717 parts := strings.Fields(string(out))
2718 if len(parts) >= 2 {
2719 return parts[1]
2720 }
2721 }
2722 }
2723 }
2724
2725 // Try WinGet (Windows)
2726 if runtime.GOOS == "windows" {
2727 if _, err := exec.LookPath("winget"); err == nil {
2728 if out, err := exec.Command("winget", "list", "--id", "floatpane.matcha", "--disable-interactivity").Output(); err == nil {
2729 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2730 for _, line := range lines {
2731 if strings.Contains(strings.ToLower(line), "floatpane.matcha") {
2732 fields := strings.Fields(line)
2733 for _, f := range fields {
2734 if len(f) > 0 && f[0] >= '0' && f[0] <= '9' && strings.Contains(f, ".") {
2735 return f
2736 }
2737 }
2738 }
2739 }
2740 }
2741 }
2742 }
2743
2744 // Try snap (Linux)
2745 if runtime.GOOS == "linux" {
2746 if _, err := exec.LookPath("snap"); err == nil {
2747 if out, err := exec.Command("snap", "list", "matcha").Output(); err == nil {
2748 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2749 if len(lines) >= 2 {
2750 fields := strings.Fields(lines[1])
2751 if len(fields) >= 2 {
2752 return fields[1]
2753 }
2754 }
2755 }
2756 }
2757
2758 if _, err := exec.LookPath("flatpak"); err == nil {
2759 if out, err := exec.Command("flatpak", "info", "com.floatpane.matcha").Output(); err == nil {
2760 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2761 for _, line := range lines {
2762 line = strings.TrimSpace(line)
2763 if strings.HasPrefix(line, "Version:") {
2764 fields := strings.Fields(line)
2765 if len(fields) >= 2 {
2766 return fields[1]
2767 }
2768 }
2769 }
2770 }
2771 }
2772 }
2773
2774 return v
2775}
2776
2777/*
2778checkForUpdatesCmd queries GitHub for the latest release tag and returns a
2779tea.Msg (UpdateAvailableMsg) if the latest version differs from the current
2780installed version. This runs in the background when the TUI initializes.
2781*/
2782func checkForUpdatesCmd() tea.Cmd {
2783 return func() tea.Msg {
2784 // Non-fatal: if anything goes wrong we just don't show the update message.
2785 const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
2786 resp, err := http.Get(api)
2787 if err != nil {
2788 return nil
2789 }
2790 defer resp.Body.Close()
2791
2792 var rel githubRelease
2793 if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
2794 return nil
2795 }
2796
2797 latest := strings.TrimPrefix(rel.TagName, "v")
2798 installed := strings.TrimPrefix(detectInstalledVersion(), "v")
2799 if latest != "" && installed != "" && latest != installed {
2800 return UpdateAvailableMsg{Latest: latest, Current: installed}
2801 }
2802 return nil
2803 }
2804}
2805
2806// runUpdateCLI implements the CLI entrypoint for `matcha update`.
2807// It detects the likely installation method and attempts the appropriate
2808// update path (Homebrew, Snap, or GitHub release binary extract).
2809// runOAuthCLI handles the "matcha oauth" subcommand for OAuth2 management.
2810// Usage:
2811//
2812// matcha oauth auth <email> [--provider gmail|outlook] [--client-id ID --client-secret SECRET]
2813// matcha oauth token <email>
2814// matcha oauth revoke <email>
2815func runOAuthCLI(args []string) {
2816 if len(args) < 1 {
2817 fmt.Fprintln(os.Stderr, "Usage: matcha oauth <auth|token|revoke> <email> [flags]")
2818 fmt.Fprintln(os.Stderr, "")
2819 fmt.Fprintln(os.Stderr, "Commands:")
2820 fmt.Fprintln(os.Stderr, " auth <email> Authorize an email account via OAuth2 (opens browser)")
2821 fmt.Fprintln(os.Stderr, " token <email> Print a fresh access token (refreshes automatically)")
2822 fmt.Fprintln(os.Stderr, " revoke <email> Revoke and delete stored OAuth2 tokens")
2823 fmt.Fprintln(os.Stderr, "")
2824 fmt.Fprintln(os.Stderr, "Flags for auth:")
2825 fmt.Fprintln(os.Stderr, " --provider gmail|outlook OAuth2 provider (auto-detected from email)")
2826 fmt.Fprintln(os.Stderr, " --client-id ID OAuth2 client ID")
2827 fmt.Fprintln(os.Stderr, " --client-secret SECRET OAuth2 client secret")
2828 fmt.Fprintln(os.Stderr, "")
2829 fmt.Fprintln(os.Stderr, "Credentials are stored per provider in:")
2830 fmt.Fprintln(os.Stderr, " Gmail: ~/.config/matcha/oauth_client.json")
2831 fmt.Fprintln(os.Stderr, " Outlook: ~/.config/matcha/oauth_client_outlook.json")
2832 os.Exit(1)
2833 }
2834
2835 // Find the Python script and pass through to it
2836 script, err := config.OAuthScriptPath()
2837 if err != nil {
2838 fmt.Fprintf(os.Stderr, "Error: %v\n", err)
2839 os.Exit(1)
2840 }
2841
2842 cmdArgs := append([]string{script}, args...)
2843 cmd := exec.Command("python3", cmdArgs...)
2844 cmd.Stdin = os.Stdin
2845 cmd.Stdout = os.Stdout
2846 cmd.Stderr = os.Stderr
2847
2848 if err := cmd.Run(); err != nil {
2849 if exitErr, ok := err.(*exec.ExitError); ok {
2850 os.Exit(exitErr.ExitCode())
2851 }
2852 fmt.Fprintf(os.Stderr, "Error: %v\n", err)
2853 os.Exit(1)
2854 }
2855}
2856
2857// stringSliceFlag implements flag.Value to allow repeated --attach flags.
2858type stringSliceFlag []string
2859
2860func (s *stringSliceFlag) String() string { return strings.Join(*s, ", ") }
2861func (s *stringSliceFlag) Set(val string) error {
2862 *s = append(*s, val)
2863 return nil
2864}
2865
2866// runSendCLI implements the CLI entrypoint for `matcha send`.
2867// It sends an email non-interactively using configured accounts.
2868func runSendCLI(args []string) {
2869 fs := flag.NewFlagSet("send", flag.ExitOnError)
2870
2871 to := fs.String("to", "", "Recipient(s), comma-separated (required)")
2872 cc := fs.String("cc", "", "CC recipient(s), comma-separated")
2873 bcc := fs.String("bcc", "", "BCC recipient(s), comma-separated")
2874 subject := fs.String("subject", "", "Email subject (required)")
2875 body := fs.String("body", "", `Email body (Markdown supported). Use "-" to read from stdin`)
2876 from := fs.String("from", "", "Sender account email (defaults to first configured account)")
2877 withSignature := fs.Bool("signature", true, "Append default signature")
2878 signSMIME := fs.Bool("sign-smime", false, "Sign with S/MIME")
2879 encryptSMIME := fs.Bool("encrypt-smime", false, "Encrypt with S/MIME")
2880 signPGP := fs.Bool("sign-pgp", false, "Sign with PGP")
2881
2882 var attachments stringSliceFlag
2883 fs.Var(&attachments, "attach", "Attachment file path (can be repeated)")
2884
2885 fs.Usage = func() {
2886 fmt.Fprintln(os.Stderr, "Usage: matcha send [flags]")
2887 fmt.Fprintln(os.Stderr, "")
2888 fmt.Fprintln(os.Stderr, "Send an email non-interactively using a configured account.")
2889 fmt.Fprintln(os.Stderr, "")
2890 fmt.Fprintln(os.Stderr, "Flags:")
2891 fs.PrintDefaults()
2892 fmt.Fprintln(os.Stderr, "")
2893 fmt.Fprintln(os.Stderr, "Examples:")
2894 fmt.Fprintln(os.Stderr, ` matcha send --to user@example.com --subject "Hello" --body "Hi there"`)
2895 fmt.Fprintln(os.Stderr, ` echo "Body text" | matcha send --to user@example.com --subject "Hello" --body -`)
2896 fmt.Fprintln(os.Stderr, ` matcha send --to user@example.com --subject "Report" --body "See attached" --attach report.pdf`)
2897 }
2898
2899 if err := fs.Parse(args); err != nil {
2900 os.Exit(1)
2901 }
2902
2903 if *to == "" || *subject == "" {
2904 fmt.Fprintln(os.Stderr, "Error: --to and --subject are required")
2905 fs.Usage()
2906 os.Exit(1)
2907 }
2908
2909 // Read body from stdin if "-"
2910 emailBody := *body
2911 if emailBody == "-" {
2912 data, err := io.ReadAll(os.Stdin)
2913 if err != nil {
2914 fmt.Fprintf(os.Stderr, "Error reading stdin: %v\n", err)
2915 os.Exit(1)
2916 }
2917 emailBody = string(data)
2918 }
2919
2920 // Load config
2921 cfg, err := config.LoadConfig()
2922 if err != nil {
2923 fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
2924 os.Exit(1)
2925 }
2926 if !cfg.HasAccounts() {
2927 fmt.Fprintln(os.Stderr, "Error: no accounts configured. Run matcha to set up an account first.")
2928 os.Exit(1)
2929 }
2930
2931 // Resolve account
2932 var account *config.Account
2933 if *from != "" {
2934 account = cfg.GetAccountByEmail(*from)
2935 if account == nil {
2936 // Also try matching against FetchEmail
2937 for i := range cfg.Accounts {
2938 if strings.EqualFold(cfg.Accounts[i].FetchEmail, *from) {
2939 account = &cfg.Accounts[i]
2940 break
2941 }
2942 }
2943 }
2944 if account == nil {
2945 fmt.Fprintf(os.Stderr, "Error: no account found matching %q\n", *from)
2946 os.Exit(1)
2947 }
2948 } else {
2949 account = cfg.GetFirstAccount()
2950 }
2951
2952 // Use account S/MIME/PGP defaults unless explicitly set
2953 if !isFlagSet(fs, "sign-smime") {
2954 *signSMIME = account.SMIMESignByDefault
2955 }
2956 if !isFlagSet(fs, "sign-pgp") {
2957 *signPGP = account.PGPSignByDefault
2958 }
2959
2960 // Append signature
2961 if *withSignature {
2962 if sig, err := config.LoadSignature(); err == nil && sig != "" {
2963 emailBody = emailBody + "\n\n" + sig
2964 }
2965 }
2966
2967 // Process inline images (same logic as TUI sendEmail)
2968 images := make(map[string][]byte)
2969 re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
2970 matches := re.FindAllStringSubmatch(emailBody, -1)
2971 for _, match := range matches {
2972 imgPath := match[1]
2973 imgData, err := os.ReadFile(imgPath)
2974 if err != nil {
2975 log.Printf("Could not read image file %s: %v", imgPath, err)
2976 continue
2977 }
2978 cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "matcha")
2979 images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
2980 emailBody = strings.Replace(emailBody, imgPath, "cid:"+cid, 1)
2981 }
2982
2983 htmlBody := markdownToHTML([]byte(emailBody))
2984
2985 // Process attachments
2986 attachMap := make(map[string][]byte)
2987 for _, attachPath := range attachments {
2988 fileData, err := os.ReadFile(attachPath)
2989 if err != nil {
2990 fmt.Fprintf(os.Stderr, "Error reading attachment %s: %v\n", attachPath, err)
2991 os.Exit(1)
2992 }
2993 attachMap[filepath.Base(attachPath)] = fileData
2994 }
2995
2996 // Send
2997 recipients := splitEmails(*to)
2998 ccList := splitEmails(*cc)
2999 bccList := splitEmails(*bcc)
3000
3001 rawMsg, sendErr := sender.SendEmail(account, recipients, ccList, bccList, *subject, emailBody, string(htmlBody), images, attachMap, "", nil, *signSMIME, *encryptSMIME, *signPGP, false)
3002 if sendErr != nil {
3003 fmt.Fprintf(os.Stderr, "Error: %v\n", sendErr)
3004 os.Exit(1)
3005 }
3006
3007 // Append to Sent folder via IMAP (Gmail auto-saves, so skip it)
3008 if account.ServiceProvider != "gmail" {
3009 if err := fetcher.AppendToSentMailbox(account, rawMsg); err != nil {
3010 log.Printf("Failed to append sent message to Sent folder: %v", err)
3011 }
3012 }
3013
3014 fmt.Println("Email sent successfully.")
3015}
3016
3017// isFlagSet returns true if the named flag was explicitly provided on the command line.
3018func isFlagSet(fs *flag.FlagSet, name string) bool {
3019 found := false
3020 fs.Visit(func(f *flag.Flag) {
3021 if f.Name == name {
3022 found = true
3023 }
3024 })
3025 return found
3026}
3027
3028func runUpdateCLI() error {
3029 const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
3030 resp, err := http.Get(api)
3031 if err != nil {
3032 return fmt.Errorf("could not query releases: %w", err)
3033 }
3034 defer resp.Body.Close()
3035
3036 var rel githubRelease
3037 if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
3038 return fmt.Errorf("could not parse release info: %w", err)
3039 }
3040
3041 latestTag := rel.TagName
3042 if strings.HasPrefix(latestTag, "v") {
3043 latestTag = latestTag[1:]
3044 }
3045
3046 fmt.Printf("Current version: %s\n", version)
3047 fmt.Printf("Latest version: %s\n", latestTag)
3048
3049 // Quick check: if already up-to-date, exit
3050 cur := version
3051 if strings.HasPrefix(cur, "v") {
3052 cur = cur[1:]
3053 }
3054 if latestTag == "" || cur == latestTag {
3055 fmt.Println("Already up to date.")
3056 return nil
3057 }
3058
3059 // Detect Homebrew
3060 if _, err := exec.LookPath("brew"); err == nil {
3061 fmt.Println("Detected Homebrew — updating taps and attempting to upgrade via brew.")
3062
3063 updateCmd := exec.Command("brew", "update")
3064 updateCmd.Stdout = os.Stdout
3065 updateCmd.Stderr = os.Stderr
3066 if err := updateCmd.Run(); err != nil {
3067 fmt.Printf("Homebrew update failed: %v\n", err)
3068 // continue to attempt upgrade even if update failed
3069 }
3070
3071 upgradeCmd := exec.Command("brew", "upgrade", "floatpane/matcha/matcha")
3072 upgradeCmd.Stdout = os.Stdout
3073 upgradeCmd.Stderr = os.Stderr
3074 if err := upgradeCmd.Run(); err == nil {
3075 fmt.Println("Successfully upgraded via Homebrew.")
3076 return nil
3077 }
3078 fmt.Printf("Homebrew upgrade failed: %v\n", err)
3079 // fallthrough to other methods
3080 }
3081
3082 // Detect snap
3083 if _, err := exec.LookPath("snap"); err == nil {
3084 // Check if matcha is installed as a snap
3085 cmdCheck := exec.Command("snap", "list", "matcha")
3086 if err := cmdCheck.Run(); err == nil {
3087 fmt.Println("Detected Snap package — attempting to refresh.")
3088 cmd := exec.Command("snap", "refresh", "matcha")
3089 cmd.Stdout = os.Stdout
3090 cmd.Stderr = os.Stderr
3091 if err := cmd.Run(); err == nil {
3092 fmt.Println("Successfully refreshed snap.")
3093 return nil
3094 }
3095 fmt.Printf("Snap refresh failed: %v\n", err)
3096 // fallthrough
3097 }
3098 }
3099 // Detect flatpak
3100 if _, err := exec.LookPath("flatpak"); err == nil {
3101 // Check if matcha is installed as a flatpak
3102 cmdCheck := exec.Command("flatpak", "info", "com.floatpane.matcha")
3103 if err := cmdCheck.Run(); err == nil {
3104 fmt.Println("Detected Flatpak package — attempting to update.")
3105 cmd := exec.Command("flatpak", "update", "-y", "com.floatpane.matcha")
3106 cmd.Stdout = os.Stdout
3107 cmd.Stderr = os.Stderr
3108 if err := cmd.Run(); err == nil {
3109 fmt.Println("Successfully updated flatpak.")
3110 return nil
3111 }
3112 fmt.Printf("Flatpak update failed: %v\n", err)
3113 // fallthrough
3114 }
3115 }
3116
3117 // Detect WinGet
3118 if _, err := exec.LookPath("winget"); err == nil {
3119 cmdCheck := exec.Command("winget", "list", "--id", "floatpane.matcha", "--disable-interactivity")
3120 if err := cmdCheck.Run(); err == nil {
3121 fmt.Println("Detected WinGet package — attempting to upgrade.")
3122 cmd := exec.Command("winget", "upgrade", "--id", "floatpane.matcha", "--disable-interactivity")
3123 cmd.Stdout = os.Stdout
3124 cmd.Stderr = os.Stderr
3125 if err := cmd.Run(); err == nil {
3126 fmt.Println("Successfully upgraded via WinGet.")
3127 return nil
3128 }
3129 fmt.Printf("WinGet upgrade failed: %v\n", err)
3130 // fallthrough
3131 }
3132 }
3133
3134 // Otherwise attempt to download the proper release asset and replace the binary.
3135 osName := runtime.GOOS
3136 arch := runtime.GOARCH
3137
3138 // Try to find a matching asset
3139 var assetURL, assetName string
3140 for _, a := range rel.Assets {
3141 n := strings.ToLower(a.Name)
3142 if strings.Contains(n, osName) && strings.Contains(n, arch) && (strings.HasSuffix(n, ".tar.gz") || strings.HasSuffix(n, ".tgz") || strings.HasSuffix(n, ".zip")) {
3143 assetURL = a.BrowserDownloadURL
3144 assetName = a.Name
3145 break
3146 }
3147 }
3148 if assetURL == "" {
3149 // Try any asset that contains 'matcha' and os/arch as a fallback
3150 for _, a := range rel.Assets {
3151 n := strings.ToLower(a.Name)
3152 if strings.Contains(n, "matcha") && (strings.Contains(n, osName) || strings.Contains(n, arch)) {
3153 assetURL = a.BrowserDownloadURL
3154 assetName = a.Name
3155 break
3156 }
3157 }
3158 }
3159
3160 if assetURL == "" {
3161 return fmt.Errorf("no suitable release artifact found for %s/%s", osName, arch)
3162 }
3163
3164 fmt.Printf("Found release asset: %s\n", assetName)
3165 fmt.Println("Downloading...")
3166
3167 // Download asset
3168 respAsset, err := http.Get(assetURL)
3169 if err != nil {
3170 return fmt.Errorf("download failed: %w", err)
3171 }
3172 defer respAsset.Body.Close()
3173
3174 // Create a temp file for the download
3175 tmpDir, err := os.MkdirTemp("", "matcha-update-*")
3176 if err != nil {
3177 return fmt.Errorf("could not create temp dir: %w", err)
3178 }
3179 defer os.RemoveAll(tmpDir)
3180
3181 assetPath := filepath.Join(tmpDir, assetName)
3182 outFile, err := os.Create(assetPath)
3183 if err != nil {
3184 return fmt.Errorf("could not create temp file: %w", err)
3185 }
3186 _, err = io.Copy(outFile, respAsset.Body)
3187 outFile.Close()
3188 if err != nil {
3189 return fmt.Errorf("could not write asset to disk: %w", err)
3190 }
3191
3192 // Determine the expected binary name based on the OS.
3193 binaryName := "matcha"
3194 if runtime.GOOS == "windows" {
3195 binaryName = "matcha.exe"
3196 }
3197
3198 // Extract the binary from the archive.
3199 var binPath string
3200 if strings.HasSuffix(assetName, ".tar.gz") || strings.HasSuffix(assetName, ".tgz") {
3201 f, err := os.Open(assetPath)
3202 if err != nil {
3203 return fmt.Errorf("could not open archive: %w", err)
3204 }
3205 defer f.Close()
3206 gzr, err := gzip.NewReader(f)
3207 if err != nil {
3208 return fmt.Errorf("could not create gzip reader: %w", err)
3209 }
3210 tr := tar.NewReader(gzr)
3211 for {
3212 hdr, err := tr.Next()
3213 if err == io.EOF {
3214 break
3215 }
3216 if err != nil {
3217 return fmt.Errorf("error reading tar: %w", err)
3218 }
3219 name := filepath.Base(hdr.Name)
3220 if name == binaryName || strings.Contains(strings.ToLower(name), "matcha") && (hdr.Typeflag == tar.TypeReg) {
3221 binPath = filepath.Join(tmpDir, binaryName)
3222 out, err := os.Create(binPath)
3223 if err != nil {
3224 return fmt.Errorf("could not create binary file: %w", err)
3225 }
3226 if _, err := io.Copy(out, tr); err != nil {
3227 out.Close()
3228 return fmt.Errorf("could not extract binary: %w", err)
3229 }
3230 out.Close()
3231 if err := os.Chmod(binPath, 0755); err != nil {
3232 return fmt.Errorf("could not make binary executable: %w", err)
3233 }
3234 break
3235 }
3236 }
3237 } else if strings.HasSuffix(assetName, ".zip") {
3238 zr, err := zip.OpenReader(assetPath)
3239 if err != nil {
3240 return fmt.Errorf("could not open zip archive: %w", err)
3241 }
3242 defer zr.Close()
3243 for _, zf := range zr.File {
3244 name := filepath.Base(zf.Name)
3245 if name == binaryName || strings.Contains(strings.ToLower(name), "matcha") && !zf.FileInfo().IsDir() {
3246 rc, err := zf.Open()
3247 if err != nil {
3248 return fmt.Errorf("could not open file in zip: %w", err)
3249 }
3250 binPath = filepath.Join(tmpDir, binaryName)
3251 out, err := os.Create(binPath)
3252 if err != nil {
3253 rc.Close()
3254 return fmt.Errorf("could not create binary file: %w", err)
3255 }
3256 if _, err := io.Copy(out, rc); err != nil {
3257 out.Close()
3258 rc.Close()
3259 return fmt.Errorf("could not extract binary: %w", err)
3260 }
3261 out.Close()
3262 rc.Close()
3263 if err := os.Chmod(binPath, 0755); err != nil {
3264 return fmt.Errorf("could not make binary executable: %w", err)
3265 }
3266 break
3267 }
3268 }
3269 } else {
3270 // For non-archive assets, assume the asset is the binary itself.
3271 binPath = assetPath
3272 if err := os.Chmod(binPath, 0755); err != nil {
3273 // ignore chmod errors but warn
3274 fmt.Printf("warning: could not chmod downloaded binary: %v\n", err)
3275 }
3276 }
3277
3278 if binPath == "" {
3279 return fmt.Errorf("could not locate matcha binary inside the release artifact")
3280 }
3281
3282 // Replace the running executable with the new binary
3283 execPath, err := os.Executable()
3284 if err != nil {
3285 return fmt.Errorf("could not determine executable path: %w", err)
3286 }
3287
3288 // Write the new binary to a temp file in same dir, then rename for atomic replacement.
3289 execDir := filepath.Dir(execPath)
3290 tmpNew := filepath.Join(execDir, fmt.Sprintf("matcha.new.%d", time.Now().Unix()))
3291 in, err := os.Open(binPath)
3292 if err != nil {
3293 return fmt.Errorf("could not open new binary: %w", err)
3294 }
3295 out, err := os.OpenFile(tmpNew, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
3296 if err != nil {
3297 in.Close()
3298 return fmt.Errorf("could not create temp binary in target dir: %w", err)
3299 }
3300 if _, err := io.Copy(out, in); err != nil {
3301 in.Close()
3302 out.Close()
3303 return fmt.Errorf("could not write new binary to disk: %w", err)
3304 }
3305 in.Close()
3306 out.Close()
3307
3308 // On Windows, a running executable cannot be overwritten directly.
3309 // Move the old binary out of the way first, then rename the new one in.
3310 if runtime.GOOS == "windows" {
3311 oldPath := execPath + ".old"
3312 _ = os.Remove(oldPath) // clean up any previous leftover
3313 if err := os.Rename(execPath, oldPath); err != nil {
3314 return fmt.Errorf("could not move old executable out of the way: %w", err)
3315 }
3316 }
3317
3318 if err := os.Rename(tmpNew, execPath); err != nil {
3319 return fmt.Errorf("could not replace executable: %w", err)
3320 }
3321
3322 fmt.Println("Successfully updated matcha to", latestTag)
3323 return nil
3324}
3325
3326func filterUnique(existing, incoming []fetcher.Email) []fetcher.Email {
3327 seen := make(map[uint32]struct{})
3328 for _, e := range existing {
3329 seen[e.UID] = struct{}{}
3330 }
3331 var unique []fetcher.Email
3332 for _, e := range incoming {
3333 if _, ok := seen[e.UID]; !ok {
3334 unique = append(unique, e)
3335 }
3336 }
3337 return unique
3338}
3339
3340func main() {
3341 // If invoked with version flag, print version and exit
3342 if len(os.Args) > 1 && (os.Args[1] == "-v" || os.Args[1] == "--version" || os.Args[1] == "version") {
3343 fmt.Printf("matcha version %s", version)
3344 if commit != "" {
3345 fmt.Printf(" (%s)", commit)
3346 }
3347 if date != "" {
3348 fmt.Printf(" built on %s", date)
3349 }
3350 fmt.Println()
3351 os.Exit(0)
3352 }
3353
3354 // If invoked as CLI update command, run updater and exit.
3355 if len(os.Args) > 1 && os.Args[1] == "update" {
3356 if err := runUpdateCLI(); err != nil {
3357 fmt.Fprintf(os.Stderr, "update failed: %v\n", err)
3358 os.Exit(1)
3359 }
3360 os.Exit(0)
3361 }
3362
3363 // Daemon CLI subcommand: matcha daemon <start|stop|status|run>
3364 if len(os.Args) > 1 && os.Args[1] == "daemon" {
3365 runDaemonCLI(os.Args[2:])
3366 os.Exit(0)
3367 }
3368
3369 // OAuth2 CLI subcommand: matcha oauth <auth|token|revoke> <email> [flags]
3370 // "gmail" is kept as an alias for backwards compatibility.
3371 if len(os.Args) > 1 && (os.Args[1] == "oauth" || os.Args[1] == "gmail") {
3372 runOAuthCLI(os.Args[2:])
3373 os.Exit(0)
3374 }
3375
3376 // Send email CLI subcommand: matcha send --to <email> --subject <subject> [flags]
3377 if len(os.Args) > 1 && os.Args[1] == "send" {
3378 runSendCLI(os.Args[2:])
3379 os.Exit(0)
3380 }
3381
3382 // Install plugin CLI subcommand: matcha install <url_or_file>
3383 if len(os.Args) > 1 && os.Args[1] == "install" {
3384 if err := matchaCli.RunInstall(os.Args[2:]); err != nil {
3385 fmt.Fprintf(os.Stderr, "install failed: %v\n", err)
3386 os.Exit(1)
3387 }
3388 os.Exit(0)
3389 }
3390
3391 // Config CLI subcommand: matcha config [plugin_name]
3392 if len(os.Args) > 1 && os.Args[1] == "config" {
3393 if err := matchaCli.RunConfig(os.Args[2:]); err != nil {
3394 fmt.Fprintf(os.Stderr, "config failed: %v\n", err)
3395 os.Exit(1)
3396 }
3397 os.Exit(0)
3398 }
3399
3400 // Contacts export CLI subcommand: matcha contacts export [flags]
3401 if len(os.Args) > 1 && os.Args[1] == "contacts" && len(os.Args) > 2 && os.Args[2] == "export" {
3402 if err := matchaCli.RunContactsExport(os.Args[3:]); err != nil {
3403 fmt.Fprintf(os.Stderr, "contacts export failed: %v\n", err)
3404 os.Exit(1)
3405 }
3406 os.Exit(0)
3407 }
3408
3409 // Marketplace TUI subcommand: matcha marketplace
3410 if len(os.Args) > 1 && os.Args[1] == "marketplace" {
3411 mp := tui.NewMarketplace(true)
3412 p := tea.NewProgram(mp)
3413 if _, err := p.Run(); err != nil {
3414 fmt.Fprintf(os.Stderr, "marketplace failed: %v\n", err)
3415 os.Exit(1)
3416 }
3417 os.Exit(0)
3418 }
3419
3420 // Migrate cache files from ~/.config/matcha/ to ~/.cache/matcha/ if needed
3421 _ = config.MigrateCacheFiles()
3422
3423 var initialModel *mainModel
3424
3425 if config.IsSecureModeEnabled() {
3426 // Secure mode: show password prompt before loading config
3427 tui.RebuildStyles()
3428 initialModel = newInitialModel(nil)
3429 initialModel.current = tui.NewPasswordPrompt()
3430 } else {
3431 cfg, err := config.LoadConfig()
3432 if err == nil && cfg.Theme != "" {
3433 theme.SetTheme(cfg.Theme)
3434 }
3435 tui.RebuildStyles()
3436
3437 // Ensure PGP keys directory exists
3438 _ = config.EnsurePGPDir()
3439
3440 if err != nil {
3441 initialModel = newInitialModel(nil)
3442 } else {
3443 initialModel = newInitialModel(cfg)
3444 }
3445 }
3446
3447 // Initialize plugin system
3448 plugins := plugin.NewManager()
3449 plugins.LoadPlugins()
3450 initialModel.plugins = plugins
3451 plugins.CallHook(plugin.HookStartup)
3452
3453 p := tea.NewProgram(initialModel)
3454
3455 if _, err := p.Run(); err != nil {
3456 plugins.Close()
3457 fmt.Printf("Alas, there's been an error: %v", err)
3458 os.Exit(1)
3459 }
3460
3461 plugins.CallHook(plugin.HookShutdown)
3462 plugins.Close()
3463}
3464
3465func runDaemonCLI(args []string) {
3466 if len(args) == 0 {
3467 fmt.Println("Usage: matcha daemon <start|stop|status|run>")
3468 fmt.Println()
3469 fmt.Println("Commands:")
3470 fmt.Println(" start Start the daemon in the background")
3471 fmt.Println(" stop Stop the running daemon")
3472 fmt.Println(" status Show daemon status")
3473 fmt.Println(" run Run the daemon in the foreground")
3474 os.Exit(1)
3475 }
3476
3477 switch args[0] {
3478 case "start":
3479 runDaemonStart()
3480 case "stop":
3481 runDaemonStop()
3482 case "status":
3483 runDaemonStatus()
3484 case "run":
3485 runDaemonRun()
3486 default:
3487 fmt.Fprintf(os.Stderr, "unknown daemon command: %s\n", args[0])
3488 os.Exit(1)
3489 }
3490}
3491
3492func runDaemonStart() {
3493 pidPath := daemonrpc.PIDPath()
3494 if pid, running := matchaDaemon.IsRunning(pidPath); running {
3495 fmt.Printf("Daemon already running (PID %d)\n", pid)
3496 return
3497 }
3498
3499 // Fork ourselves with "daemon run".
3500 exe, err := os.Executable()
3501 if err != nil {
3502 fmt.Fprintf(os.Stderr, "cannot find executable: %v\n", err)
3503 os.Exit(1)
3504 }
3505
3506 cmd := exec.Command(exe, "daemon", "run")
3507 cmd.Stdout = nil
3508 cmd.Stderr = nil
3509 cmd.Stdin = nil
3510
3511 // Detach from parent process.
3512 cmd.SysProcAttr = daemonclient.DaemonProcAttr()
3513
3514 if err := cmd.Start(); err != nil {
3515 fmt.Fprintf(os.Stderr, "failed to start daemon: %v\n", err)
3516 os.Exit(1)
3517 }
3518
3519 fmt.Printf("Daemon started (PID %d)\n", cmd.Process.Pid)
3520}
3521
3522func runDaemonStop() {
3523 pidPath := daemonrpc.PIDPath()
3524 pid, running := matchaDaemon.IsRunning(pidPath)
3525 if !running {
3526 fmt.Println("Daemon is not running")
3527 return
3528 }
3529
3530 process, err := os.FindProcess(pid)
3531 if err != nil {
3532 fmt.Fprintf(os.Stderr, "cannot find process %d: %v\n", pid, err)
3533 os.Exit(1)
3534 }
3535
3536 if err := process.Signal(os.Interrupt); err != nil {
3537 fmt.Fprintf(os.Stderr, "failed to stop daemon: %v\n", err)
3538 os.Exit(1)
3539 }
3540
3541 fmt.Printf("Daemon stopped (PID %d)\n", pid)
3542}
3543
3544func runDaemonStatus() {
3545 // Try connecting to daemon for live status.
3546 client, err := daemonclient.Dial()
3547 if err != nil {
3548 pidPath := daemonrpc.PIDPath()
3549 if pid, running := matchaDaemon.IsRunning(pidPath); running {
3550 fmt.Printf("Daemon running (PID %d) but not responding\n", pid)
3551 } else {
3552 fmt.Println("Daemon is not running")
3553 }
3554 return
3555 }
3556 defer client.Close()
3557
3558 status, err := client.Status()
3559 if err != nil {
3560 fmt.Fprintf(os.Stderr, "failed to get status: %v\n", err)
3561 os.Exit(1)
3562 }
3563
3564 fmt.Printf("Daemon running (PID %d)\n", status.PID)
3565 fmt.Printf("Uptime: %s\n", formatUptime(status.Uptime))
3566 fmt.Printf("Accounts: %d\n", len(status.Accounts))
3567 for _, acct := range status.Accounts {
3568 fmt.Printf(" - %s\n", acct)
3569 }
3570}
3571
3572func runDaemonRun() {
3573 cfg, err := config.LoadConfig()
3574 if err != nil {
3575 fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err)
3576 os.Exit(1)
3577 }
3578
3579 d := matchaDaemon.New(cfg)
3580 if err := d.Run(); err != nil {
3581 fmt.Fprintf(os.Stderr, "daemon error: %v\n", err)
3582 os.Exit(1)
3583 }
3584}
3585
3586func formatUptime(seconds int64) string {
3587 d := time.Duration(seconds) * time.Second
3588 if d < time.Minute {
3589 return fmt.Sprintf("%ds", int(d.Seconds()))
3590 }
3591 if d < time.Hour {
3592 return fmt.Sprintf("%dm %ds", int(d.Minutes()), int(d.Seconds())%60)
3593 }
3594 return fmt.Sprintf("%dh %dm", int(d.Hours()), int(d.Minutes())%60)
3595}