From 5cbcf8eae7b531730279226ae53aeafddafbd370 Mon Sep 17 00:00:00 2001 From: drew Date: Mon, 28 Jul 2025 12:23:46 +0400 Subject: [PATCH] feat: html view of the emails. send turned off --- go.mod | 6 ++ main.go | 214 +++++++++++++++++++++++++++++++++++++++++++-------- view/html.go | 64 +++++++++++++++ 3 files changed, 251 insertions(+), 33 deletions(-) create mode 100644 view/html.go diff --git a/go.mod b/go.mod index 4dcb70735c34919cdbab7271b2a6e3f2243ed5e2..66786e6fd8df61b32105e118e1470fe69410b691 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,11 @@ module github.com/andrinoff/email-cli go 1.24.5 require ( + github.com/PuerkitoBio/goquery v1.10.3 // indirect + github.com/andybalholm/cascadia v1.3.3 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/bubbles v0.21.0 // indirect github.com/charmbracelet/bubbletea v1.3.6 // indirect github.com/charmbracelet/colorprofile v0.3.1 // indirect @@ -16,11 +19,13 @@ require ( github.com/emersion/go-message v0.18.2 // indirect github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/gorilla/css v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect @@ -29,6 +34,7 @@ require ( github.com/spf13/cobra v1.9.1 // indirect github.com/spf13/pflag v1.0.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/net v0.39.0 // indirect golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.34.0 // indirect golang.org/x/text v0.27.0 // indirect diff --git a/main.go b/main.go index bf89f6d2d79f22cb5e1c6cda0681f306f9ad93b8..863bd198a7396ebbfbc6a51c83cc79182e462b09 100644 --- a/main.go +++ b/main.go @@ -2,51 +2,199 @@ package main import ( "fmt" - "log" "os" "github.com/andrinoff/email-cli/config" - "github.com/andrinoff/email-cli/tui" + "github.com/andrinoff/email-cli/fetcher" + "github.com/andrinoff/email-cli/view" // Updated package + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/viewport" // Import viewport + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" ) -func main() { - cfg, err := config.LoadConfig() - if err != nil { - // If config file does not exist, run the login UI - if os.IsNotExist(err) { - fmt.Println("No configuration found. Starting setup...") - newCfg, err := tui.RunLogin() - if err != nil { - log.Fatalf("Could not run login UI: %v", err) +var ( + appStyle = lipgloss.NewStyle().Padding(1, 2) + headerStyle = lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderBottom(true) +) + +// emailItem wraps fetcher.Email to satisfy the list.Item interface. +type emailItem struct { + fetcher.Email +} + +func (e emailItem) Title() string { return e.Subject } +func (e emailItem) Description() string { return fmt.Sprintf("From: %s", e.From) } +func (e emailItem) FilterValue() string { return e.Subject } + +type model struct { + list list.Model + spinner spinner.Model + viewport viewport.Model + state viewState + selectedItem emailItem + err error +} + +type viewState int + +const ( + choosingState viewState = iota + loadingState + inboxState + emailViewState +) + +// Messages for tea.Cmd +type ( + emailsFetchedMsg []fetcher.Email + errMsg struct{ err error } +) + +func (e errMsg) Error() string { return e.err.Error() } + +func newModel() *model { + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) + + initialItems := []list.Item{ + emailItem{fetcher.Email{Subject: "View Inbox"}}, + emailItem{fetcher.Email{Subject: "Send Email (coming soon...)"}}, + } + + m := &model{ + spinner: s, + list: list.New(initialItems, list.NewDefaultDelegate(), 0, 0), + state: choosingState, + viewport: viewport.New(10, 10), // Initial size + } + m.list.Title = "Email CLI" + m.list.SetShowStatusBar(false) + return m +} + +func (m *model) Init() tea.Cmd { + return nil +} + +func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + listHeight := msg.Height - 2 + // Adjust viewport size, leaving space for a header + viewportHeaderHeight := 4 + m.list.SetSize(msg.Width-2, listHeight) + m.viewport.Width = msg.Width + m.viewport.Height = msg.Height - viewportHeaderHeight + + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + case "esc": + if m.state == emailViewState { + m.state = inboxState + } else if m.state == inboxState { + // Reset to initial choice list + initialItems := []list.Item{ + emailItem{fetcher.Email{Subject: "View Inbox"}}, + emailItem{fetcher.Email{Subject: "Send Email (coming soon...)"}}, + } + m.list.SetItems(initialItems) + m.list.Title = "Email CLI" + m.state = choosingState } - // If user quit the login screen without saving, exit - if newCfg == nil { - fmt.Println("\nSetup cancelled. Exiting.") - os.Exit(0) + return m, nil + case "enter": + if m.state == choosingState { + selected := m.list.SelectedItem().(emailItem) + if selected.Subject == "View Inbox" { + m.state = loadingState + cmds = append(cmds, m.spinner.Tick, fetchEmails) + } + } else if m.state == inboxState { + m.selectedItem = m.list.SelectedItem().(emailItem) + m.state = emailViewState + + // Process body and set viewport content + body, err := view.ProcessBody(m.selectedItem.Body) + if err != nil { + body = fmt.Sprintf("Error rendering body: %v", err) + } + m.viewport.SetContent(body) + m.viewport.GotoTop() // Scroll to top on new email } - cfg = newCfg - } else { - // For any other error loading the config, exit - log.Fatalf("Could not load config: %v", err) } + + case emailsFetchedMsg: + items := make([]list.Item, len(msg)) + for i, email := range msg { + items[i] = emailItem{fetcher.Email(email)} + } + m.list.SetItems(items) + m.list.Title = "Inbox" + m.state = inboxState + + case errMsg: + m.err = msg + return m, tea.Quit + } + + // Handle updates for the current state + switch m.state { + case loadingState: + m.spinner, cmd = m.spinner.Update(msg) + case inboxState, choosingState: + m.list, cmd = m.list.Update(msg) + case emailViewState: + m.viewport, cmd = m.viewport.Update(msg) + } + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} + +func (m *model) View() string { + if m.err != nil { + return fmt.Sprintf("Error: %v\n", m.err) + } + + switch m.state { + case loadingState: + return fmt.Sprintf("\n\n %s Fetching emails...\n\n", m.spinner.View()) + case emailViewState: + header := fmt.Sprintf("From: %s\nSubject: %s", m.selectedItem.From, m.selectedItem.Subject) + styledHeader := headerStyle.Width(m.viewport.Width).Render(header) + return fmt.Sprintf("%s\n%s", styledHeader, m.viewport.View()) + default: // choosingState and inboxState + return appStyle.Render(m.list.View()) } +} - // Now that we have a config, let the user choose what to do - choice, err := tui.RunChoice() +func fetchEmails() tea.Msg { + cfg, err := config.LoadConfig() + if err != nil { + return errMsg{err} + } + emails, err := fetcher.FetchEmails(cfg) if err != nil { - log.Fatalf("Could not run choice UI: %v", err) + return errMsg{err} } + return emailsFetchedMsg(emails) +} - switch choice { - case "send": - fmt.Printf("Logged in as %s. Starting email composer...\n", cfg.Email) - if err := tui.RunComposer(cfg); err != nil { - log.Fatalf("Application error: %v", err) - } - case "inbox": - fmt.Printf("Logged in as %s. Opening inbox...\n", cfg.Email) - if err := tui.RunInbox(cfg); err != nil { - log.Fatalf("Application error: %v", err) - } +func main() { + p := tea.NewProgram(newModel(), tea.WithAltScreen()) + + if _, err := p.Run(); err != nil { + fmt.Println("Error running program:", err) + os.Exit(1) } } \ No newline at end of file diff --git a/view/html.go b/view/html.go new file mode 100644 index 0000000000000000000000000000000000000000..2192b70a36ba8c89ec2c92973db9d774ac16fd5f --- /dev/null +++ b/view/html.go @@ -0,0 +1,64 @@ +package view + +import ( + "fmt" + "io" + "mime/quotedprintable" + "strings" + + "github.com/PuerkitoBio/goquery" +) + +// hyperlink formats a string as a terminal-clickable hyperlink. +func hyperlink(url, text string) string { + if text == "" { + text = url + } + return fmt.Sprintf("\x1b]8;;%s\x07%s\x1b]8;;\x07", url, text) +} + +func decodeQuotedPrintable(s string) (string, error) { + reader := quotedprintable.NewReader(strings.NewReader(s)) + body, err := io.ReadAll(reader) + if err != nil { + return "", err + } + return string(body), nil +} + +// ProcessBody takes a raw email body, decodes it, and formats it as plain +// text with terminal hyperlinks. +func ProcessBody(rawBody string) (string, error) { + decodedBody, err := decodeQuotedPrintable(rawBody) + if err != nil { + // If decoding fails, fallback to the raw body + decodedBody = rawBody + } + + doc, err := goquery.NewDocumentFromReader(strings.NewReader(decodedBody)) + if err != nil { + return "", fmt.Errorf("could not parse email body: %w", err) + } + + // Add newlines after block elements for better spacing + doc.Find("p, div, h1, h2, h3, h4, h5, h6").Each(func(i int, s *goquery.Selection) { + s.After("\n\n") + }) + + // Replace
tags with newlines + doc.Find("br").Each(func(i int, s *goquery.Selection) { + s.ReplaceWithHtml("\n") + }) + + // Format links into terminal hyperlinks + doc.Find("a").Each(func(i int, s *goquery.Selection) { + href, exists := s.Attr("href") + if !exists { + return + } + s.ReplaceWithHtml(hyperlink(href, s.Text())) + }) + + // Return the document's text content, which now includes our formatting + return doc.Text(), nil +} \ No newline at end of file