woocommerce-emailing.md 28 KB

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:

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:

$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).

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_orderWC_Email_Customer_Processing_Orderwoocommerce_order_status_{pending,failed,cancelled,on-hold}_to_processing_notification
  • customer_completed_orderWC_Email_Customer_Completed_Orderwoocommerce_order_status_completed_notification
  • customer_on_hold_orderWC_Email_Customer_On_Hold_Orderwoocommerce_order_status_{pending,failed,cancelled}_to_on-hold_notification
  • customer_cancelled_orderWC_Email_Customer_Cancelled_Orderwoocommerce_order_status_{processing,on-hold}_to_cancelled_notification
  • customer_failed_orderWC_Email_Customer_Failed_Orderwoocommerce_order_status_failed_notification
  • customer_refunded_orderWC_Email_Customer_Refunded_Orderwoocommerce_order_fully_refunded_notification (trigger_full) and woocommerce_order_partially_refunded_notification (trigger_partial)
  • customer_partially_refunded_orderWC_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_invoiceWC_Email_Customer_Invoicemanual 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_accountWC_Email_Customer_New_Accountwoocommerce_created_customer_notification, routed through WC_Emails::customer_new_account() (class-wc-emails.php:262, :519)
  • customer_noteWC_Email_Customer_Notewoocommerce_new_customer_note_notification
  • customer_reset_passwordWC_Email_Customer_Reset_Passwordwoocommerce_reset_password_notification
  • customer_review_requestWC_Email_Customer_Review_Requestwoocommerce_send_review_request_notification (behind the customer_review_request feature)

Customer-facing, fulfillment (new)

  • customer_fulfillment_createdwoocommerce_fulfillment_created_notification
  • customer_fulfillment_updatedwoocommerce_fulfillment_updated_notification
  • customer_fulfillment_deletedwoocommerce_fulfillment_deleted_notification

Customer-facing, POS (new)

  • customer_pos_completed_orderwoocommerce_order_status_completed_notification via auto_trigger(), plus woocommerce_rest_order_actions_email_send
  • customer_pos_refunded_orderwoocommerce_order_{fully,partially}_refunded_notification via auto_trigger(), plus woocommerce_rest_order_actions_email_send

Admin-facing

  • new_orderWC_Email_New_Order — nine hooks: woocommerce_order_status_{pending,failed,cancelled}_to_{processing,completed,on-hold}_notification
  • cancelled_orderWC_Email_Cancelled_Orderwoocommerce_order_status_{processing,on-hold}_to_cancelled_notification
  • failed_orderWC_Email_Failed_Orderwoocommerce_order_status_{pending,on-hold}_to_failed_notification
  • admin_payment_gateway_enabledwoocommerce_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:

\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):

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_enabledapply_filters(..., true, $email_id, $email). Return false to skip logging entirely.
  • woocommerce_email_log_add_order_noteapply_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.phpunprefixed, 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.phpwc- 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:

$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):

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:

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 pointremove_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_keyssrc/Internal/DataStores/Orders/OrdersTableSearchQuery.php:446
  • HPOS: woocommerce_shop_order_search_fields — same file, :439
  • Legacy CPT: woocommerce_shop_order_search_fieldsincludes/data-stores/class-wc-order-data-store-cpt.php:590
  • HPOS list-table query args: woocommerce_order_list_table_prepare_items_query_argssrc/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).