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