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