> */ public function data() { return array_merge( parent::data(), array( Cleanup::user_meta( 'test-sends', self::SENDS_META, __( 'Test send times', 'admin-powerups-for-woocommerce' ), __( 'When each person sent test emails in the last 10 minutes, to limit how many they can send.', 'admin-powerups-for-woocommerce' ), 'cache' ), ) ); } /** * {@inheritDoc} */ public function unmet_requirement() { // WooCommerce's previewer is an internal class: check it's still there rather than trust the version. return class_exists( EmailPreview::class ) ? '' : __( 'Needs WooCommerce’s email preview, which isn’t available on this site.', 'admin-powerups-for-woocommerce' ); } /** * {@inheritDoc} */ public function shortcut() { return 'preview'; } /** * {@inheritDoc} */ public function views() { return array( 'preview' => __( 'Preview & test', 'admin-powerups-for-woocommerce' ) ); } /** * Emails offered in the Order actions menu. * * @since 1.0.0 * @return string[] */ public static function order_action_emails() { /** * Filter which emails get a "Resend …" entry in the order's Order actions menu. * WooCommerce already offers the invoice ("Send order details to customer") and "Resend new * order notification". Only emails in `apwc_resendable_emails` are used. * * @since 1.0.0 * @param string[] $ids Email IDs. */ $ids = (array) apply_filters( 'apwc_order_resend_emails', array( 'customer_processing_order', 'customer_completed_order' ) ); return array_values( array_intersect( $ids, WooEmails::resendable() ) ); } /** * {@inheritDoc} */ public function boot() { add_filter( 'woocommerce_order_actions', array( $this, 'order_actions' ), 20, 2 ); foreach ( self::order_action_emails() as $id ) { add_action( 'woocommerce_order_action_apwc_resend_' . $id, function ( $order ) use ( $id ) { $this->resend_from_order( $id, $order ); } ); } add_action( 'rest_api_init', array( $this, 'register_routes' ) ); } /** * Add "Resend …" to Order actions. * * @since 1.0.0 * @param array $actions Actions. * @param WC_Order|null $order Order (WooCommerce 8.1+). * @return array */ public function order_actions( $actions, $order = null ) { foreach ( self::order_action_emails() as $id ) { $email = WooEmails::get( $id ); // Manual emails (e.g. "Order details") are never "enabled" in WooCommerce but can always be sent. if ( $email && ( $email->is_enabled() || $email->is_manual() ) ) { /* translators: %s: email title, e.g. "Processing order". */ $actions[ 'apwc_resend_' . $id ] = sprintf( __( 'Resend “%s” email', 'admin-powerups-for-woocommerce' ), $email->get_title() ); } } return $actions; } /** * Run an order action resend and leave a note. * * @since 1.0.0 * @param string $id Email ID. * @param WC_Order $order Order. * @return void */ public function resend_from_order( $id, $order ) { if ( ! $order instanceof WC_Order || ! current_user_can( 'edit_shop_orders' ) ) { return; } $result = WooEmails::resend( $id, $order ); $order->add_order_note( is_wp_error( $result ) ? $result->get_error_message() /* translators: 1: email title, 2: staff name. */ : sprintf( __( '“%1$s” email resent manually by %2$s.', 'admin-powerups-for-woocommerce' ), WooEmails::title( $id ), wp_get_current_user()->display_name ), 0, true ); } /* ------------------------------------------------------------------ Preview & test tab */ /** * {@inheritDoc} * * @param string $view View ID. */ public function enqueue_view_assets( $view ) { wp_enqueue_script( 'apwc-email-preview', Assets::url( 'assets/js/email-preview.js' ), array( 'wp-api-fetch', 'wp-i18n' ), Assets::ver( 'assets/js/email-preview.js' ), true ); wp_set_script_translations( 'apwc-email-preview', 'admin-powerups-for-woocommerce' ); } /** * {@inheritDoc} * * @param string $view View ID. */ public function render_view( $view ) { $emails = WooEmails::titles( true ); $latest = wc_get_orders( array( 'limit' => 1, 'orderby' => 'date', 'order' => 'DESC', 'return' => 'ids', 'type' => 'shop_order', 'status' => array_keys( wc_get_order_statuses() ), ) ); $example = $latest ? wc_get_order( (int) $latest[0] ) : null; $me = wp_get_current_user()->user_email; include __DIR__ . '/views/preview.php'; } /** * REST routes. * * @since 1.0.0 * @return void */ public function register_routes() { $args = array( 'email' => array( 'type' => 'string', 'required' => true, ), 'order' => array( 'type' => 'string', 'required' => true, ), ); register_rest_route( 'apwc/v1', '/email-preview/render', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'rest_render' ), 'permission_callback' => static fn() => current_user_can( 'manage_woocommerce' ), 'args' => $args, ) ); register_rest_route( 'apwc/v1', '/email-preview/send', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'rest_send' ), 'permission_callback' => static fn() => current_user_can( 'manage_woocommerce' ), 'args' => $args + array( 'to' => array( 'type' => 'string', 'required' => true, ), ), ) ); } /** * Find an order by number or ID ("#12817" works too). See Orders::find_by_number(). * * @since 1.0.0 * @param string $term Order number. * @return WC_Order|null */ public static function find_order( $term ) { return Orders::find_by_number( (string) $term ); } /** * Render an email for a real order with WooCommerce's previewer. * * @since 1.0.0 * @param string $id Email ID. * @param WC_Order $order Order. * @return array{html: string, raw: string, subject: string, email: \WC_Email}|WP_Error */ public function build( $id, WC_Order $order ) { $email = WooEmails::get( $id ); if ( ! $email || WooEmails::is_account_email( $id ) || ! class_exists( EmailPreview::class ) ) { return new WP_Error( 'apwc_bad_email', __( 'Pick an order email.', 'admin-powerups-for-woocommerce' ), array( 'status' => 400 ) ); } $use_order = static fn() => $order; add_filter( 'woocommerce_email_preview_dummy_order', $use_order, PHP_INT_MAX ); WC()->payment_gateways(); WC()->shipping(); ob_start(); try { // WooCommerce's previewer sets the email up for the order (object, placeholders). We render it // ourselves: its render() adds sample-data filters (fake downloads, forced shipping address, // placeholder images) meant for its dummy order, which would make a real order's email wrong. $preview = wc_get_container()->get( EmailPreview::class ); $preview->set_email_type( get_class( $email ) ); $mail = clone $preview->get_email(); $mail->setup_locale(); // Raw content is what WC_Email::send() expects (it styles and filters it itself); the // preview shows the styled version, with plain text wrapped so it reads as sent. $raw = $mail->get_content(); $content = 'plain' === $mail->get_email_type() ? '
' . esc_html( $raw ) . '
' : $raw; /** This filter is documented in woocommerce/includes/emails/class-wc-email.php */ $html = apply_filters( 'woocommerce_mail_content', $mail->style_inline( $content ) ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingSinceComment, WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WooCommerce's own hook, fired as WC_Email::send() would so the preview matches. $subject = $mail->get_subject(); $mail->restore_locale(); } catch ( \Throwable $e ) { ob_end_clean(); remove_filter( 'woocommerce_email_preview_dummy_order', $use_order, PHP_INT_MAX ); return new WP_Error( 'apwc_render_failed', __( 'WooCommerce couldn’t build this email for that order.', 'admin-powerups-for-woocommerce' ), array( 'status' => 500 ) ); } ob_end_clean(); remove_filter( 'woocommerce_email_preview_dummy_order', $use_order, PHP_INT_MAX ); return array( 'html' => (string) $html, 'raw' => (string) $raw, 'subject' => wp_specialchars_decode( (string) $subject ), 'email' => $mail, ); } /** * POST /email-preview/render. * * @since 1.0.0 * @param WP_REST_Request $request Request. * @return \WP_REST_Response|WP_Error */ public function rest_render( WP_REST_Request $request ) { $order = self::find_order( (string) $request['order'] ); if ( ! $order ) { return new WP_Error( 'apwc_no_order', __( 'No order with that number.', 'admin-powerups-for-woocommerce' ), array( 'status' => 404 ) ); } $built = $this->build( sanitize_key( (string) $request['email'] ), $order ); if ( is_wp_error( $built ) ) { return $built; } return rest_ensure_response( array( 'subject' => $built['subject'], 'html' => $built['html'], /* translators: 1: order number, 2: customer email. */ 'context' => sprintf( __( 'Order #%1$s · normally goes to %2$s', 'admin-powerups-for-woocommerce' ), $order->get_order_number(), $built['email']->is_customer_email() ? $order->get_billing_email() : $built['email']->get_recipient() ), ) ); } /** * Mail headers without Cc and Bcc lines. Folded lines (starting with a space or tab) are joined * to the header above first, and header names are read the way wp_mail() reads them, so a * copy can't hide in a continuation line or behind leading whitespace. * * @since 1.0.0 * @param string|string[] $headers Headers as a string or one header per entry. * @return string|string[] Same shape as given. */ public static function without_copies( $headers ) { $is_list = is_array( $headers ); $text = $is_list ? implode( "\r\n", Str::scalars( $headers ) ) : (string) $headers; $text = (string) preg_replace( '/(?:\r\n|\n|\r)[ \t]+/', ' ', $text ); $lines = array(); foreach ( (array) preg_split( '/\r\n|\n|\r/', $text ) as $line ) { $line = (string) $line; $name = strtolower( trim( explode( ':', trim( $line ), 2 )[0] ) ); if ( '' !== trim( $line ) && ( ! str_contains( $line, ':' ) || ! in_array( $name, array( 'cc', 'bcc' ), true ) ) ) { $lines[] = $line; } } if ( $is_list ) { return $lines; } return $lines ? implode( "\r\n", $lines ) . "\r\n" : ''; } /** * Count a test send for a user, or refuse it when they've sent too many lately. Each send adds * its own meta row first and then counts, so requests running side by side can't both slip * under the limit. * * @since 1.0.0 * @param int $user_id User. * @return string|WP_Error This send's meta value (to give the slot back if nothing is sent). */ private function take_send_slot( $user_id ) { /** * Filter how many test emails one user may send from Preview & test in ten minutes. * * @since 1.0.0 * @param int $limit Sends per ten minutes (default 10; at least 1). * @param int $user_id User. */ $limit = max( 1, (int) apply_filters( 'apwc_email_preview_send_limit', 10, $user_id ) ); $now = time(); $mine = $now . ':' . wp_generate_password( 12, false ); add_user_meta( $user_id, self::SENDS_META, $mine ); $recent = 0; foreach ( (array) get_user_meta( $user_id, self::SENDS_META, false ) as $value ) { $time = (int) strtok( (string) $value, ':' ); if ( $time <= $now - self::SEND_WINDOW ) { delete_user_meta( $user_id, self::SENDS_META, $value ); } else { ++$recent; } } if ( $recent > $limit ) { delete_user_meta( $user_id, self::SENDS_META, $mine ); /* translators: %d: number of test emails. */ return new WP_Error( 'apwc_too_many_tests', sprintf( _n( 'You can send %d test email every 10 minutes. Try again in a few minutes.', 'You can send %d test emails every 10 minutes. Try again in a few minutes.', $limit, 'admin-powerups-for-woocommerce' ), $limit ), array( 'status' => 429 ) ); } return $mine; } /** * POST /email-preview/send: send the rendered email to one address, marked as a test. * * @since 1.0.0 * @param WP_REST_Request $request Request. * @return \WP_REST_Response|WP_Error */ public function rest_send( WP_REST_Request $request ) { $to = sanitize_email( (string) $request['to'] ); if ( ! is_email( $to ) ) { return new WP_Error( 'apwc_bad_to', __( 'Enter one valid email address to send the test to.', 'admin-powerups-for-woocommerce' ), array( 'status' => 400 ) ); } $order = self::find_order( (string) $request['order'] ); if ( ! $order ) { return new WP_Error( 'apwc_no_order', __( 'No order with that number.', 'admin-powerups-for-woocommerce' ), array( 'status' => 404 ) ); } $email_id = sanitize_key( (string) $request['email'] ); if ( WooEmails::is_account_email( $email_id ) ) { return new WP_Error( 'apwc_bad_email', __( 'Pick an order email.', 'admin-powerups-for-woocommerce' ), array( 'status' => 400 ) ); } $slot = $this->take_send_slot( get_current_user_id() ); if ( is_wp_error( $slot ) ) { return $slot; } $built = $this->build( $email_id, $order ); if ( is_wp_error( $built ) ) { // Nothing was sent: this attempt doesn't use up the allowance. delete_user_meta( get_current_user_id(), self::SENDS_META, $slot ); return $built; } $email = $built['email']; $email->recipient = $to; $sender = get_current_user_id(); // Mark the Email log entry of this send as a test (it can't be resent to the customer) and // record who sent it. $as_test = static function ( $row ) use ( $sender ) { if ( is_array( $row ) ) { $row['is_test'] = 1; $row['sent_by'] = $sender; } return $row; }; // A test goes only to the address given: drop every To, Cc and Bcc other code adds (Email // recipients, other plugins), in the headers WooCommerce hands to wp_mail() and again on the // mailer itself, after everything else had its turn. $only_to = static function ( $params ) use ( $to ) { if ( is_array( $params ) ) { $params[0] = $to; $params[3] = self::without_copies( $params[3] ?? '' ); } return $params; }; $only_mailer = static function ( $mailer ) use ( $to ) { if ( $mailer instanceof \PHPMailer\PHPMailer\PHPMailer ) { $mailer->clearAllRecipients(); $mailer->addAddress( $to ); } }; add_filter( 'apwc_email_log_entry', $as_test, PHP_INT_MAX ); add_filter( 'woocommerce_mail_callback_params', $only_to, PHP_INT_MAX - 1 ); // Before the Email log reads them (PHP_INT_MAX). add_action( 'phpmailer_init', $only_mailer, PHP_INT_MAX ); try { $sent = $email->send( $to, $built['subject'], $built['raw'], self::without_copies( (string) $email->get_headers() ), $email->get_attachments() ); } finally { remove_filter( 'apwc_email_log_entry', $as_test, PHP_INT_MAX ); remove_filter( 'woocommerce_mail_callback_params', $only_to, PHP_INT_MAX - 1 ); remove_action( 'phpmailer_init', $only_mailer, PHP_INT_MAX ); } if ( ! $sent ) { return new WP_Error( 'apwc_not_sent', __( 'The test email couldn’t be sent. Check that your site can send email.', 'admin-powerups-for-woocommerce' ), array( 'status' => 500 ) ); } /* translators: %s: email address. */ return rest_ensure_response( array( 'message' => sprintf( __( 'Test sent to %s.', 'admin-powerups-for-woocommerce' ), $to ) ) ); } }