feat: html view of the emails. send turned off

drew created

Change summary

go.mod       |   6 +
main.go      | 214 +++++++++++++++++++++++++++++++++++++++++++++--------
view/html.go |  64 ++++++++++++++++
3 files changed, 251 insertions(+), 33 deletions(-)

Detailed changes

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

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)
 	}
 }

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 <br> 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
+}