WooCommerce Block Emails: Order Variables Showing as Raw Tokens

WooCommerce 10.x block emails sending {site_title} and {order_number} as literal curly-brace text? Here is why the placeholder pass is skipped and the fix.
WooCommerce Block Emails: Order Variables Showing as Raw Tokens
WooCommerceWordPressEmail
May 25, 20266 min read1069 words

The Problem

A client running WooCommerce 10.8 on a Bricks-built store switched their order confirmation email from the legacy template to the new block-based editor last week. Looked great in the editor preview. The customer email it actually sent looked like this:

Hi {customer_first_name},

Your order {order_number} from {site_title} is confirmed.
We will email you again when {order_number} ships.

Total: {order_total}

Every merge token rendered as the literal curly-brace string. The plaintext fallback was fine. The Mailpoet send log showed the HTML body going out exactly as it was rendered in the editor, placeholders and all. No PHP warning, no entry in the WooCommerce log. The customer thought their order had been processed by a half-finished script.

I ran into this on a client project where the previous developer had hand-rolled a custom transactional plugin years ago, and the new block-based email pipeline silently disagreed with how that plugin filtered email content. Two flavours of the bug showed up in the same site: variables inside block content stayed raw, and variables inside a custom core/paragraph block injected via register_block_type rendered correctly. Same email, two behaviours.

Why It Happens

Legacy WooCommerce emails were a single HTML template rendered by WC_Email::get_content_html(), then run through format_string() which walked through every registered placeholder and replaced {token} with the resolved value. Anything that hooked into woocommerce_email_format_string could mutate that pass.

Block-based emails took a different path. The block content is stored as serialised blocks, rendered server-side into HTML by \Automattic\WooCommerce\Internal\Email\BlockEmailRenderer, and the placeholder replacement runs after the block render — but only on the output of the registered email blocks themselves, not on the raw HTML from arbitrary content blocks. If you wrote your tokens inside a core/paragraph block, the renderer hands them back as <p>{order_number}</p> and the post-render placeholder pass either does not run or runs against the wrong filter list because a legacy plugin has short-circuited it.

The two specific failure modes I saw:

  1. The previous developer's plugin called remove_all_filters('woocommerce_email_format_string') to stop a third-party plugin from injecting unwanted text. That filter is still the entry point for block-email placeholder resolution. The block renderer adds its callbacks to it on init, and the plugin was nuking them on wp_loaded. The block-email path therefore had no resolver registered when the email actually sent.
  2. Custom blocks that ship their own render_callback need to call Automattic\WooCommerce\Internal\Email\PlaceholderResolver::resolve() on their output before returning it. Most third-party "Email Variables" blocks do not, because they were written for the legacy template.

The block-based email developer guide covers the resolver API but is easy to miss because most stores never had to think about placeholder resolution before.

The Fix

Two patterns. One for sites where a plugin has stripped the filter, one for custom blocks that bypass the resolver.

Pattern 1: Stop nuking the email format filter. Find the offending remove_all_filters or remove_filter call and replace it with a targeted removal of the specific callback that was actually misbehaving. Anything that drops every callback from woocommerce_email_format_string will break block emails:

// Wrong: kills WooCommerce's own block resolver too.
add_action('wp_loaded', function () {
    remove_all_filters('woocommerce_email_format_string');
});

// Right: target only the third-party callback you actually wanted gone.
add_action('wp_loaded', function () {
    remove_filter(
        'woocommerce_email_format_string',
        ['Some_Third_Party_Class', 'inject_footer'],
        20
    );
});

If you do not know which plugin added the callback, run a one-line debug helper from the WordPress shell:

add_action('woocommerce_email_format_string', function ($s) {
    error_log('format_string callbacks: ' . print_r($GLOBALS['wp_filter']['woocommerce_email_format_string'], true));
    return $s;
}, 0);

That dumps every registered callback in priority order to the PHP error log the next time an email sends. Identify the one you wanted dead, remove that one only, and the block-email resolver stays alive.

Pattern 2: Resolve placeholders in custom block render callbacks. If you register your own block for use inside the email editor, the render callback has to push its output through the resolver before returning it:

register_block_type('mystore/order-summary', [
    'render_callback' => function ($attributes, $content, $block) {
        $template = sprintf(
            '<div class="order-summary"><strong>Order:</strong> %s<br><strong>Customer:</strong> %s</div>',
            '{order_number}',
            '{customer_first_name} {customer_last_name}'
        );

        // Without this, the curly braces ship to the customer as text.
        if (class_exists('\Automattic\WooCommerce\Internal\Email\PlaceholderResolver')) {
            $email = WC()->mailer()->emails[$block->context['woocommerce/email/type'] ?? ''] ?? null;
            if ($email) {
                $template = \Automattic\WooCommerce\Internal\Email\PlaceholderResolver::resolve(
                    $template,
                    $email
                );
            }
        }

        return $template;
    },
]);

The $block->context carries the active email type when the block renders inside an email. Passing the matching WC_Email instance into the resolver gives it the order, customer, and site context it needs to substitute every token. If the email type context is missing, you are rendering outside of an email send (live preview, sometimes) and you can either skip the resolution or fall back to a sample dataset.

Verify the send. WooCommerce ships a test endpoint under WooCommerce → Settings → Emails → Customer processing order → Send test email. Send one to a real inbox, view the source, and search for {. Any unresolved placeholder will jump out. If the test sends clean, fire a real test order in a staging environment and confirm the production email path uses the same resolver. The block editor preview uses sample data and will hide a broken resolver if you do not verify against an actual send.

The Lesson

Block-based emails moved placeholder resolution into the woocommerce_email_format_string filter pipeline, which means any plugin that nukes that filter for legacy reasons will silently break every block email on the site. Custom email blocks have to call the PlaceholderResolver themselves; the block renderer will not do it for you. Audit your remove_filter calls before flipping any store from legacy templates to the new editor.

If your customers are getting emails full of curly braces and you need somebody to find the offending filter without burning a day on it, that is a job I do. See my services. For a related WooCommerce email regression, read WooCommerce order email not sending.

Customers getting emails full of {order_number} placeholders? Let me fix it.

Back to blogStart a project