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