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