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