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