From f33596e2aa1979c773a4ffde4c608e3f465dae80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Aguilar?= Date: Tue, 25 Aug 2026 22:22:29 -0300 Subject: [PATCH] Render nested Action Text attachment content --- internal/htmlutil/htmlutil.go | 33 ++++++++++++++++++++++++++---- internal/htmlutil/htmlutil_test.go | 21 +++++++++++++++++++ internal/htmlutil/markdown.go | 12 ++++++++++- internal/htmlutil/markdown_test.go | 18 ++++++++++++++++ 4 files changed, 79 insertions(+), 5 deletions(-) diff --git a/internal/htmlutil/htmlutil.go b/internal/htmlutil/htmlutil.go index a012c501..30d73948 100644 --- a/internal/htmlutil/htmlutil.go +++ b/internal/htmlutil/htmlutil.go @@ -96,8 +96,9 @@ func walkNode(b *strings.Builder, n *html.Node, depth int) { } return case "action-text-attachment": - filename := getAttr(n, "filename") - if filename != "" { + if doc := parseEmbeddedActionText(n, depth); doc != nil { + walkNode(b, doc, depth+1) + } else if filename := getAttr(n, "filename"); filename != "" { fmt.Fprintf(b, "\n[%s]\n", filename) } return @@ -162,6 +163,13 @@ func walkMessageSourceNode(b *strings.Builder, n *html.Node, depth int) { case "hr": writeMessageSourceBoundary(b) return + case "action-text-attachment": + if doc := parseEmbeddedActionText(n, depth); doc != nil { + writeMessageSourceBoundary(b) + walkMessageSourceNode(b, doc, depth+1) + writeMessageSourceBoundary(b) + } + return case "template": if depth == 0 || n.Parent == nil || n.Parent.Data != "shadow-content" { return @@ -196,7 +204,7 @@ func elementProvidesMessageSourceText(n *html.Node) bool { return false } switch n.Data { - case "script", "style", "noscript", "head", "action-text-attachment": + case "script", "style", "noscript", "head": return false case "dialog": return hasAttr(n, "open") @@ -295,6 +303,21 @@ func parseTrixAttachment(n *html.Node) *trixAttachment { return &att } +func embeddedActionTextContent(n *html.Node) string { + if getAttr(n, "filename") != "" { + return "" + } + return getAttr(n, "content") +} + +func parseEmbeddedActionText(n *html.Node, depth int) *html.Node { + content := embeddedActionTextContent(n) + if content == "" { + return nil + } + return parseEmbeddedContent(content, depth) +} + func getAttr(n *html.Node, key string) string { for _, a := range n.Attr { if a.Key == key { @@ -401,7 +424,9 @@ func findImages(n *html.Node, urls *[]string, depth int) { } } case "action-text-attachment": - if imageURL := getAttr(n, "url"); isImageContentType(getAttr(n, "content-type")) && imageURL != "" { + if doc := parseEmbeddedActionText(n, depth); doc != nil { + findImages(doc, urls, depth+1) + } else if imageURL := getAttr(n, "url"); isImageContentType(getAttr(n, "content-type")) && imageURL != "" { *urls = append(*urls, imageURL) } case "figure": diff --git a/internal/htmlutil/htmlutil_test.go b/internal/htmlutil/htmlutil_test.go index f5f703b9..7a5f37ec 100644 --- a/internal/htmlutil/htmlutil_test.go +++ b/internal/htmlutil/htmlutil_test.go @@ -87,6 +87,13 @@ func TestMessageSourceTextIncludesEmbeddedEmailBody(t *testing.T) { } } +func TestMessageSourceTextIncludesEmbeddedActionTextAttachment(t *testing.T) { + html := `` + if got := strings.Join(strings.Fields(MessageSourceText(html)), " "); got != "External confirmation: BLUE-42" { + t.Errorf("MessageSourceText = %q", got) + } +} + func TestMessageSourceTextFailsClosedWhenHTMLExceedsParserDepth(t *testing.T) { html := strings.Repeat("
", 1_000) + "not selectable" + strings.Repeat("
", 1_000) if got := MessageSourceText(html); got != "" { @@ -158,6 +165,13 @@ func TestToTextActionTextAttachment(t *testing.T) { } } +func TestToTextRendersEmbeddedActionTextAttachment(t *testing.T) { + got := ToText(``) + if got != "Inside" { + t.Errorf("ToText = %q, want %q", got, "Inside") + } +} + func TestToTextTrixFigure(t *testing.T) { got := ToText(`

Before

After

`) if !strings.Contains(got, "[photo.png]") { @@ -187,6 +201,13 @@ func TestExtractImageURLsInsideEmbeddedHTMLAttachment(t *testing.T) { } } +func TestExtractImageURLsInsideEmbeddedActionTextAttachment(t *testing.T) { + urls := ExtractImageURLs(``) + if len(urls) != 1 || urls[0] != "https://example.com/logo.png" { + t.Errorf("ExtractImageURLs = %v, want the image inside the embedded body", urls) + } +} + func TestExtractAttachmentsSkipsEmbeddedHTMLAttachment(t *testing.T) { attachments := ExtractAttachments(`
`) if len(attachments) != 0 { diff --git a/internal/htmlutil/markdown.go b/internal/htmlutil/markdown.go index c29fd55a..f38bb724 100644 --- a/internal/htmlutil/markdown.go +++ b/internal/htmlutil/markdown.go @@ -111,7 +111,7 @@ func (m *markdownizer) element(n *html.Node) { case "figure": m.figure(n) case "action-text-attachment": - m.attachment(getAttr(n, "filename"), getAttr(n, "url"), getAttr(n, "content-type")) + m.renderActionTextAttachment(n) default: m.children(n) } @@ -490,6 +490,16 @@ func (m *markdownizer) image(n *html.Node) { } } +// renderActionTextAttachment renders unnamed inline content as embedded HTML while +// preserving named elements as file attachments. +func (m *markdownizer) renderActionTextAttachment(n *html.Node) { + if content := embeddedActionTextContent(n); content != "" { + m.embedded(content) + return + } + m.attachment(getAttr(n, "filename"), getAttr(n, "url"), getAttr(n, "content-type")) +} + func (m *markdownizer) figure(n *html.Node) { attachment := parseTrixAttachment(n) switch { diff --git a/internal/htmlutil/markdown_test.go b/internal/htmlutil/markdown_test.go index 5ce40196..82c9e58b 100644 --- a/internal/htmlutil/markdown_test.go +++ b/internal/htmlutil/markdown_test.go @@ -221,6 +221,24 @@ func TestToMarkdownEmbeddedHTMLAttachment(t *testing.T) { } } +// A nested action-text-attachment carries its HTML in a content attribute. +func TestToMarkdownEmbeddedActionTextAttachment(t *testing.T) { + got := toMarkdown(`
`) + + want := "Dear customer,\n\nPlease [sign the document](https://example.com/sign)." + if got != want { + t.Errorf("ToMarkdown = %q, want %q", got, want) + } +} + +func TestToMarkdownNamedActionTextAttachmentIgnoresInlineContent(t *testing.T) { + got := toMarkdown(``) + want := "📎 report.pdf" + if got != want { + t.Errorf("ToMarkdown = %q, want %q", got, want) + } +} + func TestToMarkdownEmbeddedContentStopsRecursing(t *testing.T) { nested := `
` for range embeddedContentDepthLimit + 2 {