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