Skip to content

Escape merge-tag values in transactional HTML; one-line subjects; validated List-Unsubscribe - #9

Merged
do-io merged 1 commit into
mainfrom
fix/escape-merge-tag-values
Oct 13, 2026
Merged

do-io merged 1 commit into
mainfrom
fix/escape-merge-tag-values

Conversation

@do-io

@do-io do-io commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

Found in the Odden Cloud review: TemplateMailable put every merge-tag value into the message exactly as given. Through the transactional template API the values come from the caller's own users, so a value could add markup or a link to mail that goes out from the workspace's verified domain.

Changes

  • MergeTagInterpolator::interpolate($content, $context, bool $escape = false): with $escape, values that come from the context are HTML-escaped where they are put into the text. The author's own text, including a default: fallback, is left as written. A new raw filter ({{ order_table | raw }}) is the explicit way to put trusted markup in.
  • MergeTagRegistry::interpolate() and MailBuilder::interpolate() pass the argument through. Default stays false, so callers that escape their own values (campaign and workflow compilers in crm-pro) are unchanged.
  • TemplateMailable: the HTML is interpolated with escape: true; the plain-text part and the subject are not escaped; the subject has control characters (CR/LF) removed and is one line.
  • List-Unsubscribe is only sent for an http(s) or mailto address with no whitespace, angle brackets, quotes or control characters (it was any non-empty string).

Behaviour change (release-note it)

Anyone passing HTML in data to TemplateMailable must now write {{ tag | raw }} for it. Everything else renders the same except &, <, >, " and ' in values are escaped in HTML (which is what an href="{{ url }}" needs).

Compatibility note

MergeTagInterpolator::interpolate() gained a third optional parameter. A subclass that overrides it must accept it (Odden Cloud has one; it is retired when this ships).

Tests

tests/Unit/MergeTagEscapingTest.php: escape on and off, author text and defaults untouched, raw, other filters before escaping, TemplateMailable HTML vs subject vs plain text, List-Unsubscribe table (https, mailto, javascript:, data:, header-ending input, newline, space). 128 passed (it was 115); PHPStan and Pint clean. Run locally.

🤖 Generated with Claude Code

… filter

TemplateMailable put every merge-tag value into the HTML exactly as it was given, so a value a customer typed could add markup or a link to mail sent from the workspace's domain. Values from the data are now HTML-escaped where they land in the HTML; a template author puts trusted markup in with {{ tag | raw }}, and their own default fallbacks are left alone. The subject is always one line. The List-Unsubscribe header is only sent for an http(s) or mailto address with nothing that could end the value.

MailBuilder::interpolate() and MergeTagInterpolator::interpolate() keep not escaping unless escape: true is passed, so callers that escape their own values are unaffected.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@do-io
do-io merged commit 5a80618 into main Oct 13, 2026
14 checks passed
@do-io
do-io deleted the fix/escape-merge-tag-values branch October 21, 2026 19:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant