Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 32 additions & 4 deletions internal/htmlutil/htmlutil.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -305,6 +313,24 @@ func parseTrixAttachment(n *html.Node) *trixAttachment {
return &att
}

// embeddedActionTextContent returns the markup an unnamed text/html attachment carries
// in its content attribute. HEY renders any other attachment as itself, whatever
// content it carries.
func embeddedActionTextContent(n *html.Node) string {
if getAttr(n, "filename") != "" || !isHTMLContentType(getAttr(n, "content-type")) {
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 {
Expand Down Expand Up @@ -455,7 +481,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":
Expand Down
83 changes: 83 additions & 0 deletions internal/htmlutil/htmlutil_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,13 @@ func TestMessageSourceTextIncludesEmbeddedEmailBody(t *testing.T) {
}
}

func TestMessageSourceTextIncludesEmbeddedActionTextAttachment(t *testing.T) {
html := `<action-text-attachment content-type="text/html" content="<p>External confirmation: BLUE-42</p>"></action-text-attachment>`
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("<div>", 1_000) + "not selectable" + strings.Repeat("</div>", 1_000)
if got := MessageSourceText(html); got != "" {
Expand Down Expand Up @@ -176,6 +183,13 @@ func TestToTextActionTextAttachment(t *testing.T) {
}
}

func TestToTextRendersEmbeddedActionTextAttachment(t *testing.T) {
got := ToText(`<action-text-attachment content-type="text/html" content="<p>Inside</p>"></action-text-attachment>`)
if got != "Inside" {
t.Errorf("ToText = %q, want %q", got, "Inside")
}
}

func TestToTextTrixFigure(t *testing.T) {
got := ToText(`<p>Before</p><figure data-trix-attachment='{"filename":"photo.png","url":"/img.png","contentType":"image/png"}'></figure><p>After</p>`)
if !strings.Contains(got, "[photo.png]") {
Expand Down Expand Up @@ -204,6 +218,75 @@ func TestExtractImageURLsInsideEmbeddedHTMLAttachment(t *testing.T) {
}
}

func TestExtractImageURLsInsideEmbeddedActionTextAttachment(t *testing.T) {
urls := ExtractImageURLs(`<action-text-attachment content-type="text/html" content="<img src=&quot;https://example.com/logo.png&quot;>"></action-text-attachment>`)
if len(urls) != 1 || urls[0] != "https://example.com/logo.png" {
t.Errorf("ExtractImageURLs = %v, want the image inside the embedded body", urls)
}
}

func embeddedActionTextAttachment(content string) string {
return `<action-text-attachment content-type="text/html" content="` + stdhtml.EscapeString(content) + `"></action-text-attachment>`
}

func TestEmbeddedActionTextAttachmentStopsRecursing(t *testing.T) {
withinLimit := `<p>innermost</p><img src="https://example.com/innermost.png">`
for range embeddedContentDepthLimit {
withinLimit = embeddedActionTextAttachment(withinLimit)
}
beyondLimit := embeddedActionTextAttachment(withinLimit)

for name, text := range map[string]func(string) string{"ToText": ToText, "MessageSourceText": MessageSourceText} {
if got := text(withinLimit); !strings.Contains(got, "innermost") {
t.Errorf("%s = %q, want the body at the nesting limit", name, got)
}
if got := text(beyondLimit); strings.Contains(got, "innermost") {
t.Errorf("%s = %q, should stop before the body beyond the nesting limit", name, got)
}
}
if urls := ExtractImageURLs(withinLimit); len(urls) != 1 {
t.Errorf("ExtractImageURLs = %v, want the image at the nesting limit", urls)
}
if urls := ExtractImageURLs(beyondLimit); len(urls) != 0 {
t.Errorf("ExtractImageURLs = %v, should stop before the image beyond the nesting limit", urls)
}
}

// HEY renders an attachment's content as HTML only when its content type is HTML; any
// other attachment is itself, whatever content it carries.
func TestNonHTMLActionTextAttachmentContentIsNotABody(t *testing.T) {
image := `<action-text-attachment content-type="image/png" url="https://example.com/floor-plan.png" caption="Floor plan" content="<img src=&quot;https://example.com/not-the-image.png&quot;>"></action-text-attachment>`
if urls := ExtractImageURLs(image); len(urls) != 1 || urls[0] != "https://example.com/floor-plan.png" {
t.Errorf("ExtractImageURLs = %v, want the attachment's own image", urls)
}

untyped := `<p>Visible</p><action-text-attachment content="<p>not the body</p>"></action-text-attachment>`
if got := ToText(untyped); strings.Contains(got, "not the body") {
t.Errorf("ToText = %q, should not render a non-HTML attachment's content", got)
}
if got := strings.Join(strings.Fields(MessageSourceText(untyped)), " "); got != "Visible" {
t.Errorf("MessageSourceText = %q, want only the visible text", got)
}
}

// Only an unnamed HTML attachment is an email body; a named one is a file, even when it is HTML.
func TestNamedHTMLActionTextAttachmentIsAFile(t *testing.T) {
file := `<action-text-attachment filename="itinerary.html" content-type="text/html" content="<p>Flight to Lisbon departs at 09:40</p><img src=&quot;https://example.com/boarding-pass.png&quot;>"></action-text-attachment>`

if got := toMarkdown(file); got != "📄 itinerary.html" {
t.Errorf("ToMarkdown = %q, want the file row", got)
}
if got := ToText(file); got != "[itinerary.html]" {
t.Errorf("ToText = %q, want the filename", got)
}
if got := MessageSourceText(file); got != "" {
t.Errorf("MessageSourceText = %q, want no source text from a file", got)
}
if urls := ExtractImageURLs(file); len(urls) != 0 {
t.Errorf("ExtractImageURLs = %v, want no image from inside a file", urls)
}
}

func TestExtractAttachmentsSkipsEmbeddedHTMLAttachment(t *testing.T) {
attachments := ExtractAttachments(`<figure data-trix-attachment='{"contentType":"text/html","content":"<p>body</p>"}'></figure>`)
if len(attachments) != 0 {
Expand Down
7 changes: 6 additions & 1 deletion internal/htmlutil/markdown.go
Original file line number Diff line number Diff line change
Expand Up @@ -730,7 +730,8 @@ func (m *markdownizer) embedded(content string) {
m.depth--
}

// actionTextAttachment writes an attachment node — or nothing, for a decorative image.
// actionTextAttachment writes an attachment node — or nothing, for a decorative image,
// or the markup an unnamed text/html one carries inline, which is an email body.
// HEY rewrites an inbound email's <img> tags into these nodes, so a notification
// email's avatars and icons arrive as image attachments declaring icon-sized
// dimensions, decoration beside the text that already says who or what they show. A
Expand All @@ -740,6 +741,10 @@ 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")
Expand Down
41 changes: 41 additions & 0 deletions internal/htmlutil/markdown_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,47 @@ func TestToMarkdownEmbeddedHTMLAttachment(t *testing.T) {
}
}

// A nested action-text-attachment carries its HTML in a content attribute.
func TestToMarkdownEmbeddedActionTextAttachment(t *testing.T) {
got := toMarkdown(`<figure data-trix-attachment='{"contentType":"text/html","content":"<action-text-attachment content-type=\"text/html\" content=\"<p>Dear customer,</p><p>Please <a href=&amp;quot;https://example.com/sign&amp;quot;>sign the document</a>.</p>\"></action-text-attachment>"}'></figure>`)

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(`<action-text-attachment filename="report.pdf" content-type="application/pdf" content="<p>not the body</p>"></action-text-attachment>`)
want := "📄 report.pdf"
if got != want {
t.Errorf("ToMarkdown = %q, want %q", got, want)
}
}

// HEY renders an attachment's content as HTML only when its content type is HTML.
func TestToMarkdownNonHTMLActionTextAttachmentIgnoresInlineContent(t *testing.T) {
got := toMarkdown(`<action-text-attachment content-type="image/png" url="https://example.com/floor-plan.png" caption="Floor plan" content="<p>not the body</p>"></action-text-attachment>`)
want := "![Floor plan](https://example.com/floor-plan.png)"
if got != want {
t.Errorf("ToMarkdown = %q, want %q", got, want)
}
}

func TestToMarkdownEmbeddedActionTextAttachmentStopsRecursing(t *testing.T) {
withinLimit := "<p>innermost</p>"
for range embeddedContentDepthLimit {
withinLimit = embeddedActionTextAttachment(withinLimit)
}
if got := toMarkdown(withinLimit); got != "innermost" {
t.Errorf("ToMarkdown = %q, want the body at the nesting limit", got)
}

if got := toMarkdown(embeddedActionTextAttachment(withinLimit)); strings.Contains(got, "innermost") {
t.Errorf("ToMarkdown = %q, should stop before the body beyond the nesting limit", got)
}
}

func TestToMarkdownEmbeddedContentStopsRecursing(t *testing.T) {
nested := "<p>innermost</p>"
for range embeddedContentDepthLimit + 1 {
Expand Down
Loading