> */ private $pending = array(); /** * Last wp_mail error. * * @var string */ private $last_error = ''; /** * {@inheritDoc} */ public function id() { return 'email-log'; } /** * {@inheritDoc} */ public function title() { return __( 'Email log', 'admin-powerups-for-woocommerce' ); } /** * {@inheritDoc} */ public function description() { return __( 'See every WooCommerce email: who got it, when, and whether it went out. View it as sent, resend it, and see each order’s emails.', 'admin-powerups-for-woocommerce' ); } /** * {@inheritDoc} */ public function category() { return 'emails'; } /** * {@inheritDoc} */ public function location() { return __( 'Log tab, Order page', 'admin-powerups-for-woocommerce' ); } /** * {@inheritDoc} */ public function icon() { return 'mail'; } /** * {@inheritDoc} Settings are read whenever a WooCommerce email goes out, including at checkout. */ public function autoload_settings() { return true; } /** * The log keeps answering privacy requests and deleting old entries while its table exists, * even with the power-up off (switching off keeps the log). Only that is hooked then: the * exporter, the eraser and the daily clean-up. Everything else waits for boot(). * * The table's version option stays autoloaded while the table exists, so this check never * costs a query (it's managed here, not through autoload_options()). * * @since 1.0.0 * @param \SmartWP\AdminPowerups\Settings $store Settings store. */ public function __construct( \SmartWP\AdminPowerups\Settings $store ) { parent::__construct( $store ); if ( doing_action( 'deactivate_' . APWC_BASENAME ) ) { self::plugin_deactivated(); return; } if ( doing_action( 'activate_' . APWC_BASENAME ) ) { self::plugin_activated(); } if ( Store::kept() ) { $this->listen(); } // After a plugin update (Registry::maybe_upgrade(), wp-admin only), bring a kept log up to // date whether the power-up is on or off. Hooked only while an update is pending. if ( is_admin() && APWC_VERSION !== $store->version() ) { add_action( 'apwc_upgraded', array( self::class, 'repair_kept_log' ) ); } } /** * A kept log (its version option exists): update its table to the current schema, autoload * its version again and make sure its daily clean-up is scheduled. Runs after a plugin update * (`apwc_upgraded`, on admin_init), never on a request that sends email. * * @since 1.0.0 * @return void */ public static function repair_kept_log() { if ( false === get_option( Store::VERSION ) ) { return; } Store::install(); // Only changes anything below the current schema. wp_set_option_autoload( Store::VERSION, true ); self::schedule(); } /** * Hook the daily clean-up and the privacy exporter and eraser (once). * * @since 1.0.0 * @return void */ private function listen() { if ( false !== has_action( self::PRUNE, array( $this, 'prune' ) ) ) { return; } add_action( self::PRUNE, array( $this, 'prune' ) ); add_action( self::PRUNE_MORE, array( $this, 'prune' ) ); add_filter( 'wp_privacy_personal_data_exporters', array( $this, 'register_exporter' ) ); // Early, and first in the list: WooCommerce's order eraser may anonymize the orders' // billing email, which is how this eraser finds a person's orders. add_filter( 'wp_privacy_personal_data_erasers', array( $this, 'register_eraser' ), 5 ); } /** * The plugin is being deactivated (this site): stop the clean-up jobs and stop autoloading the * table version. The log itself stays until the Data section or uninstall deletes it. * * @since 1.0.0 * @return void */ private static function plugin_deactivated() { self::unschedule(); if ( false !== get_option( Store::VERSION ) ) { wp_set_option_autoload( Store::VERSION, false ); } } /** * The plugin is being activated (this site): a log kept from before gets its daily clean-up * back, and its version autoloads again, whether the power-up is on or off. * * @since 1.0.0 * @return void */ private static function plugin_activated() { if ( ! Store::ready() ) { return; } wp_set_option_autoload( Store::VERSION, true ); self::schedule(); } /** * Schedule the daily clean-up, once. * * @since 1.0.0 * @return void */ private static function schedule() { if ( function_exists( 'as_has_scheduled_action' ) && ! as_has_scheduled_action( self::PRUNE, array(), Jobs::GROUP ) ) { as_schedule_recurring_action( time() + HOUR_IN_SECONDS, DAY_IN_SECONDS, self::PRUNE, array(), Jobs::GROUP ); } } /** * Cancel the clean-up jobs (by hook only: see docs/developer/architecture.md, Action Scheduler jobs). * * @since 1.0.0 * @return void */ private static function unschedule() { if ( function_exists( 'as_unschedule_all_actions' ) ) { as_unschedule_all_actions( self::PRUNE ); as_unschedule_all_actions( self::PRUNE_MORE ); } } /** * {@inheritDoc} * * @return array> */ public function fields() { return array( array( 'id' => 'retention', 'type' => 'number', 'label' => __( 'Keep emails for (days)', 'admin-powerups-for-woocommerce' ), 'description' => __( 'Older entries are deleted automatically once a day.', 'admin-powerups-for-woocommerce' ), 'default' => 30, 'min' => 1, 'max' => 365, ), array( 'id' => 'store_body', 'type' => 'toggle', 'label' => __( 'Keep the email content', 'admin-powerups-for-woocommerce' ), 'description' => __( 'Lets you view each email as it was sent. It includes customer details, so keep the period short.', 'admin-powerups-for-woocommerce' ), 'default' => true, ), array( 'id' => 'log_unsent', 'type' => 'toggle', 'label' => __( 'Also log emails that didn’t go out', 'admin-powerups-for-woocommerce' ), 'description' => __( 'Turned off in WooCommerce settings, or no recipient. Answers “why didn’t they get it?”', 'admin-powerups-for-woocommerce' ), 'default' => true, ), ); } /** * {@inheritDoc} * * @return array> */ public function data() { // One COUNT(*) per render: the count and the summary share it (a big log takes a while to count). $rows = null; $total = static function () use ( &$rows ) { if ( null === $rows ) { $rows = Store::count(); } return $rows; }; return array_merge( parent::data(), array( array( 'id' => 'log', 'kind' => 'content', 'label' => __( 'Logged emails', 'admin-powerups-for-woocommerce' ), 'description' => __( 'Every entry, including the email content. Deleting removes the log table; it’s created again when the power-up is on.', 'admin-powerups-for-woocommerce' ), 'count' => static fn() => $total() + ( Store::ready() ? 1 : 0 ), 'summary' => static fn() => Store::ready() /* translators: %s: number of logged emails. */ ? sprintf( _n( '%s email', '%s emails', $total(), 'admin-powerups-for-woocommerce' ), number_format_i18n( $total() ) ) : __( 'Nothing stored', 'admin-powerups-for-woocommerce' ), 'confirm' => _x( 'delete', 'word typed to confirm deleting the email log', 'admin-powerups-for-woocommerce' ), 'delete' => function () use ( &$rows ) { $rows = null; Store::drop(); if ( $this->is_enabled() ) { Store::install(); } else { self::unschedule(); // No log left to clean up. } return 0; }, ), ) ); } /** * {@inheritDoc} */ public function activate() { Store::install(); if ( false !== get_option( Store::VERSION ) ) { wp_set_option_autoload( Store::VERSION, true ); } self::schedule(); } /** * {@inheritDoc} Switching off keeps the log, so its daily clean-up keeps running (old entries * still expire) and privacy requests still find it. Deactivating the plugin stops the jobs * (see plugin_deactivated()). */ public function deactivate() { if ( doing_action( 'deactivate_' . APWC_BASENAME ) || ! Store::ready() ) { self::unschedule(); } } /** * {@inheritDoc} */ public function boot() { add_filter( 'woocommerce_mail_callback_params', array( $this, 'capture' ), PHP_INT_MAX, 2 ); add_action( 'wp_mail_failed', array( $this, 'capture_error' ) ); add_action( 'woocommerce_email_sent', array( $this, 'log_sent' ), 20, 3 ); add_action( 'woocommerce_email_disabled', array( $this, 'log_disabled' ), 20, 2 ); add_action( 'woocommerce_email_skipped', array( $this, 'log_skipped' ), 20, 3 ); // Also hooked by the constructor whenever the table exists, with the power-up on or off. $this->listen(); add_action( 'add_meta_boxes', array( $this, 'order_box' ), 46 ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) ); add_action( 'rest_api_init', array( $this, 'register_routes' ) ); } /* ------------------------------------------------------------------ capture */ /** * Remember what WooCommerce is about to hand to wp_mail (final recipients, headers, files). * * @since 1.0.0 * @param array $params [ to, subject, message, headers, attachments ]. * @param WC_Email $email Email. * @return array */ public function capture( $params, $email ) { if ( $email instanceof WC_Email ) { $this->pending[ spl_object_id( $email ) ] = (array) $params; $this->last_error = ''; } return $params; } /** * Keep the mail error of a failed send. * * @since 1.0.0 * @param \WP_Error $error Error. * @return void */ public function capture_error( $error ) { if ( is_wp_error( $error ) ) { $this->last_error = $error->get_error_message(); } } /** * Log a send. * * @since 1.0.0 * @param bool $success Sent. * @param string $email_id Email ID. * @param WC_Email $email Email. * @return void */ public function log_sent( $success, $email_id, $email ) { if ( ! $email instanceof WC_Email ) { return; } $key = spl_object_id( $email ); $params = $this->pending[ $key ] ?? array( $email->get_recipient(), $email->get_subject(), '', $email->get_headers(), array() ); unset( $this->pending[ $key ] ); $headers = $this->headers( $params[3] ?? '' ); $this->write( $email_id, $email, array( 'recipients' => $this->addresses( $params[0] ?? '' ), 'cc' => $headers['cc'], 'bcc' => $headers['bcc'], 'subject' => (string) ( $params[1] ?? '' ), 'status' => $success ? 'sent' : 'failed', 'error' => $success ? '' : ( '' !== $this->last_error ? $this->last_error : __( 'The mail server didn’t accept the email.', 'admin-powerups-for-woocommerce' ) ), 'attachments' => implode( ', ', array_map( 'wp_basename', array_filter( (array) ( $params[4] ?? array() ), 'is_string' ) ) ), // Account emails carry password-reset / set-password links: never keep their content. 'body' => $this->setting( 'store_body' ) && ! WooEmails::is_account_email( (string) $email_id ) ? (string) ( $params[2] ?? '' ) : null, ) ); $this->last_error = ''; } /** * Log an email that is turned off in WooCommerce. * * @since 1.0.0 * @param string $email_id Email ID. * @param WC_Email $email Email. * @return void */ public function log_disabled( $email_id, $email ) { $this->log_unsent( $email_id, $email, 'disabled', __( 'Turned off in WooCommerce → Settings → Emails.', 'admin-powerups-for-woocommerce' ) ); } /** * Log an email WooCommerce skipped (e.g. no recipient). * * @since 1.0.0 * @param string $reason Reason code. * @param string $email_id Email ID. * @param WC_Email $email Email. * @return void */ public function log_skipped( $reason, $email_id, $email ) { $text = 'no_recipient' === $reason ? __( 'No recipient address.', 'admin-powerups-for-woocommerce' ) : sprintf( /* translators: %s: reason code from WooCommerce. */ __( 'Skipped by WooCommerce (%s).', 'admin-powerups-for-woocommerce' ), (string) $reason ); $this->log_unsent( $email_id, $email, 'skipped', $text ); } /** * Log an email that didn't go out. * * @param string $email_id Email ID. * @param WC_Email $email Email. * @param string $status disabled|skipped. * @param string $reason Reason. * @return void */ private function log_unsent( $email_id, $email, $status, $reason ) { if ( ! $email instanceof WC_Email || ! $this->setting( 'log_unsent' ) ) { return; } $this->write( (string) $email_id, $email, array( 'recipients' => $this->addresses( (string) $email->get_recipient() ), 'status' => $status, 'error' => $reason, ) ); } /** * Write a row with the email's related object. * * @param string $email_id Email ID. * @param WC_Email $email Email. * @param array $row Columns. * @return void */ private function write( $email_id, WC_Email $email, array $row ) { $object = $email->object; $type = ''; $id = 0; if ( $object instanceof WC_Order ) { $type = 'order'; $id = $object->get_id(); } elseif ( $object instanceof WC_Product ) { $type = 'product'; $id = $object->get_id(); } elseif ( $object instanceof WP_User ) { $type = 'user'; $id = (int) $object->ID; } $row = array_merge( $row, array( 'email_id' => (string) $email_id, 'object_type' => $type, 'object_id' => $id, 'is_test' => 'order' === $type && 0 === $id ? 1 : 0, // WooCommerce's own preview test uses an unsaved order. ) ); /** * Filter an email log entry before it's saved; return false to skip it. Set `is_test` to 1 to * log it as a test (the Preview & test tab does this for its test sends). * * @since 1.0.0 * @param array|false $row Columns. * @param WC_Email $email Email. */ $row = apply_filters( 'apwc_email_log_entry', $row, $email ); if ( is_array( $row ) ) { Store::insert( $row ); } } /** * Addresses as a comma-separated list. * * @param string|string[] $to Recipients. * @return string */ private function addresses( $to ) { $list = is_array( $to ) ? $to : explode( ',', (string) $to ); return implode( ', ', array_filter( array_map( 'trim', $list ) ) ); } /** * Cc and Bcc from mail headers. * * @param string|string[] $headers Headers. * @return array{cc: string, bcc: string} */ private function headers( $headers ) { $lines = is_array( $headers ) ? $headers : preg_split( "/\r\n|\n|\r/", (string) $headers ); $out = array( 'cc' => array(), 'bcc' => array(), ); foreach ( $lines as $line ) { if ( preg_match( '/^\s*(cc|bcc)\s*:\s*(.+)$/i', (string) $line, $m ) ) { $out[ strtolower( $m[1] ) ][] = trim( $m[2] ); } } return array( 'cc' => $this->addresses( implode( ',', $out['cc'] ) ), 'bcc' => $this->addresses( implode( ',', $out['bcc'] ) ), ); } /** * Daily clean-up: delete entries past the retention period, in batches, for a limited time. * When time runs out with more to delete, a follow-up job carries on (a very busy store, or a * shorter retention) instead of waiting a day or running into the PHP time limit. * * @since 1.0.0 * @return void */ public function prune() { if ( ! Store::ready() ) { self::unschedule(); // The log was deleted: nothing left to clean up. return; } $days = (int) $this->setting( 'retention', 30 ); /** * Filter how many expired Email log entries each delete removes. * * @since 1.0.0 * @param int $batch Rows per delete (default 1000). */ $batch = max( 1, (int) apply_filters( 'apwc_email_log_prune_batch', 1000 ) ); /** * Filter how long (seconds) one Email log clean-up job keeps deleting before it hands over * to a follow-up job. * * @since 1.0.0 * @param float $seconds Time budget (default 20). */ $budget = max( 0.0, (float) apply_filters( 'apwc_email_log_prune_seconds', 20 ) ); $start = microtime( true ); do { if ( Store::prune( $days, $batch ) < $batch ) { return; // Nothing expired is left. } } while ( microtime( true ) - $start < $budget ); $this->queue_follow_up(); } /** * Queue one follow-up clean-up job, unless one is already waiting. A running follow-up (the * job calling this) doesn't count, so the chain carries on until the backlog is gone. * * @since 1.0.0 * @return void */ private function queue_follow_up() { Jobs::queue_once( self::PRUNE_MORE, 0, Jobs::GROUP ); } /* ------------------------------------------------------------------ screens */ /** * {@inheritDoc} */ public function shortcut() { return 'log'; } /** * {@inheritDoc} */ public function views() { return array( 'log' => __( 'Log', 'admin-powerups-for-woocommerce' ) ); } /** * Log tab URL. * * @since 1.0.0 * @param array $args Query args (view, s, email, status, order, paged). * @return string */ public static function url( array $args = array() ) { return add_query_arg( array_filter( $args, static fn( $v ) => '' !== $v && null !== $v && 0 !== $v ), AdminPage::url( 'email-log', 'log' ) ); } /** * {@inheritDoc} The log list, or one email with ?view=ID. * * @param string $view View ID. */ public function render_view( $view ) { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only screen filters. $get = static fn( $key ) => isset( $_GET[ $key ] ) && is_string( $_GET[ $key ] ) ? sanitize_text_field( wp_unslash( $_GET[ $key ] ) ) : ''; $num = static fn( $key ) => max( 0, (int) $get( $key ) ); // Not absint(): "-3" must not mean 3. $entry = $num( 'view' ); $per = in_array( $num( 'per_page' ), self::PER_PAGE, true ) ? $num( 'per_page' ) : 50; $filter = array( 's' => $get( 's' ), 'email' => sanitize_key( $get( 'email' ) ), 'status' => sanitize_key( $get( 'status' ) ), 'order' => $num( 'order' ), 'paged' => Paging::page( $get( 'paged' ), $per ), 'per_page' => $per, ); // phpcs:enable $open = $entry; // ?view=ID opens that email in the modal on load. $args = array( 'search' => $filter['s'], 'email_id' => $filter['email'], 'status' => $filter['status'], 'object_type' => $filter['order'] ? 'order' : '', 'object_id' => $filter['order'], 'page' => $filter['paged'], 'per_page' => $filter['per_page'], ); $result = Store::query( $args ); $last = max( 1, (int) ceil( $result['total'] / $filter['per_page'] ) ); if ( $result['total'] > 0 && $filter['paged'] > $last ) { // Past the end (last row deleted, old link): show the last page, not "No emails yet". $filter['paged'] = $last; $args['page'] = $last; $result = Store::query( $args ); } include __DIR__ . '/views/log.php'; } /** * {@inheritDoc} * * @param string $view View ID. */ public function enqueue_view_assets( $view ) { wp_enqueue_script( 'apwc-email-log', Assets::url( 'assets/js/email-log.js' ), array( 'wp-api-fetch', 'wp-i18n' ), Assets::ver( 'assets/js/email-log.js' ), true ); wp_set_script_translations( 'apwc-email-log', 'admin-powerups-for-woocommerce' ); } /** * Styles for the order "Emails" box. * * @since 1.0.0 * @return void */ public function enqueue() { if ( Screen::is_order_edit() ) { wp_enqueue_style( 'apwc-modules', Assets::url( 'assets/css/modules.css' ), array(), Assets::ver( 'assets/css/modules.css' ) ); } } /** * "Emails" box on the order screen. * * @since 1.0.0 * @return void */ public function order_box() { if ( current_user_can( 'manage_woocommerce' ) ) { add_meta_box( 'apwc-order-emails', __( 'Emails', 'admin-powerups-for-woocommerce' ), array( $this, 'render_order_box' ), Screen::order_screen_id(), 'side', 'default' ); } } /** * Render the order's emails. * * @since 1.0.0 * @param WC_Order|\WP_Post $post_or_order Order or post. * @return void */ public function render_order_box( $post_or_order ) { $order = $post_or_order instanceof WC_Order ? $post_or_order : wc_get_order( $post_or_order->ID ); if ( ! $order instanceof WC_Order ) { return; } $result = Store::query( array( 'object_type' => 'order', 'object_id' => $order->get_id(), 'per_page' => 10, ) ); include __DIR__ . '/views/order-box.php'; } /* ------------------------------------------------------------------ resend */ /** * REST routes. * * @since 1.0.0 * @return void */ public function register_routes() { register_rest_route( 'apwc/v1', '/email-log/(?P\d+)', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'rest_entry' ), 'permission_callback' => static fn() => current_user_can( 'manage_woocommerce' ), ), array( 'methods' => WP_REST_Server::DELETABLE, 'callback' => array( $this, 'rest_delete' ), 'permission_callback' => static fn() => current_user_can( 'manage_woocommerce' ), ), ) ); register_rest_route( 'apwc/v1', '/email-log/(?P\d+)/resend', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'rest_resend' ), 'permission_callback' => static fn() => current_user_can( 'manage_woocommerce' ), ) ); } /** * Page numbers to show (kept for callers; the pager lives in Admin\Components now). * * @since 1.0.0 * @param int $current Current page. * @param int $total Pages. * @return array */ public static function page_numbers( $current, $total ) { return \SmartWP\AdminPowerups\Admin\Components::page_numbers( $current, $total ); } /** * GET /email-log/{id}: the modal's content. * * @since 1.0.0 * @param WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function rest_entry( WP_REST_Request $request ) { $entry = Store::get( (int) $request['id'] ); if ( ! $entry ) { return new \WP_Error( 'apwc_no_entry', __( 'This email isn’t in the log any more.', 'admin-powerups-for-woocommerce' ), array( 'status' => 404 ) ); } ob_start(); include __DIR__ . '/views/entry.php'; return rest_ensure_response( array( 'title' => '' !== $entry->subject ? $entry->subject : WooEmails::title( $entry->email_id ), 'html' => (string) ob_get_clean(), ) ); } /** * DELETE /email-log/{id}. * * @since 1.0.0 * @param WP_REST_Request $request Request. * @return \WP_REST_Response */ public function rest_delete( WP_REST_Request $request ) { Store::delete( array( (int) $request['id'] ) ); return rest_ensure_response( array( 'deleted' => true ) ); } /** * Whether an entry can be resent. * * @since 1.0.0 * @param object $entry Entry. * @return bool */ public static function can_resend( $entry ) { // Not tests: resending re-triggers the real email, which would go to the customer. return $entry && ! (int) $entry->is_test && 'order' === $entry->object_type && $entry->object_id && in_array( $entry->email_id, WooEmails::resendable(), true ); } /** * POST /email-log/{id}/resend. * * @since 1.0.0 * @param WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function rest_resend( WP_REST_Request $request ) { $entry = Store::get( (int) $request['id'] ); $order = $entry && self::can_resend( $entry ) ? wc_get_order( (int) $entry->object_id ) : null; if ( ! $order instanceof WC_Order ) { return new \WP_Error( 'apwc_cannot_resend', __( 'This email can’t be sent again: its order is gone or the email type needs more than the order.', 'admin-powerups-for-woocommerce' ), array( 'status' => 400 ) ); } $result = WooEmails::resend( $entry->email_id, $order ); if ( is_wp_error( $result ) ) { return $result; } $order->add_order_note( sprintf( /* translators: 1: email title, 2: staff name. */ __( '“%1$s” email resent from the email log by %2$s.', 'admin-powerups-for-woocommerce' ), WooEmails::title( $entry->email_id ), wp_get_current_user()->display_name ) ); /* translators: %s: email title. */ return rest_ensure_response( array( 'message' => sprintf( __( '“%s” sent again.', 'admin-powerups-for-woocommerce' ), WooEmails::title( $entry->email_id ) ) ) ); } /* ------------------------------------------------------------------ privacy */ /** * Register the exporter. * * @since 1.0.0 * @param array> $exporters Exporters. * @return array> */ public function register_exporter( $exporters ) { $exporters['apwc-email-log'] = array( 'exporter_friendly_name' => __( 'Email log', 'admin-powerups-for-woocommerce' ), 'callback' => array( $this, 'export_personal_data' ), ); return $exporters; } /** * Register the eraser. * * @since 1.0.0 * @param array> $erasers Erasers. * @return array> */ public function register_eraser( $erasers ) { return array( 'apwc-email-log' => array( 'eraser_friendly_name' => __( 'Email log', 'admin-powerups-for-woocommerce' ), 'callback' => array( $this, 'erase_personal_data' ), ), ) + (array) $erasers; } /** * Log objects that belong to a person: their orders (by billing email, and by account when * the address has one) and their account. Emails about those, such as the store's "New order" * email, hold their details even though they went to the store. * * @since 1.0.0 * @param string $email Address. * @return array Object type => IDs. */ public function privacy_objects( $email ) { $email = (string) $email; $objects = array( 'order' => array(), 'user' => array(), ); if ( ! is_email( $email ) ) { return $objects; } $customers = array( $email ); $user = get_user_by( 'email', $email ); if ( $user instanceof WP_User ) { $customers[] = (int) $user->ID; $objects['user'] = array( (int) $user->ID ); } if ( function_exists( 'wc_get_orders' ) ) { // The same lookup WooCommerce's own privacy exporter and eraser use. $objects['order'] = array_map( 'intval', (array) wc_get_orders( array( 'customer' => $customers, 'limit' => -1, 'return' => 'ids', 'type' => 'shop_order', ) ) ); } /** * Filter the log objects that count as a person's for privacy export and erase (besides * emails sent to their address): order IDs and user IDs. * * @since 1.0.0 * @param array $objects Object type (`order`, `user`) => IDs. * @param string $email Address in the request. */ $objects = apply_filters( 'apwc_email_log_privacy_objects', $objects, $email ); return is_array( $objects ) ? $objects : array(); } /** * Export a person's logged emails (sent to them, or about their orders or account), one page * at a time, with the content as text. * * @since 1.0.0 * @param string $email Address. * @param int $page Page, from 1. * @return array{data: array>, done: bool} */ public function export_personal_data( $email, $page = 1 ) { $page = Paging::page( $page, self::PRIVACY_PAGE ); $rows = Store::for_address( (string) $email, self::PRIVACY_PAGE + 1, ( $page - 1 ) * self::PRIVACY_PAGE, $this->privacy_objects( (string) $email ) ); $done = count( $rows ) <= self::PRIVACY_PAGE; $rows = array_slice( $rows, 0, self::PRIVACY_PAGE ); $bodies = Store::bodies( array_map( 'intval', wp_list_pluck( $rows, 'id' ) ) ); $items = array(); foreach ( $rows as $row ) { $data = array( array( 'name' => __( 'Date', 'admin-powerups-for-woocommerce' ), 'value' => get_date_from_gmt( $row->created_gmt ), ), array( 'name' => __( 'Email', 'admin-powerups-for-woocommerce' ), 'value' => WooEmails::title( $row->email_id ), ), array( 'name' => __( 'Subject', 'admin-powerups-for-woocommerce' ), 'value' => $row->subject, ), ); $body = $bodies[ (int) $row->id ] ?? ''; // Content only of emails meant for this person: one they received, or a customer email // about their order. Store emails (New order, …) may hold staff-only content. if ( '' !== $body && ! WooEmails::is_account_email( (string) $row->email_id ) && ( Store::received( $row, (string) $email ) || self::is_customer_email( (string) $row->email_id ) ) ) { $data[] = array( 'name' => __( 'Content', 'admin-powerups-for-woocommerce' ), 'value' => self::text( $body ), ); } $items[] = array( 'group_id' => 'apwc-email-log', 'group_label' => __( 'Emails sent by the store', 'admin-powerups-for-woocommerce' ), 'item_id' => 'apwc-email-' . $row->id, 'data' => $data, ); } return array( 'data' => $items, 'done' => $done, ); } /** * Whether an email type goes to customers (unknown types count as store emails). * * @since 1.0.0 * @param string $email_id Email ID. * @return bool */ private static function is_customer_email( $email_id ) { $email = WooEmails::get( $email_id ); return $email && $email->is_customer_email(); } /** * An email's content as plain text for the privacy export (styles and markup removed). * * @since 1.0.0 * @param string $html Content. * @return string */ private static function text( $html ) { $text = wp_strip_all_tags( preg_replace( '#<(?:br|/p|/div|/tr|/h[1-6]|/li)\b[^>]*>#i', "\n", (string) $html ) ); $text = html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); $text = preg_replace( array( "/[ \t\x{00A0}]+/u", "/\s*\n\s*/" ), array( ' ', "\n" ), $text ); return trim( (string) $text ); } /** * Erase a person's logged emails (sent to them, or about their orders or account), one batch * per call (WordPress calls again until done). * * @since 1.0.0 * @param string $email Address. * @param int $page Page, from 1 (unused: erased rows are gone, so each call takes the first batch). * @return array{items_removed: bool, items_retained: bool, messages: string[], done: bool} */ public function erase_personal_data( $email, $page = 1 ) { $rows = Store::for_address( (string) $email, self::PRIVACY_PAGE + 1, 0, $this->privacy_objects( (string) $email ) ); $batch = array_slice( $rows, 0, self::PRIVACY_PAGE ); Store::delete( array_map( 'intval', wp_list_pluck( $batch, 'id' ) ) ); return array( 'items_removed' => (bool) $batch, 'items_retained' => false, 'messages' => array(), 'done' => count( $rows ) <= self::PRIVACY_PAGE, ); } }