.github/workflows/ci.yml 🔗
@@ -20,8 +20,5 @@ jobs:
- name: Tidy modules
run: go mod tidy
- - name: Build
- run: go build -v ./...
-
- name: Test
run: go test -v ./...
drew created
.github/workflows/ci.yml | 3
.github/workflows/labeler.yml | 19 -----
.github/workflows/release.yml | 4
tui/email_view.go | 2
tui/email_view_test.go | 140 +++++++++++++++++++++++++++++++++++++
view/html.go | 11 ++
view/html_test.go | 139 ++++++++++++++++++++++++++++++++++++
7 files changed, 291 insertions(+), 27 deletions(-)
@@ -20,8 +20,5 @@ jobs:
- name: Tidy modules
run: go mod tidy
- - name: Build
- run: go build -v ./...
-
- name: Test
run: go test -v ./...
@@ -1,19 +0,0 @@
-name: Issue Labeler
-on:
- issues:
- types: [opened, edited]
-
-jobs:
- label_issues:
- runs-on: ubuntu-latest
- permissions:
- issues: write
- steps:
- - name: Label issues
- # Update the action to the latest version
- uses: github/issue-labeler@v5
- with:
- repo-token: "${{ secrets.GITHUB_TOKEN }}"
- # Correct the path to your configuration file
- configuration-path: .github/labeler-config.yml
- not-before: "2023-01-01T00:00:00Z" # Optional: only run on new issues
@@ -1,7 +1,9 @@
name: Release
on:
- push:
+ workflow_run:
+ workflows: ["Go CI"]
+ types: [completed]
branches:
- master
@@ -114,7 +114,7 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (m *EmailView) View() string {
- header := fmt.Sprintf("From: %s\nSubject: %s", m.email.From, m.email.Subject)
+ header := fmt.Sprintf("From: %s | Subject: %s", m.email.From, m.email.Subject)
styledHeader := emailHeaderStyle.Width(m.viewport.Width).Render(header)
var help string
@@ -0,0 +1,140 @@
+package tui
+
+import (
+ "testing"
+ "time"
+
+ "github.com/andrinoff/email-cli/fetcher"
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+func TestEmailViewUpdate(t *testing.T) {
+ emailWithAttachments := fetcher.Email{
+ From: "test@example.com",
+ Subject: "Test Email with Attachments",
+ Body: "This is the body.",
+ Date: time.Now(),
+ Attachments: []fetcher.Attachment{
+ {Filename: "attachment1.txt", Data: []byte("attachment1")},
+ {Filename: "attachment2.txt", Data: []byte("attachment2")},
+ },
+ }
+
+ emailWithoutAttachments := fetcher.Email{
+ From: "test@example.com",
+ Subject: "Test Email without Attachments",
+ Body: "This is the body.",
+ Date: time.Now(),
+ }
+
+ t.Run("Focus on attachments", func(t *testing.T) {
+ emailView := NewEmailView(emailWithAttachments, 80, 24)
+ if emailView.focusOnAttachments {
+ t.Error("focusOnAttachments should be initially false")
+ }
+
+ // Tab to focus on attachments
+ model, _ := emailView.Update(tea.KeyMsg{Type: tea.KeyTab})
+ emailView = model.(*EmailView)
+
+ if !emailView.focusOnAttachments {
+ t.Error("focusOnAttachments should be true after tabbing")
+ }
+
+ // Tab back to body
+ model, _ = emailView.Update(tea.KeyMsg{Type: tea.KeyTab})
+ emailView = model.(*EmailView)
+ if emailView.focusOnAttachments {
+ t.Error("focusOnAttachments should be false after tabbing again")
+ }
+ })
+
+ t.Run("No focus on attachments when there are none", func(t *testing.T) {
+ emailView := NewEmailView(emailWithoutAttachments, 80, 24)
+ if emailView.focusOnAttachments {
+ t.Error("focusOnAttachments should be initially false")
+ }
+ // Tab
+ model, _ := emailView.Update(tea.KeyMsg{Type: tea.KeyTab})
+ emailView = model.(*EmailView)
+ if emailView.focusOnAttachments {
+ t.Error("focusOnAttachments should remain false when there are no attachments")
+ }
+ })
+
+ t.Run("Navigate attachments", func(t *testing.T) {
+ emailView := NewEmailView(emailWithAttachments, 80, 24)
+ // Focus on attachments
+ model, _ := emailView.Update(tea.KeyMsg{Type: tea.KeyTab})
+ emailView = model.(*EmailView)
+
+ if emailView.attachmentCursor != 0 {
+ t.Errorf("Initial attachmentCursor should be 0, got %d", emailView.attachmentCursor)
+ }
+
+ // Move down
+ model, _ = emailView.Update(tea.KeyMsg{Type: tea.KeyDown})
+ emailView = model.(*EmailView)
+ if emailView.attachmentCursor != 1 {
+ t.Errorf("After one down arrow, attachmentCursor should be 1, got %d", emailView.attachmentCursor)
+ }
+
+ // Move down again (should not go past the end)
+ model, _ = emailView.Update(tea.KeyMsg{Type: tea.KeyDown})
+ emailView = model.(*EmailView)
+ if emailView.attachmentCursor != 1 {
+ t.Errorf("attachmentCursor should not go past the end of the list, got %d", emailView.attachmentCursor)
+ }
+
+ // Move up
+ model, _ = emailView.Update(tea.KeyMsg{Type: tea.KeyUp})
+ emailView = model.(*EmailView)
+ if emailView.attachmentCursor != 0 {
+ t.Errorf("After one up arrow, attachmentCursor should be 0, got %d", emailView.attachmentCursor)
+ }
+ })
+
+ t.Run("Download attachment", func(t *testing.T) {
+ emailView := NewEmailView(emailWithAttachments, 80, 24)
+ // Focus on attachments
+ model, _ := emailView.Update(tea.KeyMsg{Type: tea.KeyTab})
+ emailView = model.(*EmailView)
+
+ // Move to the second attachment
+ model, _ = emailView.Update(tea.KeyMsg{Type: tea.KeyDown})
+ emailView = model.(*EmailView)
+
+ // Press enter
+ _, cmd := emailView.Update(tea.KeyMsg{Type: tea.KeyEnter})
+ if cmd == nil {
+ t.Fatal("Expected a command, but got nil")
+ }
+
+ msg := cmd()
+ downloadMsg, ok := msg.(DownloadAttachmentMsg)
+ if !ok {
+ t.Fatalf("Expected a DownloadAttachmentMsg, but got %T", msg)
+ }
+ if downloadMsg.Filename != "attachment2.txt" {
+ t.Errorf("Expected to download 'attachment2.txt', but got '%s'", downloadMsg.Filename)
+ }
+ })
+
+ t.Run("Reply to email", func(t *testing.T) {
+ emailView := NewEmailView(emailWithAttachments, 80, 24)
+
+ _, cmd := emailView.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("r")})
+ if cmd == nil {
+ t.Fatal("Expected a command, but got nil")
+ }
+
+ msg := cmd()
+ replyMsg, ok := msg.(ReplyToEmailMsg)
+ if !ok {
+ t.Fatalf("Expected a ReplyToEmailMsg, but got %T", msg)
+ }
+ if replyMsg.Email.Subject != emailWithAttachments.Subject {
+ t.Errorf("Expected reply to have subject '%s', but got '%s'", emailWithAttachments.Subject, replyMsg.Email.Subject)
+ }
+ })
+}
@@ -62,7 +62,8 @@ func ProcessBody(rawBody string, h1Style, h2Style, bodyStyle lipgloss.Style) (st
doc.Find("style, script").Remove()
- // Style headers
+ // Style headers by setting their text content.
+ // We use SetText so the h1/h2 tags remain in the document for spacing logic.
doc.Find("h1").Each(func(i int, s *goquery.Selection) {
s.SetText(h1Style.Render(s.Text()))
})
@@ -71,8 +72,9 @@ func ProcessBody(rawBody string, h1Style, h2Style, bodyStyle lipgloss.Style) (st
s.SetText(h2Style.Render(s.Text()))
})
- // Add newlines after block elements for better spacing
- doc.Find("p, div").Each(func(i int, s *goquery.Selection) {
+ // Add newlines after block elements for better spacing.
+ // THIS IS THE KEY FIX: Include h1 and h2 in the selector.
+ doc.Find("p, div, h1, h2").Each(func(i int, s *goquery.Selection) {
s.After("\n\n")
})
@@ -106,5 +108,8 @@ func ProcessBody(rawBody string, h1Style, h2Style, bodyStyle lipgloss.Style) (st
re := regexp.MustCompile(`\n{3,}`)
text = re.ReplaceAllString(text, "\n\n")
+
+ text = strings.TrimSpace(text)
+
return bodyStyle.Render(text), nil
}
@@ -0,0 +1,139 @@
+package view
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/charmbracelet/lipgloss"
+)
+
+func TestDecodeQuotedPrintable(t *testing.T) {
+ testCases := []struct {
+ name string
+ input string
+ expected string
+ }{
+ {
+ name: "Simple case",
+ input: "Hello=2C world=21",
+ expected: "Hello, world!",
+ },
+ {
+ name: "With soft line break",
+ input: "This is a long line that gets wrapped=\r\n and continues here.",
+ expected: "This is a long line that gets wrapped and continues here.",
+ },
+ {
+ name: "No encoding",
+ input: "Just a plain string.",
+ expected: "Just a plain string.",
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ decoded, err := decodeQuotedPrintable(tc.input)
+ if err != nil {
+ t.Fatalf("decodeQuotedPrintable() failed: %v", err)
+ }
+ if decoded != tc.expected {
+ t.Errorf("Expected %q, got %q", tc.expected, decoded)
+ }
+ })
+ }
+}
+
+func TestMarkdownToHTML(t *testing.T) {
+ testCases := []struct {
+ name string
+ input string
+ expected string
+ }{
+ {
+ name: "Heading",
+ input: "# Hello",
+ expected: "<h1>Hello</h1>",
+ },
+ {
+ name: "Bold",
+ input: "**bold text**",
+ expected: "<p><strong>bold text</strong></p>",
+ },
+ {
+ name: "Link",
+ input: "[link](http://example.com)",
+ expected: `<p><a href="http://example.com">link</a></p>`,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ html := markdownToHTML([]byte(tc.input))
+ // Trim newlines for consistent comparison
+ if strings.TrimSpace(string(html)) != tc.expected {
+ t.Errorf("Expected %s, got %s", tc.expected, html)
+ }
+ })
+ }
+}
+
+func TestProcessBody(t *testing.T) {
+ h1Style := lipgloss.NewStyle().SetString("H1")
+ h2Style := lipgloss.NewStyle().SetString("H2")
+ bodyStyle := lipgloss.NewStyle().SetString("BODY")
+
+ testCases := []struct {
+ name string
+ input string
+ expected string
+ }{
+ {
+ name: "Simple HTML",
+ input: "<p>Hello, world!</p>",
+ expected: "Hello, world!",
+ },
+ {
+ name: "With link HTML",
+ input: `<a href="http://example.com">Click here</a>`,
+ expected: "Click here",
+ },
+ {
+ name: "With image HTML",
+ input: `<img src="http://example.com/img.png" alt="alt text">`,
+ expected: "[Click here to view image: alt text]",
+ },
+ {
+ name: "With headers HTML",
+ input: "<h1>Header 1</h1>",
+ expected: "Header 1",
+ },
+ {
+ name: "With link Markdown",
+ input: `[Click here](http://example.com)`,
+ expected: "Click here",
+ },
+ {
+ name: "With image Markdown",
+ input: `>`,
+ expected: "[Click here to view image: alt text]",
+ },
+ {
+ name: "With headers Markdown",
+ input: "# Header 1",
+ expected: "Header 1",
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ processed, err := ProcessBody(tc.input, h1Style, h2Style, bodyStyle)
+ if err != nil {
+ t.Fatalf("ProcessBody() failed: %v", err)
+ }
+ // Use Contains because styles add ANSI codes
+ if !strings.Contains(processed, tc.expected) {
+ t.Errorf("Processed body does not contain expected text.\nGot: %q\nWant to contain: %q", processed, tc.expected)
+ }
+ })
+ }
+}