diff --git a/internal/htmlutil/htmlutil.go b/internal/htmlutil/htmlutil.go index 4d69dcc3..a81dc532 100644 --- a/internal/htmlutil/htmlutil.go +++ b/internal/htmlutil/htmlutil.go @@ -106,8 +106,9 @@ func walkNode(b *strings.Builder, n *html.Node, depth int) { if isImageContentType(getAttr(n, "content-type")) && isDecorativeImage(getAttr(n, "width"), getAttr(n, "height")) { return } - 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 @@ -172,6 +173,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 @@ -206,7 +214,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") @@ -305,6 +313,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 { @@ -455,7 +478,9 @@ func findImages(n *html.Node, urls *[]string, depth int) { if isImageContentType(getAttr(n, "content-type")) && isDecorativeImage(getAttr(n, "width"), getAttr(n, "height")) { return } - 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 7ee42300..ebc09650 100644 --- a/internal/htmlutil/htmlutil_test.go +++ b/internal/htmlutil/htmlutil_test.go @@ -105,6 +105,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 != "" { @@ -176,6 +183,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]") { @@ -204,6 +218,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 3c4a8eac..2b004935 100644 --- a/internal/htmlutil/markdown.go +++ b/internal/htmlutil/markdown.go @@ -709,10 +709,16 @@ func (m *markdownizer) embedded(content string) { // dimensions, decoration beside the text that already says who or what they show. A // caption names an image its missing filename does not — the alt text of the // the node used to be. +// actionTextAttachment renders an unnamed attachment's inline content as embedded HTML +// and a named one as a file attachment. func (m *markdownizer) actionTextAttachment(n *html.Node) { if isImageContentType(getAttr(n, "content-type")) && isDecorativeImage(getAttr(n, "width"), getAttr(n, "height")) { return } + if content := embeddedActionTextContent(n); content != "" { + m.embedded(content) + return + } filename := getAttr(n, "filename") if filename == "" { filename = getAttr(n, "caption") diff --git a/internal/htmlutil/markdown_test.go b/internal/htmlutil/markdown_test.go index ad5accee..7ff694b8 100644 --- a/internal/htmlutil/markdown_test.go +++ b/internal/htmlutil/markdown_test.go @@ -449,6 +449,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 := "

innermost

" for range embeddedContentDepthLimit + 1 {