Bricksforge 4.0.0 — Email Designer: Twig expressions in the Image URL field are destroyed before rendering

Hello,

I have a custom TWIG function for the email designer to return the product variation image URL in woocommerce emails but the images stopped rendering after the 4.0 update. Here is a summary of the problem with a suggested fix:

Summary

In the Email Designer’s image element, the Image URL setting is passed through esc_url() while it still contains the raw Twig expression. esc_url() strips the {{ }} delimiters, so by the time parse_twig() runs there is no tag left to evaluate. The literal, mangled string is shipped in the email as the src.

This affects any Twig in a URL-typed setting, including custom functions registered through the documented bricksforge/email_designer/custom_functions filter.

Impact

Every order confirmation email sends broken product images:

<img src="http://%20getVariationImage(item)%20">

There is no error, no log entry and no admin notice. The snippet still loads, the filter still fires and the function is still registered — it is simply never called. That makes it very hard to trace.

Root cause

Escaping happens at element build time, but Twig is not parsed until the whole message is assembled.

EmailDesigner.php, image element (line numbers from stock 4.0.0):

3176:  $image_url_setting = $this->get_setting("imageUrl", $element);3177:  if (!empty($image_url_setting)) {3178:      $image_url = $image_url_setting;3179:  }...3184:  $image_url = esc_url($image_url);      // <-- value is still raw Twig here3185:  $image_link_escaped = esc_url($image_link);

parse_twig() does not run until the assembled message is complete:

446:   $mail['message'] = $this->parse_twig($mail['message'], $bricks_fields);

So the transformation is:

esc_url('{{ getVariationImage(item) }}')// => 'http://%20getVariationImage(item)%20'

The braces are removed and a scheme is prepended. Twig then sees plain text.

Only URL-typed settings are affected. Text settings are untouched, and alt uses esc_attr() (line 3183), which preserves braces — Twig still works there. That is why only functions used in the Image URL field break, which makes the regression look far more specific than it is.

$image_link (line 3185) has the identical problem and will break Twig in the image Link field too.

Reproduction

  1. Register a custom function via bricksforge/email_designer/custom_functions:
add_filter('bricksforge/email_designer/custom_functions', function ($functions) {    $functions[] = new \Twig\TwigFunction('getVariationImage', function ($product_item) {        $product_id = !empty($product_item['variation_id'])            ? $product_item['variation_id']            : $product_item['product_id'];        $product = wc_get_product($product_id);        return $product ? wp_get_attachment_image_url($product->get_image_id(), 'medium_large') : '';    });    return $functions;});
  1. In an Email Designer template, add a FOR logic element with the rule item in wc_order_items, place an image element inside it, and set its Image URL to {{ getVariationImage(item) }}.
  2. Trigger the WooCommerce “order placed” email.

Expected: <img src="https://.../product-image.jpg"> Actual: <img src="http://%20getVariationImage(item)%20">

Evidence

Rendered through 4.0.0’s own sandboxed Twig environment against a real order, with and without the esc_url() call:

output
without esc_url <img src="https://essexmonastery.com/.../The-enlargement-of-the-heart-front_-2-.jpg"> … correct, one per item
with esc_url (stock 4.0.0) <img src="http://%20getVariationImage(item)%20"> … repeated per item

Calling the registered function directly returns a correct URL, confirming the function and the filter are healthy and that the escaping is the sole cause.

Note: the new EmailDesignerSecurityPolicy sandbox is not implicated — it correctly merges custom function names into its allowlist, and the expression renders fine under it.

Suggested fix

The value must survive until Twig has run, and the resolved URL should then be escaped. Escaping a template expression is not meaningful — it is not a URL yet.

Minimal stopgap (what we have applied locally):

// Escape only values that contain no Twig expression.$image_url = (strpos($image_url, '{{') !== false || strpos($image_url, '{%') !== false)    ? $image_url    : esc_url($image_url);

This restores 3.x behaviour, and we verified static URLs are still escaped (...logo.jpg?a=1&b=<script> → ...logo.jpg?a=1&#038;b=script).

However, we do not think this is the complete fix, and we would not suggest shipping it as-is. It leaves the Twig output unescaped in the src attribute. If a template author writes {{ some_submitted_field }} into a URL field, a quote in that value could break out of the attribute. Since 4.0 hardened this area deliberately, the proper fix is presumably to defer escaping until after parse_twig() — for example by sanitising the src/href attributes of the rendered message once, after line 446, rather than escaping each setting at build time.

Note that per-element Twig evaluation is not a viable alternative here: the {% for %} and {% endfor %} logic elements are siblings of the image element, so the item loop variable only exists in the assembled-message context.

The same treatment is needed for $image_link (line 3185) and any other URL-typed setting.

Hi,

thank you for the excellent analysis. It is correct in every detail and saved me a lot of time.

The image element escapes the Image URL and the Link setting with esc_url when the element is built, while Twig is only parsed once the whole message has been assembled. esc_url strips the curly braces and prepends a scheme, so by the time Twig runs there is no expression left. That escaping was added in 4.0, which is why it worked in 3.x. The button link has the same problem.

I’ll fix this so that escaping happens after Twig has run and the resolved URL is what gets escaped. Your local stopgap is fine to keep until then. It restores the old behaviour, and since the value in your case comes from your own function rather than from a submitted field, the concern you raised does not apply to your template.

Best,
Daniele

Hello Daniele,

Thanks for your attention on this matter :smiley:

Kind regards,
Barnabas

1 Like

Hi Daniel!

Following up on my earlier report about Twig tags being stripped from the Image URL field in the Email Designer. I’ve found the same problem in the Button → Link and Image → Link fields.

In my Processing Order template I have a button linking to {{site_url}}/my-account/view-order/{{wc_order_id}}. When the email goes out, the link comes through as http://site_url/my-account/view-order/wc_order_id instead of the real URL.

I had a look at includes/email-designer/EmailDesigner.php, and it seems to be the same cause as before. apply_template() runs esc_url() on the links while it builds the message:
• Button, around line 3244: $button_link_escaped = esc_url($button_link); (used for both the Outlook and the normal href)
• Image link, around line 3193: $image_link_escaped = esc_url($image_link);

Twig is only parsed later, in finish() (around line 446). By then esc_url() has already removed the {{ }} and added http:// in front, so Twig never sees the tags. Both site_url and wc_order_id exist in the WooCommerce variables, so the tags themselves are fine.

Would it be possible to escape these URLs after Twig has rendered them? That way the 4.0 security changes stay in place and dynamic links still work.

For now I’ve patched it locally so esc_url() is skipped when the value contains {{ or {%. With that, the button correctly links to https://mysitedomain.com/my-account/view-order/81999. I’d rather not keep a modified plugin file, so I’ll remove the patch once your fix is out.

I’m running Bricksforge 4.0.0, WooCommerce 11.1.2, WordPress 7.1.2 and PHP 8.5.

Thanks so much for looking into it! :folded_hands:

Hi Barnabas,

thanks for the follow-up, and your analysis is correct again.

The fix is the same for all three: escape after Twig has run, so what gets escaped is the resolved URL and not a template expression. That is what I am doing, and it will cover the button and the image link together with the Image URL field - no separate fix needed for them.

Best,
Daniele

1 Like