Commit 5e7c5748717 for woocommerce
commit 5e7c57487171c6988c0a282b26f2e691fc3e2a2c
Author: Tom Cafferkey <tjcafferkey@gmail.com>
Date: Fri Sep 11 09:49:19 2026 +0100
Add configurable order withdrawal emails (#67799)
* Add configurable order withdrawal emails
* Add changelog entry for order withdrawal emails
* Update versions and remove redundant functions
* Return to public method
* Ensure order withdrawal emails are available for direct processing
* Normalize field data consistently
* Move order withdrawal customizable emails
* Add email preview data
* Fix orderwithdrawal merge conflict issue
* Remove if condition
* Remove accidental inclusion of email WC_Email_Customer_Abandoned_Cart_Recovery
* Fix tests
diff --git a/plugins/woocommerce/changelog/add-order-withdrawal-emails b/plugins/woocommerce/changelog/add-order-withdrawal-emails
new file mode 100644
index 00000000000..6dec8a1a602
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-order-withdrawal-emails
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add configurable customer and merchant emails for order withdrawal requests.
diff --git a/plugins/woocommerce/includes/class-wc-emails.php b/plugins/woocommerce/includes/class-wc-emails.php
index 88466c05046..c855da5dc0e 100644
--- a/plugins/woocommerce/includes/class-wc-emails.php
+++ b/plugins/woocommerce/includes/class-wc-emails.php
@@ -309,6 +309,10 @@ class WC_Emails {
if ( FeaturesUtil::feature_is_enabled( 'customer_review_request' ) ) {
$emails['WC_Email_Customer_Review_Request'] = __DIR__ . '/emails/class-wc-email-customer-review-request.php';
}
+ if ( FeaturesUtil::feature_is_enabled( 'order_withdrawal' ) ) {
+ $emails['WC_Email_Customer_Order_Withdrawal_Requested'] = __DIR__ . '/emails/class-wc-email-customer-order-withdrawal-requested.php';
+ $emails['WC_Email_Order_Withdrawal_Requested'] = __DIR__ . '/emails/class-wc-email-order-withdrawal-requested.php';
+ }
// Prime caches to reduce future queries.
wp_prime_option_caches(
diff --git a/plugins/woocommerce/includes/emails/class-wc-email-customer-order-withdrawal-requested.php b/plugins/woocommerce/includes/emails/class-wc-email-customer-order-withdrawal-requested.php
new file mode 100644
index 00000000000..59e5bd058ad
--- /dev/null
+++ b/plugins/woocommerce/includes/emails/class-wc-email-customer-order-withdrawal-requested.php
@@ -0,0 +1,166 @@
+<?php
+/**
+ * Class WC_Email_Customer_Order_Withdrawal_Requested file.
+ *
+ * @package WooCommerce\Emails
+ */
+
+use Automattic\WooCommerce\Internal\OrderWithdrawal\Emails\OrderWithdrawalEmailDataFormatter;
+use Automattic\WooCommerce\Internal\OrderWithdrawal\OrderWithdrawalFormProcessor;
+
+defined( 'ABSPATH' ) || exit;
+
+if ( ! class_exists( 'WC_Email_Customer_Order_Withdrawal_Requested', false ) ) :
+
+ /**
+ * Customer order withdrawal request email.
+ *
+ * @class WC_Email_Customer_Order_Withdrawal_Requested
+ * @package WooCommerce\Classes\Emails
+ */
+ class WC_Email_Customer_Order_Withdrawal_Requested extends WC_Email {
+
+ /**
+ * Form data submitted by the customer.
+ *
+ * @var array<string,string>
+ */
+ public array $withdrawal_data = array();
+
+ /**
+ * Submission timestamp.
+ *
+ * @var int
+ */
+ public int $submitted_at = 0;
+
+ /**
+ * Data formatter.
+ *
+ * @var OrderWithdrawalEmailDataFormatter
+ */
+ private OrderWithdrawalEmailDataFormatter $formatter;
+
+ /**
+ * Constructor.
+ */
+ public function __construct() {
+ $this->id = 'customer_order_withdrawal_requested';
+ $this->customer_email = true;
+ $this->title = __( 'Order withdrawal request received', 'woocommerce' );
+ $this->description = __( 'Sent to customers when their order withdrawal request is received.', 'woocommerce' );
+ $this->email_group = 'order-changes';
+ $this->template_html = 'emails/customer-order-withdrawal-requested.php';
+ $this->template_plain = 'emails/plain/customer-order-withdrawal-requested.php';
+ $this->placeholders = array(
+ '{order_number}' => '',
+ '{order_billing_name}' => '',
+ );
+ $this->formatter = new OrderWithdrawalEmailDataFormatter();
+
+ parent::__construct();
+ }
+
+ /**
+ * Get email subject.
+ *
+ * @return string
+ */
+ public function get_default_subject() {
+ return __( 'We received your withdrawal request', 'woocommerce' );
+ }
+
+ /**
+ * Get email heading.
+ *
+ * @return string
+ */
+ public function get_default_heading() {
+ return __( 'We received your withdrawal request', 'woocommerce' );
+ }
+
+ /**
+ * Default content to show below main email content.
+ *
+ * @return string
+ */
+ public function get_default_additional_content() {
+ return __( 'We will review your request and contact you about next steps, including any refund due.', 'woocommerce' );
+ }
+
+ /**
+ * Trigger the sending of this email.
+ *
+ * @param array<string,mixed> $data Form data.
+ * @param int $submitted_at Unix timestamp for the submission.
+ * @return bool Whether the email was sent successfully.
+ */
+ public function trigger( array $data, int $submitted_at ): bool {
+ $this->setup_locale();
+
+ $data = $this->formatter->normalize_withdrawal_data( $data );
+
+ if ( null === $data ) {
+ $this->restore_locale();
+
+ return false;
+ }
+
+ $this->object = (object) $data;
+ $this->withdrawal_data = $data;
+ $this->submitted_at = $submitted_at;
+ $this->recipient = $data[ OrderWithdrawalFormProcessor::FIELD_EMAIL ];
+ $this->placeholders['{order_number}'] = $data[ OrderWithdrawalFormProcessor::FIELD_ORDER_NUMBER ];
+ $this->placeholders['{order_billing_name}'] = $this->formatter->get_customer_name( $data );
+
+ $sent = $this->send_notification();
+
+ $this->restore_locale();
+
+ return $sent;
+ }
+
+ /**
+ * Get content html.
+ *
+ * @return string
+ */
+ public function get_content_html() {
+ return wc_get_template_html(
+ $this->template_html,
+ array(
+ 'email_heading' => $this->get_heading(),
+ 'additional_content' => $this->get_additional_content(),
+ 'withdrawal_data' => $this->withdrawal_data,
+ 'detail_rows' => $this->formatter->get_detail_rows( $this->withdrawal_data, $this->submitted_at ),
+ 'sent_to_admin' => false,
+ 'plain_text' => false,
+ 'email' => $this,
+ )
+ );
+ }
+
+ /**
+ * Get content plain.
+ *
+ * @return string
+ */
+ public function get_content_plain() {
+ return wc_get_template_html(
+ $this->template_plain,
+ array(
+ 'email_heading' => $this->get_heading(),
+ 'additional_content' => $this->get_additional_content(),
+ 'withdrawal_data' => $this->withdrawal_data,
+ 'detail_rows' => $this->formatter->get_detail_rows( $this->withdrawal_data, $this->submitted_at ),
+ 'sent_to_admin' => false,
+ 'plain_text' => true,
+ 'email' => $this,
+ )
+ );
+ }
+ }
+
+endif;
+
+return new WC_Email_Customer_Order_Withdrawal_Requested();
diff --git a/plugins/woocommerce/includes/emails/class-wc-email-order-withdrawal-requested.php b/plugins/woocommerce/includes/emails/class-wc-email-order-withdrawal-requested.php
new file mode 100644
index 00000000000..769aee16b06
--- /dev/null
+++ b/plugins/woocommerce/includes/emails/class-wc-email-order-withdrawal-requested.php
@@ -0,0 +1,306 @@
+<?php
+/**
+ * Class WC_Email_Order_Withdrawal_Requested file.
+ *
+ * @package WooCommerce\Emails
+ */
+
+use Automattic\WooCommerce\Internal\OrderWithdrawal\Emails\OrderWithdrawalEmailDataFormatter;
+use Automattic\WooCommerce\Internal\OrderWithdrawal\OrderWithdrawalFormProcessor;
+use Automattic\WooCommerce\Utilities\FeaturesUtil;
+
+defined( 'ABSPATH' ) || exit;
+
+if ( ! class_exists( 'WC_Email_Order_Withdrawal_Requested', false ) ) :
+
+ /**
+ * Merchant order withdrawal request email.
+ *
+ * @class WC_Email_Order_Withdrawal_Requested
+ * @package WooCommerce\Classes\Emails
+ */
+ class WC_Email_Order_Withdrawal_Requested extends WC_Email {
+
+ /**
+ * Form data submitted by the customer.
+ *
+ * @var array<string,string>
+ */
+ public array $withdrawal_data = array();
+
+ /**
+ * Matched order, if found.
+ *
+ * @var WC_Order|null
+ */
+ public ?WC_Order $matched_order = null;
+
+ /**
+ * Submission timestamp.
+ *
+ * @var int
+ */
+ public int $submitted_at = 0;
+
+ /**
+ * Whether the matched order is outside the valid withdrawal window.
+ *
+ * @var bool
+ */
+ public bool $outside_withdrawal_window = false;
+
+ /**
+ * Withdrawal window warning message.
+ *
+ * @var string
+ */
+ public string $withdrawal_window_warning = '';
+
+ /**
+ * Data formatter.
+ *
+ * @var OrderWithdrawalEmailDataFormatter
+ */
+ private OrderWithdrawalEmailDataFormatter $formatter;
+
+ /**
+ * Constructor.
+ */
+ public function __construct() {
+ $this->id = 'order_withdrawal_requested';
+ $this->title = __( 'Order withdrawal request', 'woocommerce' );
+ $this->description = __( 'Sent to chosen recipients when a customer submits an order withdrawal request.', 'woocommerce' );
+ $this->email_group = 'orders';
+ $this->template_html = 'emails/admin-order-withdrawal-requested.php';
+ $this->template_plain = 'emails/plain/admin-order-withdrawal-requested.php';
+ $this->placeholders = array(
+ '{order_number}' => '',
+ '{order_billing_name}' => '',
+ );
+ $this->formatter = new OrderWithdrawalEmailDataFormatter();
+
+ parent::__construct();
+
+ $this->recipient = $this->get_option( 'recipient', get_option( 'admin_email' ) );
+ }
+
+ /**
+ * Get email subject.
+ *
+ * @return string
+ */
+ public function get_default_subject() {
+ return __( '[{site_title}]: Order withdrawal request for order {order_number}', 'woocommerce' );
+ }
+
+ /**
+ * Get email heading.
+ *
+ * @return string
+ */
+ public function get_default_heading() {
+ return __( 'Order withdrawal request received', 'woocommerce' );
+ }
+
+ /**
+ * Default content to show below main email content.
+ *
+ * @return string
+ */
+ public function get_default_additional_content() {
+ return __( 'Review the request details and contact the customer about next steps.', 'woocommerce' );
+ }
+
+ /**
+ * Trigger the sending of this email.
+ *
+ * @param array<string,mixed> $data Form data.
+ * @param WC_Order|null $matched_order Matched order, if found.
+ * @param int $submitted_at Unix timestamp for the submission.
+ * @param bool $outside_withdrawal_window Whether the matched order is outside the withdrawal window.
+ * @param string $withdrawal_window_warning Withdrawal window warning message.
+ * @return bool Whether the email was sent successfully.
+ */
+ public function trigger( array $data, ?WC_Order $matched_order, int $submitted_at, bool $outside_withdrawal_window, string $withdrawal_window_warning ): bool {
+ $this->setup_locale();
+
+ $data = $this->formatter->normalize_withdrawal_data( $data );
+
+ if ( null === $data ) {
+ $this->restore_locale();
+
+ return false;
+ }
+
+ $this->object = $matched_order ? $matched_order : (object) $data;
+ $this->withdrawal_data = $data;
+ $this->matched_order = $matched_order;
+ $this->submitted_at = $submitted_at;
+ $this->outside_withdrawal_window = $outside_withdrawal_window;
+ $this->withdrawal_window_warning = $withdrawal_window_warning;
+ $this->placeholders['{order_number}'] = $data[ OrderWithdrawalFormProcessor::FIELD_ORDER_NUMBER ];
+ $this->placeholders['{order_billing_name}'] = $this->formatter->get_customer_name( $data );
+
+ $sent = $this->send_notification();
+
+ $this->restore_locale();
+
+ return $sent;
+ }
+
+ /**
+ * Get email headers.
+ *
+ * @return string
+ */
+ public function get_headers() {
+ $headers = 'Content-Type: ' . $this->get_content_type() . "\r\n";
+ $name = $this->formatter->get_customer_name( $this->withdrawal_data );
+ $email = $this->withdrawal_data[ OrderWithdrawalFormProcessor::FIELD_EMAIL ] ?? '';
+
+ if ( '' !== $name && is_email( $email ) ) {
+ $headers .= 'Reply-to: ' . sanitize_text_field( $name ) . ' <' . sanitize_email( $email ) . ">\r\n";
+ }
+
+ if ( FeaturesUtil::feature_is_enabled( 'email_improvements' ) ) {
+ $cc = $this->get_cc_recipient();
+ if ( ! empty( $cc ) ) {
+ $headers .= 'Cc: ' . sanitize_text_field( $cc ) . "\r\n";
+ }
+
+ $bcc = $this->get_bcc_recipient();
+ if ( ! empty( $bcc ) ) {
+ $headers .= 'Bcc: ' . sanitize_text_field( $bcc ) . "\r\n";
+ }
+ }
+
+ /**
+ * Filter the email headers.
+ *
+ * @since 2.0.0
+ * @param string $headers Email headers.
+ * @param string $email_id Email ID.
+ * @param object|bool $object Email object.
+ * @param WC_Email $email Email instance.
+ */
+ return apply_filters( 'woocommerce_email_headers', $headers, $this->id, $this->object, $this );
+ }
+
+ /**
+ * Get content html.
+ *
+ * @return string
+ */
+ public function get_content_html() {
+ return wc_get_template_html(
+ $this->template_html,
+ array(
+ 'email_heading' => $this->get_heading(),
+ 'additional_content' => $this->get_additional_content(),
+ 'withdrawal_data' => $this->withdrawal_data,
+ 'detail_rows' => $this->formatter->get_detail_rows( $this->withdrawal_data, $this->submitted_at ),
+ 'matched_order' => $this->matched_order,
+ 'outside_withdrawal_window' => $this->outside_withdrawal_window,
+ 'withdrawal_window_warning' => $this->withdrawal_window_warning,
+ 'sent_to_admin' => true,
+ 'plain_text' => false,
+ 'email' => $this,
+ )
+ );
+ }
+
+ /**
+ * Get content plain.
+ *
+ * @return string
+ */
+ public function get_content_plain() {
+ return wc_get_template_html(
+ $this->template_plain,
+ array(
+ 'email_heading' => $this->get_heading(),
+ 'additional_content' => $this->get_additional_content(),
+ 'withdrawal_data' => $this->withdrawal_data,
+ 'detail_rows' => $this->formatter->get_detail_rows( $this->withdrawal_data, $this->submitted_at ),
+ 'matched_order' => $this->matched_order,
+ 'outside_withdrawal_window' => $this->outside_withdrawal_window,
+ 'withdrawal_window_warning' => $this->withdrawal_window_warning,
+ 'sent_to_admin' => true,
+ 'plain_text' => true,
+ 'email' => $this,
+ )
+ );
+ }
+
+ /**
+ * Initialise settings form fields.
+ */
+ public function init_form_fields(): void {
+ /* translators: %s: list of placeholders */
+ $placeholder_text = sprintf( __( 'Available placeholders: %s', 'woocommerce' ), '<code>' . esc_html( implode( '</code>, <code>', array_keys( $this->placeholders ) ) ) . '</code>' );
+ $this->form_fields = array(
+ 'enabled' => array(
+ 'title' => __( 'Enable/Disable', 'woocommerce' ),
+ 'type' => 'checkbox',
+ 'label' => __( 'Enable this email notification', 'woocommerce' ),
+ 'default' => 'yes',
+ ),
+ 'recipient' => array(
+ 'title' => __( 'Recipient(s)', 'woocommerce' ),
+ 'type' => 'text',
+ /* translators: %s: WP admin email. */
+ 'description' => sprintf( __( 'Enter recipients (comma separated) for this email. Defaults to %s.', 'woocommerce' ), '<code>' . esc_attr( get_option( 'admin_email' ) ) . '</code>' ),
+ 'placeholder' => '',
+ 'default' => '',
+ 'desc_tip' => true,
+ ),
+ 'subject' => array(
+ 'title' => __( 'Subject', 'woocommerce' ),
+ 'type' => 'text',
+ 'desc_tip' => true,
+ 'description' => $placeholder_text,
+ 'placeholder' => $this->get_default_subject(),
+ 'default' => '',
+ ),
+ 'heading' => array(
+ 'title' => __( 'Email heading', 'woocommerce' ),
+ 'type' => 'text',
+ 'desc_tip' => true,
+ 'description' => $placeholder_text,
+ 'placeholder' => $this->get_default_heading(),
+ 'default' => '',
+ ),
+ 'additional_content' => array(
+ 'title' => __( 'Additional content', 'woocommerce' ),
+ 'description' => __( 'Text to appear below the main email content.', 'woocommerce' ) . ' ' . $placeholder_text,
+ 'css' => 'width:400px; height: 75px;',
+ 'placeholder' => __( 'N/A', 'woocommerce' ),
+ 'type' => 'textarea',
+ 'default' => $this->get_default_additional_content(),
+ 'desc_tip' => true,
+ ),
+ 'email_type' => array(
+ 'title' => __( 'Email type', 'woocommerce' ),
+ 'type' => 'select',
+ 'description' => __( 'Choose which format of email to send.', 'woocommerce' ),
+ 'default' => 'html',
+ 'class' => 'email_type wc-enhanced-select',
+ 'options' => $this->get_email_type_options(),
+ 'desc_tip' => true,
+ ),
+ );
+
+ if ( FeaturesUtil::feature_is_enabled( 'email_improvements' ) ) {
+ $this->form_fields['cc'] = $this->get_cc_field();
+ $this->form_fields['bcc'] = $this->get_bcc_field();
+ }
+
+ if ( $this->block_email_editor_enabled ) {
+ $this->form_fields['preheader'] = $this->get_preheader_field();
+ }
+ }
+ }
+
+endif;
+
+return new WC_Email_Order_Withdrawal_Requested();
diff --git a/plugins/woocommerce/src/Internal/OrderWithdrawal/Emails/OrderWithdrawalEmailDataFormatter.php b/plugins/woocommerce/src/Internal/OrderWithdrawal/Emails/OrderWithdrawalEmailDataFormatter.php
new file mode 100644
index 00000000000..02b4eca5a78
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/OrderWithdrawal/Emails/OrderWithdrawalEmailDataFormatter.php
@@ -0,0 +1,111 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\OrderWithdrawal\Emails;
+
+use Automattic\WooCommerce\Internal\OrderWithdrawal\OrderWithdrawalFormProcessor;
+
+/**
+ * Formats order withdrawal email data for templates.
+ *
+ * @internal Just for internal use.
+ */
+final class OrderWithdrawalEmailDataFormatter {
+
+ /**
+ * Normalize and validate submitted withdrawal data before rendering emails.
+ *
+ * @param array<string,mixed> $data Form data.
+ * @return array<string,string>|null
+ */
+ public function normalize_withdrawal_data( array $data ): ?array {
+ $required_fields = array(
+ OrderWithdrawalFormProcessor::FIELD_FIRST_NAME,
+ OrderWithdrawalFormProcessor::FIELD_LAST_NAME,
+ OrderWithdrawalFormProcessor::FIELD_EMAIL,
+ OrderWithdrawalFormProcessor::FIELD_ORDER_NUMBER,
+ OrderWithdrawalFormProcessor::FIELD_WITHDRAWAL_TYPE,
+ OrderWithdrawalFormProcessor::FIELD_ADDITIONAL_DETAILS,
+ );
+
+ foreach ( $required_fields as $field ) {
+ if ( ! isset( $data[ $field ] ) || ! is_scalar( $data[ $field ] ) ) {
+ return null;
+ }
+ }
+
+ $normalized_data = array(
+ OrderWithdrawalFormProcessor::FIELD_FIRST_NAME => sanitize_text_field( (string) $data[ OrderWithdrawalFormProcessor::FIELD_FIRST_NAME ] ),
+ OrderWithdrawalFormProcessor::FIELD_LAST_NAME => sanitize_text_field( (string) $data[ OrderWithdrawalFormProcessor::FIELD_LAST_NAME ] ),
+ OrderWithdrawalFormProcessor::FIELD_EMAIL => sanitize_email( (string) $data[ OrderWithdrawalFormProcessor::FIELD_EMAIL ] ),
+ OrderWithdrawalFormProcessor::FIELD_ORDER_NUMBER => sanitize_text_field( (string) $data[ OrderWithdrawalFormProcessor::FIELD_ORDER_NUMBER ] ),
+ OrderWithdrawalFormProcessor::FIELD_WITHDRAWAL_TYPE => sanitize_text_field( (string) $data[ OrderWithdrawalFormProcessor::FIELD_WITHDRAWAL_TYPE ] ),
+ OrderWithdrawalFormProcessor::FIELD_ADDITIONAL_DETAILS => sanitize_textarea_field( (string) $data[ OrderWithdrawalFormProcessor::FIELD_ADDITIONAL_DETAILS ] ),
+ );
+
+ if ( '' === $normalized_data[ OrderWithdrawalFormProcessor::FIELD_EMAIL ] || ! is_email( $normalized_data[ OrderWithdrawalFormProcessor::FIELD_EMAIL ] ) ) {
+ return null;
+ }
+
+ if ( '' === $normalized_data[ OrderWithdrawalFormProcessor::FIELD_ORDER_NUMBER ] ) {
+ return null;
+ }
+
+ if ( ! in_array( $normalized_data[ OrderWithdrawalFormProcessor::FIELD_WITHDRAWAL_TYPE ], array( OrderWithdrawalFormProcessor::WITHDRAWAL_TYPE_FULL, OrderWithdrawalFormProcessor::WITHDRAWAL_TYPE_SPECIFIC ), true ) ) {
+ return null;
+ }
+
+ return $normalized_data;
+ }
+
+ /**
+ * Get the customer's full name for display.
+ *
+ * @param array<string,string> $data Form data.
+ */
+ public function get_customer_name( array $data ): string {
+ return trim( ( $data[ OrderWithdrawalFormProcessor::FIELD_FIRST_NAME ] ?? '' ) . ' ' . ( $data[ OrderWithdrawalFormProcessor::FIELD_LAST_NAME ] ?? '' ) );
+ }
+
+ /**
+ * Get the label for a withdrawal type value.
+ *
+ * @param string $withdrawal_type Withdrawal type value.
+ */
+ public function get_withdrawal_type_label( string $withdrawal_type ): string {
+ $options = array(
+ OrderWithdrawalFormProcessor::WITHDRAWAL_TYPE_FULL => __( 'The full order', 'woocommerce' ),
+ OrderWithdrawalFormProcessor::WITHDRAWAL_TYPE_SPECIFIC => __( 'Specific items only', 'woocommerce' ),
+ );
+
+ return $options[ $withdrawal_type ] ?? '';
+ }
+
+ /**
+ * Get order withdrawal detail rows for email templates.
+ *
+ * @param array<string,string> $data Form data.
+ * @param int $submitted_at Unix timestamp for the submission.
+ * @return array<string,string>
+ */
+ public function get_detail_rows( array $data, int $submitted_at ): array {
+ $date_format = (string) get_option( 'date_format' );
+ $time_format = (string) get_option( 'time_format' );
+ $additional_details = $data[ OrderWithdrawalFormProcessor::FIELD_ADDITIONAL_DETAILS ] ?? '';
+ $additional_details = '' === $additional_details ? __( 'None provided', 'woocommerce' ) : $additional_details;
+ $submitted_at_text = wp_date( trim( $date_format . ' ' . $time_format ), $submitted_at );
+
+ if ( false === $submitted_at_text ) {
+ $submitted_at_text = '';
+ }
+
+ return array(
+ __( 'Submitted', 'woocommerce' ) => $submitted_at_text,
+ __( 'Name', 'woocommerce' ) => $this->get_customer_name( $data ),
+ __( 'Email address', 'woocommerce' ) => $data[ OrderWithdrawalFormProcessor::FIELD_EMAIL ] ?? '',
+ __( 'Order number', 'woocommerce' ) => $data[ OrderWithdrawalFormProcessor::FIELD_ORDER_NUMBER ] ?? '',
+ __( 'Withdrawing', 'woocommerce' ) => $this->get_withdrawal_type_label( $data[ OrderWithdrawalFormProcessor::FIELD_WITHDRAWAL_TYPE ] ?? '' ),
+ __( 'Additional details', 'woocommerce' ) => $additional_details,
+ );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/OrderWithdrawal/Emails/OrderWithdrawalEmailPreview.php b/plugins/woocommerce/src/Internal/OrderWithdrawal/Emails/OrderWithdrawalEmailPreview.php
new file mode 100644
index 00000000000..94d0d8cfba5
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/OrderWithdrawal/Emails/OrderWithdrawalEmailPreview.php
@@ -0,0 +1,69 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\OrderWithdrawal\Emails;
+
+use Automattic\WooCommerce\Internal\OrderWithdrawal\OrderWithdrawalFormProcessor;
+use Automattic\WooCommerce\Internal\RegisterHooksInterface;
+use WC_Email_Customer_Order_Withdrawal_Requested;
+use WC_Email_Order_Withdrawal_Requested;
+use WC_Order;
+
+/**
+ * Prepares order withdrawal emails for preview.
+ *
+ * @internal Just for internal use.
+ */
+final class OrderWithdrawalEmailPreview implements RegisterHooksInterface {
+
+ /**
+ * Register email preview hooks.
+ *
+ * @since 11.2.0
+ */
+ public function register(): void {
+ add_filter( 'woocommerce_prepare_email_for_preview', array( $this, 'prepare_email_for_preview' ), 10, 1 );
+ }
+
+ /**
+ * Populate order withdrawal data for email previews.
+ *
+ * @param mixed $email Email being prepared for preview.
+ * @return mixed
+ *
+ * @since 11.2.0
+ */
+ public function prepare_email_for_preview( $email ) {
+ if (
+ (
+ ! $email instanceof WC_Email_Customer_Order_Withdrawal_Requested
+ && ! $email instanceof WC_Email_Order_Withdrawal_Requested
+ )
+ || ! $email->object instanceof WC_Order
+ ) {
+ return $email;
+ }
+
+ $order = $email->object;
+ $order_date = $order->get_date_created();
+ $email->withdrawal_data = array(
+ OrderWithdrawalFormProcessor::FIELD_FIRST_NAME => $order->get_billing_first_name(),
+ OrderWithdrawalFormProcessor::FIELD_LAST_NAME => $order->get_billing_last_name(),
+ OrderWithdrawalFormProcessor::FIELD_EMAIL => $order->get_billing_email(),
+ OrderWithdrawalFormProcessor::FIELD_ORDER_NUMBER => $order->get_order_number(),
+ OrderWithdrawalFormProcessor::FIELD_WITHDRAWAL_TYPE => OrderWithdrawalFormProcessor::WITHDRAWAL_TYPE_SPECIFIC,
+ OrderWithdrawalFormProcessor::FIELD_ADDITIONAL_DETAILS => __( 'I would like to withdraw the first item from this order.', 'woocommerce' ),
+ );
+ $email->submitted_at = $order_date ? $order_date->getTimestamp() : time();
+ $email->placeholders['{order_number}'] = $order->get_order_number();
+ $email->placeholders['{order_billing_name}'] = $order->get_formatted_billing_full_name();
+
+ if ( $email instanceof WC_Email_Order_Withdrawal_Requested ) {
+ $email->matched_order = $order;
+ $email->outside_withdrawal_window = false;
+ $email->withdrawal_window_warning = '';
+ }
+
+ return $email;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/OrderWithdrawal/OrderWithdrawalController.php b/plugins/woocommerce/src/Internal/OrderWithdrawal/OrderWithdrawalController.php
index 74144d58f2b..8cdab00f4ea 100644
--- a/plugins/woocommerce/src/Internal/OrderWithdrawal/OrderWithdrawalController.php
+++ b/plugins/woocommerce/src/Internal/OrderWithdrawal/OrderWithdrawalController.php
@@ -4,6 +4,7 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Internal\OrderWithdrawal;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
+use Automattic\WooCommerce\Internal\OrderWithdrawal\Emails\OrderWithdrawalEmailPreview;
use Automattic\WooCommerce\Internal\RegisterHooksInterface;
use Automattic\WooCommerce\Utilities\FeaturesUtil;
@@ -40,20 +41,29 @@ final class OrderWithdrawalController implements RegisterHooksInterface {
*/
private OrderWithdrawalFeatureHighlightNotification $feature_highlight_notification;
+ /**
+ * Email preview handler.
+ *
+ * @var OrderWithdrawalEmailPreview
+ */
+ private OrderWithdrawalEmailPreview $email_preview;
+
/**
* Initialize dependencies.
*
* @param OrderWithdrawalFormProcessor $form_processor Form processor.
* @param OrderWithdrawalFormView $form_view Form view.
* @param OrderWithdrawalFeatureHighlightNotification $feature_highlight_notification Feature highlight notification.
+ * @param OrderWithdrawalEmailPreview $email_preview Email preview handler.
* @internal
*
* @since 11.1.0
*/
- final public function init( OrderWithdrawalFormProcessor $form_processor, OrderWithdrawalFormView $form_view, OrderWithdrawalFeatureHighlightNotification $feature_highlight_notification ): void { // phpcs:ignore Generic.CodeAnalysis.UnnecessaryFinalModifier.Found -- Required by WooCommerce injection method rules.
+ final public function init( OrderWithdrawalFormProcessor $form_processor, OrderWithdrawalFormView $form_view, OrderWithdrawalFeatureHighlightNotification $feature_highlight_notification, OrderWithdrawalEmailPreview $email_preview ): void { // phpcs:ignore Generic.CodeAnalysis.UnnecessaryFinalModifier.Found -- Required by WooCommerce injection method rules.
$this->form_processor = $form_processor;
$this->form_view = $form_view;
$this->feature_highlight_notification = $feature_highlight_notification;
+ $this->email_preview = $email_preview;
}
/**
@@ -85,6 +95,8 @@ final class OrderWithdrawalController implements RegisterHooksInterface {
add_filter( 'woocommerce_endpoint_' . self::ENDPOINT_KEY . '_title', array( $this, 'get_endpoint_title' ), 10, 1 );
add_filter( 'woocommerce_settings_pages', array( $this, 'add_endpoint_setting' ), 10, 1 );
add_action( 'woocommerce_account_' . self::ENDPOINT_KEY . '_endpoint', array( $this, 'render_view' ) );
+
+ $this->email_preview->register();
}
/**
diff --git a/plugins/woocommerce/src/Internal/OrderWithdrawal/OrderWithdrawalFormProcessor.php b/plugins/woocommerce/src/Internal/OrderWithdrawal/OrderWithdrawalFormProcessor.php
index f8d6a61fb89..4e1feb693d4 100644
--- a/plugins/woocommerce/src/Internal/OrderWithdrawal/OrderWithdrawalFormProcessor.php
+++ b/plugins/woocommerce/src/Internal/OrderWithdrawal/OrderWithdrawalFormProcessor.php
@@ -5,9 +5,12 @@ namespace Automattic\WooCommerce\Internal\OrderWithdrawal;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\Notes;
+use Automattic\WooCommerce\Internal\OrderWithdrawal\Emails\OrderWithdrawalEmailDataFormatter;
use Automattic\WooCommerce\Internal\Orders\OrderNoteGroup;
use Automattic\WooCommerce\Utilities\OrderUtil;
use Throwable;
+use WC_Email_Customer_Order_Withdrawal_Requested;
+use WC_Email_Order_Withdrawal_Requested;
use WC_Geolocation;
use WC_Order;
use WC_Rate_Limiter;
@@ -47,6 +50,13 @@ final class OrderWithdrawalFormProcessor {
private const RATE_LIMIT_EMAIL_PREFIX = 'order_withdrawal_email_';
private const RATE_LIMIT_DELAY = MINUTE_IN_SECONDS / 2;
+ /**
+ * Data formatter used by email and note content.
+ *
+ * @var OrderWithdrawalEmailDataFormatter|null
+ */
+ private ?OrderWithdrawalEmailDataFormatter $email_data_formatter = null;
+
/**
* Process the current order withdrawal request.
*
@@ -462,7 +472,7 @@ final class OrderWithdrawalFormProcessor {
$note = sprintf(
/* translators: %s: withdrawal type label. */
__( 'Order withdrawal requested. Withdrawal type: %s.', 'woocommerce' ),
- $this->get_withdrawal_type_label( $data[ self::FIELD_WITHDRAWAL_TYPE ] )
+ $this->get_email_data_formatter()->get_withdrawal_type_label( $data[ self::FIELD_WITHDRAWAL_TYPE ] ?? '' )
);
try {
@@ -634,17 +644,17 @@ final class OrderWithdrawalFormProcessor {
* @param int $submitted_at Unix timestamp for the submission.
*/
private function send_customer_order_withdrawal_email( array $data, int $submitted_at ): bool {
- $subject = __( 'We received your withdrawal request', 'woocommerce' );
- $heading = __( 'We received your withdrawal request', 'woocommerce' );
- $body = '<p>' . esc_html__( 'We have received your request to withdraw from the order below.', 'woocommerce' ) . '</p>';
- $body .= $this->get_email_details_html( $data, $submitted_at );
- $body .= '<p>' . esc_html__( 'We will review your request and contact you about next steps, including any refund due.', 'woocommerce' ) . '</p>';
-
- return wc_mail(
- $data[ self::FIELD_EMAIL ],
- $subject,
- $this->wrap_email_message( $heading, $body )
- );
+ $email = WC()->mailer()->get_emails()['WC_Email_Customer_Order_Withdrawal_Requested'] ?? include WC_ABSPATH . 'includes/emails/class-wc-email-customer-order-withdrawal-requested.php';
+
+ if ( ! $email instanceof WC_Email_Customer_Order_Withdrawal_Requested ) {
+ return false;
+ }
+
+ if ( ! $email->is_enabled() ) {
+ return true;
+ }
+
+ return $email->trigger( $data, $submitted_at );
}
/**
@@ -655,150 +665,34 @@ final class OrderWithdrawalFormProcessor {
* @param int $submitted_at Unix timestamp for the submission.
*/
private function send_merchant_order_withdrawal_email( array $data, ?WC_Order $matched_order, int $submitted_at ): bool {
- $recipient = sanitize_email( (string) get_option( 'admin_email' ) );
+ $email = WC()->mailer()->get_emails()['WC_Email_Order_Withdrawal_Requested'] ?? include WC_ABSPATH . 'includes/emails/class-wc-email-order-withdrawal-requested.php';
- if ( '' === $recipient || ! is_email( $recipient ) ) {
+ if ( ! $email instanceof WC_Email_Order_Withdrawal_Requested ) {
return false;
}
- $subject = sprintf(
- /* translators: %s: order number. */
- __( 'Order withdrawal request for order %s', 'woocommerce' ),
- $data[ self::FIELD_ORDER_NUMBER ]
- );
- $heading = __( 'Order withdrawal request received', 'woocommerce' );
- $body = '<p>' . esc_html__( 'A customer submitted an order withdrawal request.', 'woocommerce' ) . '</p>';
-
- if ( $matched_order instanceof WC_Order ) {
- $body .= '<p>' . esc_html__( 'WooCommerce matched this request to an order and added an order note.', 'woocommerce' ) . '</p>';
- } else {
- $body .= '<p>' . esc_html__( 'WooCommerce could not match this request to an order automatically, so no order note was added.', 'woocommerce' ) . '</p>';
+ if ( ! $email->is_enabled() ) {
+ return true;
}
- $body .= $this->get_email_details_html( $data, $submitted_at );
-
- if ( $matched_order instanceof WC_Order ) {
- $order_url = $matched_order->get_edit_order_url();
-
- if ( $this->is_order_outside_withdrawal_window( $matched_order ) ) {
- $body .= '<p>' . esc_html( $this->get_withdrawal_window_warning_message() ) . '</p>';
- }
-
- $body .= sprintf(
- '<p>%s</p>',
- sprintf(
- /* translators: %d: order ID. */
- esc_html__( 'Matched order ID: %d', 'woocommerce' ),
- $matched_order->get_id()
- )
- );
-
- if ( '' !== $order_url ) {
- $body .= sprintf(
- '<p><a href="%1$s">%2$s</a></p>',
- esc_url( $order_url ),
- esc_html__( 'View matched order', 'woocommerce' )
- );
- }
- }
-
- return wc_mail(
- $recipient,
- $subject,
- $this->wrap_email_message( $heading, $body ),
- $this->get_merchant_email_headers( $data )
+ return $email->trigger(
+ $data,
+ $matched_order,
+ $submitted_at,
+ $matched_order instanceof WC_Order && $this->is_order_outside_withdrawal_window( $matched_order ),
+ $matched_order instanceof WC_Order ? $this->get_withdrawal_window_warning_message() : ''
);
}
/**
- * Get merchant email headers.
- *
- * @param array<string,string> $data Form data.
- * @return string
- */
- private function get_merchant_email_headers( array $data ): string {
- $headers = array( 'Content-Type: text/html; charset=UTF-8' );
- $name = $this->get_customer_name( $data );
- $email = $data[ self::FIELD_EMAIL ];
-
- if ( '' !== $name && is_email( $email ) ) {
- $headers[] = sprintf( 'Reply-To: %1$s <%2$s>', $name, $email );
- }
-
- return implode( "\r\n", $headers );
- }
-
- /**
- * Wrap an email body in the WooCommerce email template.
- *
- * @param string $heading Email heading.
- * @param string $body Email body.
+ * Get the order withdrawal email data formatter.
*/
- private function wrap_email_message( string $heading, string $body ): string {
- return WC()->mailer()->wrap_message( $heading, $body );
- }
-
- /**
- * Get the email details list.
- *
- * @param array<string,string> $data Form data.
- * @param int $submitted_at Unix timestamp for the submission.
- */
- private function get_email_details_html( array $data, int $submitted_at ): string {
- $date_format = (string) get_option( 'date_format' );
- $time_format = (string) get_option( 'time_format' );
- $additional_details = '' === $data[ self::FIELD_ADDITIONAL_DETAILS ] ? __( 'None provided', 'woocommerce' ) : $data[ self::FIELD_ADDITIONAL_DETAILS ];
- $submitted_at_text = wp_date( trim( $date_format . ' ' . $time_format ), $submitted_at );
-
- if ( false === $submitted_at_text ) {
- $submitted_at_text = '';
+ private function get_email_data_formatter(): OrderWithdrawalEmailDataFormatter {
+ if ( null === $this->email_data_formatter ) {
+ $this->email_data_formatter = new OrderWithdrawalEmailDataFormatter();
}
- $rows = array(
- __( 'Submitted', 'woocommerce' ) => $submitted_at_text,
- __( 'Name', 'woocommerce' ) => $this->get_customer_name( $data ),
- __( 'Email address', 'woocommerce' ) => $data[ self::FIELD_EMAIL ],
- __( 'Order number', 'woocommerce' ) => $data[ self::FIELD_ORDER_NUMBER ],
- __( 'Withdrawing', 'woocommerce' ) => $this->get_withdrawal_type_label( $data[ self::FIELD_WITHDRAWAL_TYPE ] ),
- __( 'Additional details', 'woocommerce' ) => $additional_details,
- );
-
- $html = '<ul>';
-
- foreach ( $rows as $label => $value ) {
- $html .= sprintf(
- '<li><strong>%1$s:</strong> %2$s</li>',
- esc_html( $label ),
- nl2br( esc_html( $value ) )
- );
- }
-
- $html .= '</ul>';
-
- return $html;
- }
-
- /**
- * Get the customer's full name for display.
- *
- * @param array<string,string> $data Form data.
- */
- private function get_customer_name( array $data ): string {
- return trim( $data[ self::FIELD_FIRST_NAME ] . ' ' . $data[ self::FIELD_LAST_NAME ] );
- }
-
- /**
- * Get the label for a withdrawal type value.
- *
- * @param string $withdrawal_type Withdrawal type value.
- */
- private function get_withdrawal_type_label( string $withdrawal_type ): string {
- $options = array(
- self::WITHDRAWAL_TYPE_FULL => __( 'The full order', 'woocommerce' ),
- self::WITHDRAWAL_TYPE_SPECIFIC => __( 'Specific items only', 'woocommerce' ),
- );
-
- return $options[ $withdrawal_type ] ?? '';
+ return $this->email_data_formatter;
}
/**
diff --git a/plugins/woocommerce/templates/emails/admin-order-withdrawal-requested.php b/plugins/woocommerce/templates/emails/admin-order-withdrawal-requested.php
new file mode 100644
index 00000000000..28cd2bb7cf5
--- /dev/null
+++ b/plugins/woocommerce/templates/emails/admin-order-withdrawal-requested.php
@@ -0,0 +1,89 @@
+<?php
+/**
+ * Admin order withdrawal request email
+ *
+ * This template can be overridden by copying it to yourtheme/woocommerce/emails/admin-order-withdrawal-requested.php.
+ *
+ * HOWEVER, on occasion WooCommerce will need to update template files and you
+ * (the theme developer) will need to copy the new files to your theme to
+ * maintain compatibility. We try to do this as little as possible, but it does
+ * happen. When this occurs the version of the template file will be bumped and
+ * the readme will list any important changes.
+ *
+ * @see https://woocommerce.com/document/template-structure/
+ * @package WooCommerce\Templates\Emails
+ * @version 11.2.0
+ *
+ * @var string $email_heading Email heading.
+ * @var string $additional_content Additional content below the body.
+ * @var array<string,string> $withdrawal_data Withdrawal request data.
+ * @var array<string,string> $detail_rows Withdrawal request detail rows.
+ * @var \WC_Order|null $matched_order Matched order, if found.
+ * @var bool $outside_withdrawal_window Whether the matched order is outside the withdrawal window.
+ * @var string $withdrawal_window_warning Withdrawal window warning message.
+ * @var bool $sent_to_admin Whether sent to admin.
+ * @var bool $plain_text Whether plain-text variant.
+ * @var \WC_Email $email Email object.
+ */
+
+use Automattic\WooCommerce\Utilities\FeaturesUtil;
+
+defined( 'ABSPATH' ) || exit;
+
+$email_improvements_enabled = FeaturesUtil::feature_is_enabled( 'email_improvements' );
+
+/**
+ * Fires to output the email header.
+ *
+ * @hooked WC_Emails::email_header()
+ * @since 3.7.0
+ */
+do_action( 'woocommerce_email_header', $email_heading, $email ); ?>
+
+<?php echo $email_improvements_enabled ? '<div class="email-introduction">' : ''; ?>
+<p><?php esc_html_e( 'A customer submitted an order withdrawal request.', 'woocommerce' ); ?></p>
+<?php if ( $matched_order instanceof WC_Order ) : ?>
+ <p><?php esc_html_e( 'WooCommerce matched this request to an order and added an order note.', 'woocommerce' ); ?></p>
+<?php else : ?>
+ <p><?php esc_html_e( 'WooCommerce could not match this request to an order automatically, so no order note was added.', 'woocommerce' ); ?></p>
+<?php endif; ?>
+<?php echo $email_improvements_enabled ? '</div>' : ''; ?>
+
+<ul>
+ <?php foreach ( $detail_rows as $label => $value ) : ?>
+ <li><strong><?php echo esc_html( $label ); ?>:</strong> <?php echo nl2br( esc_html( $value ) ); ?></li>
+ <?php endforeach; ?>
+</ul>
+
+<?php if ( $matched_order instanceof WC_Order ) : ?>
+ <?php if ( $outside_withdrawal_window ) : ?>
+ <p><?php echo esc_html( $withdrawal_window_warning ); ?></p>
+ <?php endif; ?>
+ <p>
+ <?php
+ printf(
+ /* translators: %d: order ID. */
+ esc_html__( 'Matched order ID: %d', 'woocommerce' ),
+ absint( $matched_order->get_id() )
+ );
+ ?>
+ </p>
+ <?php if ( '' !== $matched_order->get_edit_order_url() ) : ?>
+ <p><a href="<?php echo esc_url( $matched_order->get_edit_order_url() ); ?>"><?php esc_html_e( 'View matched order', 'woocommerce' ); ?></a></p>
+ <?php endif; ?>
+<?php endif; ?>
+
+<?php
+if ( $additional_content ) {
+ echo $email_improvements_enabled ? '<table border="0" cellpadding="0" cellspacing="0" width="100%" role="presentation"><tr><td class="email-additional-content">' : '';
+ echo wp_kses_post( wpautop( wptexturize( $additional_content ) ) );
+ echo $email_improvements_enabled ? '</td></tr></table>' : '';
+}
+
+/**
+ * Fires to output the email footer.
+ *
+ * @hooked WC_Emails::email_footer()
+ * @since 3.7.0
+ */
+do_action( 'woocommerce_email_footer', $email );
diff --git a/plugins/woocommerce/templates/emails/customer-order-withdrawal-requested.php b/plugins/woocommerce/templates/emails/customer-order-withdrawal-requested.php
new file mode 100644
index 00000000000..68ae0c6ebb5
--- /dev/null
+++ b/plugins/woocommerce/templates/emails/customer-order-withdrawal-requested.php
@@ -0,0 +1,63 @@
+<?php
+/**
+ * Customer order withdrawal request email
+ *
+ * This template can be overridden by copying it to yourtheme/woocommerce/emails/customer-order-withdrawal-requested.php.
+ *
+ * HOWEVER, on occasion WooCommerce will need to update template files and you
+ * (the theme developer) will need to copy the new files to your theme to
+ * maintain compatibility. We try to do this as little as possible, but it does
+ * happen. When this occurs the version of the template file will be bumped and
+ * the readme will list any important changes.
+ *
+ * @see https://woocommerce.com/document/template-structure/
+ * @package WooCommerce\Templates\Emails
+ * @version 11.2.0
+ *
+ * @var string $email_heading Email heading.
+ * @var string $additional_content Additional content below the body.
+ * @var array<string,string> $withdrawal_data Withdrawal request data.
+ * @var array<string,string> $detail_rows Withdrawal request detail rows.
+ * @var bool $sent_to_admin Whether sent to admin.
+ * @var bool $plain_text Whether plain-text variant.
+ * @var \WC_Email $email Email object.
+ */
+
+use Automattic\WooCommerce\Utilities\FeaturesUtil;
+
+defined( 'ABSPATH' ) || exit;
+
+$email_improvements_enabled = FeaturesUtil::feature_is_enabled( 'email_improvements' );
+
+/**
+ * Fires to output the email header.
+ *
+ * @hooked WC_Emails::email_header()
+ * @since 3.7.0
+ */
+do_action( 'woocommerce_email_header', $email_heading, $email ); ?>
+
+<?php echo $email_improvements_enabled ? '<div class="email-introduction">' : ''; ?>
+<p><?php esc_html_e( 'We have received your request to withdraw from the order below.', 'woocommerce' ); ?></p>
+<?php echo $email_improvements_enabled ? '</div>' : ''; ?>
+
+<ul>
+ <?php foreach ( $detail_rows as $label => $value ) : ?>
+ <li><strong><?php echo esc_html( $label ); ?>:</strong> <?php echo nl2br( esc_html( $value ) ); ?></li>
+ <?php endforeach; ?>
+</ul>
+
+<?php
+if ( $additional_content ) {
+ echo $email_improvements_enabled ? '<table border="0" cellpadding="0" cellspacing="0" width="100%" role="presentation"><tr><td class="email-additional-content email-additional-content-aligned">' : '';
+ echo wp_kses_post( wpautop( wptexturize( $additional_content ) ) );
+ echo $email_improvements_enabled ? '</td></tr></table>' : '';
+}
+
+/**
+ * Fires to output the email footer.
+ *
+ * @hooked WC_Emails::email_footer()
+ * @since 3.7.0
+ */
+do_action( 'woocommerce_email_footer', $email );
diff --git a/plugins/woocommerce/templates/emails/plain/admin-order-withdrawal-requested.php b/plugins/woocommerce/templates/emails/plain/admin-order-withdrawal-requested.php
new file mode 100644
index 00000000000..87c40a913c1
--- /dev/null
+++ b/plugins/woocommerce/templates/emails/plain/admin-order-withdrawal-requested.php
@@ -0,0 +1,79 @@
+<?php
+/**
+ * Admin order withdrawal request email (plain text)
+ *
+ * This template can be overridden by copying it to yourtheme/woocommerce/emails/plain/admin-order-withdrawal-requested.php.
+ *
+ * HOWEVER, on occasion WooCommerce will need to update template files and you
+ * (the theme developer) will need to copy the new files to your theme to
+ * maintain compatibility. We try to do this as little as possible, but it does
+ * happen. When this occurs the version of the template file will be bumped and
+ * the readme will list any important changes.
+ *
+ * @see https://woocommerce.com/document/template-structure/
+ * @package WooCommerce\Templates\Emails\Plain
+ * @version 11.2.0
+ *
+ * @var string $email_heading Email heading.
+ * @var string $additional_content Additional content below the body.
+ * @var array<string,string> $withdrawal_data Withdrawal request data.
+ * @var array<string,string> $detail_rows Withdrawal request detail rows.
+ * @var \WC_Order|null $matched_order Matched order, if found.
+ * @var bool $outside_withdrawal_window Whether the matched order is outside the withdrawal window.
+ * @var string $withdrawal_window_warning Withdrawal window warning message.
+ * @var bool $sent_to_admin Whether sent to admin.
+ * @var bool $plain_text Whether plain-text variant.
+ * @var \WC_Email $email Email object.
+ */
+
+defined( 'ABSPATH' ) || exit;
+
+echo "=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=\n";
+echo esc_html( wp_strip_all_tags( $email_heading ) );
+echo "\n=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=\n\n";
+
+echo esc_html__( 'A customer submitted an order withdrawal request.', 'woocommerce' ) . "\n\n";
+
+if ( $matched_order instanceof WC_Order ) {
+ echo esc_html__( 'WooCommerce matched this request to an order and added an order note.', 'woocommerce' ) . "\n\n";
+} else {
+ echo esc_html__( 'WooCommerce could not match this request to an order automatically, so no order note was added.', 'woocommerce' ) . "\n\n";
+}
+
+foreach ( $detail_rows as $label => $value ) {
+ echo esc_html( $label ) . ': ' . esc_html( $value ) . "\n";
+}
+
+if ( $matched_order instanceof WC_Order ) {
+ echo "\n";
+
+ if ( $outside_withdrawal_window ) {
+ echo esc_html( $withdrawal_window_warning ) . "\n\n";
+ }
+
+ printf(
+ /* translators: %d: order ID. */
+ esc_html__( 'Matched order ID: %d', 'woocommerce' ),
+ absint( $matched_order->get_id() )
+ );
+ echo "\n";
+
+ if ( '' !== $matched_order->get_edit_order_url() ) {
+ echo esc_url( $matched_order->get_edit_order_url() ) . "\n";
+ }
+}
+
+echo "\n----------------------------------------\n\n";
+
+if ( $additional_content ) {
+ echo esc_html( wp_strip_all_tags( wptexturize( $additional_content ) ) );
+ echo "\n\n----------------------------------------\n\n";
+}
+
+/**
+ * Filter the email footer text.
+ *
+ * @param string $footer_text The footer text.
+ * @since 2.3.0
+ */
+echo wp_kses_post( apply_filters( 'woocommerce_email_footer_text', get_option( 'woocommerce_email_footer_text' ) ) );
diff --git a/plugins/woocommerce/templates/emails/plain/customer-order-withdrawal-requested.php b/plugins/woocommerce/templates/emails/plain/customer-order-withdrawal-requested.php
new file mode 100644
index 00000000000..90f581441eb
--- /dev/null
+++ b/plugins/woocommerce/templates/emails/plain/customer-order-withdrawal-requested.php
@@ -0,0 +1,51 @@
+<?php
+/**
+ * Customer order withdrawal request email (plain text)
+ *
+ * This template can be overridden by copying it to yourtheme/woocommerce/emails/plain/customer-order-withdrawal-requested.php.
+ *
+ * HOWEVER, on occasion WooCommerce will need to update template files and you
+ * (the theme developer) will need to copy the new files to your theme to
+ * maintain compatibility. We try to do this as little as possible, but it does
+ * happen. When this occurs the version of the template file will be bumped and
+ * the readme will list any important changes.
+ *
+ * @see https://woocommerce.com/document/template-structure/
+ * @package WooCommerce\Templates\Emails\Plain
+ * @version 11.2.0
+ *
+ * @var string $email_heading Email heading.
+ * @var string $additional_content Additional content below the body.
+ * @var array<string,string> $withdrawal_data Withdrawal request data.
+ * @var array<string,string> $detail_rows Withdrawal request detail rows.
+ * @var bool $sent_to_admin Whether sent to admin.
+ * @var bool $plain_text Whether plain-text variant.
+ * @var \WC_Email $email Email object.
+ */
+
+defined( 'ABSPATH' ) || exit;
+
+echo "=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=\n";
+echo esc_html( wp_strip_all_tags( $email_heading ) );
+echo "\n=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=\n\n";
+
+echo esc_html__( 'We have received your request to withdraw from the order below.', 'woocommerce' ) . "\n\n";
+
+foreach ( $detail_rows as $label => $value ) {
+ echo esc_html( $label ) . ': ' . esc_html( $value ) . "\n";
+}
+
+echo "\n----------------------------------------\n\n";
+
+if ( $additional_content ) {
+ echo esc_html( wp_strip_all_tags( wptexturize( $additional_content ) ) );
+ echo "\n\n----------------------------------------\n\n";
+}
+
+/**
+ * Filter the email footer text.
+ *
+ * @param string $footer_text The footer text.
+ * @since 2.3.0
+ */
+echo wp_kses_post( apply_filters( 'woocommerce_email_footer_text', get_option( 'woocommerce_email_footer_text' ) ) );
diff --git a/plugins/woocommerce/tests/php/src/Internal/OrderWithdrawal/OrderWithdrawalTest.php b/plugins/woocommerce/tests/php/src/Internal/OrderWithdrawal/OrderWithdrawalTest.php
index d73ec1f2822..a3c7842728d 100644
--- a/plugins/woocommerce/tests/php/src/Internal/OrderWithdrawal/OrderWithdrawalTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/OrderWithdrawal/OrderWithdrawalTest.php
@@ -5,12 +5,16 @@ namespace Automattic\WooCommerce\Tests\Internal\OrderWithdrawal;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\Notes;
+use Automattic\WooCommerce\Internal\Admin\EmailPreview\EmailPreview;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
+use Automattic\WooCommerce\Internal\OrderWithdrawal\Emails\OrderWithdrawalEmailPreview;
use Automattic\WooCommerce\Internal\OrderWithdrawal\OrderWithdrawalController;
use Automattic\WooCommerce\Internal\OrderWithdrawal\OrderWithdrawalFormProcessor;
use Automattic\WooCommerce\Internal\OrderWithdrawal\OrderWithdrawalFormState;
use Automattic\WooCommerce\Internal\OrderWithdrawal\OrderWithdrawalFormView;
use Automattic\WooCommerce\Internal\OrderWithdrawal\OrderWithdrawalFeatureHighlightNotification;
+use WC_Email_Customer_Order_Withdrawal_Requested;
+use WC_Email_Order_Withdrawal_Requested;
use WC_Order;
use WC_Rate_Limiter;
use WC_Unit_Test_Case;
@@ -112,6 +116,10 @@ class OrderWithdrawalTest extends WC_Unit_Test_Case {
public function setUp(): void {
parent::setUp();
+ $bootstrap = \WC_Unit_Tests_Bootstrap::instance();
+ require_once $bootstrap->plugin_dir . '/includes/emails/class-wc-email-customer-order-withdrawal-requested.php';
+ require_once $bootstrap->plugin_dir . '/includes/emails/class-wc-email-order-withdrawal-requested.php';
+
$this->sut = new OrderWithdrawalFormProcessor();
$this->original_post = $_POST; // phpcs:ignore WordPress.Security.NonceVerification.Missing
$this->had_request_method = filter_has_var( INPUT_SERVER, 'REQUEST_METHOD' );
@@ -671,14 +679,9 @@ class OrderWithdrawalTest extends WC_Unit_Test_Case {
* @testdox Should register cleanup hooks for HPOS and legacy order deletion.
*/
public function test_controller_registers_order_deletion_cleanup_hooks(): void {
- $this->enable_feature();
-
- $controller = new OrderWithdrawalController();
- $controller->init(
- $this->sut,
- new OrderWithdrawalFormView(),
- new OrderWithdrawalFeatureHighlightNotification()
- );
+ $controller = new OrderWithdrawalController();
+ $email_preview = new OrderWithdrawalEmailPreview();
+ $controller->init( $this->sut, new OrderWithdrawalFormView(), new OrderWithdrawalFeatureHighlightNotification(), $email_preview );
try {
$controller->register();
@@ -686,9 +689,11 @@ class OrderWithdrawalTest extends WC_Unit_Test_Case {
$this->assertNotFalse( has_action( 'woocommerce_before_delete_order', array( $this->sut, 'delete_order_withdrawal_inbox_note_for_order' ) ) );
$this->assertNotFalse( has_action( 'before_delete_post', array( $this->sut, 'delete_order_withdrawal_inbox_note_for_order' ) ) );
+ $this->assertFalse( has_filter( 'woocommerce_prepare_email_for_preview', array( $email_preview, 'prepare_email_for_preview' ) ) );
} finally {
- remove_action( FeaturesController::FEATURE_ENABLED_CHANGED_ACTION, array( $controller, 'maybe_flush_rewrite_rules' ), 10 );
remove_action( 'init', array( $controller, 'register_feature_hooks' ), 0 );
+ remove_action( FeaturesController::FEATURE_ENABLED_CHANGED_ACTION, array( $controller, 'maybe_flush_rewrite_rules' ), 10 );
+ remove_filter( 'woocommerce_prepare_email_for_preview', array( $email_preview, 'prepare_email_for_preview' ), 10 );
remove_filter( 'woocommerce_get_query_vars', array( $controller, 'add_query_var' ), 10 );
remove_filter( 'woocommerce_endpoint_order-withdrawal_title', array( $controller, 'get_endpoint_title' ), 10 );
remove_filter( 'woocommerce_settings_pages', array( $controller, 'add_endpoint_setting' ), 10 );
@@ -699,33 +704,67 @@ class OrderWithdrawalTest extends WC_Unit_Test_Case {
}
}
+ /**
+ * @testdox Should use standard dummy order data in order withdrawal email previews.
+ */
+ public function test_order_withdrawal_email_preview_uses_standard_dummy_order_data(): void {
+ $this->enable_feature();
+
+ $email_preview_handler = new OrderWithdrawalEmailPreview();
+ $email_preview_handler->register();
+
+ try {
+ WC()->mailer()->init();
+
+ $email_preview = new EmailPreview();
+ $email_preview->set_email_type( WC_Email_Customer_Order_Withdrawal_Requested::class );
+ $customer_email = $email_preview->get_email();
+
+ $this->assertSame( 'John', $customer_email->withdrawal_data[ OrderWithdrawalFormProcessor::FIELD_FIRST_NAME ] );
+ $this->assertSame( 'Doe', $customer_email->withdrawal_data[ OrderWithdrawalFormProcessor::FIELD_LAST_NAME ] );
+ $this->assertSame( 'john@company.com', $customer_email->withdrawal_data[ OrderWithdrawalFormProcessor::FIELD_EMAIL ] );
+ $this->assertSame( '12345', $customer_email->withdrawal_data[ OrderWithdrawalFormProcessor::FIELD_ORDER_NUMBER ] );
+ $this->assertSame( 'John Doe', $customer_email->placeholders['{order_billing_name}'] );
+ $this->assertGreaterThan( 0, $customer_email->submitted_at );
+
+ $email_preview->set_email_type( WC_Email_Order_Withdrawal_Requested::class );
+ $merchant_email = $email_preview->get_email();
+
+ $this->assertSame( $merchant_email->object, $merchant_email->matched_order );
+ $this->assertStringContainsString( '12345', $merchant_email->get_subject() );
+ } finally {
+ remove_filter( 'woocommerce_prepare_email_for_preview', array( $email_preview_handler, 'prepare_email_for_preview' ) );
+ $this->disable_feature();
+ WC()->mailer()->init();
+ }
+ }
+
/**
* @testdox Should skip the feature highlight notification hooks when order withdrawal is enabled.
*/
public function test_controller_skips_feature_highlight_notification_hooks_when_feature_is_enabled(): void {
$this->enable_feature();
- $controller = new OrderWithdrawalController();
- $notification = new OrderWithdrawalFeatureHighlightNotification();
+ $controller = new OrderWithdrawalController();
+ $notification = new OrderWithdrawalFeatureHighlightNotification();
+ $email_preview = new OrderWithdrawalEmailPreview();
- $controller->init(
- $this->sut,
- new OrderWithdrawalFormView(),
- $notification
- );
+ $controller->init( $this->sut, new OrderWithdrawalFormView(), $notification, $email_preview );
try {
$controller->register();
- $this->assertNotFalse( has_action( 'init', array( $controller, 'register_feature_hooks' ) ), 'The controller should defer feature-specific hook registration until init.' );
+ $this->assertNotFalse( has_action( 'init', array( $controller, 'register_feature_hooks' ) ), 'The controller should defer feature hook registration until init.' );
$controller->register_feature_hooks();
+ $this->assertNotFalse( has_filter( 'woocommerce_prepare_email_for_preview', array( $email_preview, 'prepare_email_for_preview' ) ), 'The controller should initialize email previews when the feature is enabled.' );
$this->assertFalse( has_action( 'update_option_woocommerce_coming_soon', array( $notification, 'maybe_add_note_when_store_goes_live' ) ), 'The feature highlight notification should not listen for coming-soon changes when the feature is enabled.' );
$this->assertFalse( has_action( 'wc_admin_daily', array( $notification, 'possibly_add_note' ) ), 'The feature highlight notification should not run daily when the feature is enabled.' );
} finally {
remove_action( 'init', array( $controller, 'register_feature_hooks' ), 0 );
remove_action( FeaturesController::FEATURE_ENABLED_CHANGED_ACTION, array( $controller, 'maybe_flush_rewrite_rules' ), 10 );
+ remove_filter( 'woocommerce_prepare_email_for_preview', array( $email_preview, 'prepare_email_for_preview' ), 10 );
remove_filter( 'woocommerce_get_query_vars', array( $controller, 'add_query_var' ), 10 );
remove_filter( 'woocommerce_endpoint_order-withdrawal_title', array( $controller, 'get_endpoint_title' ), 10 );
remove_filter( 'woocommerce_settings_pages', array( $controller, 'add_endpoint_setting' ), 10 );
@@ -743,13 +782,15 @@ class OrderWithdrawalTest extends WC_Unit_Test_Case {
$this->enable_feature();
$this->disable_feature();
- $controller = new OrderWithdrawalController();
- $notification = new OrderWithdrawalFeatureHighlightNotification();
+ $controller = new OrderWithdrawalController();
+ $notification = new OrderWithdrawalFeatureHighlightNotification();
+ $email_preview = new OrderWithdrawalEmailPreview();
$controller->init(
$this->sut,
new OrderWithdrawalFormView(),
- $notification
+ $notification,
+ $email_preview
);
try {
@@ -975,6 +1016,61 @@ class OrderWithdrawalTest extends WC_Unit_Test_Case {
$this->assertSame( 'https://example.test/account/withdraw-order/', $args['form_action_url'], 'The view should expose the form action URL.' );
}
+ /**
+ * @testdox Customer order withdrawal email should reject malformed trigger data.
+ */
+ public function test_customer_order_withdrawal_email_rejects_malformed_trigger_data(): void {
+ $email = new WC_Email_Customer_Order_Withdrawal_Requested();
+ $capture = $this->capture_wp_mail();
+
+ try {
+ $result = $email->trigger(
+ array_merge(
+ $this->get_valid_form_data(),
+ array(
+ OrderWithdrawalFormProcessor::FIELD_EMAIL => 'not-an-email',
+ OrderWithdrawalFormProcessor::FIELD_WITHDRAWAL_TYPE => array( OrderWithdrawalFormProcessor::WITHDRAWAL_TYPE_FULL ),
+ )
+ ),
+ time()
+ );
+
+ $this->assertFalse( $result, 'Malformed trigger data should prevent the customer email from sending.' );
+ $this->assertCount( 0, $capture['captures'], 'Malformed trigger data should not call wp_mail().' );
+ } finally {
+ $capture['remove']();
+ }
+ }
+
+ /**
+ * @testdox Merchant order withdrawal email should reject malformed trigger data.
+ */
+ public function test_merchant_order_withdrawal_email_rejects_malformed_trigger_data(): void {
+ $email = new WC_Email_Order_Withdrawal_Requested();
+ $capture = $this->capture_wp_mail();
+
+ try {
+ $result = $email->trigger(
+ array_merge(
+ $this->get_valid_form_data(),
+ array(
+ OrderWithdrawalFormProcessor::FIELD_ORDER_NUMBER => '',
+ OrderWithdrawalFormProcessor::FIELD_WITHDRAWAL_TYPE => array( OrderWithdrawalFormProcessor::WITHDRAWAL_TYPE_FULL ),
+ )
+ ),
+ null,
+ time(),
+ false,
+ ''
+ );
+
+ $this->assertFalse( $result, 'Malformed trigger data should prevent the merchant email from sending.' );
+ $this->assertCount( 0, $capture['captures'], 'Malformed trigger data should not call wp_mail().' );
+ } finally {
+ $capture['remove']();
+ }
+ }
+
/**
* Prepare a form POST request.
*