Build Custom WooCommerce Email Notifications from Scratch

WooCommerce’s email system is built on the abstract WC_Email class, which handles template rendering, header/footer wrapping with the WooCommerce email CSS framework, SMTP delivery via wp_mail(), admin settings fields for subject/heading/recipient, and placeholder substitution for dynamic values like order number and customer name. Every built-in WooCommerce email — new order, processing, completed, refunded, etc. — extends WC_Email and is registered in the woocommerce_email_classes filter. Custom emails follow the same pattern: extend WC_Email, define $this->template_html and $this->template_plain pointing to PHP template files, add an add_action() inside the constructor that binds a WooCommerce hook to $this->trigger(), implement trigger() to load the order, set $this->recipient and $this->placeholders, and call $this->send(). The hook naming convention for status-transition emails is woocommerce_order_status_{from}_{to}_notification (order changed from from to to) or woocommerce_order_status_{to}_notification (order arrived at to from any status) — WooCommerce fires both when an order status changes. Template files follow WordPress’s overrideable template hierarchy: WooCommerce first looks in child-theme/woocommerce/{template}, then the plugin’s own templates/ directory declared in the email’s $this->template_base — this lets stores override the email’s HTML in their theme without modifying the plugin. wc_get_template_html() renders a template file to a string using output buffering, exposing the order object and email instance as template variables. The $this->placeholders array combined with $this->format_string() enables store owners to customise the subject and heading in WooCommerce → Settings → Emails using tokens like {order_number}, {order_date}, and any custom tokens your plugin defines. The WooCommerce order lifecycle post covered the standard status graph; this post extends that with a custom “Shipped” status that triggers a custom WC_Email subclass.

Problem: A WooCommerce store ships orders via a third-party courier and marks them “Shipped” when the courier API confirms pickup. WooCommerce’s built-in “Completed” email fires when the store physically delivers the product — but customers want an email at the earlier Shipped stage with a tracking number included in the email body.

Solution: Register a custom wc-custom-shipped order status, extend WC_Email as WC_Email_Custom_Shipped, bind it to the status-transition hook, and create an HTML template that exposes the order object so the tracking meta can be rendered inline.

// ── Extend WC_Email to create a custom order notification ─────────────────
class WC_Email_Custom_Shipped extends WC_Email {

    public function __construct() {
        $this->id             = 'custom_order_shipped';
        $this->title          = __( 'Custom: Order Shipped', 'myplugin' );
        $this->description    = __( 'Sent to the customer when order status changes to "Shipped".', 'myplugin' );
        $this->template_html  = 'emails/custom-order-shipped.php';
        $this->template_plain = 'emails/plain/custom-order-shipped.php';
        $this->template_base  = MYPLUGIN_PATH . 'templates/';

        // Trigger: our custom status transition hook
        $this->customer_email = true;
        add_action( 'woocommerce_order_status_custom-shipped_notification', [ $this, 'trigger' ], 10, 2 );

        // Call parent AFTER setting properties
        parent::__construct();

        $this->recipient = $this->get_option( 'recipient', '' );
    }

    /** @param int $order_id */
    public function trigger( int $order_id, WC_Order $order = null ): void {
        $this->setup_locale();

        $order = $order instanceof WC_Order ? $order : wc_get_order( $order_id );
        if ( ! $order instanceof WC_Order ) {
            $this->restore_locale();
            return;
        }

        $this->object                         = $order;
        $this->recipient                      = $order->get_billing_email();
        $this->placeholders['{order_number}'] = $order->get_order_number();
        $this->placeholders['{order_date}']   = wc_format_datetime( $order->get_date_created() );

        if ( $this->is_enabled() && $this->get_recipient() ) {
            $this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments() );
        }

        $this->restore_locale();
    }

    public function get_subject(): string {
        return $this->format_string( $this->get_option(
            'subject',
            /* translators: %s: order number */
            sprintf( __( 'Your order #%s has been shipped!', 'myplugin' ), '{order_number}' )
        ) );
    }

    public function get_heading(): string {
        return $this->format_string( $this->get_option(
            'heading',
            __( 'Your order is on its way', 'myplugin' )
        ) );
    }

    public function get_content_html(): string {
        return wc_get_template_html(
            $this->template_html,
            [
                '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
        );
    }

    public function get_content_plain(): string {
        return wc_get_template_html(
            $this->template_plain,
            [
                'order'              => $this->object,
                'email_heading'      => $this->get_heading(),
                'additional_content' => $this->get_additional_content(),
                'sent_to_admin'      => false,
                'plain_text'         => true,
                'email'              => $this,
            ],
            '',
            $this->template_base
        );
    }

    public function init_form_fields(): void {
        $this->form_fields = array_merge( [
            'tracking_info_note' => [
                'title' => __( 'Plugin Note', 'myplugin' ),
                'type'  => 'title',
                'desc'  => __( 'This email is sent when an order moves to "Shipped" status.', 'myplugin' ),
            ],
        ], parent::get_form_fields() );
    }
}

// ── Register the email class with WooCommerce ──────────────────────────────
add_filter( 'woocommerce_email_classes', function( array $emails ): array {
    $emails['WC_Email_Custom_Shipped'] = new WC_Email_Custom_Shipped();
    return $emails;
} );

// ── Register the custom "shipped" order status ──────────────────────────────
add_action( 'init', function(): void {
    register_post_status( 'wc-custom-shipped', [
        'label'                     => _x( 'Shipped', 'WooCommerce order status', 'myplugin' ),
        'public'                    => false,
        'show_in_admin_all_list'    => true,
        'show_in_admin_status_list' => true,
        'label_count'               => _n_noop( 'Shipped (%s)', 'Shipped (%s)', 'myplugin' ),
    ] );
} );

add_filter( 'wc_order_statuses', function( array $statuses ): array {
    $statuses['wc-custom-shipped'] = _x( 'Shipped', 'WooCommerce order status', 'myplugin' );
    return $statuses;
} );

<?php
// templates/emails/custom-order-shipped.php
// Variables: $order (WC_Order), $email_heading (string), $plain_text (bool), $email (WC_Email)
defined( 'ABSPATH' ) || exit;

// WooCommerce standard header (logo, brand colour from WooCommerce email settings)
do_action( 'woocommerce_email_header', $email_heading, $email ); ?>

<p><?php
    /* translators: %s: customer first name */
    printf(
        esc_html__( 'Hi %s, great news!', 'myplugin' ),
        esc_html( $order->get_billing_first_name() )
    );
?></p>
<p><?php esc_html_e( 'Your order has been picked up by our courier and is on its way.', 'myplugin' ); ?></p>

<?php
$tracking = $order->get_meta( '_courier_tracking_number', true );
if ( $tracking ) : ?>
    <p><strong><?php esc_html_e( 'Tracking Number:', 'myplugin' ); ?></strong>
    <?php echo esc_html( $tracking ); ?></p>
<?php endif; ?>

<?php do_action( 'woocommerce_order_details_before_order_table', $order ); ?>

<?php
// Standard WooCommerce order table with items, totals, and billing address
do_action( 'woocommerce_email_order_details', $order, false, false, $email );
do_action( 'woocommerce_email_order_meta',    $order, false, false, $email );
do_action( 'woocommerce_email_customer_details', $order, false, false, $email );
do_action( 'woocommerce_email_footer', $email );

NOTE: The parent::__construct() call in a WC_Email subclass must come after all $this-> property assignments including $this->template_html, $this->template_plain, and $this->template_base — the parent constructor reads these properties to load saved option values and default them if not set. Reversing this order means get_content_html() points to the wrong path. Also, the woocommerce_email_classes filter fires on every request — instantiating the email class inside this filter (as WooCommerce itself does) is intentional and not a performance concern because WC_Email::__construct() only sets properties and registers hooks, it does not connect to the database or perform I/O.