# WooCommerce reference — emailing, order statuses, hooks 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`**. --- # Part 1 — Emailing ## 1.1 The full send pipeline ``` 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() ``` ### Consequences that bite **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: ```php 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. ## 1.2 Bootstrap — `WC_Emails::init_transactional_emails()` `includes/class-wc-emails.php:99-147`. Builds the list of "parent" hooks, then attaches one dispatcher to each: ```php $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`): ```php 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`: ```php 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). ## 1.3 The 21 `WC_Email` classes Everything 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_notification` - `customer_completed_order` — `WC_Email_Customer_Completed_Order` — `woocommerce_order_status_completed_notification` - `customer_on_hold_order` — `WC_Email_Customer_On_Hold_Order` — `woocommerce_order_status_{pending,failed,cancelled}_to_on-hold_notification` - `customer_cancelled_order` — `WC_Email_Customer_Cancelled_Order` — `woocommerce_order_status_{processing,on-hold}_to_cancelled_notification` - `customer_failed_order` — `WC_Email_Customer_Failed_Order` — `woocommerce_order_status_failed_notification` - `customer_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_action`s 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_notification` - `customer_reset_password` — `WC_Email_Customer_Reset_Password` — `woocommerce_reset_password_notification` - `customer_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_notification` - `customer_fulfillment_updated` — `woocommerce_fulfillment_updated_notification` - `customer_fulfillment_deleted` — `woocommerce_fulfillment_deleted_notification` **Customer-facing, POS (new)** - `customer_pos_completed_order` — `woocommerce_order_status_completed_notification` via `auto_trigger()`, plus `woocommerce_rest_order_actions_email_send` - `customer_pos_refunded_order` — `woocommerce_order_{fully,partially}_refunded_notification` via `auto_trigger()`, plus `woocommerce_rest_order_actions_email_send` **Admin-facing** - `new_order` — `WC_Email_New_Order` — nine hooks: `woocommerce_order_status_{pending,failed,cancelled}_to_{processing,completed,on-hold}_notification` - `cancelled_order` — `WC_Email_Cancelled_Order` — `woocommerce_order_status_{processing,on-hold}_to_cancelled_notification` - `failed_order` — `WC_Email_Failed_Order` — `woocommerce_order_status_{pending,on-hold}_to_failed_notification` - `admin_payment_gateway_enabled` — `woocommerce_payment_gateway_enabled_notification` > **A single `_notification` hook can fire more than one email.** `woocommerce_order_status_pending_to_processing_notification` triggers **both** `customer_processing_order` and the admin `new_order`. The hook name therefore cannot identify *which* email is being sent. If you need per-email granularity, read `$email->id` from `woocommerce_mail_callback`. ## 1.4 Emails that never touch `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 `:1041` - `WC_Emails::no_stock()` — `:1098`, `wp_mail()` at `:1128` - `WC_Emails::backorder()` — `:1185`, `wp_mail()` at `:1214` - Email-editor preview send — `packages/email-editor/src/Engine/class-send-preview-email.php:173` The 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. ## 1.5 `DeferredEmailQueue` — new in 10.8.0 `src/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: ```php \WC()->queue()->add( 'woocommerce_send_queued_transactional_email', array( $filter, $args ), 'woocommerce-emails' ); ``` - AS hook: `woocommerce_send_queued_transactional_email` - AS group: `woocommerce-emails` - Object args (`WC_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. ## 1.6 `EmailLogger` — new in 10.9.0 `src/Internal/Email/EmailLogger.php`. Registers (`:45-49`): ```php 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_sent` fires immediately after the mail callback returns, using that return value as `$success`. Short-circuit `wp_mail()` and return `true`, and `EmailLogger` records "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. ## 1.7 Relevant options `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}`. --- # Part 2 — Order statuses ## 2.1 Two enums, two spellings 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`). ## 2.2 Registration `WC_Post_Types::register_post_status()` — `includes/class-wc-post-types.php:594`, hooked to `init` priority **9**: ```php $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: 1. `register_post_status( 'wc-my-status', [...] )` on `init` — makes it a real post status. (Or filter `woocommerce_register_shop_order_post_statuses`.) 2. `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`): ```php 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`). ## 2.3 Transition mechanics `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: ```php 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: 1. `woocommerce_order_status_completed` — `( $order_id, $order, $status_transition )` 2. `woocommerce_order_status_processing_to_completed` — `( $order_id, $order )` 3. `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. ## 2.4 Status helpers `includes/wc-order-functions.php`: - `wc_is_order_status( $maybe_status )` — `:123`, accepts the `wc-` prefixed form - `wc_get_is_paid_statuses()` — `:134`, defaults to `[processing, completed]`, filter `woocommerce_order_is_paid_statuses` - `wc_get_is_pending_statuses()` — `:151`, defaults to `[pending]`, filter `woocommerce_order_is_pending_statuses` - `wc_get_order_status_name( $status )` — `:169`, tolerates either spelling --- # Part 3 — Hook reference ## 3.1 Email hooks — `WC_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)` — `:1235` - `woocommerce_mail_content` — `(string $message)` — `:1233`, after inline styling - `woocommerce_mail_style_inline_callback` — `:900` - `woocommerce_email_sent` — action, `(bool $return, string $id, WC_Email $email)` — `:1253` - `woocommerce_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)` — `:718` - `woocommerce_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` — `:788` - `woocommerce_email_styles` — `:937` - `woocommerce_emogrifier` — action, `:953` - `woocommerce_email_subject_{$id}` — `:566` - `woocommerce_email_heading_{$id}` — `:613` - `woocommerce_email_preheader` — `:591` - `woocommerce_email_additional_content_{$id}` — `:548` - `woocommerce_email_format_string`, `..._find`, `..._replace` — `:402`, `:410` (placeholder substitution) **Addressing** - `woocommerce_email_from_address` — `(string $address, WC_Email $email, string $from_email)` — `:1056` - `woocommerce_email_from_name` — `:1045` - `woocommerce_email_recipient_{$id}` — `:631` - `woocommerce_email_cc_recipient_{$id}` — `:651` - `woocommerce_email_bcc_recipient_{$id}` — `:672` - `woocommerce_email_reply_to_enabled` / `..._address` / `..._name` — `:1073`, `:1111`, `:1092` **Config / misc** - `woocommerce_email_enabled_{$id}` — `(bool $enabled, $object, WC_Email $email)` — `:827` - `woocommerce_email_get_option`, `woocommerce_email_title`, `woocommerce_email_description` — `:818`, `:797`, `:806` - `woocommerce_email_groups`, `woocommerce_email_group_title` — `:476`, `:497` - `woocommerce_allow_switching_email_locale` / `..._restoring_...` / `woocommerce_email_setup_locale` / `woocommerce_email_restore_locale` — `:426`, `:446`, `:428`, `:448` - `woocommerce_is_email_preview` — `:743` - `woocommerce_template_directory`, `woocommerce_locate_core_template` — `:1458`, `:1475` - `woocommerce_email_settings_before` / `..._after` — actions, `:1582`, `:1595` ## 3.2 Email hooks — `WC_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)` — `:183` - `woocommerce_email_header` / `woocommerce_email_footer` — actions, `:465`, `:475` - `woocommerce_email_order_meta_fields` / `..._meta_keys` — `:641`, `:650` - `woocommerce_email_customer_details_fields` — `:792` - Stock alerts: `woocommerce_should_send_low_stock_notification` `:1019`, `woocommerce_should_send_no_stock_notification` `:1110`; and `woocommerce_email_{recipient,subject,content}_{low_stock,no_stock,backorder}` ## 3.3 Logging hooks — `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)` ## 3.4 Order status hooks - `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_statuses` ## 3.5 Admin order-list search and filtering HPOS and the legacy CPT store use different hooks. Anything touching the Orders screen must handle both. - **HPOS**: `woocommerce_order_table_search_query_meta_keys` — `src/Internal/DataStores/Orders/OrdersTableSearchQuery.php:446` - **HPOS**: `woocommerce_shop_order_search_fields` — same file, `:439` - **Legacy CPT**: `woocommerce_shop_order_search_fields` — `includes/data-stores/class-wc-order-data-store-cpt.php:590` - **HPOS list-table query args**: `woocommerce_order_list_table_prepare_items_query_args` — `src/Internal/Admin/Orders/ListTable.php:418` - Bulk actions: `bulk_actions-woocommerce_page_wc-orders` (HPOS) vs `bulk_actions-edit-shop_order` (legacy); handlers on `handle_bulk_actions-…` respectively. --- # Part 4 — Notes for `studiou-wc-mail-queue` Cross-references into `docs/plans/implement-plan-00.md`: 1. **Context tagging** uses `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). 2. **From address** must be resolved at enqueue time — the filters are detached the instant `wp_mail()` returns (§1.1). 3. **`$headers` is a string** for WooCommerce emails and never contains `From:` (§1.1). 4. **Attachments are file paths** supplied by third parties via `woocommerce_email_attachments` (§3.1) and may be temp files. 5. **`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. 6. **"Deferred emails" must be off** (§1.5). It is not a rate limiter. 7. **Stock alerts can never be queued** (§1.4) — correct behaviour, and they never appear in the settings selector. 8. **Build the handled-types selector from `WC()->mailer()->get_emails()`** (§1.3), never a hardcoded list. `customer_reset_password` is in there — never default it on. 9. **Read `$email->id` per call, never cache it** — the refund email mutates its own `id` (§1.1).