wc-emails-classic

Customize WooCommerce transactional emails the classic PHP-template way (NOT the block email editor) — extend WC_Email, declare $id / $title / $template_html / $template_plain / $placeholders, hook tr

SKILL.md

GitHub

WooCommerce: classic transactional emails (WC_Email)

For plugins and themes that customize WooCommerce's transactional emails — order confirmations, refund notifications, shipping alerts, custom vendor / fulfillment notifications. This skill covers the classic PHP-template path: WC_Email class extension, template override, wc_get_template_html. The block email editor is a separate path; this skill is intentionally non-block.

Misconception this skill corrects

"I'll override the email by hooking into wp_mail or copying the template into my plugin folder."

Both paths fail. WooCommerce emails go through WC_Email::send() (includes/emails/class-wc-email.php:1128), not the WP wp_mail directly — hooking wp_mail works for incidental tweaks but misses WC's email-framework features (admin Settings, reusable header/footer, plain-text fallback, locale switching). And templates resolve theme-first: wc_locate_template looks for <theme>/woocommerce/<file>, then <theme>/<file>, and only falls back to the $default_path (which is your plugin's template_base if you passed one, otherwise WC core's templates/). Plugin-shipped templates require setting $this->template_base so the fallback hits your file instead of WC core's.

To actually customize, you have three correct paths:

  1. Override an existing email's template — drop a copy in theme/woocommerce/emails/<file>.php. Easiest, no PHP class.
  2. Override email strings via Settings — admin types subject / heading / additional content into the WC settings UI; class reads from $this->get_option(...).
  3. Add a brand-new email class — your plugin needs a new email type WC doesn't have. Extend WC_Email, register via woocommerce_email_classes filter.

When to use this skill

Trigger when ANY of the following is true:

  • Adding a new transactional email (vendor split, fulfillment milestone, refund-request received, custom status change).
  • Overriding an existing email template's HTML / plain text.
  • Customizing email strings beyond what WC's Settings UI exposes.
  • Reviewing PR code that touches WC_Email, wc_get_template_html, woocommerce_email_classes, or any woocommerce_order_status_*_notification action.
  • Debugging "my custom email isn't firing" / "the template renders the default content even though I overrode it".

Architecture in one paragraph

WC ships a singleton WC_Emails (includes/class-wc-emails.php) that loads a list of WC_Email subclasses on init, filterable via woocommerce_email_classes. Each class declares its own $id, customer-facing $title, $template_html / $template_plain paths (resolved via wc_get_template_html), and an enabled flag in admin. The class wires its own triggers — usually to woocommerce_order_status_<from>_to_<to>_notification actions — and renders into HTML / plain-text using the templates. The customer-facing subject and heading default to the values returned by get_default_subject() / get_default_heading(), overridable in admin via Settings → Emails. Templates can be overridden in theme/woocommerce/emails/ without touching the class.

Template override — the no-class path

your-theme/
└── woocommerce/
    └── emails/
        └── customer-processing-order.php   ← copy of templates/emails/customer-processing-order.php
        └── plain/
            └── customer-processing-order.php   ← plain-text version

Copy the file from wp-content/plugins/woocommerce/templates/emails/<file>.php (and templates/emails/plain/<file>.php for the plain version) into your active theme. WC's wc_get_template_html() resolves the theme override before falling back to its bundled copy.

This is the right path for cosmetic / structural changes (rearranging blocks, adding a notice, swapping the logo, restyling). No PHP plugin code required.

Caveat: template overrides break across major WC versions. When WC ships a new version of customer-processing-order.php, your override stays at the old version — and may render against new variables that don't exist or miss new features. The WP admin's Status page surfaces stale overrides; review yearly.

Add a brand-new email class

For an email WC doesn't ship — e.g. "Order shipped via a specific carrier" or "Refund request received".

namespace MyPlugin\Email;

class MyCustomEmail extends \WC_Email {

    public function __construct() {
        $this->id             = 'myplugin_custom';        // unique slug
        $this->customer_email = true;                      // false for admin-only emails
        $this->title          = __( 'Custom shipping update', 'myplugin' );
        $this->description    = __( 'Sent to the customer when their order ships via the priority lane.', 'myplugin' );
        $this->template_html  = 'emails/myplugin-custom.php';
        $this->template_plain = 'emails/plain/myplugin-custom.php';

        // template_base is the directory WC searches for the template files
        // when wc_get_template_html() is called. By pointing at your plugin's
        // templates/ folder, your template gets found even with no theme override.
        $this->template_base  = MYPLUGIN_PLUGIN_PATH . 'templates/';

        $this->placeholders = array(
            '{order_number}' => '',
            '{order_date}'   => '',
        );

        // Bind the email to a specific WC action. The action name is the
        // canonical "X happened, send notifications" hook WC fires.
        add_action( 'myplugin/order_priority_shipped_notification', array( $this, 'trigger' ), 10, 2 );

        // Required — call parent constructor LAST so settings are loaded.
        parent::__construct();
    }

    public function get_default_subject(): string {
        return __( 'Your {order_number} order has shipped (priority)', 'myplugin' );
    }

    public function get_default_heading(): string {
        return __( 'Your order is on the way', 'myplugin' );
    }

    /**
     * Build context, look up recipient, send.
     *
     * @param int           $order_id
     * @param \WC_Order|false $order
     */
    public function trigger( $order_id, $order = false ): void {
        $this->setup_locale();

        if ( $order_id && ! ( $order instanceof \WC_Order ) ) {
            $order = wc_get_order( $order_id );
        }

        if ( $order instanceof \WC_Order ) {
            $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_content_html(): string {
        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
        );
    }

    public function get_content_plain(): string {
        return wc_get_template_html(
            $this->template_plain,
            array(
                '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
        );
    }
}

Register the class:

add_filter( 'woocommerce_email_classes', static function ( array $emails ): array {
    $emails['myplugin_custom'] = new \MyPlugin\Email\MyCustomEmail();
    return $emails;
} );

The filter callback runs once on init. The array key matches $this->id. WC instantiates the class, which registers its own trigger actions in the constructor.

Triggering the email

Two paths:

Path A — fire on a WC core status transition. Bind your trigger() to one of the existing notification actions:

add_action( 'woocommerce_order_status_processing_to_completed_notification', array( $this, 'trigger' ), 10, 2 );

The _notification suffix is WC convention. All woocommerce_order_status_*_notification actions receive ( $order_id, $order ). WC dispatches them inside WC_Order::set_status() after the actual status change.

Path B — fire on a custom event. Define your own action in your plugin's logic and bind the email to it:

// Somewhere in your plugin code:
do_action( 'myplugin/order_priority_shipped_notification', $order_id, $order );

// In the email class constructor:
add_action( 'myplugin/order_priority_shipped_notification', array( $this, 'trigger' ), 10, 2 );

The action name is yours. The _notification suffix is convention but not required for custom hooks.

Template variables

The variables passed to wc_get_template_html() become local variables in the template. The standard set across WC's built-in emails:

  • $order — the WC_Order instance
  • $email_heading — the H1 to render
  • $additional_content — admin-customizable text from Settings
  • $sent_to_admin — bool, true for admin-targeted emails
  • $plain_text — bool, true for the plain-text variant
  • $email — the WC_Email instance (for accessing helpers like $email->customer_note)

Your template can call wc_get_template( 'emails/email-header.php', array( 'email_heading' => $email_heading ) ) and wc_get_template( 'emails/email-footer.php' ) to inherit the WC header/footer style — the standard pattern across all built-in emails.

WC 10.8 email additions

WooCommerce 10.8 added two useful template-level filters to templates/emails/email-order-details.php:

add_filter( 'woocommerce_email_order_details_heading', static function ( string $heading, WC_Order $order, WC_Email $email ): string {
    return __( 'Items in this order', 'myplugin' );
}, 10, 3 );

add_filter( 'woocommerce_email_display_order_number', static function ( bool $display, WC_Order $order, WC_Email $email ): bool {
    return ! $email instanceof WC_Email || $email->id !== 'myplugin_internal';
}, 10, 3 );

Notes:

  • woocommerce_email_order_details_heading is only used when the email_improvements feature is enabled; without that feature the classic template keeps the old order-heading shape.
  • woocommerce_email_display_order_number is applied by both HTML and plain order-details templates, so it is the safer way to hide the order number than overriding both files.
  • The new Customer Review Request email (customer_review_request) is feature-gated, disabled by default, scheduled through Action Scheduler, and triggered via woocommerce_send_review_request_notification. It has dedicated filters such as woocommerce_should_send_review_request and woocommerce_review_request_delay_seconds; do not reimplement the scheduling if you only need to adjust eligibility or timing.

Critical rules

  • Extend WC_Email, not WP_Mail or rolling your own. The framework gives you Settings UI, locale switching, plain-text fallback, header/footer reuse for free.
  • Register via woocommerce_email_classes filter (class-wc-emails.php:333). The filter runs on init; your callback is the only entry point.
  • $this->id is unique and stable. Once shipped, renaming it invalidates admin-saved settings (subject overrides, recipient overrides, enabled state). Treat it like a public API contract.
  • Set $this->template_base = MYPLUGIN_PLUGIN_PATH . 'templates/' for plugin-shipped templates, otherwise wc_get_template only searches the theme + WC's own templates folder.
  • Call parent::__construct() LAST in your constructor (after declaring $id, $title, etc.). The parent reads $this->id to load saved settings.
  • setup_locale() / restore_locale() around send() — for customer emails ($this->is_customer_email()), switches the active locale to the site's default locale via wc_switch_to_site_locale() so transactional content is consistent regardless of the visitor's current language. Verified at class-wc-email.php:421-423: if ( $switch_email_locale && $this->is_customer_email() && apply_filters( 'woocommerce_email_setup_locale', true ) ) { wc_switch_to_site_locale(); }. It does NOT switch to a per-customer language. Built-in emails do this; new customer-facing emails should too.
  • is_enabled() + get_recipient() guard before send(). Without it, disabled emails still fire and emails with empty recipients hard-error in wp_mail.
  • Hook trigger() to _notification-suffixed actions for WC status transitions. Don't bind to woocommerce_order_status_<status> (without _notification) — that fires earlier in the pipeline, before the order is fully saved.
  • Use the 10.8 order-details filters before overriding templates when the customization is just the order-summary heading or order-number visibility.
  • Templates in theme override plugin override WC core. Document your template files as overridable; users will copy them into theme/woocommerce/emails/.

Common mistakes

// WRONG — registering email outside woocommerce_email_classes
add_action( 'init', function () {
    new MyCustomEmail();
} );
// The email class instantiates but never enters WC_Emails->emails array,
// so its Settings panel doesn't appear and the trigger may double-register.

// RIGHT
add_filter( 'woocommerce_email_classes', function ( $emails ) {
    $emails['myplugin_custom'] = new MyCustomEmail();
    return $emails;
} );

// WRONG — calling parent::__construct first
public function __construct() {
    parent::__construct(); // BUG — reads $this->id before the child sets it
    $this->id = 'myplugin_custom';
}

// RIGHT — set props first, parent last
public function __construct() {
    $this->id = 'myplugin_custom';
    $this->title = '...';
    // ... set all properties ...
    parent::__construct();
}

// WRONG — missing template_base for plugin-shipped templates
$this->template_html = 'emails/myplugin-custom.php';
// (no template_base set) → wc_get_template_html searches only theme + WC core

// RIGHT
$this->template_base = MYPLUGIN_PLUGIN_PATH . 'templates/';

// WRONG — binding trigger to non-notification action
add_action( 'woocommerce_order_status_completed', array( $this, 'trigger' ), 10, 2 );
// Fires too early in some contexts; WC's own emails always use the _notification suffix.

// RIGHT
add_action( 'woocommerce_order_status_processing_to_completed_notification', array( $this, 'trigger' ), 10, 2 );

// WRONG — sending without is_enabled / get_recipient guard
$this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments() );
// Disabled email still fires; empty recipient throws.

// RIGHT
if ( $this->is_enabled() && $this->get_recipient() ) {
    $this->send( /* ... */ );
}

// WRONG — hardcoding subject in the class
public function get_default_subject() {
    return 'Your order has shipped'; // not translatable, not admin-overridable
}

// RIGHT — translatable, admin can override in Settings → Emails
public function get_default_subject() {
    return __( 'Your {order_number} order has shipped', 'myplugin' );
}

Sending programmatically (one-off)

If you need to send a WC-styled email NOT tied to a WC_Email subclass — e.g. a one-off notification that doesn't warrant a class — pass through WC_Emails:

$mailer = WC()->mailer();
$mailer->send(
    'recipient@example.com',
    'Subject line',
    $mailer->wrap_message( 'Heading', '<p>Body HTML</p>' ), // wraps in WC header/footer
    array(),  // headers
    array()   // attachments
);

WC()->mailer() returns the WC_Emails singleton. The wrap_message helper applies the same header/footer template the regular emails use, so the one-off keeps the brand styling.

Cross-references

  • Run wc-payment-gateway when the email is tied to payment events — the gateway calls payment_complete() which fires status transitions which fire _notification actions.
  • Run wc-hpos-compatibility if the email reads custom order meta — $order->get_meta() (HPOS-aware), not get_post_meta against the order ID.
  • Run wp-i18n-audit on email strings — translatable strings need text-domain consistency, and email-context translation has timing nuances (setup_locale switches the site locale mid-request).

What this skill does NOT cover

  • Block email editor — explicitly out of scope. The BlockEmailRenderer (WC 10.5+) and woocommerce_email_block_template_html filter are the modern alternative; sibling skill if there's demand.
  • Email deliverability (SPF / DKIM / DMARC, SMTP plugins, transactional providers like SendGrid). Server-side / WP-level concerns.
  • HTML email styling beyond what WC's bundled emails/email-styles.php provides.
  • Custom unsubscribe / one-click unsubscribe header support (RFC 8058) — WC core doesn't ship this; provider-specific.
  • Email log / queue plugins — observation layer above the framework.

References