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