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