fix: put the mailbox signature and the opt-out footer inside the container an HTML email was laid out in instead of after it, by locating that container with a new offset-keeping outline scan in internal/pkg/mailhtml and splicing into it, and centring the line on the card's own width when a builder export has no single container to sit in, so neither renders hard left in the page background any more (issue #462) (#505)

This commit is contained in:
Matthew Meszaros
2026-09-14 08:11:52 -07:00
committed by GitHub
parent c28f915648
commit 5ec367de8a
10 changed files with 1243 additions and 32 deletions
+1 -1
View File
@@ -172,7 +172,7 @@ Turn it off when your provider already saves its own copy of anything submitted
The same tab sets the **display name**, **reply-to** (empty uses the mailbox address), **signature** in plain text and HTML, and **tags** for grouping. Tags belong to the workspace: one anybody creates is there for every teammate, on every mailbox. A new display name is on the From header of the next message the mailbox sends, campaign, reply or warmup alike; nothing needs to be reconnected.
The HTML signature is placed in a block of its own one line below the body, so it arrives without the stack of blank lines above it that Apple Mail and Outlook used to show. Put any extra spacing you want inside the signature itself. The plain-text signature follows the body after a single blank line.
The HTML signature is placed in a block of its own one line below the body, so it arrives without the stack of blank lines above it that Apple Mail and Outlook used to show. Put any extra spacing you want inside the signature itself. On a body laid out in HTML it goes inside the container the email was built in, so it lines up with the copy it signs off rather than sitting under it at the left edge of the window (the same placement as the [opt-out line](/guides/unsubscribe/#where-it-appears)). The plain-text signature follows the body after a single blank line.
The signature editor has four views. **HTML** is the visual surface with bold, italic, underline, links and images. The `</>` button next to it swaps that for the raw markup, which is sent exactly as written, so a table-based signature from another tool can be pasted in whole. **Preview** renders it the way a mail client will, in its own frame. **Plain** holds the plain-text version, generated from the HTML while **Sync** is on.
+8
View File
@@ -17,6 +17,14 @@ Warmbly appends an opt-out to every campaign email, after the signature. There a
The wording of the sentence and of the link text is yours to change.
### Where it appears
The opt-out line goes last: after the body, after any hand-placed content, and after the mailbox signature.
In an email laid out in HTML, last means inside the email, not under it. A designed message is a column of content centred on the page, and an opt-out appended after that column renders against the left edge of the window, on the page background, with none of the styling the rest of the message carries. Warmbly places the line inside the container the email was built in, so it picks up the same width, padding and alignment as the copy above it. A message built as a stack of full-width rows (what a drag-and-drop builder exports) has no single container to sit in, so the line is centred on the same width as the rows instead. The same applies to the mailbox signature.
An email written in the visual composer has no layout of its own, and the line is simply appended to the end of it.
**Reply to opt out** is the default because it reads as a personal email, which is what cold outreach is. A formal unsubscribe link and footer are the strongest signal a mailbox provider has that a message is bulk marketing, and several deliverability teams report worse placement for cold email that carries one. A reply that asks to stop is detected and honoured automatically (see below), so the plain sentence is a real mechanism, not a courtesy. CAN-SPAM, CASL and the Australian Spam Act all accept a reply as the opt-out method.
**Unsubscribe link** is the right choice when your list skews toward consumers, when your legal team asks for a link, or when your volume is high enough that provider bulk-sender rules apply. The link is unique to the recipient and campaign, signed so it cannot be guessed or altered, and valid for a year after the send. Clicking it opens a plain confirmation page with one button. Nothing happens until the button is pressed, because link scanners and preview fetchers follow every link in an email. The page then offers a way back for anyone who unsubscribed by mistake.
+471
View File
@@ -0,0 +1,471 @@
package mailhtml
import (
"regexp"
"strconv"
"strings"
)
// Elements that wrap other elements rather than carry copy of their own. Only
// these are followed when looking for the container an email was laid out in.
var layoutElements = map[string]bool{
"table": true, "tbody": true, "thead": true, "tfoot": true, "tr": true,
"td": true, "th": true, "div": true, "center": true,
"section": true, "article": true, "main": true,
}
// Table sections a row can be appended to.
var tableSections = map[string]bool{
"table": true, "tbody": true, "thead": true, "tfoot": true,
}
// Elements that hold no layout of their own: metadata, and a line break,
// which is a plausible thing to find trailing a container and should not be
// read as a second one.
//
// <noscript> is deliberately NOT here. Mail clients run no scripts, so its
// fallback content is content the reader sees, and skipping one that follows
// the layout would put the opt-out line above copy instead of last.
var nonLayoutElements = map[string]bool{
"head": true, "meta": true, "link": true, "style": true, "script": true,
"title": true, "base": true, "br": true, "wbr": true,
}
// maxContainerDepth bounds the descent. Real mail nests four or five wrappers
// deep; anything past this is markup we should not be reasoning about.
const maxContainerDepth = 32
// AppendToContent places a fragment at the end of a message's visible content:
// inside the container the email was laid out in, not after it.
//
// A designed email is a centred box inside a full-width table, and
// InsertBeforeBodyEnd puts the signature and the opt-out footer after that
// box: flush against the left edge of the window, in the page's own
// background, styled by nothing (issue #462). The container is the deepest
// element that still holds all of the message, so appending inside it is what
// makes the footer read as the last line of the email rather than as debris
// under it.
//
// Anything this cannot place with certainty falls back to InsertBeforeBodyEnd,
// and nothing here ever reparses or re-renders the body: the result is always
// the caller's own bytes with the fragment spliced into one offset.
func AppendToContent(body, fragment string) string {
if fragment == "" {
return body
}
// Past this a message is already unsendable (Gmail clips at ~102 KB), so
// it is not worth the scan.
if body == "" || len(body) > maxInlineBytes {
return InsertBeforeBodyEnd(body, fragment)
}
root := outline(body)
target := contentContainer(root)
// A row reached on its own means the table has several cells across; the
// footer belongs under them, as a row of its own.
if target != nil && target.name == "tr" {
if target.parent != nil && tableSections[target.parent.name] {
target = target.parent
} else {
target = nil
}
}
if target != nil && !layoutElements[target.name] {
target = nil
}
// The insertion point is inside the content column for a simply laid out
// email, and is still the full width of the page for a builder export,
// whose rows each centre a card of their own. In the second case the
// footer has to centre itself or it renders hard left all the same.
scope := target
if scope == nil {
scope = root
}
if !widthConstrained(target) {
if w := contentWidth(scope); w > 0 {
fragment = centredBlock(w, fragment)
}
}
if target == nil {
return InsertBeforeBodyEnd(body, fragment)
}
at := target.contentEnd
if at < 0 || at > len(body) {
return InsertBeforeBodyEnd(body, fragment)
}
if tableSections[target.name] {
// A <p> written straight into a table is hoisted back out of it by
// every HTML parser, which lands it exactly where the bug put it.
fragment = tableRow(target, fragment)
}
return body[:at] + fragment + body[at:]
}
// contentContainer returns the deepest element that still contains the whole
// message: from the body down, while there is exactly one layout element
// holding everything and no copy written beside it. nil when that is the body
// itself, which is what InsertBeforeBodyEnd already does.
func contentContainer(root *outlineNode) *outlineNode {
cur := root
if b := firstNamed(root, "body"); b != nil {
cur = b
} else if h := firstNamed(root, "html"); h != nil {
cur = h
}
for depth := 0; depth < maxContainerDepth; depth++ {
// Copy written directly here means this element is the message, not a
// wrapper around it.
if cur.hasText {
break
}
kids := layoutChildren(cur)
if len(kids) != 1 || !layoutElements[kids[0].name] {
break
}
cur = kids[0]
}
if cur == root || cur.name == "body" || cur.name == "html" {
return nil
}
return cur
}
// layoutChildren lists the children that take up room on the page. Metadata is
// not layout, and neither is anything the author hid: the tracking pixel and
// the preheader line are both children of <body> in a designed email, and
// counting either would stop the descent at the body and leave the footer
// exactly where it is today.
func layoutChildren(n *outlineNode) []*outlineNode {
var out []*outlineNode
for _, c := range n.children {
if nonLayoutElements[c.name] || takesNoSpace(c) {
continue
}
out = append(out, c)
}
return out
}
// takesNoSpace reports whether an element is written to take up no room: the
// preheader ("display:none", or the max-height:0 / mso-hide variants every
// template uses), a hidden attribute, or a 1x1 tracking pixel.
func takesNoSpace(n *outlineNode) bool {
if _, ok := n.attr("hidden"); ok {
return true
}
if n.name == "img" {
w, _ := n.attr("width")
h, _ := n.attr("height")
if w == "1" && h == "1" {
return true
}
}
style := n.style()
switch {
case strings.Contains(style, "display:none"), strings.Contains(style, "mso-hide:all"):
return true
case strings.Contains(style, "max-height:0") && strings.Contains(style, "overflow:hidden"):
return true
}
return false
}
// firstNamed finds <body> or <html>, which sit within a few levels of the
// root in any document that has them. The depth bound keeps a deeply nested
// body (or an adversarial one) from being walked in full to learn nothing.
func firstNamed(n *outlineNode, name string) *outlineNode {
if n.depth > 6 {
return nil
}
for _, c := range n.children {
if c.name == name {
return c
}
if found := firstNamed(c, name); found != nil {
return found
}
}
return nil
}
// tableRow wraps a fragment as the last row of a table section, spanning every
// column and picking up the side padding of the rows above it so the footer
// lines up with the copy rather than sitting against the edge of the box.
func tableRow(section *outlineNode, fragment string) string {
cols, cell := 0, (*outlineNode)(nil)
for _, row := range section.children {
if row.name != "tr" {
continue
}
n := 0
for _, c := range row.children {
if c.name != "td" && c.name != "th" {
continue
}
if n == 0 {
cell = c
}
// A table is as wide as its grid, not as its cells: counting a
// <td colspan="2"> as one column made the appended row stop short
// of the copy above it.
n += cellSpan(c)
}
if n > cols {
cols = n
}
}
td := "<td"
if cols > 1 {
td += ` colspan="` + strconv.Itoa(cols) + `"`
}
if pad := sidePadding(cell); pad != "" {
td += ` style="` + pad + `"`
}
return "<tr>" + td + ">" + fragment + "</td></tr>"
}
// cellSpan is how many grid columns a cell occupies. HTML caps colspan at
// 1000, and anything unreadable counts as the one column it is written as.
func cellSpan(cell *outlineNode) int {
raw, ok := cell.attr("colspan")
if !ok {
return 1
}
n, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || n < 1 || n > 1000 {
return 1
}
return n
}
// sidePadding returns the left/right padding of a cell as a style declaration,
// read from padding-left/padding-right or from the padding shorthand.
func sidePadding(cell *outlineNode) string {
if cell == nil {
return ""
}
style := cell.style()
left, right := declValue(style, "padding-left"), declValue(style, "padding-right")
if left == "" || right == "" {
// The shorthand is top / right / bottom / left, and the sides only
// mirror each other until it names all four: reading the left from
// the second value put the footer under a four-value cell out of line
// with the copy in it.
if parts := strings.Fields(declValue(style, "padding")); len(parts) > 0 {
l, r := parts[0], parts[0]
if len(parts) > 1 {
l, r = parts[1], parts[1]
}
if len(parts) > 3 {
l = parts[3]
}
if left == "" {
left = l
}
if right == "" {
right = r
}
}
}
// Only a real length is copied. The value is whatever the author wrote,
// and writing one that is not back out unquoted produced a style
// attribute that closed itself early and broke the cell.
if !cssLength.MatchString(left) {
left = ""
}
if !cssLength.MatchString(right) {
right = ""
}
if left == "" && right == "" {
return ""
}
var out []string
if left != "" {
out = append(out, "padding-left:"+left)
}
if right != "" {
out = append(out, "padding-right:"+right)
}
return strings.Join(out, ";")
}
// cssLength is a length we are willing to copy into markup of our own.
var cssLength = regexp.MustCompile(`(?i)^[0-9]+(\.[0-9]+)?(px|pt|em|rem|%)?$`)
// declValue reads one property out of a normalised style attribute (see
// outlineNode.style). It matches on a declaration boundary, so "padding" does
// not answer with the value of "padding-left".
func declValue(style, prop string) string {
for i := 0; i < len(style); {
end := strings.IndexByte(style[i:], ';')
if end < 0 {
end = len(style)
} else {
end += i
}
decl := style[i:end]
if colon := strings.IndexByte(decl, ':'); colon > 0 && decl[:colon] == prop {
return decl[colon+1:]
}
i = end + 1
}
return ""
}
// A content column is between these widths. Narrower is a call-to-action
// button or a spacer, wider is the page rather than the column the copy sits
// in. The floor is deliberately above button territory: the narrowest real
// template is around 480px, and a centred 300px button in a card whose own
// width lives in a stylesheet was otherwise the only candidate in sight.
const (
minContentWidth = 400
maxContentWidth = 1000
)
// widthConstrained reports whether an insertion point already sits inside the
// email's content column, i.e. some element around it declares a pixel width.
// A footer added there is as wide as the copy above it and needs nothing.
func widthConstrained(n *outlineNode) bool {
for ; n != nil; n = n.parent {
if w := declaredWidth(n); w > 0 && w < maxContentWidth {
return true
}
}
return false
}
// contentWidth returns the width of the centred card an email was built
// around, or 0 when it has none.
//
// A builder export (Unlayer, Beefree, Stripo) is a stack of full-width rows
// each holding its own centred 600px table, so the deepest element that still
// contains the whole message is the full-width cell those rows sit in.
// Appending there is inside the document but still against the left edge of
// the window, which is the bug. Knowing the card's width lets the footer be
// centred on it instead.
//
// Only an element that is itself centred counts, so a coloured box that
// happens to declare a width in a left-aligned email is never mistaken for
// the card.
func contentWidth(root *outlineNode) int {
counts := map[int]int{}
var walk func(*outlineNode, int)
seen := 0
walk = func(n *outlineNode, depth int) {
for _, c := range n.children {
if seen++; seen > 2000 || depth > maxContainerDepth {
return
}
if (c.name == "table" || c.name == "div") && len(c.children) > 0 && isCentred(c) {
if w := declaredWidth(c); w >= minContentWidth && w < maxContentWidth {
// The outermost centred box is the card. Whatever is
// centred inside it is a module of the card, not another
// candidate to be counted against it.
counts[w]++
continue
}
}
walk(c, depth+1)
}
}
walk(root, 0)
best, bestCount := 0, 0
for w, n := range counts {
if n > bestCount || (n == bestCount && w > best) {
best, bestCount = w, n
}
}
return best
}
// isCentred reports whether an element is centred on the page: by its own
// align attribute, by auto side margins, or by the cell it sits in.
func isCentred(n *outlineNode) bool {
if align, _ := n.attr("align"); strings.EqualFold(align, "center") {
return true
}
if marginAuto(n.style()) {
return true
}
if p := n.parent; p != nil {
if align, _ := p.attr("align"); strings.EqualFold(align, "center") {
return true
}
}
return false
}
// marginAuto reports whether a style centres its element with auto side
// margins. The shorthand has to be read rather than matched as text: MJML
// writes "margin:0px auto" and a hand-written template writes "margin:0 auto",
// and a check for either spelling misses the other one.
func marginAuto(style string) bool {
if declValue(style, "margin-left") == "auto" && declValue(style, "margin-right") == "auto" {
return true
}
parts := strings.Fields(declValue(style, "margin"))
switch len(parts) {
case 2, 3:
return parts[1] == "auto"
case 4:
return parts[1] == "auto" && parts[3] == "auto"
}
return false
}
// declaredWidth reads an element's width in pixels from its width attribute or
// its style, or 0 for a percentage or no width at all.
func declaredWidth(n *outlineNode) int {
if n.name == "" {
return 0
}
if v, ok := n.attr("width"); ok {
if w := pixels(v); w > 0 {
return w
}
}
style := n.style()
for _, prop := range []string{"width", "max-width"} {
if w := pixels(declValue(style, prop)); w > 0 {
return w
}
}
return 0
}
// pixels reads "600" or "600px" as 600, and anything else (a percentage, a
// calc(), an em) as 0.
func pixels(v string) int {
v = strings.TrimSpace(strings.ToLower(v))
v = strings.TrimSuffix(v, "px")
n := 0
if v == "" {
return 0
}
for i := 0; i < len(v); i++ {
if v[i] < '0' || v[i] > '9' {
return 0
}
n = n*10 + int(v[i]-'0')
if n > 1<<20 {
return 0
}
}
return n
}
// centredBlock puts a fragment in a table centred on the email's own content
// width. A table, not a max-width div: Outlook's Word engine ignores
// max-width and auto margins, and a footer it renders full width and hard
// left is the bug this is here to fix.
func centredBlock(width int, fragment string) string {
w := strconv.Itoa(width)
return `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="width:100%;border-collapse:collapse"><tr>` +
`<td align="center" style="padding:0">` +
`<table role="presentation" width="` + w + `" cellpadding="0" cellspacing="0" border="0" align="center" style="width:` + w + `px;max-width:100%;border-collapse:collapse"><tr>` +
`<td align="left" style="padding:0 16px">` + fragment + `</td></tr></table>` +
`</td></tr></table>`
}
+323
View File
@@ -0,0 +1,323 @@
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 = `<html><body style="background:#f1f5f9">` +
`<table width="100%" cellpadding="0"><tr><td align="center">` +
`<table width="600" bgcolor="#ffffff"><tr><td style="padding:32px 24px">` +
`<h1>Hi</h1><p>Body</p>` +
`</td></tr></table></td></tr></table></body></html>`
func TestAppendToContentLandsInsideTheCard(t *testing.T) {
out := AppendToContent(centredCard, "[F]")
if !strings.Contains(out, "<p>Body</p>[F]</td></tr></table>") {
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 <body> 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 := `<body><div style="display:none;max-height:0;overflow:hidden">Quick question</div>` +
`<table width="600"><tr><td style="padding:24px">Hi</td></tr></table>` +
`<img src="https://t.test/t/o/x.png" width="1" height="1" style="display:none;" alt="" /></body>`
out := AppendToContent(body, "[F]")
if !strings.Contains(out, "Hi[F]</td>") {
t.Fatalf("hidden siblings stopped the descent:\n%s", out)
}
}
// A <p> 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 := `<table width="600"><tbody>` +
`<tr><td>Banner</td><td>Logo</td></tr>` +
`<tr><td style="padding:20px 30px">Copy</td><td>Aside</td></tr>` +
`</tbody></table>`
out := AppendToContent(body, "[F]")
want := `<tr><td colspan="2" style="padding-left:30px;padding-right:30px">[F]</td></tr></tbody>`
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 := `<div class="u-row-container" style="padding:0px"><div class="u-row" style="margin:0 auto;min-width:320px;max-width:600px;background-color:#ffffff">` +
`<div style="display:table;width:100%"><div class="u-col" style="display:table-cell"><div style="padding:20px"><p>Copy</p></div></div></div>` +
`</div></div>`
body := `<body><table id="u_body" style="width:100%;margin:0 auto"><tbody><tr><td>` + row + row + `</td></tr></tbody></table></body>`
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]</td></tr></table></td></tr></table></td></tr></tbody></table></body>") {
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{
"<p>a</p>": "<p>a</p>[F]",
"<p>a</p><p>b</p>": "<p>a</p><p>b</p>[F]",
"<html><p>x</p></html>": "<html><p>x</p>[F]</html>",
"<html><body>x</body></html>": "<html><body>x[F]</body></html>",
"<p>a</p><style>i{}</style></body>": "<p>a</p><style>i{}</style>[F]</body>",
} {
if got := AppendToContent(in, "[F]"); got != want {
t.Errorf("AppendToContent(%q):\n got %q\nwant %q", in, got, want)
}
}
if got := AppendToContent("<p>a</p>", ""); got != "<p>a</p>" {
t.Errorf("an empty fragment changed the body: %q", got)
}
}
// Outlook conditional comments carry their own </body> 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 := `<html><body>` +
`<!--[if mso]><table width="600"><tr><td><![endif]-->` +
`<table width="600"><tr><td style="padding:16px"><p>Hi</p></td></tr></table>` +
`<!--[if mso]></td></tr></table></body><![endif]-->` +
`</body></html>`
out := AppendToContent(body, "[F]")
if !strings.Contains(out, "<p>Hi</p>[F]</td>") {
t.Fatalf("footer did not land in the real table's cell:\n%s", out)
}
if !strings.Contains(out, `<!--[if mso]></td></tr></table></body><![endif]-->`) {
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,
`<table width="600"><tr><td>a</td><td>b</td></tr></table>`,
`<div style="max-width:600px;margin:0 auto"><p>Hi</p><p>Bye</p></div>`,
`<p>a</p>`,
`<td>orphan cell`,
`<div><style>td{}</style><p>x</p>`,
"\xa4\xa4\xa4\xa4</BodY>",
} {
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 <p> 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 := `<table width="600"><tbody><tr><td><p>One<td><p>Two<tr><td>Three<td>Four</tbody></table>`
if out := AppendToContent(body, "[F]"); !strings.Contains(out, `<tr><td colspan="2">[F]</td></tr></tbody>`) {
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 := `<table width="600"><tr><td background=/bg.gif/><p>a</p><p>b</p></td></tr></table>`
if out := AppendToContent(unquoted, "[F]"); !strings.Contains(out, "<p>b</p>[F]</td>") {
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 := `<table width="600"><tr><td>a</td></tr><tr><td style="padding:10px 20px 10px 40px">b</td></tr></table>`
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 `<!--[if mso | IE]><table align="center" border="0" width="600"><tr><td><![endif]-->` +
`<div style="margin:0px auto;max-width:600px;">` +
`<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;"><tbody><tr>` +
`<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">` +
`<div class="mj-column-per-100" style="font-size:0px;text-align:left;display:inline-block;vertical-align:top;width:100%;">` +
`<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%"><tbody><tr>` +
`<td style="font-size:0px;padding:10px 25px;word-break:break-word;">` +
`<div style="font-family:Ubuntu;font-size:13px;line-height:1;text-align:left;color:#000000;">` + text + `</div>` +
`</td></tr></tbody></table></div></td></tr></tbody></table></div>` +
`<!--[if mso | IE]></td></tr></table><![endif]-->`
}
// 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 `<body style="background-color:#F4F4F4;"><div style="background-color:#F4F4F4;">` +
strings.Join(sections, "") + `</div></body>`
}
// 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]</div>") {
t.Errorf("a single-section export did not land in its content block:\n%s", one)
}
if strings.Contains(one, `<table role="presentation" width="600"`) {
t.Errorf("a container that already holds the copy needs no wrapper:\n%s", one)
}
// Several sections have no single container to sit in, so the line
// centres itself on the width the sections share.
multi := AppendToContent(page(mjmlSection("Hello"), mjmlSection("Second")), "[F]")
if !strings.Contains(multi, `<table role="presentation" width="600"`) {
t.Errorf("a multi-section export was not centred on the section width:\n%s", multi)
}
if !strings.Contains(multi, "[F]</td></tr></table></td></tr></table></div></body>") {
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 := `<body><table class="body"><tr><td align="center"><center>` +
`<table align="center" class="container"><tbody><tr><td class="wrapper">` +
`<p>Hello</p><p>More copy</p>` +
`<table align="center" width="300" class="button"><tr><td>Book a demo</td></tr></table>` +
`</td></tr></tbody></table></center></td></tr></table></body>`
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, "</table>[F]</td>") {
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 := `<table width="600"><tr><td>a</td></tr><tr><td style='padding:0 "x'>b</td></tr></table>`
if out := AppendToContent(garbled, "[F]"); !strings.Contains(out, "<tr><td>[F]</td></tr>") {
t.Errorf("a value that is not a length was copied into the markup:\n%s", out)
}
ems := `<table width="600"><tr><td>a</td></tr><tr><td style="padding:1em 2em">b</td></tr></table>`
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 <noscript> in the body is copy the reader
// sees. Skipping one that follows the layout put the opt-out line above it
// instead of last.
func TestAppendToContentDoesNotSkipNoscriptContent(t *testing.T) {
body := `<body><table width="600"><tr><td>Copy</td></tr></table>` +
`<noscript><p>Enable images to see this</p></noscript></body>`
out := AppendToContent(body, "[F]")
if strings.Index(out, "[F]") < strings.Index(out, "<noscript>") {
t.Errorf("the line landed above visible noscript copy:\n%s", out)
}
}
// A table is as wide as its grid, not as its cells.
func TestAppendedRowSpansColspan(t *testing.T) {
body := `<table width="600"><tbody>` +
`<tr><td colspan="3">Banner</td></tr>` +
`<tr><td colspan="2">Copy</td><td>Aside</td></tr>` +
`</tbody></table>`
if out := AppendToContent(body, "[F]"); !strings.Contains(out, `<tr><td colspan="3">[F]</td></tr>`) {
t.Errorf("the appended row stopped short of the grid:\n%s", out)
}
// Anything unreadable counts as the one column it is written as.
odd := `<table width="600"><tr><td colspan="nope">a</td><td>b</td></tr><tr><td>c</td><td>d</td></tr></table>`
if out := AppendToContent(odd, "[F]"); !strings.Contains(out, `<tr><td colspan="2">[F]</td></tr>`) {
t.Errorf("an unreadable colspan should count as one column:\n%s", out)
}
}
// Every ancestor walk in the outline runs from an element to the root, so the
// nesting limit is what keeps the whole scan linear. Without it, a body that
// opens thousands of inline elements and never closes them makes every later
// start tag rescan the entire open chain: 250 KB of it took four seconds of a
// send's time, and 2 MB took minutes.
//
// Asserted on the depth rather than on the clock. The bound is what makes the
// walk cheap, and a wall-clock threshold measures the machine instead: under
// the race detector CI runs with, the same input takes a hundred times longer
// while being just as bounded.
func TestOutlineNestingIsBounded(t *testing.T) {
const k = 20000
body := strings.Repeat("<span>", k) + strings.Repeat("<p></p>", k)
if d := deepest(outline(body)); d > maxOutlineDepth {
t.Errorf("nesting reached %d, past the %d bound: every ancestor walk is quadratic again", d, maxOutlineDepth)
}
// Nesting that is deep but properly closed, and within the descent's own
// limit, still reads correctly.
deep := strings.Repeat("<div>", 20) + "<p>a</p><p>b</p>" + strings.Repeat("</div>", 20)
if out := AppendToContent(deep, "[F]"); !strings.Contains(out, "<p>b</p>[F]</div>") {
t.Errorf("deep but valid nesting was mis-read:\n%s", out)
}
}
// deepest returns the deepest element in an outline.
func deepest(n *outlineNode) int {
d := n.depth
for _, c := range n.children {
if cd := deepest(c); cd > d {
d = cd
}
}
return d
}
+11
View File
@@ -22,6 +22,10 @@ func FuzzInlineCSS(f *testing.F) {
f.Add("<style>@weird-at-rule</style><p>hi</p>")
f.Add("\xa4\xa4\xa4\xa4</BodY>")
f.Add("<html><body><p>\u0130stanbul</p></body></html>")
// Table layout, which is what the append pass has to reason about.
f.Add(`<table width="600"><tr><td>a</td><td>b</td></tr></table>`)
f.Add(`<body><table><tr><td><table width="600"><tr><td>x`)
f.Add(`<div style="margin:0 auto;max-width:600px"><p>x</p></div>`)
f.Fuzz(func(t *testing.T, body string) {
out := InlineCSS(body)
@@ -32,5 +36,12 @@ func FuzzInlineCSS(f *testing.F) {
_ = ToPlainText(body)
_ = Lint(body, len(body))
_ = InsertBeforeBodyEnd(body, "[F]")
// The append pass may only ever hand back the caller's own bytes with
// something inserted at one offset: it runs on markup a sender pasted
// from somewhere else, and a body it rewrote is a message the author
// did not write.
if out := AppendToContent(body, "[F]"); !isSplice(body, out) {
t.Errorf("AppendToContent rewrote the body:\n in %q\nout %q", body, out)
}
})
}
+373
View File
@@ -0,0 +1,373 @@
package mailhtml
import "strings"
// An outline is the element tree of a body with the source offsets that make
// appending to it a splice instead of a reparse.
//
// html.Parse would give a better tree, but it can only give it back by
// re-rendering, and re-rendering a hand-written email rewrites markup its
// author never asked us to touch. Everything here therefore works in offsets
// into the caller's own string: the only edit anyone makes from it is
// body[:at] + fragment + body[at:].
type outlineNode struct {
name string // lowercased tag name; "" for the root
attrs string // the start tag's raw attribute text
// contentEnd is where this element's content stops: the '<' of its end
// tag, the '<' of whatever closed it implicitly, or the end of the body.
contentEnd int
children []*outlineNode
parent *outlineNode
// depth is the element's nesting level, 0 for the root.
depth int
// hasText records non-whitespace text written directly inside this
// element. An element holding copy of its own is where the message is,
// not a wrapper around where the message is.
hasText bool
// styleOnce memoises style(), which several passes ask each element for.
styleOnce *string
}
// maxOutlineDepth bounds how deeply elements may nest. Every ancestor walk in
// here is bounded by it, which is what keeps the scan linear: without it, a
// body that opens thousands of inline elements and never closes them makes
// every later start tag rescan the whole open chain, and 260 KB of
// "<span>...<p></p>..." took four seconds of a send's time.
//
// Real mail nests around a dozen deep and MJML about twenty, so anything past
// this is markup no reader is going to see laid out the way it was written.
const maxOutlineDepth = 64
// Elements that never contain anything, so they never open a scope.
var voidElements = map[string]bool{
"area": true, "base": true, "br": true, "col": true, "embed": true,
"hr": true, "img": true, "input": true, "link": true, "meta": true,
"param": true, "source": true, "track": true, "wbr": true,
}
// Elements whose content is text, not markup: a "<td>" inside one of these is
// characters the reader sees or CSS, never an element.
var rawTextElements = map[string]bool{
"script": true, "style": true, "textarea": true, "title": true,
}
// Elements a start tag closes implicitly, keyed by the tag being opened. HTML
// mail omits these end tags constantly, and an outline that believed a <td>
// stayed open until </table> would nest every later cell inside the first.
var impliedClose = map[string][]string{
"li": {"li"},
"dt": {"dt", "dd"},
"dd": {"dt", "dd"},
"option": {"option"},
"td": {"td", "th"},
"th": {"td", "th"},
"tr": {"td", "th", "tr"},
"tbody": {"td", "th", "tr", "tbody", "thead", "tfoot"},
"thead": {"td", "th", "tr", "tbody", "thead", "tfoot"},
"tfoot": {"td", "th", "tr", "tbody", "thead", "tfoot"},
}
// Block-level elements, which close an open <p>.
var blockElements = map[string]bool{
"address": true, "article": true, "aside": true, "blockquote": true,
"center": true, "details": true, "div": true, "dl": true, "fieldset": true,
"figure": true, "footer": true, "form": true, "h1": true, "h2": true,
"h3": true, "h4": true, "h5": true, "h6": true, "header": true, "hr": true,
"li": true, "main": true, "menu": true, "nav": true, "ol": true, "p": true,
"pre": true, "section": true, "table": true, "ul": true,
}
// outline builds the element tree of body. It never fails: markup it cannot
// make sense of leaves the tree shallower, which only means an append lands
// where it lands today.
func outline(body string) *outlineNode {
root := &outlineNode{contentEnd: len(body)}
cur := root
// closeTo ends n and everything still open inside it. n has to be cur or
// one of its ancestors; both callers find it by walking that chain.
closeTo := func(n *outlineNode, at int) {
for c := cur; c != root; c = c.parent {
c.contentEnd = at
if c == n {
cur = n.parent
return
}
}
}
// closeNamed closes the outermost run of elements a start tag ends
// implicitly. It walks through anything the author was entitled to leave
// open, so neither "<td><b>x<td>" nor "<td><p>x<td>" nests the second
// cell inside the first.
closeNamed := func(names []string, at int) {
var outermost *outlineNode
for n := cur; n != nil && n != root; n = n.parent {
if contains(names, n.name) {
outermost = n
continue
}
if !inlineElements[n.name] && !optionalEndTag[n.name] {
break
}
}
if outermost != nil {
closeTo(outermost, at)
}
}
for i := 0; i < len(body); {
lt := strings.IndexByte(body[i:], '<')
if lt < 0 {
if strings.TrimSpace(body[i:]) != "" {
cur.hasText = true
}
break
}
if strings.TrimSpace(body[i:i+lt]) != "" {
cur.hasText = true
}
i += lt
if hasPrefixFold(body[i:], "<!--") {
end := strings.Index(body[i+4:], "-->")
if end < 0 {
break
}
i += 4 + end + 3
continue
}
end := TagEnd(body[i:])
if end < 0 {
break
}
tag := body[i : i+end+1]
next := i + end + 1
if len(tag) > 1 && (tag[1] == '!' || tag[1] == '?') {
i = next // doctype, processing instruction, bogus comment
continue
}
closing := len(tag) > 1 && tag[1] == '/'
name, attrs, selfClosing := splitTag(tag)
if name == "" {
// Not markup after all ("a < b"): the '<' is text.
cur.hasText = true
i++
continue
}
switch {
case closing:
if !voidElements[name] {
if n := openAncestor(cur, root, name); n != nil {
closeTo(n, i)
}
}
case voidElements[name]:
cur.children = append(cur.children, &outlineNode{name: name, attrs: attrs, parent: cur, depth: cur.depth + 1, contentEnd: i})
default:
if names, ok := impliedClose[name]; ok {
closeNamed(names, i)
} else if blockElements[name] {
closeNamed([]string{"p"}, i)
}
n := &outlineNode{name: name, attrs: attrs, parent: cur, depth: cur.depth + 1, contentEnd: len(body)}
cur.children = append(cur.children, n)
if !selfClosing && n.depth < maxOutlineDepth {
cur = n
if rawTextElements[name] {
// The content is text; skip it whole so a "<td>" written
// in a stylesheet is not read as an element. Resume ON the
// closing tag, which closes n on the next pass.
closer := "</" + name
if at := skipRawText(body, next, closer); at < len(body) {
next = at - len(closer)
} else {
next = len(body)
}
}
}
}
i = next
}
for c := cur; c != root; c = c.parent {
c.contentEnd = len(body)
}
return root
}
// openAncestor returns the nearest open element named name, or nil when the
// end tag closes nothing (a stray </div>, which is ignored rather than
// unwinding the whole tree).
func openAncestor(cur, root *outlineNode, name string) *outlineNode {
for n := cur; n != nil && n != root; n = n.parent {
if n.name == name {
return n
}
}
return nil
}
func contains(names []string, name string) bool {
for _, n := range names {
if n == name {
return true
}
}
return false
}
// splitTag reads a full "<...>" slice into its lowercased tag name, the raw
// attribute text after it, and whether it closed itself. An empty name means
// the '<' did not start a tag.
func splitTag(tag string) (name, attrs string, selfClosing bool) {
i := 1
if i < len(tag) && tag[i] == '/' {
i++
}
start := i
if i >= len(tag) || !isLetter(tag[i]) {
return "", "", false
}
for i < len(tag) {
c := tag[i]
if isLetter(c) || c >= '0' && c <= '9' || c == ':' || c == '-' || c == '_' {
i++
continue
}
break
}
// A name has to be followed by whitespace or the end of the tag; "<3" and
// "<b@d" are text, not markup.
if i < len(tag)-1 && !isSpaceByte(tag[i]) && tag[i] != '/' {
return "", "", false
}
rest := strings.TrimRight(tag[i:len(tag)-1], " \t\r\n\f")
// A trailing '/' only closes the tag when it is not the last character of
// an unquoted attribute value: "<a href=/promo/>" is a link to a path,
// and reading it as self-closing left the anchor's content a sibling.
if strings.HasSuffix(rest, "/") {
if before := len(rest) - 2; before < 0 || isSpaceByte(rest[before]) || rest[before] == '"' || rest[before] == '\'' {
selfClosing = true
rest = rest[:len(rest)-1]
}
}
return strings.ToLower(tag[start:i]), strings.TrimSpace(rest), selfClosing
}
// Elements whose end tag HTML makes optional, so an author leaves them open
// on purpose and the markup after one is not inside it.
var optionalEndTag = map[string]bool{
"p": true, "li": true, "dt": true, "dd": true, "option": true,
}
// Inline elements, which an author may leave open without meaning to nest
// everything after them inside.
var inlineElements = map[string]bool{
"a": true, "abbr": true, "b": true, "big": true, "cite": true, "code": true,
"em": true, "font": true, "i": true, "label": true, "s": true, "small": true,
"span": true, "strike": true, "strong": true, "sub": true, "sup": true,
"tt": true, "u": true,
}
func isLetter(c byte) bool {
return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z'
}
func isSpaceByte(c byte) bool {
return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f'
}
// attr returns one attribute's value, and whether it was written at all: a
// bare "hidden" has no value but is still present.
func (n *outlineNode) attr(name string) (string, bool) {
attrs := n.attrs
for i := 0; i < len(attrs); {
for i < len(attrs) && isSpaceByte(attrs[i]) {
i++
}
start := i
for i < len(attrs) && !isSpaceByte(attrs[i]) && attrs[i] != '=' {
i++
}
key := attrs[start:i]
if key == "" {
i++
continue
}
for i < len(attrs) && isSpaceByte(attrs[i]) {
i++
}
if i >= len(attrs) || attrs[i] != '=' {
if strings.EqualFold(key, name) {
return "", true
}
continue
}
i++
for i < len(attrs) && isSpaceByte(attrs[i]) {
i++
}
var val string
switch {
case i < len(attrs) && (attrs[i] == '"' || attrs[i] == '\''):
quote := attrs[i]
i++
if j := strings.IndexByte(attrs[i:], quote); j >= 0 {
val, i = attrs[i:i+j], i+j+1
} else {
val, i = attrs[i:], len(attrs)
}
default:
vs := i
for i < len(attrs) && !isSpaceByte(attrs[i]) {
i++
}
val = attrs[vs:i]
}
if strings.EqualFold(key, name) {
return val, true
}
}
return "", false
}
// style returns the element's style attribute folded to lower case and
// normalised around its punctuation, so "display : NONE !important" can be
// matched as "display:none". The spaces INSIDE a value are kept: stripping
// them all turned the shorthand "padding: 20px 30px" into one unreadable
// length. Containment only: no offset is ever taken from it.
func (n *outlineNode) style() string {
if n.styleOnce != nil {
return *n.styleOnce
}
raw, _ := n.attr("style")
v := strings.ToLower(strings.Join(strings.Fields(raw), " "))
for _, pair := range [][2]string{{" :", ":"}, {": ", ":"}, {" ;", ";"}, {"; ", ";"}} {
v = strings.ReplaceAll(v, pair[0], pair[1])
}
n.styleOnce = &v
return v
}
// TagEnd returns the index of the '>' that closes the tag starting at s[0], or
// -1 when there is none. A '>' inside a quoted attribute value does not close
// anything: reading one as the end split `<a title="x > y" href="URL">` into a
// tag and a run of text, and the href in that "text" was then rewritten into a
// dead link.
func TagEnd(s string) int {
var quote byte
for i := 1; i < len(s); i++ {
switch c := s[i]; {
case quote != 0:
if c == quote {
quote = 0
}
case c == '"' || c == '\'':
quote = c
case c == '>':
return i
}
}
return -1
}
+10 -4
View File
@@ -34,11 +34,17 @@ func TestDesignedEmailThroughTheSendPath(t *testing.T) {
}
withSig := AddSignature(body, `<p>Ana Perez</p>`, true)
if !strings.Contains(withSig, "Ana Perez</p></div></body></html>") {
t.Errorf("signature did not land just inside </body>:\n%s", withSig[len(withSig)-160:])
// Inside the laid-out container, not after it: appended at the end of the
// document it would render flush against the left edge of the window, in
// the page background, styled by nothing (issue #462).
if !strings.Contains(withSig, "Ana Perez</p></div></td></tr></table>") {
t.Errorf("signature did not land inside the email container:\n%s", withSig)
}
if strings.Contains(withSig[:strings.Index(withSig, "[if mso]")], "Ana Perez") {
t.Error("signature landed before the conditional comment")
// The conditional comment carries its own </body>. Reading that one as the
// document's put the signature inside a comment, where Outlook showed it
// and every other client did not (issue #393).
if !strings.Contains(withSig, "<!--[if mso]></body><![endif]-->") {
t.Errorf("signature landed inside the conditional comment:\n%s", withSig)
}
final := mailhtml.InlineCSS(withSig)
+4 -25
View File
@@ -46,14 +46,15 @@ func optOutFooter(settings models.UnsubscribeSettings, linkURL string) (htmlPart
}
// appendOptOut adds the footer after everything else (signature included) so
// it sits where a reader expects an opt-out: last.
// it sits where a reader expects an opt-out: last, and inside the container
// the email was laid out in rather than under it (issue #462).
func appendOptOut(bodyHTML, bodyPlain string, settings models.UnsubscribeSettings, linkURL string) (string, string) {
htmlPart, plainPart := optOutFooter(settings, linkURL)
if htmlPart == "" {
return bodyHTML, bodyPlain
}
if bodyHTML != "" {
bodyHTML = mailhtml.InsertBeforeBodyEnd(bodyHTML, htmlPart)
bodyHTML = mailhtml.AppendToContent(bodyHTML, htmlPart)
}
if bodyPlain != "" {
bodyPlain += "\n\n" + plainPart
@@ -84,7 +85,7 @@ func linkifyUnsubscribeURL(bodyHTML, linkURL, linkText string) string {
depth := 0 // open <a> elements around the current text node
for i := 0; i < len(bodyHTML); {
if bodyHTML[i] == '<' {
end := tagEnd(bodyHTML[i:])
end := mailhtml.TagEnd(bodyHTML[i:])
if end < 0 {
b.WriteString(bodyHTML[i:]) // unterminated tag: copy the rest verbatim
break
@@ -116,28 +117,6 @@ func linkifyUnsubscribeURL(bodyHTML, linkURL, linkText string) string {
return b.String()
}
// tagEnd returns the index of the '>' that closes the tag starting at s[0], or
// -1 when there is none. A '>' inside a quoted attribute value does not close
// anything: reading one as the end split `<a title="x > y" href="URL">` into a
// tag and a run of text, and the href in that "text" was then rewritten into a
// dead link.
func tagEnd(s string) int {
var quote byte
for i := 1; i < len(s); i++ {
switch c := s[i]; {
case quote != 0:
if c == quote {
quote = 0
}
case c == '"' || c == '\'':
quote = c
case c == '>':
return i
}
}
return -1
}
// isTagStart reports whether tag (a full "<...>" slice) is the named tag,
// case-insensitively: isTagStart(`<A HREF="x">`, "a") and
// isTagStart("</A>", "/a") are both true.
+38
View File
@@ -140,3 +140,41 @@ func TestFinishBodyLinkifiesHTMLAndKeepsThePlainURL(t *testing.T) {
t.Fatalf("nil settings should still linkify: %s", htmlOut)
}
}
// Issue #462: an email written in HTML is a card centred inside a full-width
// page table, and both the mailbox signature and the opt-out footer were
// appended after it. They rendered against the left edge of the window, in
// the page background, styled by nothing. Both belong inside the card.
func TestSignatureAndOptOutLandInsideTheEmailContainer(t *testing.T) {
body := `<html><body style="background:#f1f5f9">` +
`<table width="100%"><tr><td align="center">` +
`<table width="600" bgcolor="#ffffff"><tr><td style="padding:32px 24px">` +
`<h1>Built by Ayonix Design</h1><p>Explore the work.</p>` +
`</td></tr></table></td></tr></table></body></html>`
// In send order: the open-tracking pixel goes on first, at the end of the
// document. It is a child of <body> from then on, and reading it as
// layout would stop the descent there and put everything after it back
// outside the card.
body = AddOpenTrackingPixel(body, uuid.New(), "t.example.com")
withSig := AddSignature(body, "<p>Karan Barad</p>", true)
link := models.UnsubscribeSettings{Mode: models.UnsubscribeModeLink, LinkIntro: "Not the right person, or not interested?", LinkText: "Unsubscribe"}
out, _ := appendOptOut(withSig, "", link, "https://api.example.com/unsubscribe/tok")
// The card's own cell, from its padding to the </td> that closes it.
start, end := strings.Index(out, `padding:32px 24px`), strings.Index(out, "</td></tr></table></td>")
if start < 0 || end < start {
t.Fatalf("the card's cell is no longer recognisable:\n%s", out)
}
cell := out[start:end]
for _, want := range []string{"Karan Barad", "Unsubscribe"} {
if !strings.Contains(cell, want) {
t.Errorf("%q is outside the email container:\n%s", want, out)
}
}
// In that order: the opt-out is the last line a reader sees.
if strings.Index(cell, "Karan Barad") > strings.Index(cell, "Unsubscribe") {
t.Errorf("the footer should follow the signature:\n%s", cell)
}
}
+4 -2
View File
@@ -279,8 +279,10 @@ func AddSignature(body string, signature string, isHTML bool) string {
}
block := `<div style="margin-top:16px">` + signature + `</div>`
// Trailing content belongs inside the document, as for the pixel and footer.
return mailhtml.InsertBeforeBodyEnd(body, block)
// Inside the container the email was laid out in, as for the opt-out
// footer: after it, a signature lands against the left edge of the window
// instead of under the copy it signs off (issue #462).
return mailhtml.AppendToContent(body, block)
}
// AddOpenTrackingPixel adds an invisible tracking pixel to HTML email.