Files
warmbly/internal/pkg/mailhtml/outbound_test.go
T

282 lines
10 KiB
Go

package mailhtml
import (
"strings"
"testing"
)
// A designed email: a stylesheet with classes, a media query, a :hover rule,
// a hidden preheader and a table layout. This is the shape issue #393 reports.
const designed = `<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Newsletter</title>
<style>
/* brand */
body { margin:0; background-color:#f4f4f4; font-family: Helvetica, Arial, sans-serif; }
.wrap { width:600px; margin:0 auto; }
.btn { background:#0284c7; color:#ffffff !important; padding:12px 20px; }
td.cell { padding: 16px; }
a:hover { text-decoration: underline; }
@media only screen and (max-width:600px) { .wrap { width:100% !important; } }
</style></head>
<body>
<div style="display:none;max-height:0">Quick question about your pipeline</div>
<table class="wrap"><tr><td class="cell">
<h1>Hi Ana</h1>
<p>We help teams like <b>Acme</b> book more meetings.</p>
<ul><li>Deliverability first</li><li>Real warmup</li></ul>
<p><a class="btn" href="https://example.com/demo" style="color:#eeeeee">Book a demo</a></p>
</td></tr></table>
</body></html>`
func TestInlineCSSMovesRulesOntoElements(t *testing.T) {
out := InlineCSS(designed)
for _, want := range []string{
`style="margin: 0; background-color: #f4f4f4`, // body
`width: 600px; margin: 0 auto`, // .wrap on the table
`style="padding: 16px"`, // td.cell
} {
if !strings.Contains(out, want) {
t.Errorf("expected inlined %q in:\n%s", want, out)
}
}
}
func TestInlineCSSHonoursTheCascade(t *testing.T) {
out := InlineCSS(designed)
// .btn's color is !important, so it beats the anchor's own style attribute.
if !strings.Contains(out, "color: #ffffff !important") {
t.Errorf("!important sheet rule should beat an inline one:\n%s", out)
}
if strings.Contains(out, "#eeeeee") {
t.Errorf("the losing inline color should not survive:\n%s", out)
}
// Without !important the author's own inline value wins.
plain := InlineCSS(`<style>p{color:red}</style><p style="color:blue">x</p>`)
if !strings.Contains(plain, "color: blue") || strings.Contains(plain, "red") {
t.Errorf("inline style should beat a sheet rule: %q", plain)
}
}
func TestInlineCSSKeepsWhatItCannotInline(t *testing.T) {
out := InlineCSS(designed)
if !strings.Contains(out, "@media") {
t.Errorf("media queries must stay in the <style> block:\n%s", out)
}
if !strings.Contains(out, "a:hover") {
t.Errorf(":hover must stay in the <style> block:\n%s", out)
}
// The inlined rules are gone from the block, or the payload carries the
// stylesheet twice and Gmail's clipping limit arrives sooner.
if strings.Contains(out, "td.cell") {
t.Errorf("an inlined rule should not remain in the block:\n%s", out)
}
}
func TestInlineCSSSpecificityOrder(t *testing.T) {
out := InlineCSS(`<style>p{color:red}.x{color:green}#y{color:blue}</style><p id="y" class="x">t</p>`)
if !strings.Contains(out, "color: blue") {
t.Errorf("the id selector should win: %q", out)
}
}
func TestInlineCSSShorthandPrecedesLonghand(t *testing.T) {
out := InlineCSS(`<style>td{margin:0}</style><table><tr><td style="margin-top:8px">c</td></tr></table>`)
m, mt := strings.Index(out, "margin: 0"), strings.Index(out, "margin-top: 8px")
if m < 0 || mt < 0 || m > mt {
t.Errorf("shorthand must be written before the longhand that narrows it: %q", out)
}
}
func TestInlineCSSLeavesAFragmentAFragment(t *testing.T) {
out := InlineCSS(`<style>.x{color:red}</style><p class="x">hi</p>`)
if strings.Contains(out, "<html") || strings.Contains(out, "<body") {
t.Errorf("a fragment body must not gain a document wrapper: %q", out)
}
if !strings.Contains(out, "color: red") {
t.Errorf("fragment rules should still inline: %q", out)
}
}
func TestInlineCSSKeepsUninlinableRulesOfAFragment(t *testing.T) {
// The parser hoists a leading <style> into the head; dropping the head on
// the way out would lose the media query with it.
out := InlineCSS(`<style>@media (max-width:600px){.x{width:100%}}</style><p class="x">hi</p>`)
if !strings.Contains(out, "@media") {
t.Errorf("a fragment's media query must survive: %q", out)
}
}
func TestInlineCSSIsANoOpWithoutAStylesheet(t *testing.T) {
body := `<p>Hi {{.FirstName}}, <a href="https://x.test">link</a></p>`
if got := InlineCSS(body); got != body {
t.Errorf("a body with no <style> must ship byte for byte:\n got %q\nwant %q", got, body)
}
}
func TestInlineCSSRespectsTheAuthorsOptOut(t *testing.T) {
body := `<style data-warmbly-inline="false">.x{color:red}</style><p class="x">hi</p>`
out := InlineCSS(body)
if strings.Contains(out, `style="color`) {
t.Errorf("an opted-out stylesheet must not be inlined: %q", out)
}
if !strings.Contains(out, ".x{color:red}") {
t.Errorf("an opted-out stylesheet must survive verbatim: %q", out)
}
}
func TestInlineCSSSkipsAPrintStylesheet(t *testing.T) {
out := InlineCSS(`<style media="print">.x{color:red}</style><p class="x">hi</p>`)
if strings.Contains(out, `style="color`) {
t.Errorf("a print stylesheet is not what the reader sees: %q", out)
}
}
func TestInlineCSSSurvivesBrokenCSS(t *testing.T) {
// Unterminated blocks, a comment inside a value, a url() holding braces
// and semicolons: none of it may panic or lose the document.
for _, css := range []string{
`.a{color:red`,
`.a{background:url(data:image/png;base64,AA{BB)}`,
`.a{color:/* c */red}`,
`@media{`,
`.a{content:"}"}`,
} {
out := InlineCSS(`<style>` + css + `</style><p class="a">hi</p>`)
if !strings.Contains(out, "hi") {
t.Errorf("CSS %q lost the body: %q", css, out)
}
}
}
func TestInsertBeforeBodyEndIgnoresAConditionalComment(t *testing.T) {
// Outlook conditional comments carry their own </body>. Inserting there
// put the signature and the opt-out footer inside a comment.
body := `<html><body><p>a</p><!--[if mso]></body><![endif]--></body></html>`
out := InsertBeforeBodyEnd(body, "[F]")
if !strings.Contains(out, "[F]</body></html>") {
t.Errorf("insert landed in the conditional comment: %q", out)
}
}
func TestInsertBeforeBodyEndVariants(t *testing.T) {
cases := map[string]string{
"<p>a</p>": "<p>a</p>[F]",
"<html><body>x</BODY ></html>": "<html><body>x[F]</BODY ></html>",
"<html><p>x</p></html>": "<html><p>x</p>[F]</html>",
"<body>x</body><body>y</body>": "<body>x</body><body>y[F]</body>",
"<p>a</p><style>i{}</style></body>": "<p>a</p><style>i{}</style>[F]</body>",
}
for in, want := range cases {
if got := InsertBeforeBodyEnd(in, "[F]"); got != want {
t.Errorf("InsertBeforeBodyEnd(%q):\n got %q\nwant %q", in, got, want)
}
}
}
func TestToPlainTextKeepsStructureAndDropsCSS(t *testing.T) {
got := ToPlainText(designed)
if strings.Contains(got, "font-family") || strings.Contains(got, "@media") || strings.Contains(got, "{") {
t.Errorf("the stylesheet must not reach the text part:\n%s", got)
}
if strings.Contains(got, "Quick question about your pipeline") {
t.Errorf("a hidden preheader is written to be read once:\n%s", got)
}
if !strings.Contains(got, "We help teams like Acme book more meetings.") {
t.Errorf("inline elements must not run their words together:\n%s", got)
}
if !strings.Contains(got, "- Deliverability first\n- Real warmup") {
t.Errorf("list items should be bulleted lines:\n%s", got)
}
if !strings.Contains(got, "Book a demo (https://example.com/demo)") {
t.Errorf("a link should keep its destination:\n%s", got)
}
if strings.Contains(got, "\n\n\n") {
t.Errorf("no more than one blank line between blocks:\n%s", got)
}
}
func TestToPlainTextLists(t *testing.T) {
got := ToPlainText(`<ol><li>one</li><li>two</li></ol><ul><li>a</li></ul>`)
if !strings.Contains(got, "1. one") || !strings.Contains(got, "2. two") {
t.Errorf("ordered items should be numbered: %q", got)
}
if !strings.Contains(got, "- a") {
t.Errorf("unordered items should be dashed: %q", got)
}
// A <ul> inside an <ol> must not inherit the numbering.
nested := ToPlainText(`<ol><li>one<ul><li>inner</li></ul></li></ol>`)
if !strings.Contains(nested, "- inner") {
t.Errorf("a nested ul keeps its bullets: %q", nested)
}
}
func TestToPlainTextTableRows(t *testing.T) {
got := ToPlainText(`<table><tr><td>a</td><td>b</td></tr><tr><td>c</td></tr></table>`)
if !strings.Contains(got, "a b") {
t.Errorf("cells are columns of a row: %q", got)
}
if !strings.Contains(got, "\nc") {
t.Errorf("rows are lines: %q", got)
}
}
func TestToPlainTextLinkThatIsItsOwnLabel(t *testing.T) {
got := ToPlainText(`<a href="https://x.test/a">https://x.test/a</a>`)
if got != "https://x.test/a" {
t.Errorf("a bare URL should not be repeated: %q", got)
}
}
func TestToPlainTextEntitiesAndBreaks(t *testing.T) {
got := ToPlainText(`<p>Tom &amp; Jerry<br>next line</p>`)
if got != "Tom & Jerry\nnext line" {
t.Errorf("entities decode and <br> breaks: %q", got)
}
}
func TestLintNamesWhatClientsDo(t *testing.T) {
codes := func(fs []Finding) map[string]bool {
m := map[string]bool{}
for _, f := range fs {
m[f.Code] = true
}
return m
}
got := codes(Lint(`<link rel="stylesheet" href="https://x.test/a.css">`+
`<script>alert(1)</script><div style="position:absolute">x</div>`+
`<img src="https://x.test/a.png">`, 1000))
for _, want := range []string{"external_stylesheet", "script_stripped", "outlook_unsupported_css", "image_missing_alt"} {
if !got[want] {
t.Errorf("expected finding %q, got %v", want, got)
}
}
// background-position is not position; a false alarm here fires on almost
// every real template.
quiet := codes(Lint(`<div style="background-position:center">x</div>`, 100))
if quiet["outlook_unsupported_css"] {
t.Error("background-position must not read as position")
}
big := codes(Lint("<p>x</p>", 200*1024))
if !big["gmail_clipping"] {
t.Error("a message past 102 KB is clipped by Gmail")
}
media := codes(Lint(`<style>@media (max-width:600px){.x{width:100%}}</style>`, 100))
if !media["media_query_needs_important"] {
t.Error("a media query with no !important cannot override an inline style")
}
}
func TestLintIsQuietOnAnOrdinaryBody(t *testing.T) {
if f := Lint(`<p>Hi Ana, do you have ten minutes on Thursday?</p>`, 400); len(f) != 0 {
t.Errorf("a plain cold email should raise nothing, got %v", f)
}
}