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