diff --git a/docs/content/docs/guides/unsubscribe.mdx b/docs/content/docs/guides/unsubscribe.mdx index 6afc440c..1f98aba6 100644 --- a/docs/content/docs/guides/unsubscribe.mdx +++ b/docs/content/docs/guides/unsubscribe.mdx @@ -21,7 +21,17 @@ The wording of the sentence and of the link text is yours to change. **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. -You can also place the link inside your own copy instead of the footer: insert the **Unsubscribe link** variable from the variable menu, or type `{{.UnsubscribeLink}}`. Click tracking never rewrites it, so an opt-out is never counted as a click. +You can also place the link inside your own copy instead of the footer: insert the **Unsubscribe link** variable from the variable menu, or type `{{.UnsubscribeLink}}`. + +Dropped into your copy on its own, the variable becomes a real link in the HTML message, labelled with the same **Link text** the footer uses ("Unsubscribe" by default). The recipient reads a word, not the signed address. To choose the wording yourself, select the text first and then pick **Unsubscribe link** from the variable menu, or press the link button and use the **Unsubscribe** shortcut next to the address field: the selected text becomes the link and Warmbly fills in the address at send time. + +Two things follow from that. The plain-text alternative of the message has nowhere to hide a URL, so it carries the address in full when you place the variable on its own, and only your wording when you wrap your own link around it. And click tracking never rewrites the link either way, so an opt-out is never counted as a click. + +### Plain-text campaigns + +A campaign with **Plain text only** switched on ships no HTML at all, so there is nothing for a link to hide inside: link mode and a hand-placed variable both print the full signed address in the copy, which is the one place a cold email least wants a long tracking-shaped URL. The composer warns while you are writing and the launch check warns again before you start. + +The answer there is the header. It is not visible copy, it works the same on a plain-text send, and it is what Gmail and Yahoo actually look for. Leave **Unsubscribe header** on, leave the opt-out line as the reply sentence, and the recipient gets a one-click control in their mail client plus a human sentence in the message. ## The List-Unsubscribe header diff --git a/internal/app/advanced/plain_text_optout_test.go b/internal/app/advanced/plain_text_optout_test.go new file mode 100644 index 00000000..a27ae4bd --- /dev/null +++ b/internal/app/advanced/plain_text_optout_test.go @@ -0,0 +1,77 @@ +package advanced + +import ( + "strings" + "testing" + + "github.com/warmbly/warmbly/internal/models" +) + +// The workspace default: a reply line, no link anywhere. +func replyLineSettings() models.UnsubscribeSettings { + return models.DefaultAdvancedOutreachSettings().Unsubscribe +} + +func TestPlainTextOptOutPassesWithTheReplyLine(t *testing.T) { + c := &models.Campaign{TextOnly: true, UnsubscribeMode: "inherit"} + got := plainTextOptOutResult(c, replyLineSettings(), []models.Sequence{ + emailStep(0, "Quick question", "Hi there, worth a chat?"), + }) + if !got.Passed { + t.Fatalf("reply-line opt-out should pass on a plain-text campaign: %+v", got) + } +} + +func TestPlainTextOptOutWarnsOnLinkMode(t *testing.T) { + c := &models.Campaign{TextOnly: true, UnsubscribeMode: "link"} + got := plainTextOptOutResult(c, replyLineSettings(), nil) + if got.Passed || got.Severity != "warning" { + t.Fatalf("link mode on a plain-text campaign should warn: %+v", got) + } + if !strings.Contains(got.Message, "opt-out line is set to Unsubscribe link") || got.Remediation == "" { + t.Fatalf("warning should name the cause and a way out: %+v", got) + } +} + +func TestPlainTextOptOutWarnsOnAHandPlacedVariable(t *testing.T) { + c := &models.Campaign{TextOnly: true, UnsubscribeMode: "inherit"} + seqs := []models.Sequence{ + emailStep(0, "Quick question", "Hi there"), + {Kind: "wait", Position: 1}, + emailStep(2, "Following up", "Not for you? "+models.UnsubscribeLinkToken), + } + got := plainTextOptOutResult(c, replyLineSettings(), seqs) + if got.Passed { + t.Fatalf("a hand-placed variable on a plain-text campaign should warn: %+v", got) + } + // Unnamed steps fall back to their place in builder order, not `position`, + // which is 0-based on some campaigns and 1-based on others. + if !strings.Contains(got.Message, "step 3 places the unsubscribe link variable") { + t.Fatalf("warning should name the step: %s", got.Message) + } + + named := []models.Sequence{{Kind: "email", Name: "Follow-up", BodyPlain: models.UnsubscribeLinkToken}} + if got := plainTextOptOutResult(c, replyLineSettings(), named); !strings.Contains(got.Message, `the step "Follow-up" places`) { + t.Fatalf("a named step should be named: %s", got.Message) + } +} + +func TestPlainTextOptOutIgnoresNonEmailStepsAndOtherCopy(t *testing.T) { + c := &models.Campaign{TextOnly: true, UnsubscribeMode: "inherit"} + seqs := []models.Sequence{ + {Kind: "action", Position: 0, BodyPlain: models.UnsubscribeLinkToken}, + emailStep(1, "Quick question", "Reply and I'll stop emailing."), + } + if got := plainTextOptOutResult(c, replyLineSettings(), seqs); !got.Passed { + t.Fatalf("a non-email node's config must not be read as copy: %+v", got) + } +} + +func TestPlainTextOptOutCampaignModeOverridesTheWorkspaceLink(t *testing.T) { + // Workspace is on link mode, this campaign opted back to the reply line. + workspace := models.UnsubscribeSettings{Mode: models.UnsubscribeModeLink} + c := &models.Campaign{TextOnly: true, UnsubscribeMode: "text"} + if got := plainTextOptOutResult(c, workspace, nil); !got.Passed { + t.Fatalf("campaign override to the reply line should pass: %+v", got) + } +} diff --git a/internal/app/advanced/service.go b/internal/app/advanced/service.go index 3d35d5aa..bfb6b5ed 100644 --- a/internal/app/advanced/service.go +++ b/internal/app/advanced/service.go @@ -1774,6 +1774,13 @@ func (s *service) RunPreflight(ctx context.Context, organizationID, campaignID u checks = append(checks, check) } + // A plain-text campaign has no HTML for an anchor to hide a URL in, so an + // in-body opt-out link prints its whole signed address in the copy. The + // List-Unsubscribe header does the same job and the reader never sees it. + if settings.Preflight.CheckUnsubscribeHeader && campaign.TextOnly { + checks = append(checks, s.plainTextOptOutCheck(ctx, campaign, settings.Unsubscribe, &recommendations)) + } + if settings.Preflight.CheckTrackingDomain && (campaign.OpenTracking || campaign.LinkTracking) { // The same pool the scheduler sends from (explicit senders, tags, or // every active mailbox when neither is picked), so a campaign on the @@ -2048,6 +2055,68 @@ func worstStepContentScore(seqs []models.Sequence, attachmentsFor func(models.Se return worst, worstStep, issue, scored } +// plainTextOptOutCheck loads the campaign's steps and reports whether a +// plain-text-only campaign is putting the opt-out link in the body. A step list +// it could not read is scanned as empty: the mode alone is still worth warning +// about, and a failed read must not invent a step that carries the variable. +func (s *service) plainTextOptOutCheck(ctx context.Context, campaign *models.Campaign, unsub models.UnsubscribeSettings, recommendations *[]string) models.PreflightCheckResult { + seqs, err := s.campaignRepo.GetSequencesByCampaignID(ctx, campaign.ID) + if err != nil { + seqs = nil + } + check := plainTextOptOutResult(campaign, unsub, seqs) + if !check.Passed { + *recommendations = append(*recommendations, "On a plain-text campaign, let the unsubscribe header carry the opt-out instead of a link in the body.") + } + return check +} + +// plainTextOptOutResult warns when a plain-text-only campaign puts the opt-out +// link in the body: either through link mode or a step that places the +// {{.UnsubscribeLink}} variable itself. Only called when campaign.TextOnly is +// set, so it never fires on an HTML campaign, where the link renders as a word. +func plainTextOptOutResult(campaign *models.Campaign, unsub models.UnsubscribeSettings, seqs []models.Sequence) models.PreflightCheckResult { + check := models.PreflightCheckResult{ + Key: "plain_text_opt_out", + Passed: true, + Severity: "warning", + Message: "Plain text only: the opt-out puts no raw URL in the copy.", + } + + where := "" + if unsub.Effective(campaign.UnsubscribeMode).Mode == models.UnsubscribeModeLink { + where = "the opt-out line is set to Unsubscribe link" + } else { + // Named, not numbered: `position` is 0-based on some campaigns and + // 1-based on others, so a number computed from it would point at the + // wrong step. The list arrives in builder order, so the index is the + // honest fallback when a step has no name. + for i, seq := range seqs { + if seq.Kind != "" && seq.Kind != "email" { + continue + } + if strings.Contains(seq.Subject, models.UnsubscribeLinkToken) || + strings.Contains(seq.BodyHTML, models.UnsubscribeLinkToken) || + strings.Contains(seq.BodyPlain, models.UnsubscribeLinkToken) { + if name := strings.TrimSpace(seq.Name); name != "" { + where = fmt.Sprintf("the step %q places the unsubscribe link variable", name) + } else { + where = fmt.Sprintf("step %d places the unsubscribe link variable", i+1) + } + break + } + } + } + if where == "" { + return check + } + + check.Passed = false + check.Message = fmt.Sprintf("This campaign sends plain text only and %s, so recipients read the whole signed unsubscribe address instead of a word.", where) + check.Remediation = "Keep the List-Unsubscribe header on and switch the opt-out line to Reply to opt out, or turn plain text off so the link can render as a word." + return check +} + // contentScoreCheck scores every step's copy and reports the worst. A step list // it could not read reports as FAILED, not passed: a check that did not run // must never look like one that succeeded. diff --git a/internal/models/advanced_outreach.go b/internal/models/advanced_outreach.go index 5400e287..d9a400ba 100644 --- a/internal/models/advanced_outreach.go +++ b/internal/models/advanced_outreach.go @@ -126,6 +126,12 @@ const ( UnsubscribeCopyMaxLen = 300 ) +// UnsubscribeLinkToken is the template token a step places by hand to put the +// recipient's own opt-out link in its copy. The send path renders it as an +// anchor in HTML, but plain text has nowhere to hide the address, so preflight +// looks for it on a plain-text campaign. +const UnsubscribeLinkToken = "{{.UnsubscribeLink}}" + // UnsubscribeSettings is the workspace default for the in-body opt-out. The // List-Unsubscribe header is a per-campaign flag and is not part of this. type UnsubscribeSettings struct { diff --git a/internal/tasks/campaign_task.go b/internal/tasks/campaign_task.go index 6ea16091..f47d2e85 100644 --- a/internal/tasks/campaign_task.go +++ b/internal/tasks/campaign_task.go @@ -541,6 +541,12 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error { bodyHTML = "" } + // STEP 10.7: A hand-placed {{.UnsubscribeLink}} resolved to the bare signed + // URL; give it an anchor so the recipient reads "Unsubscribe" and not the + // API address (issue #341). After the plain part was derived, so plain text + // keeps the URL it needs, and before tracking, which leaves it alone. + bodyHTML = linkifyUnsubscribeURL(bodyHTML, unsubscribeURL, optOut.LinkText) + // STEP 10.75: Score the copy the recipient will actually receive, after // merge fields, spintax, A/B and AI blocks have resolved. Advisory: it // warns once per step and never blocks or delays the send. diff --git a/internal/tasks/optout.go b/internal/tasks/optout.go index a329c585..b57168b6 100644 --- a/internal/tasks/optout.go +++ b/internal/tasks/optout.go @@ -61,3 +61,72 @@ func appendOptOut(bodyHTML, bodyPlain string, settings models.UnsubscribeSetting } return bodyHTML, bodyPlain } + +// linkifyUnsubscribeURL turns a hand-placed {{.UnsubscribeLink}} into a real +// link. The variable resolves to the recipient's signed URL, so a token +// dropped into prose ships as the bare API address (issue #341); this wraps +// every loose occurrence in an anchor labelled with the workspace's +// unsubscribe link text instead. A URL the author already put in an +// attribute (their own ) is inside a tag and left untouched, and one +// sitting as the text of an existing anchor becomes that anchor's label +// rather than a nested link. +func linkifyUnsubscribeURL(bodyHTML, linkURL, linkText string) string { + if bodyHTML == "" || linkURL == "" || !strings.Contains(bodyHTML, linkURL) { + return bodyHTML + } + label := html.EscapeString(strings.TrimSpace(linkText)) + if label == "" { + label = models.DefaultUnsubscribeLinkText + } + anchor := `` + label + `` + + var b strings.Builder + b.Grow(len(bodyHTML) + len(anchor)) + depth := 0 // open elements around the current text node + for i := 0; i < len(bodyHTML); { + if bodyHTML[i] == '<' { + end := strings.IndexByte(bodyHTML[i:], '>') + if end < 0 { + b.WriteString(bodyHTML[i:]) // unterminated tag: copy the rest verbatim + break + } + tag := bodyHTML[i : i+end+1] + switch { + case isTagStart(tag, "a"): + depth++ + case isTagStart(tag, "/a"): + if depth > 0 { + depth-- + } + } + b.WriteString(tag) + i += end + 1 + continue + } + stop := len(bodyHTML) + if next := strings.IndexByte(bodyHTML[i:], '<'); next >= 0 { + stop = i + next + } + with := anchor + if depth > 0 { + with = label + } + b.WriteString(strings.ReplaceAll(bodyHTML[i:stop], linkURL, with)) + i = stop + } + return b.String() +} + +// isTagStart reports whether tag (a full "<...>" slice) is the named tag, +// case-insensitively: isTagStart(``, "a") and +// isTagStart("", "/a") are both true. +func isTagStart(tag, name string) bool { + if len(tag) < len(name)+2 || !strings.EqualFold(tag[1:1+len(name)], name) { + return false + } + switch c := tag[1+len(name)]; c { + case '>', '/', ' ', '\t', '\n', '\r', '\f': + return true + } + return false +} diff --git a/internal/tasks/optout_test.go b/internal/tasks/optout_test.go index 562e6506..5def2d66 100644 --- a/internal/tasks/optout_test.go +++ b/internal/tasks/optout_test.go @@ -41,3 +41,92 @@ func TestOptOutFooter(t *testing.T) { t.Fatalf("off mode changed the body: %q / %q", h, p) } } + +func TestLinkifyUnsubscribeURL(t *testing.T) { + const url = "https://api.example.com/unsubscribe/tok123" + + // The variable chip the composer saves: a bare URL in a text node. + got := linkifyUnsubscribeURL(`

Not interested? `+url+`

`, url, "Unsubscribe") + want := `

Not interested? Unsubscribe

` + if got != want { + t.Fatalf("bare token not linkified:\n got %s\nwant %s", got, want) + } + + // The workspace's own link wording is what the anchor says. + if got := linkifyUnsubscribeURL("

"+url+"

", url, "Opt out"); !strings.Contains(got, `>Opt out`) { + t.Fatalf("link text ignored: %s", got) + } + if got := linkifyUnsubscribeURL("

"+url+"

", url, " "); !strings.Contains(got, ">Unsubscribe") { + t.Fatalf("blank link text should fall back to the default: %s", got) + } + if got := linkifyUnsubscribeURL("

"+url+"

", url, `Bill & "Ted"`); !strings.Contains(got, `>Bill & "Ted"`) { + t.Fatalf("link text not escaped: %s", got) + } + + // An author's own anchor is left exactly as written: no second link, no + // rewritten href. + own := `

click here

` + if got := linkifyUnsubscribeURL(own, url, "Unsubscribe"); got != own { + t.Fatalf("author's anchor was rewritten: %s", got) + } + + // The URL as the text of an existing anchor becomes its label rather than + // a nested link. + nested := `` + url + `` + if got := linkifyUnsubscribeURL(nested, url, "Unsubscribe"); got != `Unsubscribe` { + t.Fatalf("URL inside an anchor should become the label: %s", got) + } + + // Nothing to do cases. + if got := linkifyUnsubscribeURL("

Hi

", url, "Unsubscribe"); got != "

Hi

" { + t.Fatalf("body without the link changed: %s", got) + } + if got := linkifyUnsubscribeURL("

"+url+"

", "", "Unsubscribe"); got != "

"+url+"

" { + t.Fatalf("no link to mint should be a no-op: %s", got) + } + if got := linkifyUnsubscribeURL("", url, "Unsubscribe"); got != "" { + t.Fatalf("empty body changed: %q", got) + } + + // A malformed body (unterminated tag) is copied through, never truncated. + if got := linkifyUnsubscribeURL("

hi closes , so what follows is + // loose text again. + mixed := `x or ` + url + if got := linkifyUnsubscribeURL(mixed, url, "Unsubscribe"); got != `x or Unsubscribe` { + t.Fatalf("mixed-case anchors mishandled: %s", got) + } +} + +func TestLinkifiedUnsubscribeLinkSurvivesTracking(t *testing.T) { + const url = "https://api.example.com/unsubscribe/tok123" + body := linkifyUnsubscribeURL(`

Or `+url+` to stop. pricing

`, url, "Unsubscribe") + out, links := WrapLinksForTracking(body, uuid.New(), uuid.New(), "t.example.com") + if len(links) != 1 || links[0].Destination != "https://acme.com/pricing" { + t.Fatalf("expected only the pricing link to be ticketed, got %+v", links) + } + if !strings.Contains(out, `href="`+url+`">Unsubscribe`) { + t.Fatalf("unsubscribe anchor was rewritten: %s", out) + } +} + +func TestFinishBodyLinkifiesHTMLAndKeepsThePlainURL(t *testing.T) { + const url = "https://api.example.com/unsubscribe/tok123" + off := models.UnsubscribeSettings{Mode: models.UnsubscribeModeOff, LinkText: "Unsubscribe"} + htmlOut, plainOut := finishBody(`

Bye. `+url+`

`, "", false, nil, &off, url) + if !strings.Contains(htmlOut, `Unsubscribe`) { + t.Fatalf("html part not linkified: %s", htmlOut) + } + // Plain text cannot hide a URL, so it keeps the address itself. + if !strings.Contains(plainOut, url) { + t.Fatalf("plain part lost the link: %q", plainOut) + } + // No settings at all still linkifies, with the default wording. + htmlOut, _ = finishBody(`

`+url+`

`, "x", false, nil, nil, url) + if !strings.Contains(htmlOut, ">Unsubscribe") { + t.Fatalf("nil settings should still linkify: %s", htmlOut) + } +} diff --git a/internal/tasks/preview.go b/internal/tasks/preview.go index e578d26f..f13a8758 100644 --- a/internal/tasks/preview.go +++ b/internal/tasks/preview.go @@ -85,9 +85,10 @@ func (s *tasksService) PreviewEmail(ctx context.Context, orgID uuid.UUID, in Ema } // finishBody applies what the send path adds after rendering, in its order: -// derive the plain part, drop HTML for a plain-text campaign, add the mailbox -// signature, then the opt-out footer (nil settings skip it). Shared by the -// preview and the test send so both show what a recipient gets. +// derive the plain part, drop HTML for a plain-text campaign, turn a +// hand-placed unsubscribe link into an anchor, add the mailbox signature, +// then the opt-out footer (nil settings skip it). Shared by the preview and +// the test send so both show what a recipient gets. func finishBody(bodyHTML, bodyPlain string, textOnly bool, account *models.Email, optOut *models.UnsubscribeSettings, unsubURL string) (string, string) { if bodyPlain == "" && bodyHTML != "" { bodyPlain = ExtractPlainTextFromHTML(bodyHTML) @@ -95,6 +96,12 @@ func finishBody(bodyHTML, bodyPlain string, textOnly bool, account *models.Email if textOnly { bodyHTML = "" } + // After the plain part is derived, so plain text keeps the URL it needs. + linkText := "" + if optOut != nil { + linkText = optOut.LinkText + } + bodyHTML = linkifyUnsubscribeURL(bodyHTML, unsubURL, linkText) if account != nil && account.SignatureSync { if bodyHTML != "" { bodyHTML = AddSignature(bodyHTML, account.SignatureHTML, true) diff --git a/web/src/app/app/settings/sending/page.tsx b/web/src/app/app/settings/sending/page.tsx index 87dd2573..462180d8 100644 --- a/web/src/app/app/settings/sending/page.tsx +++ b/web/src/app/app/settings/sending/page.tsx @@ -230,7 +230,7 @@ function SendingSettings() {
{isLoading || !draft ? (
@@ -311,7 +311,7 @@ function UnsubscribeRows({ mode === "text" ? "A plain sentence inviting a reply. Reads like a personal email; the reply is detected and honoured automatically." : mode === "link" - ? "A sentence with a real unsubscribe link. One click on a confirmation page; the mail client may also show its own Unsubscribe button." + ? "A sentence with a real unsubscribe link. One click on a confirmation page; the mail client may also show its own Unsubscribe button. Reads as bulk mail where a reply reads as a person, so keep it for lists that need a link. On a plain-text campaign it prints the full address in the copy." : "No opt-out in the body. Keep the unsubscribe header on in each campaign, or you are relying on recipients replying." } > diff --git a/web/src/components/app/campaigns/preferences/CampaignAppearance.tsx b/web/src/components/app/campaigns/preferences/CampaignAppearance.tsx index 87f46a65..e86ad9b1 100644 --- a/web/src/components/app/campaigns/preferences/CampaignAppearance.tsx +++ b/web/src/components/app/campaigns/preferences/CampaignAppearance.tsx @@ -180,7 +180,7 @@ export function DeliverabilitySection({ )} + The opt-out appended after the signature of every email in this campaign. Reply to opt out reads + as a personal email and is honoured automatically; a link is for lists that need one, and the + header above already covers the bulk-sender rules. The workspace default is set under Settings + > Sending. + {newCampaign.text_only && ( + + This campaign sends plain text only, where a link has nowhere to hide its address: the + recipient reads the full unsubscribe URL. Prefer the header and the reply line here. + + )} + + } control={ ${escapeHtml(serverPreview.body_plain)}` : '

Nothing to preview yet.

'), @@ -321,6 +329,16 @@ export default function EmailContentEditor({ {tplIssue} It'll fall back to plain text — fix it before sending.

) : null} + {plainTextUnsubLink && ( +

+ + + This campaign sends plain text only, so the unsubscribe link cannot render as a word and the + recipient reads its whole address. Leave the unsubscribe header on and invite a reply + instead, or turn plain text off in Preferences. + +

+ )} {(serverPreview?.unresolved?.length ?? 0) > 0 && (

diff --git a/web/src/components/app/campaigns/sequences/RichTextEditor.tsx b/web/src/components/app/campaigns/sequences/RichTextEditor.tsx index c41119ae..c867ecec 100644 --- a/web/src/components/app/campaigns/sequences/RichTextEditor.tsx +++ b/web/src/components/app/campaigns/sequences/RichTextEditor.tsx @@ -49,6 +49,7 @@ import { FormLinkNode } from "./nodes/FormLinkNode"; import EditorSuggest from "./nodes/EditorSuggest"; import { TOKEN_META, + UNSUBSCRIBE_TOKEN, cleanFieldName, parseToken, buildToken, @@ -67,6 +68,17 @@ function insertToken(editor: Editor, token: string) { } } +// Picking the unsubscribe link with text selected links that text instead of +// dropping a chip, so the copy keeps its own wording. With no selection the +// chip is inserted and the send path gives it an anchor of its own. +function insertLinkToken(editor: Editor, token: string) { + if (token !== UNSUBSCRIBE_TOKEN || editor.state.selection.empty) { + insertToken(editor, token); + return; + } + editor.chain().focus().setLink({ href: token }).run(); +} + export default function RichTextEditor({ html, onChange, @@ -227,7 +239,11 @@ function Toolbar({ editor, variables, links = [] }: { editor: Editor; variables: - insertToken(editor, v)} variables={variables} links={links} /> + (links.includes(v) ? insertLinkToken(editor, v) : insertToken(editor, v))} + variables={variables} + links={links} + /> + )}