From 5ec367de8a461535606a615a0d63e2f9e0624536 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Mon, 14 Sep 2026 08:11:52 -0700 Subject: [PATCH] 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) --- docs/content/docs/guides/mailboxes.mdx | 2 +- docs/content/docs/guides/unsubscribe.mdx | 8 + internal/pkg/mailhtml/append.go | 471 +++++++++++++++++++++++ internal/pkg/mailhtml/append_test.go | 323 ++++++++++++++++ internal/pkg/mailhtml/fuzz_test.go | 11 + internal/pkg/mailhtml/outline.go | 373 ++++++++++++++++++ internal/tasks/html_email_test.go | 14 +- internal/tasks/optout.go | 29 +- internal/tasks/optout_test.go | 38 ++ internal/tasks/template.go | 6 +- 10 files changed, 1243 insertions(+), 32 deletions(-) create mode 100644 internal/pkg/mailhtml/append.go create mode 100644 internal/pkg/mailhtml/append_test.go create mode 100644 internal/pkg/mailhtml/outline.go diff --git a/docs/content/docs/guides/mailboxes.mdx b/docs/content/docs/guides/mailboxes.mdx index 9b77c4bcc..2e434bbd8 100644 --- a/docs/content/docs/guides/mailboxes.mdx +++ b/docs/content/docs/guides/mailboxes.mdx @@ -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. diff --git a/docs/content/docs/guides/unsubscribe.mdx b/docs/content/docs/guides/unsubscribe.mdx index 00b31e5d8..69475fa43 100644 --- a/docs/content/docs/guides/unsubscribe.mdx +++ b/docs/content/docs/guides/unsubscribe.mdx @@ -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. diff --git a/internal/pkg/mailhtml/append.go b/internal/pkg/mailhtml/append.go new file mode 100644 index 000000000..368ed1c85 --- /dev/null +++ b/internal/pkg/mailhtml/append.go @@ -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. +// +//