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