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
- 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;});
- 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) }}. - 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&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.
