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