#48 from andrinoff/next

drew created

Change summary

.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(-)

Detailed changes

.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 ./...

.github/workflows/labeler.yml 🔗

@@ -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

tui/email_view.go 🔗

@@ -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

tui/email_view_test.go 🔗

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

view/html.go 🔗

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

view/html_test.go 🔗

@@ -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:    `![alt text](http://example.com/img.png)>`,
+			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)
+			}
+		})
+	}
+}