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