Extracted from the vendored source at docs/Wiki/woocommerce/, WooCommerce 10.9.4.
Every claim below was read from that tree. file:line references are relative to docs/Wiki/woocommerce/. Re-verify after a WooCommerce major upgrade — several things here are new in 10.8/10.9 and behave differently from what older tutorials and the project's own analysis document describe.
Two path traps worth internalising up front:
WC_Email is at includes/emails/class-wc-email.php, not includes/class-wc-email.php.WC_Emails (plural, the registry) is at includes/class-wc-emails.php.WC_Order::update_status('completed') includes/class-wc-order.php:401
└─ set_status() :318 → records $this->status_transition
└─ save() → status_transition() :432
└─ do_action('woocommerce_order_status_completed', $id, $order, $transition)
│
│ WC_Emails::init_transactional_emails() hooked one of these two:
├─ WC_Emails::send_transactional_email() (default)
└─ WC_Emails::queue_transactional_email() ("Deferred emails" feature on)
└─ DeferredEmailQueue::push() → shutdown → Action Scheduler
└─ WC_Emails::send_queued_transactional_email()
│
└─ do_action_ref_array('woocommerce_order_status_completed_notification', $args)
└─ WC_Email_Customer_Completed_Order::trigger($order_id, $order)
├─ setup_locale()
├─ $this->object / recipient / placeholders
└─ send_notification() class-wc-email.php:1140
├─ !is_enabled() → do_action('woocommerce_email_disabled') → bail
├─ !get_recipient()→ do_action('woocommerce_email_skipped') → bail
└─ send( $to, get_subject(), get_content(), get_headers(), get_attachments() )
│ class-wc-email.php:1228
├─ add_filter('wp_mail_from', [$this,'get_from_address'])
├─ add_filter('wp_mail_from_name', [$this,'get_from_name'])
├─ add_filter('wp_mail_content_type', [$this,'get_content_type'])
├─ apply_filters('woocommerce_mail_content', style_inline($message))
├─ apply_filters('woocommerce_mail_callback', 'wp_mail', $this) :1234
├─ apply_filters('woocommerce_mail_callback_params', [...], $this) :1235
├─ $return = (bool) $mail_callback( ...$params ) ← wp_mail()
├─ remove_filter('wp_mail_from', …) ← detached immediately
└─ do_action('woocommerce_email_sent', $return, $this->id, $this) :1253
└─ EmailLogger::handle_woocommerce_email_sent()
The wp_mail_from / wp_mail_from_name / wp_mail_content_type filters exist only for the duration of the wp_mail() call. Anything that defers the actual send past send() returning loses the shop's configured sender and falls back to wordpress@{sitename}. Resolve them while still inside wp_mail().
get_from_address( $from_email = '' ) (class-wc-email.php:1055) ignores its argument entirely:
public function get_from_address( $from_email = '' ) {
$from_email = apply_filters( 'woocommerce_email_from_address', get_option( 'woocommerce_email_from_address' ), $this, $from_email );
return sanitize_email( $from_email );
}
So apply_filters('wp_mail_from', 'anything') while WooCommerce's filter is attached returns the shop's real sender.
$headers is a string, not an array. get_headers() (class-wc-email.php:684-719) returns \r\n-delimited text containing Content-Type, Reply-to, and — only when the email_improvements feature is on — Cc: / Bcc:. It never contains a From: line.
The subject reaching wp_mail() has already been through wp_specialchars_decode() (:1235).
$this->id is mutated at runtime by the refund email. WC_Email_Customer_Refunded_Order::set_email_strings() (class-wc-email-customer-refunded-order.php:195) swaps its own id between customer_refunded_order and customer_partially_refunded_order depending on $this->partial_refund. Read $email->id at woocommerce_mail_callback time; never cache it per instance.
WC_Emails::init_transactional_emails()includes/class-wc-emails.php:99-147. Builds the list of "parent" hooks, then attaches one dispatcher to each:
$email_actions = apply_filters( 'woocommerce_email_actions', array(
'woocommerce_low_stock',
'woocommerce_no_stock',
'woocommerce_product_on_backorder',
'woocommerce_order_status_pending_to_processing',
'woocommerce_order_status_pending_to_completed',
'woocommerce_order_status_processing_to_cancelled',
'woocommerce_order_status_pending_to_failed',
'woocommerce_order_status_pending_to_on-hold',
'woocommerce_order_status_failed_to_processing',
'woocommerce_order_status_failed_to_completed',
'woocommerce_order_status_failed_to_on-hold',
'woocommerce_order_status_cancelled_to_processing',
'woocommerce_order_status_cancelled_to_completed',
'woocommerce_order_status_cancelled_to_on-hold',
'woocommerce_order_status_on-hold_to_processing',
'woocommerce_order_status_on-hold_to_cancelled',
'woocommerce_order_status_on-hold_to_failed',
'woocommerce_order_status_completed',
'woocommerce_order_status_failed',
'woocommerce_order_fully_refunded',
'woocommerce_order_partially_refunded',
'woocommerce_send_review_request',
'woocommerce_new_customer_note',
'woocommerce_created_customer',
'woocommerce_payment_gateway_enabled',
) );
$defer_default = FeaturesUtil::feature_is_enabled( 'deferred_transactional_emails' );
if ( apply_filters( 'woocommerce_defer_transactional_emails', $defer_default ) ) {
self::$deferred_queue = wc_get_container()->get( DeferredEmailQueue::class );
foreach ( $email_actions as $action ) {
add_action( $action, array( __CLASS__, 'queue_transactional_email' ), 10, 10 );
}
} else {
foreach ( $email_actions as $action ) {
add_action( $action, array( __CLASS__, 'send_transactional_email' ), 10, 10 );
}
}
Both dispatchers ultimately fire do_action_ref_array( $filter . '_notification', $args ) — the _notification suffix is where the individual WC_Email classes attach their trigger().
So there are two hook layers, and they are not interchangeable:
woocommerce_order_status_completed — the "parent" hook. WC_Emails listens here.woocommerce_order_status_completed_notification — the derived hook. WC_Email_*::trigger() listens here.WC_Emails::init() also wires the non-class emails directly (:259-262):
add_action( 'woocommerce_low_stock_notification', array( $this, 'low_stock' ) );
add_action( 'woocommerce_no_stock_notification', array( $this, 'no_stock' ) );
add_action( 'woocommerce_product_on_backorder_notification', array( $this, 'backorder' ) );
add_action( 'woocommerce_created_customer_notification', array( $this, 'customer_new_account' ), 10, 3 );
And at the end of init(), :273:
do_action( 'woocommerce_email', $this ); // $this = WC_Emails instance, ->emails is the class map
This is the canonical place to remove_action() a core email's trigger — the instances exist and their hooks are registered. Hook it at a late priority (99).
WC_Email classesEverything in includes/emails/. WC()->mailer()->get_emails() (class-wc-emails.php:344) returns them keyed by class name; the registry itself is filterable via woocommerce_email_classes (:336).
Format below: id — class — trigger hook(s).
Customer-facing, order status driven
customer_processing_order — WC_Email_Customer_Processing_Order — woocommerce_order_status_{pending,failed,cancelled,on-hold}_to_processing_notificationcustomer_completed_order — WC_Email_Customer_Completed_Order — woocommerce_order_status_completed_notificationcustomer_on_hold_order — WC_Email_Customer_On_Hold_Order — woocommerce_order_status_{pending,failed,cancelled}_to_on-hold_notificationcustomer_cancelled_order — WC_Email_Customer_Cancelled_Order — woocommerce_order_status_{processing,on-hold}_to_cancelled_notificationcustomer_failed_order — WC_Email_Customer_Failed_Order — woocommerce_order_status_failed_notificationcustomer_refunded_order — WC_Email_Customer_Refunded_Order — woocommerce_order_fully_refunded_notification (trigger_full) and woocommerce_order_partially_refunded_notification (trigger_partial)customer_partially_refunded_order — WC_Email_Customer_Partially_Refunded_Order — a thin subclass of the above that sets partial_refund = true and remove_actions the inherited trigger_partial. It exists so the partial variant gets its own settings row; the parent still does the sending and swaps $this->id at runtime.Customer-facing, other
customer_invoice — WC_Email_Customer_Invoice — manual only. WC()->mailer()->customer_invoice($order) from the order-edit Order actions box (includes/admin/meta-boxes/class-wc-meta-box-order-actions.php:146) and from REST (src/Internal/Orders/OrderActionsRestController.php:669).customer_new_account — WC_Email_Customer_New_Account — woocommerce_created_customer_notification, routed through WC_Emails::customer_new_account() (class-wc-emails.php:262, :519)customer_note — WC_Email_Customer_Note — woocommerce_new_customer_note_notificationcustomer_reset_password — WC_Email_Customer_Reset_Password — woocommerce_reset_password_notificationcustomer_review_request — WC_Email_Customer_Review_Request — woocommerce_send_review_request_notification (behind the customer_review_request feature)Customer-facing, fulfillment (new)
customer_fulfillment_created — woocommerce_fulfillment_created_notificationcustomer_fulfillment_updated — woocommerce_fulfillment_updated_notificationcustomer_fulfillment_deleted — woocommerce_fulfillment_deleted_notificationCustomer-facing, POS (new)
customer_pos_completed_order — woocommerce_order_status_completed_notification via auto_trigger(), plus woocommerce_rest_order_actions_email_sendcustomer_pos_refunded_order — woocommerce_order_{fully,partially}_refunded_notification via auto_trigger(), plus woocommerce_rest_order_actions_email_sendAdmin-facing
new_order — WC_Email_New_Order — nine hooks: woocommerce_order_status_{pending,failed,cancelled}_to_{processing,completed,on-hold}_notificationcancelled_order — WC_Email_Cancelled_Order — woocommerce_order_status_{processing,on-hold}_to_cancelled_notificationfailed_order — WC_Email_Failed_Order — woocommerce_order_status_{pending,on-hold}_to_failed_notificationadmin_payment_gateway_enabled — woocommerce_payment_gateway_enabled_notificationA single
_notificationhook can fire more than one email.woocommerce_order_status_pending_to_processing_notificationtriggers bothcustomer_processing_orderand the adminnew_order. The hook name therefore cannot identify which email is being sent. If you need per-email granularity, read$email->idfromwoocommerce_mail_callback.
WC_Email::send()These call wp_mail() directly. No woocommerce_mail_callback, no woocommerce_email_sent, no WC_Email instance:
WC_Emails::low_stock() — class-wc-emails.php:1007, wp_mail() at :1041WC_Emails::no_stock() — :1098, wp_mail() at :1128WC_Emails::backorder() — :1185, wp_mail() at :1214packages/email-editor/src/Engine/class-send-preview-email.php:173The three stock alerts are admin notifications, filterable through woocommerce_email_recipient_{low_stock,no_stock,backorder}, ..._subject_..., ..._content_..., and gated by woocommerce_should_send_{low,no}_stock_notification. They are not WC_Email subclasses, so they never appear in get_emails() or the Emails settings screen.
DeferredEmailQueue — new in 10.8.0src/Internal/Email/DeferredEmailQueue.php. Gated on the "Deferred emails" feature toggle (WooCommerce → Settings → Advanced → Features), declared at src/Internal/Features/FeaturesController.php:432 with 'enabled_by_default' => false.
How it works: push() collects (filter, args) during the request, registers a shutdown hook at priority 100, and dispatch() schedules one Action Scheduler action per email:
\WC()->queue()->add( 'woocommerce_send_queued_transactional_email', array( $filter, $args ), 'woocommerce-emails' );
woocommerce_send_queued_transactional_emailwoocommerce-emailsWC_Order, WC_Product, WC_Payment_Gateway, StockNotification) are collapsed to {type, id} references and re-fetched on the worker side. push() returns false for anything it cannot represent, and the caller falls back to sending synchronously.init() registers the AS handler unconditionally, so already-scheduled jobs still drain after the feature is switched off. Disabling mid-flight is safe.What it does not do: rate limiting. WC()->queue()->add() is an async action with no timestamp — Action Scheduler runs it as soon as it can, in batches (default ~25 per run). It decouples sending from the request and nothing more. This is exactly "variant A" from docs/analyza-woocommerce-email-throttling.md, now a first-class core feature. It does not solve a host-side outbound rate limit, and it must not be mistaken for a fix.
It supersedes the legacy WC_Background_Emailer (includes/class-wc-background-emailer.php), which still ships.
EmailLogger — new in 10.9.0src/Internal/Email/EmailLogger.php. Registers (:45-49):
add_action( 'wp_mail_failed', array( $this, 'capture_mail_error' ), 10, 1 );
add_action( 'woocommerce_email_sent', array( $this, 'handle_woocommerce_email_sent' ), 10, 3 );
add_action( 'woocommerce_email_disabled', array( $this, 'handle_woocommerce_email_disabled' ), 10, 2 );
add_action( 'woocommerce_email_skipped', array( $this, 'handle_woocommerce_email_skipped' ), 10, 3 );
It writes to the WooCommerce logger under source transactional-emails, and — via maybe_add_order_note() (:152) — adds an order note recording each send attempt against the order.
Two escape hatches, both new in 10.9.0:
woocommerce_email_log_enabled — apply_filters(..., true, $email_id, $email). Return false to skip logging entirely.woocommerce_email_log_add_order_note — apply_filters(..., true, $email_id, $email, $order). Return false to suppress just the order note, keeping the log entry.Relevant to any plugin that short-circuits
pre_wp_mail.woocommerce_email_sentfires immediately after the mail callback returns, using that return value as$success. Short-circuitwp_mail()and returntrue, andEmailLoggerrecords "sent" — and writes the order note — at enqueue time, not at actual send time. Suppress it with the two filters above and write your own note when the mail really leaves.
woocommerce_email_from_address, woocommerce_email_from_name, woocommerce_email_reply_to_enabled, woocommerce_email_reply_to_address, woocommerce_email_reply_to_name.
Per-email settings live under woocommerce_{$email_id}_settings, read through WC_Settings_API. The enabled key is surfaced by is_enabled() (class-wc-email.php:826) and is filterable per email via woocommerce_email_enabled_{$id}.
WooCommerce 10.x replaced the bare strings with enums. They differ only by the wc- prefix, and mixing them up is the classic bug.
src/Enums/OrderStatus.php — unprefixed, what $order->get_status() returns and what update_status() accepts:
pending processing on-hold completed cancelled refunded failed
trash new auto-draft draft checkout-draft
src/Enums/OrderInternalStatus.php — wc- prefixed, the registered post-status / DB value:
wc-pending wc-processing wc-on-hold wc-completed wc-cancelled wc-refunded wc-failed
OrderStatus::PAYMENT_COMPLETE_STATUSES is also defined (:103).
WC_Post_Types::register_post_status() — includes/class-wc-post-types.php:594, hooked to init priority 9:
$order_statuses = apply_filters( 'woocommerce_register_shop_order_post_statuses', array( /* the seven */ ) );
foreach ( $order_statuses as $order_status => $values ) {
register_post_status( $order_status, $values );
}
Registering a custom order status means two steps, and both are required:
register_post_status( 'wc-my-status', [...] ) on init — makes it a real post status. (Or filter woocommerce_register_shop_order_post_statuses.)add_filter( 'wc_order_statuses', … ) — makes WooCommerce's admin dropdowns and reports aware of it.wc_get_order_statuses() (includes/wc-order-functions.php:104):
function wc_get_order_statuses() {
$order_statuses = array(
OrderInternalStatus::PENDING => _x( 'Pending payment', 'Order status', 'woocommerce' ),
OrderInternalStatus::PROCESSING => _x( 'Processing', 'Order status', 'woocommerce' ),
OrderInternalStatus::ON_HOLD => _x( 'On hold', 'Order status', 'woocommerce' ),
OrderInternalStatus::COMPLETED => _x( 'Completed', 'Order status', 'woocommerce' ),
OrderInternalStatus::CANCELLED => _x( 'Cancelled', 'Order status', 'woocommerce' ),
OrderInternalStatus::REFUNDED => _x( 'Refunded', 'Order status', 'woocommerce' ),
OrderInternalStatus::FAILED => _x( 'Failed', 'Order status', 'woocommerce' ),
);
return apply_filters( 'wc_order_statuses', $order_statuses );
}
Note the keys here are prefixed (wc-completed), while the transition hooks below use the unprefixed form (completed).
WC_Order::update_status( $new_status, $note = '', $manual = false ) — includes/class-wc-order.php:401. Bails if the order has no ID. Calls set_status() (:318), which records a pending transition on the object, then save(), which calls status_transition() (:432).
status_transition() fires, in order:
do_action( 'woocommerce_order_status_' . $to, $order_id, $order, $status_transition );
// ... adds the "Order status changed from X to Y." note, unless $note === false
// ... skipped entirely when $from is draft / auto-draft / new / checkout-draft
if ( ! empty( $status_transition['from'] ) ) {
do_action( 'woocommerce_order_status_' . $from . '_to_' . $to, $order_id, $order );
do_action( 'woocommerce_order_status_changed', $order_id, $from, $to, $order );
}
So for a processing → completed transition you get, in sequence:
woocommerce_order_status_completed — ( $order_id, $order, $status_transition )woocommerce_order_status_processing_to_completed — ( $order_id, $order )woocommerce_order_status_changed — ( $order_id, $from, $to, $order )$status_transition is array( 'from' => …, 'to' => …, 'note' => string|false, 'manual' => bool ). Note the third argument is only passed to the first hook.
An order created directly in a status (no from) fires only hook 1. Code that relies solely on woocommerce_order_status_changed will miss those.
The whole body is wrapped in try/catch — an exception thrown by your hook is swallowed and logged, not propagated.
includes/wc-order-functions.php:
wc_is_order_status( $maybe_status ) — :123, accepts the wc- prefixed formwc_get_is_paid_statuses() — :134, defaults to [processing, completed], filter woocommerce_order_is_paid_statuseswc_get_is_pending_statuses() — :151, defaults to [pending], filter woocommerce_order_is_pending_statuseswc_get_order_status_name( $status ) — :169, tolerates either spellingWC_Email (includes/emails/class-wc-email.php)Sending
woocommerce_mail_callback — (callable 'wp_mail', WC_Email $email) — :1234. The only place the WC_Email instance is exposed immediately before wp_mail(). Return a different callable to replace the mailer entirely (which bypasses wp_mail() and everything hooked to it).woocommerce_mail_callback_params — (array [$to,$subject,$message,$headers,$attachments], WC_Email $email) — :1235woocommerce_mail_content — (string $message) — :1233, after inline stylingwoocommerce_mail_style_inline_callback — :900woocommerce_email_sent — action, (bool $return, string $id, WC_Email $email) — :1253woocommerce_email_disabled — action, (string $id, WC_Email $email) — :1150 (10.9.0)woocommerce_email_skipped — action, (string $reason, string $id, WC_Email $email) — :1169 (10.9.0). Only reason today is WC_Email::SKIP_REASON_NO_RECIPIENT (:40).Composition
woocommerce_email_headers — (string $header, string $id, $object, WC_Email $email) — :718woocommerce_email_attachments — (array $attachments, string $id, $object, WC_Email $email) — :727. Empty in core; invoice/packing-slip plugins push file paths here.woocommerce_email_content_type — :788woocommerce_email_styles — :937woocommerce_emogrifier — action, :953woocommerce_email_subject_{$id} — :566woocommerce_email_heading_{$id} — :613woocommerce_email_preheader — :591woocommerce_email_additional_content_{$id} — :548woocommerce_email_format_string, ..._find, ..._replace — :402, :410 (placeholder substitution)Addressing
woocommerce_email_from_address — (string $address, WC_Email $email, string $from_email) — :1056woocommerce_email_from_name — :1045woocommerce_email_recipient_{$id} — :631woocommerce_email_cc_recipient_{$id} — :651woocommerce_email_bcc_recipient_{$id} — :672woocommerce_email_reply_to_enabled / ..._address / ..._name — :1073, :1111, :1092Config / misc
woocommerce_email_enabled_{$id} — (bool $enabled, $object, WC_Email $email) — :827woocommerce_email_get_option, woocommerce_email_title, woocommerce_email_description — :818, :797, :806woocommerce_email_groups, woocommerce_email_group_title — :476, :497woocommerce_allow_switching_email_locale / ..._restoring_... / woocommerce_email_setup_locale / woocommerce_email_restore_locale — :426, :446, :428, :448woocommerce_is_email_preview — :743woocommerce_template_directory, woocommerce_locate_core_template — :1458, :1475woocommerce_email_settings_before / ..._after — actions, :1582, :1595WC_Emails (includes/class-wc-emails.php)woocommerce_email — action, (WC_Emails $this) — :273. Fires at the end of init(), after every WC_Email instance has registered its trigger(). The canonical detach point — remove_action() here, at priority 99.woocommerce_email_classes — (array $emails) — :336. Register or unregister email classes.woocommerce_email_actions — (array $actions) — :99. The parent-hook list.woocommerce_defer_transactional_emails — (bool $defer) — :137. Overrides the feature toggle.woocommerce_allow_send_queued_transactional_email — (bool, string $filter, array $args) — :183woocommerce_email_header / woocommerce_email_footer — actions, :465, :475woocommerce_email_order_meta_fields / ..._meta_keys — :641, :650woocommerce_email_customer_details_fields — :792woocommerce_should_send_low_stock_notification :1019, woocommerce_should_send_no_stock_notification :1110; and woocommerce_email_{recipient,subject,content}_{low_stock,no_stock,backorder}EmailLogger (src/Internal/Email/EmailLogger.php)woocommerce_email_log_enabled — (bool, string $id, WC_Email $email)woocommerce_email_log_add_order_note — (bool, string $id, WC_Email $email, WC_Order $order)wc_order_statuses — (array $statuses) — prefixed keys. Admin dropdowns, reports.woocommerce_register_shop_order_post_statuses — (array $statuses) — register_post_status() args.woocommerce_order_status_{$to} — action, ( $order_id, $order, $status_transition )woocommerce_order_status_{$from}_to_{$to} — action, ( $order_id, $order )woocommerce_order_status_changed — action, ( $order_id, $from, $to, $order )woocommerce_order_status_{$hook}_notification — action, derived from each $email_actions entry. Arguments vary by email.woocommerce_order_is_paid_statuses, woocommerce_order_is_pending_statusesHPOS and the legacy CPT store use different hooks. Anything touching the Orders screen must handle both.
woocommerce_order_table_search_query_meta_keys — src/Internal/DataStores/Orders/OrdersTableSearchQuery.php:446woocommerce_shop_order_search_fields — same file, :439woocommerce_shop_order_search_fields — includes/data-stores/class-wc-order-data-store-cpt.php:590woocommerce_order_list_table_prepare_items_query_args — src/Internal/Admin/Orders/ListTable.php:418bulk_actions-woocommerce_page_wc-orders (HPOS) vs bulk_actions-edit-shop_order (legacy); handlers on handle_bulk_actions-… respectively.studiou-wc-mail-queueCross-references into docs/plans/implement-plan-00.md:
woocommerce_mail_callback (§1.1, §3.1) because it is the only pre-wp_mail() hook carrying the WC_Email instance. _notification hooks cannot identify the email (§1.3).wp_mail() returns (§1.1).$headers is a string for WooCommerce emails and never contains From: (§1.1).woocommerce_email_attachments (§3.1) and may be temp files.EmailLogger will record "sent" and write an order note at enqueue time (§1.6). Suppress with woocommerce_email_log_enabled / woocommerce_email_log_add_order_note and emit our own note on real send.WC()->mailer()->get_emails() (§1.3), never a hardcoded list. customer_reset_password is in there — never default it on.$email->id per call, never cache it — the refund email mutates its own id (§1.1).