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 m.current = tui.NewStatus("Sending email...")
1004
1005 // Get the account to send from
1006 var account *config.Account
1007 if msg.AccountID != "" && m.config != nil {
1008 account = m.config.GetAccountByID(msg.AccountID)
1009 }
1010 if account == nil && m.config != nil {
1011 account = m.config.GetFirstAccount()
1012 }
1013
1014 // Save contact and delete draft in background
1015 go func() {
1016 // Save the recipient as a contact
1017 if msg.To != "" {
1018 recipients := strings.Split(msg.To, ",")
1019 for _, r := range recipients {
1020 r = strings.TrimSpace(r)
1021 if r == "" {
1022 continue
1023 }
1024 name, email := parseEmailAddress(r)
1025 if err := config.AddContact(name, email); err != nil {
1026 log.Printf("Error saving contact: %v", err)
1027 }
1028 }
1029 }
1030 // Delete the draft since email is being sent
1031 if draftID != "" {
1032 if err := config.DeleteDraft(draftID); err != nil {
1033 log.Printf("Error deleting draft after send: %v", err)
1034 }
1035 }
1036 }()
1037
1038 return m, tea.Batch(m.current.Init(), sendEmail(account, msg))
1039
1040 case tui.EmailResultMsg:
1041 if msg.Err != nil {
1042 log.Printf("Failed to send email: %v", msg.Err)
1043 m.previousModel = tui.NewChoice()
1044 m.previousModel, _ = m.previousModel.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1045 m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
1046 return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1047 return tui.RestoreViewMsg{}
1048 })
1049 }
1050 if m.plugins != nil {
1051 m.plugins.CallHook(plugin.HookEmailSendAfter)
1052 }
1053 m.current = tui.NewChoice()
1054 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1055 return m, m.current.Init()
1056
1057 case tui.DeleteEmailMsg:
1058 tui.ClearKittyGraphics()
1059 m.previousModel = m.current
1060 m.current = tui.NewStatus("Deleting email...")
1061
1062 account := m.config.GetAccountByID(msg.AccountID)
1063 if account == nil {
1064 if m.folderInbox != nil {
1065 m.current = m.folderInbox
1066 }
1067 return m, nil
1068 }
1069
1070 folderName := "INBOX"
1071 if m.folderInbox != nil {
1072 folderName = m.folderInbox.GetCurrentFolder()
1073 }
1074 return m, tea.Batch(m.current.Init(), deleteFolderEmailCmd(account, msg.UID, msg.AccountID, folderName, msg.Mailbox))
1075
1076 case tui.ArchiveEmailMsg:
1077 tui.ClearKittyGraphics()
1078 m.previousModel = m.current
1079 m.current = tui.NewStatus("Archiving email...")
1080
1081 account := m.config.GetAccountByID(msg.AccountID)
1082 if account == nil {
1083 if m.folderInbox != nil {
1084 m.current = m.folderInbox
1085 }
1086 return m, nil
1087 }
1088
1089 folderName := "INBOX"
1090 if m.folderInbox != nil {
1091 folderName = m.folderInbox.GetCurrentFolder()
1092 }
1093 return m, tea.Batch(m.current.Init(), archiveFolderEmailCmd(account, msg.UID, msg.AccountID, folderName, msg.Mailbox))
1094
1095 case tui.EmailMarkedReadMsg:
1096 if msg.Err != nil {
1097 log.Printf("Error marking email as read: %v", msg.Err)
1098 }
1099 return m, nil
1100
1101 case tui.EmailActionDoneMsg:
1102 if msg.Err != nil {
1103 log.Printf("Action failed: %v", msg.Err)
1104 if m.folderInbox != nil {
1105 m.previousModel = m.folderInbox
1106 }
1107 m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
1108 return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1109 return tui.RestoreViewMsg{}
1110 })
1111 }
1112
1113 // Remove email from stores
1114 m.removeEmailFromStores(msg.UID, msg.AccountID)
1115
1116 if m.folderInbox != nil {
1117 m.folderInbox.RemoveEmail(msg.UID, msg.AccountID)
1118 m.current = m.folderInbox
1119 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1120 return m, m.current.Init()
1121 }
1122 m.current = tui.NewChoice()
1123 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1124 return m, m.current.Init()
1125
1126 case tui.DownloadAttachmentMsg:
1127 m.previousModel = m.current
1128 m.current = tui.NewStatus(fmt.Sprintf("Downloading %s...", msg.Filename))
1129
1130 account := m.config.GetAccountByID(msg.AccountID)
1131 if account == nil {
1132 m.current = m.previousModel
1133 return m, nil
1134 }
1135
1136 email := m.getEmailByIndex(msg.Index, msg.Mailbox)
1137 if email == nil {
1138 m.current = m.previousModel
1139 return m, nil
1140 }
1141
1142 // Find the correct attachment to get encoding
1143 var encoding string
1144 for _, att := range email.Attachments {
1145 if att.PartID == msg.PartID {
1146 encoding = att.Encoding
1147 break
1148 }
1149 }
1150 newMsg := tui.DownloadAttachmentMsg{
1151 Index: msg.Index,
1152 Filename: msg.Filename,
1153 PartID: msg.PartID,
1154 Data: msg.Data,
1155 AccountID: msg.AccountID,
1156 Encoding: encoding,
1157 Mailbox: msg.Mailbox,
1158 }
1159 return m, tea.Batch(m.current.Init(), downloadAttachmentCmd(account, email.UID, newMsg))
1160
1161 case tui.AttachmentDownloadedMsg:
1162 var statusMsg string
1163 if msg.Err != nil {
1164 statusMsg = fmt.Sprintf("Error downloading: %v", msg.Err)
1165 } else {
1166 statusMsg = fmt.Sprintf("Saved to %s", msg.Path)
1167 }
1168 m.current = tui.NewStatus(statusMsg)
1169 return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1170 return tui.RestoreViewMsg{}
1171 })
1172
1173 case tui.RestoreViewMsg:
1174 if m.previousModel != nil {
1175 m.current = m.previousModel
1176 m.previousModel = nil
1177 }
1178 return m, nil
1179 }
1180
1181 if cmd := m.pluginNotifyCmd(); cmd != nil {
1182 cmds = append(cmds, cmd)
1183 }
1184
1185 return m, tea.Batch(cmds...)
1186}
1187
1188func (m *mainModel) View() tea.View {
1189 v := m.current.View()
1190 v.AltScreen = true
1191 return v
1192}
1193
1194func (m *mainModel) getEmailByIndex(index int, mailbox tui.MailboxKind) *fetcher.Email {
1195 if index >= 0 && index < len(m.emails) {
1196 return &m.emails[index]
1197 }
1198 return nil
1199}
1200
1201func (m *mainModel) getEmailByUIDAndAccount(uid uint32, accountID string, mailbox tui.MailboxKind) *fetcher.Email {
1202 for i := range m.emails {
1203 if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1204 return &m.emails[i]
1205 }
1206 }
1207 return nil
1208}
1209
1210func (m *mainModel) getEmailIndex(uid uint32, accountID string, mailbox tui.MailboxKind) int {
1211 for i := range m.emails {
1212 if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1213 return i
1214 }
1215 }
1216 return -1
1217}
1218
1219func (m *mainModel) updateEmailBodyByUID(uid uint32, accountID string, mailbox tui.MailboxKind, body string, attachments []fetcher.Attachment) {
1220 for i := range m.emails {
1221 if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1222 m.emails[i].Body = body
1223 m.emails[i].Attachments = attachments
1224 break
1225 }
1226 }
1227 if emails, ok := m.emailsByAcct[accountID]; ok {
1228 for i := range emails {
1229 if emails[i].UID == uid {
1230 emails[i].Body = body
1231 emails[i].Attachments = attachments
1232 break
1233 }
1234 }
1235 }
1236}
1237
1238func (m *mainModel) markEmailAsReadInStores(uid uint32, accountID string) {
1239 for i := range m.emails {
1240 if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1241 m.emails[i].IsRead = true
1242 break
1243 }
1244 }
1245 if emails, ok := m.emailsByAcct[accountID]; ok {
1246 for i := range emails {
1247 if emails[i].UID == uid {
1248 emails[i].IsRead = true
1249 break
1250 }
1251 }
1252 }
1253 // Update folder email cache
1254 for folderName, folderEmails := range m.folderEmails {
1255 for i := range folderEmails {
1256 if folderEmails[i].UID == uid && folderEmails[i].AccountID == accountID {
1257 folderEmails[i].IsRead = true
1258 m.folderEmails[folderName] = folderEmails
1259 go saveFolderEmailsToCache(folderName, folderEmails)
1260 break
1261 }
1262 }
1263 }
1264 // Update the inbox UI
1265 if m.folderInbox != nil {
1266 m.folderInbox.GetInbox().MarkEmailAsRead(uid, accountID)
1267 }
1268}
1269
1270func (m *mainModel) removeEmailFromStores(uid uint32, accountID string) {
1271 var filtered []fetcher.Email
1272 for _, e := range m.emails {
1273 if !(e.UID == uid && e.AccountID == accountID) {
1274 filtered = append(filtered, e)
1275 }
1276 }
1277 m.emails = filtered
1278 if emails, ok := m.emailsByAcct[accountID]; ok {
1279 var filteredAcct []fetcher.Email
1280 for _, e := range emails {
1281 if e.UID != uid {
1282 filteredAcct = append(filteredAcct, e)
1283 }
1284 }
1285 m.emailsByAcct[accountID] = filteredAcct
1286 }
1287}
1288
1289// pluginNotifyCmd checks for a pending plugin notification and returns a command if one exists.
1290func (m *mainModel) pluginNotifyCmd() tea.Cmd {
1291 if m.plugins == nil {
1292 return nil
1293 }
1294 if n, ok := m.plugins.TakePendingNotification(); ok {
1295 return func() tea.Msg {
1296 return tui.PluginNotifyMsg{Message: n.Message, Duration: n.Duration}
1297 }
1298 }
1299 return nil
1300}
1301
1302func (m *mainModel) syncPluginStatus() {
1303 if m.plugins == nil {
1304 return
1305 }
1306 if m.folderInbox != nil {
1307 m.folderInbox.GetInbox().SetPluginStatus(m.plugins.StatusText(plugin.StatusInbox))
1308 }
1309 switch v := m.current.(type) {
1310 case *tui.Composer:
1311 v.SetPluginStatus(m.plugins.StatusText(plugin.StatusComposer))
1312 case *tui.EmailView:
1313 v.SetPluginStatus(m.plugins.StatusText(plugin.StatusEmailView))
1314 }
1315}
1316
1317func flattenAndSort(emailsByAccount map[string][]fetcher.Email) []fetcher.Email {
1318 var allEmails []fetcher.Email
1319 for _, emails := range emailsByAccount {
1320 allEmails = append(allEmails, emails...)
1321 }
1322 for i := 0; i < len(allEmails); i++ {
1323 for j := i + 1; j < len(allEmails); j++ {
1324 if allEmails[j].Date.After(allEmails[i].Date) {
1325 allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
1326 }
1327 }
1328 }
1329 return allEmails
1330}
1331
1332func fetchAllAccountsEmails(cfg *config.Config, mailbox tui.MailboxKind) tea.Cmd {
1333 return func() tea.Msg {
1334 emailsByAccount := make(map[string][]fetcher.Email)
1335 var mu sync.Mutex
1336 var wg sync.WaitGroup
1337
1338 for _, account := range cfg.Accounts {
1339 wg.Add(1)
1340 go func(acc config.Account) {
1341 defer wg.Done()
1342 var emails []fetcher.Email
1343 var err error
1344 switch mailbox {
1345 case tui.MailboxSent:
1346 emails, err = fetcher.FetchSentEmails(&acc, initialEmailLimit, 0)
1347 case tui.MailboxTrash:
1348 emails, err = fetcher.FetchTrashEmails(&acc, initialEmailLimit, 0)
1349 case tui.MailboxArchive:
1350 emails, err = fetcher.FetchArchiveEmails(&acc, initialEmailLimit, 0)
1351 default:
1352 emails, err = fetcher.FetchEmails(&acc, initialEmailLimit, 0)
1353 }
1354 if err != nil {
1355 log.Printf("Error fetching from %s: %v", acc.Email, err)
1356 return
1357 }
1358 mu.Lock()
1359 emailsByAccount[acc.ID] = emails
1360 mu.Unlock()
1361 }(account)
1362 }
1363
1364 wg.Wait()
1365 return tui.AllEmailsFetchedMsg{EmailsByAccount: emailsByAccount, Mailbox: mailbox}
1366 }
1367}
1368
1369func fetchEmails(account *config.Account, limit, offset uint32, mailbox tui.MailboxKind) tea.Cmd {
1370 return func() tea.Msg {
1371 var emails []fetcher.Email
1372 var err error
1373 if mailbox == tui.MailboxSent {
1374 emails, err = fetcher.FetchSentEmails(account, limit, offset)
1375 } else {
1376 emails, err = fetcher.FetchEmails(account, limit, offset)
1377 }
1378 if err != nil {
1379 return tui.FetchErr(err)
1380 }
1381 if offset == 0 {
1382 return tui.EmailsFetchedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1383 }
1384 return tui.EmailsAppendedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1385 }
1386}
1387
1388func fetchEmailsForMailbox(account *config.Account, limit, offset uint32, mailbox tui.MailboxKind) tea.Cmd {
1389 return func() tea.Msg {
1390 var emails []fetcher.Email
1391 var err error
1392 switch mailbox {
1393 case tui.MailboxSent:
1394 emails, err = fetcher.FetchSentEmails(account, limit, offset)
1395 case tui.MailboxTrash:
1396 emails, err = fetcher.FetchTrashEmails(account, limit, offset)
1397 case tui.MailboxArchive:
1398 emails, err = fetcher.FetchArchiveEmails(account, limit, offset)
1399 default:
1400 emails, err = fetcher.FetchEmails(account, limit, offset)
1401 }
1402 if err != nil {
1403 return tui.FetchErr(err)
1404 }
1405 if offset == 0 {
1406 return tui.EmailsFetchedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1407 }
1408 return tui.EmailsAppendedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
1409 }
1410}
1411
1412func loadCachedEmails() tea.Cmd {
1413 return func() tea.Msg {
1414 cache, err := config.LoadEmailCache()
1415 if err != nil {
1416 return tui.CachedEmailsLoadedMsg{Cache: nil}
1417 }
1418 return tui.CachedEmailsLoadedMsg{Cache: cache}
1419 }
1420}
1421
1422func refreshEmails(cfg *config.Config, mailbox tui.MailboxKind, counts map[string]int) tea.Cmd {
1423 return func() tea.Msg {
1424 emailsByAccount := make(map[string][]fetcher.Email)
1425 var mu sync.Mutex
1426 var wg sync.WaitGroup
1427
1428 for _, account := range cfg.Accounts {
1429 wg.Add(1)
1430 go func(acc config.Account) {
1431 defer wg.Done()
1432 var emails []fetcher.Email
1433 var err error
1434
1435 limit := uint32(initialEmailLimit)
1436 if counts != nil {
1437 if c, ok := counts[acc.ID]; ok && c > 0 {
1438 limit = uint32(c)
1439 }
1440 }
1441
1442 if mailbox == tui.MailboxSent {
1443 emails, err = fetcher.FetchSentEmails(&acc, limit, 0)
1444 } else {
1445 emails, err = fetcher.FetchEmails(&acc, limit, 0)
1446 }
1447 if err != nil {
1448 log.Printf("Error fetching from %s: %v", acc.Email, err)
1449 return
1450 }
1451 mu.Lock()
1452 emailsByAccount[acc.ID] = emails
1453 mu.Unlock()
1454 }(account)
1455 }
1456
1457 wg.Wait()
1458 return tui.EmailsRefreshedMsg{EmailsByAccount: emailsByAccount, Mailbox: mailbox}
1459 }
1460}
1461
1462func emailsToCache(emails []fetcher.Email) []config.CachedEmail {
1463 var cached []config.CachedEmail
1464 for _, email := range emails {
1465 cached = append(cached, config.CachedEmail{
1466 UID: email.UID,
1467 From: email.From,
1468 To: email.To,
1469 Subject: email.Subject,
1470 Date: email.Date,
1471 MessageID: email.MessageID,
1472 AccountID: email.AccountID,
1473 IsRead: email.IsRead,
1474 })
1475 }
1476 return cached
1477}
1478
1479func cacheToEmails(cached []config.CachedEmail) []fetcher.Email {
1480 var emails []fetcher.Email
1481 for _, c := range cached {
1482 emails = append(emails, fetcher.Email{
1483 UID: c.UID,
1484 From: c.From,
1485 To: c.To,
1486 Subject: c.Subject,
1487 Date: c.Date,
1488 MessageID: c.MessageID,
1489 AccountID: c.AccountID,
1490 IsRead: c.IsRead,
1491 })
1492 }
1493 return emails
1494}
1495
1496func saveFolderEmailsToCache(folderName string, emails []fetcher.Email) {
1497 cached := emailsToCache(emails)
1498 if err := config.SaveFolderEmailCache(folderName, cached); err != nil {
1499 log.Printf("Error saving folder email cache for %s: %v", folderName, err)
1500 }
1501}
1502
1503func loadFolderEmailsFromCache(folderName string) []fetcher.Email {
1504 cached, err := config.LoadFolderEmailCache(folderName)
1505 if err != nil {
1506 return nil
1507 }
1508 return cacheToEmails(cached)
1509}
1510
1511func saveEmailsToCache(emails []fetcher.Email) {
1512 if len(emails) > maxCacheEmails {
1513 emails = emails[:maxCacheEmails]
1514 }
1515 var cachedEmails []config.CachedEmail
1516 for _, email := range emails {
1517 cachedEmails = append(cachedEmails, config.CachedEmail{
1518 UID: email.UID,
1519 From: email.From,
1520 To: email.To,
1521 Subject: email.Subject,
1522 Date: email.Date,
1523 MessageID: email.MessageID,
1524 AccountID: email.AccountID,
1525 IsRead: email.IsRead,
1526 })
1527
1528 // Save sender as a contact
1529 if email.From != "" {
1530 name, emailAddr := parseEmailAddress(email.From)
1531 if err := config.AddContact(name, emailAddr); err != nil {
1532 log.Printf("Error saving contact from email: %v", err)
1533 }
1534 }
1535 }
1536 cache := &config.EmailCache{Emails: cachedEmails}
1537 if err := config.SaveEmailCache(cache); err != nil {
1538 log.Printf("Error saving email cache: %v", err)
1539 }
1540}
1541
1542// parseEmailAddress parses "Name <email>" or just "email" format
1543func parseEmailAddress(addr string) (name, email string) {
1544 addr = strings.TrimSpace(addr)
1545 if idx := strings.Index(addr, "<"); idx != -1 {
1546 name = strings.TrimSpace(addr[:idx])
1547 endIdx := strings.Index(addr, ">")
1548 if endIdx > idx {
1549 email = strings.TrimSpace(addr[idx+1 : endIdx])
1550 } else {
1551 email = strings.TrimSpace(addr[idx+1:])
1552 }
1553 } else {
1554 email = addr
1555 }
1556 return name, email
1557}
1558
1559func fetchEmailBodyCmd(cfg *config.Config, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
1560 return func() tea.Msg {
1561 account := cfg.GetAccountByID(accountID)
1562 if account == nil {
1563 return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: fmt.Errorf("account not found")}
1564 }
1565
1566 var (
1567 body string
1568 attachments []fetcher.Attachment
1569 err error
1570 )
1571 switch mailbox {
1572 case tui.MailboxSent:
1573 body, attachments, err = fetcher.FetchSentEmailBody(account, uid)
1574 case tui.MailboxTrash:
1575 body, attachments, err = fetcher.FetchTrashEmailBody(account, uid)
1576 case tui.MailboxArchive:
1577 body, attachments, err = fetcher.FetchArchiveEmailBody(account, uid)
1578 default:
1579 body, attachments, err = fetcher.FetchEmailBody(account, uid)
1580 }
1581 if err != nil {
1582 return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1583 }
1584
1585 return tui.EmailBodyFetchedMsg{
1586 UID: uid,
1587 Body: body,
1588 Attachments: attachments,
1589 AccountID: accountID,
1590 Mailbox: mailbox,
1591 }
1592 }
1593}
1594
1595func markdownToHTML(md []byte) []byte {
1596 return clib.MarkdownToHTML(md)
1597}
1598
1599func splitEmails(s string) []string {
1600 if s == "" {
1601 return nil
1602 }
1603 parts := strings.Split(s, ",")
1604 var res []string
1605 for _, p := range parts {
1606 if trimmed := strings.TrimSpace(p); trimmed != "" {
1607 res = append(res, trimmed)
1608 }
1609 }
1610 return res
1611}
1612
1613func sendEmail(account *config.Account, msg tui.SendEmailMsg) tea.Cmd {
1614 return func() tea.Msg {
1615 if account == nil {
1616 return tui.EmailResultMsg{Err: fmt.Errorf("no account configured")}
1617 }
1618
1619 recipients := splitEmails(msg.To)
1620 cc := splitEmails(msg.Cc)
1621 bcc := splitEmails(msg.Bcc)
1622 body := msg.Body
1623 // Append signature if present
1624 if msg.Signature != "" {
1625 body = body + "\n\n" + msg.Signature
1626 }
1627 // Append quoted text if present (for replies)
1628 if msg.QuotedText != "" {
1629 body = body + msg.QuotedText
1630 }
1631 images := make(map[string][]byte)
1632 attachments := make(map[string][]byte)
1633
1634 re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
1635 matches := re.FindAllStringSubmatch(body, -1)
1636
1637 for _, match := range matches {
1638 imgPath := match[1]
1639 imgData, err := os.ReadFile(imgPath)
1640 if err != nil {
1641 log.Printf("Could not read image file %s: %v", imgPath, err)
1642 continue
1643 }
1644 cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "matcha")
1645 images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
1646 body = strings.Replace(body, imgPath, "cid:"+cid, 1)
1647 }
1648
1649 htmlBody := markdownToHTML([]byte(body))
1650
1651 for _, attachPath := range msg.AttachmentPaths {
1652 fileData, err := os.ReadFile(attachPath)
1653 if err != nil {
1654 log.Printf("Could not read attachment file %s: %v", attachPath, err)
1655 continue
1656 }
1657 _, filename := filepath.Split(attachPath)
1658 attachments[filename] = fileData
1659 }
1660
1661 err := sender.SendEmail(account, recipients, cc, bcc, msg.Subject, body, string(htmlBody), images, attachments, msg.InReplyTo, msg.References, msg.SignSMIME, msg.EncryptSMIME)
1662 if err != nil {
1663 log.Printf("Failed to send email: %v", err)
1664 return tui.EmailResultMsg{Err: err}
1665 }
1666 return tui.EmailResultMsg{}
1667 }
1668}
1669
1670func deleteEmailCmd(account *config.Account, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
1671 return func() tea.Msg {
1672 var err error
1673 switch mailbox {
1674 case tui.MailboxSent:
1675 err = fetcher.DeleteSentEmail(account, uid)
1676 case tui.MailboxTrash:
1677 err = fetcher.DeleteTrashEmail(account, uid)
1678 case tui.MailboxArchive:
1679 err = fetcher.DeleteArchiveEmail(account, uid)
1680 default:
1681 err = fetcher.DeleteEmail(account, uid)
1682 }
1683 return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1684 }
1685}
1686
1687func archiveEmailCmd(account *config.Account, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
1688 return func() tea.Msg {
1689 var err error
1690 if mailbox == tui.MailboxSent {
1691 err = fetcher.ArchiveSentEmail(account, uid)
1692 } else {
1693 err = fetcher.ArchiveEmail(account, uid)
1694 }
1695 return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1696 }
1697}
1698
1699// --- External editor command ---
1700
1701// openExternalEditor writes the body to a temp file, opens $EDITOR, and reads back the result.
1702func openExternalEditor(body string) tea.Cmd {
1703 editor := os.Getenv("EDITOR")
1704 if editor == "" {
1705 editor = os.Getenv("VISUAL")
1706 }
1707 if editor == "" {
1708 editor = "vi"
1709 }
1710
1711 tmpFile, err := os.CreateTemp("", "matcha-*.md")
1712 if err != nil {
1713 return func() tea.Msg {
1714 return tui.EditorFinishedMsg{Err: fmt.Errorf("creating temp file: %w", err)}
1715 }
1716 }
1717 tmpPath := tmpFile.Name()
1718
1719 if _, err := tmpFile.WriteString(body); err != nil {
1720 tmpFile.Close()
1721 os.Remove(tmpPath)
1722 return func() tea.Msg {
1723 return tui.EditorFinishedMsg{Err: fmt.Errorf("writing temp file: %w", err)}
1724 }
1725 }
1726 tmpFile.Close()
1727
1728 parts := strings.Fields(editor)
1729 args := append(parts[1:], tmpPath)
1730 c := exec.Command(parts[0], args...)
1731 return tea.ExecProcess(c, func(err error) tea.Msg {
1732 defer os.Remove(tmpPath)
1733 if err != nil {
1734 return tui.EditorFinishedMsg{Err: err}
1735 }
1736 content, readErr := os.ReadFile(tmpPath)
1737 if readErr != nil {
1738 return tui.EditorFinishedMsg{Err: readErr}
1739 }
1740 return tui.EditorFinishedMsg{Body: string(content)}
1741 })
1742}
1743
1744// --- IDLE command ---
1745
1746// listenForIdleUpdates blocks until an IDLE update arrives, then returns it as a tea.Msg.
1747func listenForIdleUpdates(ch <-chan fetcher.IdleUpdate) tea.Cmd {
1748 return func() tea.Msg {
1749 update, ok := <-ch
1750 if !ok {
1751 return nil
1752 }
1753 return tui.IdleNewMailMsg{
1754 AccountID: update.AccountID,
1755 FolderName: update.FolderName,
1756 }
1757 }
1758}
1759
1760// --- Folder-based command functions ---
1761
1762func fetchFoldersCmd(cfg *config.Config) tea.Cmd {
1763 return func() tea.Msg {
1764 if !cfg.HasAccounts() {
1765 return nil
1766 }
1767 foldersByAccount := make(map[string][]fetcher.Folder)
1768 seen := make(map[string]fetcher.Folder)
1769 var mu sync.Mutex
1770 var wg sync.WaitGroup
1771
1772 for _, account := range cfg.Accounts {
1773 wg.Add(1)
1774 go func(acc config.Account) {
1775 defer wg.Done()
1776 folders, err := fetcher.FetchFolders(&acc)
1777 if err != nil {
1778 return
1779 }
1780 mu.Lock()
1781 foldersByAccount[acc.ID] = folders
1782 for _, f := range folders {
1783 if _, ok := seen[f.Name]; !ok {
1784 seen[f.Name] = f
1785 }
1786 }
1787 mu.Unlock()
1788 }(account)
1789 }
1790 wg.Wait()
1791
1792 var merged []fetcher.Folder
1793 for _, f := range seen {
1794 merged = append(merged, f)
1795 }
1796
1797 return tui.FoldersFetchedMsg{
1798 FoldersByAccount: foldersByAccount,
1799 MergedFolders: merged,
1800 }
1801 }
1802}
1803
1804func fetchFolderEmailsCmd(cfg *config.Config, folderName string) tea.Cmd {
1805 return func() tea.Msg {
1806 emailsByAccount := make(map[string][]fetcher.Email)
1807 var mu sync.Mutex
1808 var wg sync.WaitGroup
1809
1810 for _, account := range cfg.Accounts {
1811 wg.Add(1)
1812 go func(acc config.Account) {
1813 defer wg.Done()
1814 emails, err := fetcher.FetchFolderEmails(&acc, folderName, initialEmailLimit, 0)
1815 if err != nil {
1816 // Folder may not exist for this account — silently skip
1817 return
1818 }
1819 mu.Lock()
1820 emailsByAccount[acc.ID] = emails
1821 mu.Unlock()
1822 }(account)
1823 }
1824
1825 wg.Wait()
1826
1827 // Flatten all account emails
1828 var allEmails []fetcher.Email
1829 for _, emails := range emailsByAccount {
1830 allEmails = append(allEmails, emails...)
1831 }
1832 // Sort newest first
1833 for i := 0; i < len(allEmails); i++ {
1834 for j := i + 1; j < len(allEmails); j++ {
1835 if allEmails[j].Date.After(allEmails[i].Date) {
1836 allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
1837 }
1838 }
1839 }
1840
1841 return tui.FolderEmailsFetchedMsg{
1842 Emails: allEmails,
1843 FolderName: folderName,
1844 }
1845 }
1846}
1847
1848func fetchFolderEmailsPaginatedCmd(account *config.Account, folderName string, limit, offset uint32) tea.Cmd {
1849 return func() tea.Msg {
1850 emails, err := fetcher.FetchFolderEmails(account, folderName, limit, offset)
1851 if err != nil {
1852 return tui.FetchErr(err)
1853 }
1854 return tui.FolderEmailsAppendedMsg{
1855 Emails: emails,
1856 AccountID: account.ID,
1857 FolderName: folderName,
1858 }
1859 }
1860}
1861
1862func fetchFolderEmailBodyCmd(cfg *config.Config, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
1863 return func() tea.Msg {
1864 account := cfg.GetAccountByID(accountID)
1865 if account == nil {
1866 return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: fmt.Errorf("account not found")}
1867 }
1868
1869 body, attachments, err := fetcher.FetchFolderEmailBody(account, folderName, uid)
1870 if err != nil {
1871 return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1872 }
1873
1874 return tui.EmailBodyFetchedMsg{
1875 UID: uid,
1876 Body: body,
1877 Attachments: attachments,
1878 AccountID: accountID,
1879 Mailbox: mailbox,
1880 }
1881 }
1882}
1883
1884func markEmailAsReadCmd(account *config.Account, uid uint32, accountID string, folderName string) tea.Cmd {
1885 return func() tea.Msg {
1886 err := fetcher.MarkEmailAsReadInMailbox(account, folderName, uid)
1887 return tui.EmailMarkedReadMsg{UID: uid, AccountID: accountID, Err: err}
1888 }
1889}
1890
1891func deleteFolderEmailCmd(account *config.Account, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
1892 return func() tea.Msg {
1893 err := fetcher.DeleteFolderEmail(account, folderName, uid)
1894 return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1895 }
1896}
1897
1898func archiveFolderEmailCmd(account *config.Account, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
1899 return func() tea.Msg {
1900 err := fetcher.ArchiveFolderEmail(account, folderName, uid)
1901 return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
1902 }
1903}
1904
1905func moveEmailToFolderCmd(account *config.Account, uid uint32, accountID string, sourceFolder, destFolder string) tea.Cmd {
1906 return func() tea.Msg {
1907 err := fetcher.MoveEmailToFolder(account, uid, sourceFolder, destFolder)
1908 return tui.EmailMovedMsg{
1909 UID: uid,
1910 AccountID: accountID,
1911 SourceFolder: sourceFolder,
1912 DestFolder: destFolder,
1913 Err: err,
1914 }
1915 }
1916}
1917
1918func downloadAttachmentCmd(account *config.Account, uid uint32, msg tui.DownloadAttachmentMsg) tea.Cmd {
1919 return func() tea.Msg {
1920 // Download and decode the attachment using encoding provided in msg.Encoding.
1921 var data []byte
1922 var err error
1923 switch msg.Mailbox {
1924 case tui.MailboxSent:
1925 data, err = fetcher.FetchSentAttachment(account, uid, msg.PartID, msg.Encoding)
1926 case tui.MailboxTrash:
1927 data, err = fetcher.FetchTrashAttachment(account, uid, msg.PartID, msg.Encoding)
1928 case tui.MailboxArchive:
1929 data, err = fetcher.FetchArchiveAttachment(account, uid, msg.PartID, msg.Encoding)
1930 default:
1931 data, err = fetcher.FetchAttachment(account, uid, msg.PartID, msg.Encoding)
1932 }
1933 if err != nil {
1934 return tui.AttachmentDownloadedMsg{Err: err}
1935 }
1936
1937 homeDir, err := os.UserHomeDir()
1938 if err != nil {
1939 return tui.AttachmentDownloadedMsg{Err: err}
1940 }
1941 downloadsPath := filepath.Join(homeDir, "Downloads")
1942 if _, err := os.Stat(downloadsPath); os.IsNotExist(err) {
1943 if mkErr := os.MkdirAll(downloadsPath, 0755); mkErr != nil {
1944 return tui.AttachmentDownloadedMsg{Err: mkErr}
1945 }
1946 }
1947
1948 // Save the attachment using an exclusive create so we never overwrite an existing file.
1949 // If the filename already exists, append \" (n)\" before the extension.
1950 origName := msg.Filename
1951 ext := filepath.Ext(origName)
1952 base := strings.TrimSuffix(origName, ext)
1953 candidate := origName
1954 i := 1
1955 var filePath string
1956
1957 for {
1958 filePath = filepath.Join(downloadsPath, candidate)
1959
1960 // Try to create file exclusively. If it already exists, os.OpenFile will return an error
1961 // that satisfies os.IsExist(err), so we can increment the candidate.
1962 f, err := os.OpenFile(filePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
1963 if err != nil {
1964 if os.IsExist(err) {
1965 // file exists, try next candidate
1966 candidate = fmt.Sprintf("%s (%d)%s", base, i, ext)
1967 i++
1968 continue
1969 }
1970 // Some other error while attempting to create file
1971 log.Printf("error creating file %s: %v", filePath, err)
1972 return tui.AttachmentDownloadedMsg{Err: err}
1973 }
1974
1975 // Successfully created the file descriptor; write and close.
1976 if _, writeErr := f.Write(data); writeErr != nil {
1977 _ = f.Close()
1978 log.Printf("error writing to file %s: %v", filePath, writeErr)
1979 return tui.AttachmentDownloadedMsg{Err: writeErr}
1980 }
1981 if closeErr := f.Close(); closeErr != nil {
1982 log.Printf("warning: error closing file %s: %v", filePath, closeErr)
1983 }
1984
1985 // file saved successfully
1986 break
1987 }
1988
1989 log.Printf("attachment saved to %s", filePath)
1990
1991 // Try to open the file using a platform-specific opener asynchronously and log the outcome.
1992 go func(p string) {
1993 var cmd *exec.Cmd
1994 switch runtime.GOOS {
1995 case "darwin":
1996 cmd = exec.Command("open", p)
1997 case "linux":
1998 cmd = exec.Command("xdg-open", p)
1999 case "windows":
2000 // 'start' is a cmd builtin; provide an empty title argument to avoid interpreting the path as the title.
2001 cmd = exec.Command("cmd", "/c", "start", "", p)
2002 default:
2003 // Unsupported OS: nothing to do.
2004 return
2005 }
2006 if err := cmd.Start(); err != nil {
2007 log.Printf("failed to open file %s: %v", p, err)
2008 }
2009 }(filePath)
2010
2011 return tui.AttachmentDownloadedMsg{Path: filePath, Err: nil}
2012 }
2013}
2014
2015/*
2016detectInstalledVersion returns a best-effort installed version string.
2017Priority:
2018 1. If the build-in `version` variable is set to something other than "dev", return it.
2019 2. If Homebrew is present and reports a version for `matcha`, return that.
2020 3. If snap is present and lists `matcha`, return that.
2021 4. Fallback to the build `version` (likely "dev").
2022*/
2023func detectInstalledVersion() string {
2024 v := strings.TrimSpace(version)
2025 if v != "dev" && v != "" {
2026 return v
2027 }
2028
2029 // Try Homebrew (macOS)
2030 if runtime.GOOS == "darwin" {
2031 if _, err := exec.LookPath("brew"); err == nil {
2032 // `brew list --versions matcha` prints: matcha 1.2.3
2033 if out, err := exec.Command("brew", "list", "--versions", "matcha").Output(); err == nil {
2034 parts := strings.Fields(string(out))
2035 if len(parts) >= 2 {
2036 return parts[1]
2037 }
2038 }
2039 }
2040 }
2041
2042 // Try WinGet (Windows)
2043 if runtime.GOOS == "windows" {
2044 if _, err := exec.LookPath("winget"); err == nil {
2045 if out, err := exec.Command("winget", "list", "--id", "floatpane.matcha", "--disable-interactivity").Output(); err == nil {
2046 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2047 for _, line := range lines {
2048 if strings.Contains(strings.ToLower(line), "floatpane.matcha") {
2049 fields := strings.Fields(line)
2050 for _, f := range fields {
2051 if len(f) > 0 && f[0] >= '0' && f[0] <= '9' && strings.Contains(f, ".") {
2052 return f
2053 }
2054 }
2055 }
2056 }
2057 }
2058 }
2059 }
2060
2061 // Try snap (Linux)
2062 if runtime.GOOS == "linux" {
2063 if _, err := exec.LookPath("snap"); err == nil {
2064 if out, err := exec.Command("snap", "list", "matcha").Output(); err == nil {
2065 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2066 if len(lines) >= 2 {
2067 fields := strings.Fields(lines[1])
2068 if len(fields) >= 2 {
2069 return fields[1]
2070 }
2071 }
2072 }
2073 }
2074
2075 if _, err := exec.LookPath("flatpak"); err == nil {
2076 if out, err := exec.Command("flatpak", "info", "com.floatpane.matcha").Output(); err == nil {
2077 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2078 for _, line := range lines {
2079 line = strings.TrimSpace(line)
2080 if strings.HasPrefix(line, "Version:") {
2081 fields := strings.Fields(line)
2082 if len(fields) >= 2 {
2083 return fields[1]
2084 }
2085 }
2086 }
2087 }
2088 }
2089 }
2090
2091 return v
2092}
2093
2094/*
2095checkForUpdatesCmd queries GitHub for the latest release tag and returns a
2096tea.Msg (UpdateAvailableMsg) if the latest version differs from the current
2097installed version. This runs in the background when the TUI initializes.
2098*/
2099func checkForUpdatesCmd() tea.Cmd {
2100 return func() tea.Msg {
2101 // Non-fatal: if anything goes wrong we just don't show the update message.
2102 const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
2103 resp, err := http.Get(api)
2104 if err != nil {
2105 return nil
2106 }
2107 defer resp.Body.Close()
2108
2109 var rel githubRelease
2110 if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
2111 return nil
2112 }
2113
2114 latest := strings.TrimPrefix(rel.TagName, "v")
2115 installed := strings.TrimPrefix(detectInstalledVersion(), "v")
2116 if latest != "" && installed != "" && latest != installed {
2117 return UpdateAvailableMsg{Latest: latest, Current: installed}
2118 }
2119 return nil
2120 }
2121}
2122
2123// runUpdateCLI implements the CLI entrypoint for `matcha update`.
2124// It detects the likely installation method and attempts the appropriate
2125// update path (Homebrew, Snap, or GitHub release binary extract).
2126// runGmailOAuthCLI handles the "matcha gmail" subcommand for OAuth2 management.
2127// Usage:
2128//
2129// matcha gmail auth <email> [--client-id ID --client-secret SECRET]
2130// matcha gmail token <email>
2131// matcha gmail revoke <email>
2132func runGmailOAuthCLI(args []string) {
2133 if len(args) < 1 {
2134 fmt.Fprintln(os.Stderr, "Usage: matcha gmail <auth|token|revoke> <email> [flags]")
2135 fmt.Fprintln(os.Stderr, "")
2136 fmt.Fprintln(os.Stderr, "Commands:")
2137 fmt.Fprintln(os.Stderr, " auth <email> Authorize a Gmail account via OAuth2 (opens browser)")
2138 fmt.Fprintln(os.Stderr, " token <email> Print a fresh access token (refreshes automatically)")
2139 fmt.Fprintln(os.Stderr, " revoke <email> Revoke and delete stored OAuth2 tokens")
2140 fmt.Fprintln(os.Stderr, "")
2141 fmt.Fprintln(os.Stderr, "Before using OAuth2, create ~/.config/matcha/oauth_client.json with:")
2142 fmt.Fprintln(os.Stderr, ` {"client_id": "YOUR_ID", "client_secret": "YOUR_SECRET"}`)
2143 fmt.Fprintln(os.Stderr, "")
2144 fmt.Fprintln(os.Stderr, "Get credentials at: https://console.cloud.google.com/apis/credentials")
2145 os.Exit(1)
2146 }
2147
2148 // Find the Python script and pass through to it
2149 script, err := config.OAuthScriptPath()
2150 if err != nil {
2151 fmt.Fprintf(os.Stderr, "Error: %v\n", err)
2152 os.Exit(1)
2153 }
2154
2155 cmdArgs := append([]string{script}, args...)
2156 cmd := exec.Command("python3", cmdArgs...)
2157 cmd.Stdin = os.Stdin
2158 cmd.Stdout = os.Stdout
2159 cmd.Stderr = os.Stderr
2160
2161 if err := cmd.Run(); err != nil {
2162 if exitErr, ok := err.(*exec.ExitError); ok {
2163 os.Exit(exitErr.ExitCode())
2164 }
2165 fmt.Fprintf(os.Stderr, "Error: %v\n", err)
2166 os.Exit(1)
2167 }
2168}
2169
2170func runUpdateCLI() error {
2171 const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
2172 resp, err := http.Get(api)
2173 if err != nil {
2174 return fmt.Errorf("could not query releases: %w", err)
2175 }
2176 defer resp.Body.Close()
2177
2178 var rel githubRelease
2179 if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
2180 return fmt.Errorf("could not parse release info: %w", err)
2181 }
2182
2183 latestTag := rel.TagName
2184 if strings.HasPrefix(latestTag, "v") {
2185 latestTag = latestTag[1:]
2186 }
2187
2188 fmt.Printf("Current version: %s\n", version)
2189 fmt.Printf("Latest version: %s\n", latestTag)
2190
2191 // Quick check: if already up-to-date, exit
2192 cur := version
2193 if strings.HasPrefix(cur, "v") {
2194 cur = cur[1:]
2195 }
2196 if latestTag == "" || cur == latestTag {
2197 fmt.Println("Already up to date.")
2198 return nil
2199 }
2200
2201 // Detect Homebrew
2202 if _, err := exec.LookPath("brew"); err == nil {
2203 fmt.Println("Detected Homebrew — updating taps and attempting to upgrade via brew.")
2204
2205 updateCmd := exec.Command("brew", "update")
2206 updateCmd.Stdout = os.Stdout
2207 updateCmd.Stderr = os.Stderr
2208 if err := updateCmd.Run(); err != nil {
2209 fmt.Printf("Homebrew update failed: %v\n", err)
2210 // continue to attempt upgrade even if update failed
2211 }
2212
2213 upgradeCmd := exec.Command("brew", "upgrade", "floatpane/matcha/matcha")
2214 upgradeCmd.Stdout = os.Stdout
2215 upgradeCmd.Stderr = os.Stderr
2216 if err := upgradeCmd.Run(); err == nil {
2217 fmt.Println("Successfully upgraded via Homebrew.")
2218 return nil
2219 }
2220 fmt.Printf("Homebrew upgrade failed: %v\n", err)
2221 // fallthrough to other methods
2222 }
2223
2224 // Detect snap
2225 if _, err := exec.LookPath("snap"); err == nil {
2226 // Check if matcha is installed as a snap
2227 cmdCheck := exec.Command("snap", "list", "matcha")
2228 if err := cmdCheck.Run(); err == nil {
2229 fmt.Println("Detected Snap package — attempting to refresh.")
2230 cmd := exec.Command("snap", "refresh", "matcha")
2231 cmd.Stdout = os.Stdout
2232 cmd.Stderr = os.Stderr
2233 if err := cmd.Run(); err == nil {
2234 fmt.Println("Successfully refreshed snap.")
2235 return nil
2236 }
2237 fmt.Printf("Snap refresh failed: %v\n", err)
2238 // fallthrough
2239 }
2240 }
2241 // Detect flatpak
2242 if _, err := exec.LookPath("flatpak"); err == nil {
2243 // Check if matcha is installed as a flatpak
2244 cmdCheck := exec.Command("flatpak", "info", "com.floatpane.matcha")
2245 if err := cmdCheck.Run(); err == nil {
2246 fmt.Println("Detected Flatpak package — attempting to update.")
2247 cmd := exec.Command("flatpak", "update", "-y", "com.floatpane.matcha")
2248 cmd.Stdout = os.Stdout
2249 cmd.Stderr = os.Stderr
2250 if err := cmd.Run(); err == nil {
2251 fmt.Println("Successfully updated flatpak.")
2252 return nil
2253 }
2254 fmt.Printf("Flatpak update failed: %v\n", err)
2255 // fallthrough
2256 }
2257 }
2258
2259 // Detect WinGet
2260 if _, err := exec.LookPath("winget"); err == nil {
2261 cmdCheck := exec.Command("winget", "list", "--id", "floatpane.matcha", "--disable-interactivity")
2262 if err := cmdCheck.Run(); err == nil {
2263 fmt.Println("Detected WinGet package — attempting to upgrade.")
2264 cmd := exec.Command("winget", "upgrade", "--id", "floatpane.matcha", "--disable-interactivity")
2265 cmd.Stdout = os.Stdout
2266 cmd.Stderr = os.Stderr
2267 if err := cmd.Run(); err == nil {
2268 fmt.Println("Successfully upgraded via WinGet.")
2269 return nil
2270 }
2271 fmt.Printf("WinGet upgrade failed: %v\n", err)
2272 // fallthrough
2273 }
2274 }
2275
2276 // Otherwise attempt to download the proper release asset and replace the binary.
2277 osName := runtime.GOOS
2278 arch := runtime.GOARCH
2279
2280 // Try to find a matching asset
2281 var assetURL, assetName string
2282 for _, a := range rel.Assets {
2283 n := strings.ToLower(a.Name)
2284 if strings.Contains(n, osName) && strings.Contains(n, arch) && (strings.HasSuffix(n, ".tar.gz") || strings.HasSuffix(n, ".tgz") || strings.HasSuffix(n, ".zip")) {
2285 assetURL = a.BrowserDownloadURL
2286 assetName = a.Name
2287 break
2288 }
2289 }
2290 if assetURL == "" {
2291 // Try any asset that contains 'matcha' and os/arch as a fallback
2292 for _, a := range rel.Assets {
2293 n := strings.ToLower(a.Name)
2294 if strings.Contains(n, "matcha") && (strings.Contains(n, osName) || strings.Contains(n, arch)) {
2295 assetURL = a.BrowserDownloadURL
2296 assetName = a.Name
2297 break
2298 }
2299 }
2300 }
2301
2302 if assetURL == "" {
2303 return fmt.Errorf("no suitable release artifact found for %s/%s", osName, arch)
2304 }
2305
2306 fmt.Printf("Found release asset: %s\n", assetName)
2307 fmt.Println("Downloading...")
2308
2309 // Download asset
2310 respAsset, err := http.Get(assetURL)
2311 if err != nil {
2312 return fmt.Errorf("download failed: %w", err)
2313 }
2314 defer respAsset.Body.Close()
2315
2316 // Create a temp file for the download
2317 tmpDir, err := os.MkdirTemp("", "matcha-update-*")
2318 if err != nil {
2319 return fmt.Errorf("could not create temp dir: %w", err)
2320 }
2321 defer os.RemoveAll(tmpDir)
2322
2323 assetPath := filepath.Join(tmpDir, assetName)
2324 outFile, err := os.Create(assetPath)
2325 if err != nil {
2326 return fmt.Errorf("could not create temp file: %w", err)
2327 }
2328 _, err = io.Copy(outFile, respAsset.Body)
2329 outFile.Close()
2330 if err != nil {
2331 return fmt.Errorf("could not write asset to disk: %w", err)
2332 }
2333
2334 // Determine the expected binary name based on the OS.
2335 binaryName := "matcha"
2336 if runtime.GOOS == "windows" {
2337 binaryName = "matcha.exe"
2338 }
2339
2340 // Extract the binary from the archive.
2341 var binPath string
2342 if strings.HasSuffix(assetName, ".tar.gz") || strings.HasSuffix(assetName, ".tgz") {
2343 f, err := os.Open(assetPath)
2344 if err != nil {
2345 return fmt.Errorf("could not open archive: %w", err)
2346 }
2347 defer f.Close()
2348 gzr, err := gzip.NewReader(f)
2349 if err != nil {
2350 return fmt.Errorf("could not create gzip reader: %w", err)
2351 }
2352 tr := tar.NewReader(gzr)
2353 for {
2354 hdr, err := tr.Next()
2355 if err == io.EOF {
2356 break
2357 }
2358 if err != nil {
2359 return fmt.Errorf("error reading tar: %w", err)
2360 }
2361 name := filepath.Base(hdr.Name)
2362 if name == binaryName || strings.Contains(strings.ToLower(name), "matcha") && (hdr.Typeflag == tar.TypeReg) {
2363 binPath = filepath.Join(tmpDir, binaryName)
2364 out, err := os.Create(binPath)
2365 if err != nil {
2366 return fmt.Errorf("could not create binary file: %w", err)
2367 }
2368 if _, err := io.Copy(out, tr); err != nil {
2369 out.Close()
2370 return fmt.Errorf("could not extract binary: %w", err)
2371 }
2372 out.Close()
2373 if err := os.Chmod(binPath, 0755); err != nil {
2374 return fmt.Errorf("could not make binary executable: %w", err)
2375 }
2376 break
2377 }
2378 }
2379 } else if strings.HasSuffix(assetName, ".zip") {
2380 zr, err := zip.OpenReader(assetPath)
2381 if err != nil {
2382 return fmt.Errorf("could not open zip archive: %w", err)
2383 }
2384 defer zr.Close()
2385 for _, zf := range zr.File {
2386 name := filepath.Base(zf.Name)
2387 if name == binaryName || strings.Contains(strings.ToLower(name), "matcha") && !zf.FileInfo().IsDir() {
2388 rc, err := zf.Open()
2389 if err != nil {
2390 return fmt.Errorf("could not open file in zip: %w", err)
2391 }
2392 binPath = filepath.Join(tmpDir, binaryName)
2393 out, err := os.Create(binPath)
2394 if err != nil {
2395 rc.Close()
2396 return fmt.Errorf("could not create binary file: %w", err)
2397 }
2398 if _, err := io.Copy(out, rc); err != nil {
2399 out.Close()
2400 rc.Close()
2401 return fmt.Errorf("could not extract binary: %w", err)
2402 }
2403 out.Close()
2404 rc.Close()
2405 if err := os.Chmod(binPath, 0755); err != nil {
2406 return fmt.Errorf("could not make binary executable: %w", err)
2407 }
2408 break
2409 }
2410 }
2411 } else {
2412 // For non-archive assets, assume the asset is the binary itself.
2413 binPath = assetPath
2414 if err := os.Chmod(binPath, 0755); err != nil {
2415 // ignore chmod errors but warn
2416 fmt.Printf("warning: could not chmod downloaded binary: %v\n", err)
2417 }
2418 }
2419
2420 if binPath == "" {
2421 return fmt.Errorf("could not locate matcha binary inside the release artifact")
2422 }
2423
2424 // Replace the running executable with the new binary
2425 execPath, err := os.Executable()
2426 if err != nil {
2427 return fmt.Errorf("could not determine executable path: %w", err)
2428 }
2429
2430 // Write the new binary to a temp file in same dir, then rename for atomic replacement.
2431 execDir := filepath.Dir(execPath)
2432 tmpNew := filepath.Join(execDir, fmt.Sprintf("matcha.new.%d", time.Now().Unix()))
2433 in, err := os.Open(binPath)
2434 if err != nil {
2435 return fmt.Errorf("could not open new binary: %w", err)
2436 }
2437 out, err := os.OpenFile(tmpNew, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
2438 if err != nil {
2439 in.Close()
2440 return fmt.Errorf("could not create temp binary in target dir: %w", err)
2441 }
2442 if _, err := io.Copy(out, in); err != nil {
2443 in.Close()
2444 out.Close()
2445 return fmt.Errorf("could not write new binary to disk: %w", err)
2446 }
2447 in.Close()
2448 out.Close()
2449
2450 // On Windows, a running executable cannot be overwritten directly.
2451 // Move the old binary out of the way first, then rename the new one in.
2452 if runtime.GOOS == "windows" {
2453 oldPath := execPath + ".old"
2454 _ = os.Remove(oldPath) // clean up any previous leftover
2455 if err := os.Rename(execPath, oldPath); err != nil {
2456 return fmt.Errorf("could not move old executable out of the way: %w", err)
2457 }
2458 }
2459
2460 if err := os.Rename(tmpNew, execPath); err != nil {
2461 return fmt.Errorf("could not replace executable: %w", err)
2462 }
2463
2464 fmt.Println("Successfully updated matcha to", latestTag)
2465 return nil
2466}
2467
2468func filterUnique(existing, incoming []fetcher.Email) []fetcher.Email {
2469 seen := make(map[uint32]struct{})
2470 for _, e := range existing {
2471 seen[e.UID] = struct{}{}
2472 }
2473 var unique []fetcher.Email
2474 for _, e := range incoming {
2475 if _, ok := seen[e.UID]; !ok {
2476 unique = append(unique, e)
2477 }
2478 }
2479 return unique
2480}
2481
2482func main() {
2483 // If invoked with version flag, print version and exit
2484 if len(os.Args) > 1 && (os.Args[1] == "-v" || os.Args[1] == "--version" || os.Args[1] == "version") {
2485 fmt.Printf("matcha version %s", version)
2486 if commit != "" {
2487 fmt.Printf(" (%s)", commit)
2488 }
2489 if date != "" {
2490 fmt.Printf(" built on %s", date)
2491 }
2492 fmt.Println()
2493 os.Exit(0)
2494 }
2495
2496 // If invoked as CLI update command, run updater and exit.
2497 if len(os.Args) > 1 && os.Args[1] == "update" {
2498 if err := runUpdateCLI(); err != nil {
2499 fmt.Fprintf(os.Stderr, "update failed: %v\n", err)
2500 os.Exit(1)
2501 }
2502 os.Exit(0)
2503 }
2504
2505 // Gmail OAuth2 CLI subcommand: matcha gmail <auth|token|revoke> <email> [flags]
2506 if len(os.Args) > 1 && os.Args[1] == "gmail" {
2507 runGmailOAuthCLI(os.Args[2:])
2508 os.Exit(0)
2509 }
2510
2511 cfg, err := config.LoadConfig()
2512 if err == nil && cfg.Theme != "" {
2513 theme.SetTheme(cfg.Theme)
2514 }
2515 tui.RebuildStyles()
2516
2517 var initialModel *mainModel
2518 if err != nil {
2519 initialModel = newInitialModel(nil)
2520 } else {
2521 initialModel = newInitialModel(cfg)
2522 }
2523
2524 // Initialize plugin system
2525 plugins := plugin.NewManager()
2526 plugins.LoadPlugins()
2527 initialModel.plugins = plugins
2528 plugins.CallHook(plugin.HookStartup)
2529
2530 p := tea.NewProgram(initialModel)
2531
2532 if _, err := p.Run(); err != nil {
2533 plugins.Close()
2534 fmt.Printf("Alas, there's been an error: %v", err)
2535 os.Exit(1)
2536 }
2537
2538 plugins.CallHook(plugin.HookShutdown)
2539 plugins.Close()
2540}