package mailhtml import ( "strings" "testing" ) // The shape issue #462 reports: an HTML email laid out as a card centred // inside a full-width page table. Appended at the end of the document, the // signature and the opt-out footer render against the left edge of the // window, in the page background, styled by nothing. const centredCard = `` + `
` + `
` + `

Hi

Body

` + `
` func TestAppendToContentLandsInsideTheCard(t *testing.T) { out := AppendToContent(centredCard, "[F]") if !strings.Contains(out, "

Body

[F]") { t.Fatalf("footer did not land in the card's own cell:\n%s", out) } } // A preheader and a tracking pixel are both children of in a designed // email. Counting either as layout stops the descent at the body and leaves // the footer exactly where the bug put it. func TestAppendToContentIgnoresThePreheaderAndThePixel(t *testing.T) { body := `
Quick question
` + `
Hi
` + `` out := AppendToContent(body, "[F]") if !strings.Contains(out, "Hi[F]") { t.Fatalf("hidden siblings stopped the descent:\n%s", out) } } // A

written straight into a table is hoisted back out of it by every HTML // parser, which lands it exactly where the bug put it. A table with rows of // its own gets a row instead, spanning every column and picking up the side // padding of the rows above so the footer lines up with the copy. func TestAppendToContentAddsARowToATable(t *testing.T) { body := `` + `` + `` + `
BannerLogo
CopyAside
` out := AppendToContent(body, "[F]") want := `[F]` if !strings.Contains(out, want) { t.Fatalf("expected %s in:\n%s", want, out) } } // A builder export (Unlayer, Beefree, Stripo) is a stack of full-width rows // each centring a card of its own, so the deepest element holding the whole // message is the full-width cell. Appending there is inside the document but // still hard left; the footer has to centre itself on the card's width. func TestAppendToContentCentresItselfInABuilderExport(t *testing.T) { row := `

` + `

Copy

` + `
` body := `
` + row + row + `
` out := AppendToContent(body, "[F]") if !strings.Contains(out, `width="600"`) || !strings.Contains(out, `align="center"`) { t.Fatalf("footer was not centred on the card's width:\n%s", out) } if !strings.Contains(out, "[F]") { t.Fatalf("footer did not land at the end of the content:\n%s", out) } // The same card, written as one container rather than a stack of rows, // needs no wrapper: the insertion point is already the content column. if out := AppendToContent(centredCard, "[F]"); strings.Contains(out, "role=\"presentation\"") { t.Fatalf("a width-constrained insertion point should not be wrapped:\n%s", out) } } // An email with no layout is every campaign written in the visual editor. // Nothing about those changes: the footer goes at the end, unwrapped. func TestAppendToContentLeavesAPlainBodyAlone(t *testing.T) { for in, want := range map[string]string{ "

a

": "

a

[F]", "

a

b

": "

a

b

[F]", "

x

": "

x

[F]", "x": "x[F]", "

a

": "

a

[F]", } { if got := AppendToContent(in, "[F]"); got != want { t.Errorf("AppendToContent(%q):\n got %q\nwant %q", in, got, want) } } if got := AppendToContent("

a

", ""); got != "

a

" { t.Errorf("an empty fragment changed the body: %q", got) } } // Outlook conditional comments carry their own and their own tables. // Reading either as the document's puts the footer inside a comment, where // Outlook shows it and every other client does not (issue #393). func TestAppendToContentIgnoresConditionalComments(t *testing.T) { body := `` + `` + `

Hi

` + `` + `` out := AppendToContent(body, "[F]") if !strings.Contains(out, "

Hi

[F]") { t.Fatalf("footer did not land in the real table's cell:\n%s", out) } if !strings.Contains(out, ``) { t.Fatalf("footer landed inside a conditional comment:\n%s", out) } } // Whatever the shape, the body is the caller's own bytes with something // inserted at exactly one offset. Nothing here reparses or re-renders: a // message that ships as its author wrote it is the whole point. func TestAppendToContentOnlySplices(t *testing.T) { for _, body := range []string{ centredCard, designed, `
ab
`, `

Hi

Bye

`, `

a

`, `orphan cell`, `

x

`, "\xa4\xa4\xa4\xa4", } { out := AppendToContent(body, "[F]") if !strings.Contains(out, "[F]") { t.Errorf("fragment was dropped from %q: %q", body, out) } if !isSplice(body, out) { t.Errorf("body was rewritten, not spliced:\n in %q\nout %q", body, out) } } } // isSplice reports whether out is body with one run of text inserted: the // common prefix and the common suffix together have to account for all of it. func isSplice(body, out string) bool { p := 0 for p < len(body) && p < len(out) && body[p] == out[p] { p++ } s := 0 for s < len(body)-p && s < len(out)-p && body[len(body)-1-s] == out[len(out)-1-s] { s++ } return p+s >= len(body) } // HTML mail leaves end tags off constantly, and the outline has to close the // same elements a browser would or the shape it reports is not the shape the // reader sees. func TestAppendToContentReadsMarkupWithOmittedEndTags(t *testing.T) { // An unclosed

before the next cell: the second cell is a sibling of // the first, not something nested inside it, so the table is two columns // across and the appended row has to span both. body := `

One

Two

ThreeFour
` if out := AppendToContent(body, "[F]"); !strings.Contains(out, `[F]`) { t.Errorf("omitted end tags mis-read the table:\n%s", out) } // A trailing slash inside an unquoted attribute value is part of the // value, not a self-closing tag. Read as one, the element never opens and // everything written inside it becomes a sibling, which moves the end of // the content somewhere the reader does not see it. unquoted := `

a

b

` if out := AppendToContent(unquoted, "[F]"); !strings.Contains(out, "

b

[F]") { t.Errorf("an unquoted path was read as a self-closing tag:\n%s", out) } } // The padding shorthand is top / right / bottom / left, and the sides only // mirror each other until it names all four. func TestAppendedRowMatchesAFourValuePadding(t *testing.T) { body := `
a
b
` if out := AppendToContent(body, "[F]"); !strings.Contains(out, `style="padding-left:40px;padding-right:20px"`) { t.Errorf("the appended row did not line up with the copy above it:\n%s", out) } } // mjmlSection is one MJML row: a centred 600px div wrapping a full-width // table, between the Outlook conditional comments MJML emits around it. func mjmlSection(text string) string { return `` + `
` + `` + `
` + `
` + `` + `
` + `
` + text + `
` + `
` + `` } // MJML is the most common way a designed email reaches us, and it writes its // centring as "margin:0px auto". Matching the hand-written "margin:0 auto" as // text missed it, and a multi-section export went out hard left. func TestAppendToContentHandlesMJML(t *testing.T) { page := func(sections ...string) string { return `
` + strings.Join(sections, "") + `
` } // One section is a single container all the way down, so the line goes // inside the last text block, padded like the copy around it. one := AppendToContent(page(mjmlSection("Hello")), "[F]") if !strings.Contains(one, "Hello[F]
") { t.Errorf("a single-section export did not land in its content block:\n%s", one) } if strings.Contains(one, `
") { t.Errorf("the line did not land at the end of the content:\n%s", multi) } } // A card whose width lives in a stylesheet declares none inline, so the only // centred element with a pixel width can be the call-to-action button. Sizing // the opt-out line on that squeezed it to a third of the copy above it. func TestAppendToContentIsNotSizedByAButton(t *testing.T) { body := `
` + `
` + `

Hello

More copy

` + `
Book a demo
` + `
` out := AppendToContent(body, "[F]") if strings.Contains(out, `role="presentation"`) { t.Errorf("the line was sized on the button rather than the copy:\n%s", out) } if !strings.Contains(out, "[F]") { t.Errorf("the line did not land at the end of the card's cell:\n%s", out) } } // The padding copied onto the appended row is whatever the author wrote in // their own style attribute. Writing a value that is not a length back out // unquoted produced a style attribute that closed itself early and broke the // cell, so anything that is not a length is dropped instead. func TestAppendedRowOnlyCopiesARealLength(t *testing.T) { garbled := `
a
b
` if out := AppendToContent(garbled, "[F]"); !strings.Contains(out, "[F]") { t.Errorf("a value that is not a length was copied into the markup:\n%s", out) } ems := `
a
b
` if out := AppendToContent(ems, "[F]"); !strings.Contains(out, `style="padding-left:2em;padding-right:2em"`) { t.Errorf("a length in em should still line the row up:\n%s", out) } } // Mail clients run no scripts, so a