prefix . 'storesuit_stock_alerts'; } public static function install(): void { // 2: the cancel key is indexed (Denetim R0-8): the public cancel route looked it up among every waiting request. if ( get_option( self::SCHEMA ) === '2' ) { return; } global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $table = self::table(); $charset = $wpdb->get_charset_collate(); dbDelta( "CREATE TABLE $table ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, product_id bigint(20) unsigned NOT NULL, email varchar(190) NOT NULL, email_hash char(64) NOT NULL, customer_id bigint(20) unsigned NOT NULL DEFAULT 0, status varchar(12) NOT NULL DEFAULT 'waiting', token char(32) NOT NULL, created datetime NOT NULL, notified datetime NULL, PRIMARY KEY (id), UNIQUE KEY product_email (product_id,email_hash), KEY status_product (status,product_id), KEY email_hash (email_hash), KEY token (token) ) $charset;" ); update_option( self::SCHEMA, '2', false ); } public static function hooks(): void { add_filter( 'woocommerce_get_stock_html', array( __CLASS__, 'form' ), 20, 2 ); add_action( 'wp_enqueue_scripts', array( __CLASS__, 'assets' ) ); add_action( 'rest_api_init', array( __CLASS__, 'routes' ) ); add_action( 'woocommerce_product_set_stock_status', array( __CLASS__, 'restocked' ), 10, 2 ); add_action( 'woocommerce_variation_set_stock_status', array( __CLASS__, 'restocked' ), 10, 2 ); add_action( 'transition_post_status', array( __CLASS__, 'republished' ), 10, 3 ); add_action( self::HOOK, array( __CLASS__, 'send_batch' ) ); add_action( self::CLEANUP, array( __CLASS__, 'cleanup' ) ); add_filter( 'woocommerce_email_classes', static function ( $emails ) { require_once __DIR__ . '/StockAlertEmail.php'; $emails['StoreSuit_Stock_Alert'] = new StockAlertEmail(); return $emails; } ); add_filter( 'wp_privacy_personal_data_exporters', static function ( $exporters ) { $exporters['storesuit-stock-alerts'] = array( 'exporter_friendly_name'=>I18n::t( 'Back-in-stock requests' ), 'callback'=>array( __CLASS__, 'export' ) ); return $exporters; } ); add_filter( 'wp_privacy_personal_data_erasers', static function ( $erasers ) { $erasers['storesuit-stock-alerts'] = array( 'eraser_friendly_name'=>I18n::t( 'Back-in-stock requests' ), 'callback'=>array( __CLASS__, 'erase' ) ); return $erasers; } ); add_action( 'init', static function () { if ( ! wp_next_scheduled( self::CLEANUP ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', self::CLEANUP ); } }, 99 ); } /* ---------- Settings ---------- */ public static function settings(): array { $saved = get_option( self::SETTINGS, array() ); $saved = is_array( $saved ) ? $saved : array(); return array( 'enabled'=>! empty( $saved['enabled'] ), 'auto_send'=>! array_key_exists( 'auto_send', $saved ) || ! empty( $saved['auto_send'] ) ); } /* ---------- Storefront ---------- */ /** On sale in the store: published, and for a variation its product too (a variation keeps "publish" when its product goes back to draft). */ private static function published( \WC_Product $product ): bool { return $product->get_status() === 'publish' && ( ! $product->is_type( 'variation' ) || get_post_status( $product->get_parent_id() ) === 'publish' ); } private static function eligible( $product ): bool { // A variation of a product taken off sale takes no new request (Codex SA-02). return $product instanceof \WC_Product && ! $product->is_type( array( 'variable', 'grouped', 'external' ) ) && ! $product->is_in_stock() && self::published( $product ); } /** * Not a
: WooCommerce prints option and grouped stock text inside its own cart form, where a nested * form is dropped by the browser and its button would submit the cart. Fields carry no name for the same reason. */ public static function form( $html, $product ) { if ( ! self::settings()['enabled'] || ! self::eligible( $product ) || is_admin() ) { return $html; } self::front_assets(); $user = wp_get_current_user(); $email = $user && $user->exists() ? $user->user_email : ''; $id = (string) $product->get_id(); return $html . '
' . '

' . esc_html( (string) apply_filters( 'storesuit_stock_alert_title', I18n::t( 'Email me when this is back in stock' ), $product ) ) . '

' . '' . '' . '' . '' . '

'; } public static function assets(): void { if ( self::settings()['enabled'] && function_exists( 'is_product' ) && is_product() ) { self::front_assets(); } // Options loaded later by AJAX need the script too. } private static function front_assets(): void { if ( wp_script_is( 'storesuit-stock-alerts', 'enqueued' ) ) { return; } wp_enqueue_script( 'storesuit-stock-alerts', plugins_url( 'assets/stock-alerts.js', STORESUIT_FILE ), array(), (string) filemtime( STORESUIT_DIR . 'assets/stock-alerts.js' ), true ); wp_localize_script( 'storesuit-stock-alerts', 'StoreSuitStockAlerts', array( 'url' => esc_url_raw( rest_url( 'storesuit/v1/stock-alerts' ) ), 'sending' => I18n::t( 'Saving…' ), 'done' => I18n::t( 'Done. We will email you once when it is back in stock.' ), 'cancel' => I18n::t( 'Cancel this request' ), 'cancelled'=> I18n::t( 'Your request was cancelled.' ), 'failed' => I18n::t( 'This could not be saved. Check the address and try again.' ), 'consent' => I18n::t( 'Tick the box to confirm the email.' ), 'email' => I18n::t( 'Enter a valid email address.' ), ) ); wp_register_style( 'storesuit-stock-alerts', false, array(), STORESUIT_VERSION ); wp_enqueue_style( 'storesuit-stock-alerts' ); wp_add_inline_style( 'storesuit-stock-alerts', '.storesuit-stock-alert{display:grid;gap:.6em;margin:1em 0;text-align:start;padding:1em;border:1px solid currentColor;border-color:color-mix(in srgb,currentColor 20%,transparent);border-radius:4px}.storesuit-stock-alert__title{margin:0;font-weight:600}.storesuit-stock-alert__field{display:grid;gap:.25em}:where(.storesuit-stock-alert__field) input{box-sizing:border-box;width:100%;max-width:28em;padding:.5em .6em;font:inherit}.storesuit-stock-alert .button{justify-self:start}.storesuit-stock-alert-row td{padding-top:0}.storesuit-stock-alert__consent{display:flex;gap:.5em;align-items:flex-start;font-size:.9em}.storesuit-stock-alert__hp{position:absolute!important;left:-9999px!important;width:1px;height:1px;opacity:0}.storesuit-stock-alert__status:empty{display:none}' ); } /** * The storefront's two routes are public on purpose (permission_callback __return_true): a shopper who is not signed in * asks for one e-mail, or cancels that request with the key only its own answer carried. The consent box, a hidden field * for bots, an hourly limit per visitor network (an IPv6 /64), a daily limit per inbox and the product's own checks guard * them; nothing private is answered. * Every parameter is sanitized by WordPress before the callback runs, and validated there. */ public static function routes(): void { register_rest_route( 'storesuit/v1', '/stock-alerts', array( 'methods'=>'POST', 'callback'=>array( __CLASS__, 'rest_subscribe' ), 'permission_callback'=>'__return_true', 'args'=>array( 'product'=>array( 'description'=>'The out-of-stock product or variation.', 'sanitize_callback'=>'absint' ), 'email'=>array( 'description'=>'The shopper\'s email address.', 'sanitize_callback'=>'sanitize_email' ), 'consent'=>array( 'description'=>'The ticked consent box.', 'sanitize_callback'=>'rest_sanitize_boolean' ), // Anything other than text (an array, say) counts as filled in, as before: only a bot sends it. 'website'=>array( 'description'=>'A hidden field that only bots fill in.', 'sanitize_callback'=>static fn( $value ) => is_scalar( $value ) ? sanitize_text_field( (string) $value ) : 'filled' ), ), ) ); register_rest_route( 'storesuit/v1', '/stock-alerts/cancel', array( 'methods'=>'POST', 'callback'=>array( __CLASS__, 'rest_cancel' ), 'permission_callback'=>'__return_true', 'args'=>array( 'token'=>array( 'description'=>'The cancel key of the shopper\'s own request.', 'sanitize_callback'=>'sanitize_key' ) ), ) ); } public static function rest_subscribe( \WP_REST_Request $request ) { try { return rest_ensure_response( self::subscribe( (array) $request->get_json_params() + (array) $request->get_body_params(), Store::visitor_ip() ) ); } catch ( \Throwable $e ) { return new \WP_Error( 'storesuit_stock_alert', $e->getMessage(), array( 'status'=>400 ) ); } } public static function rest_cancel( \WP_REST_Request $request ) { $token = sanitize_key( (string) ( $request->get_param( 'token' ) ?? '' ) ); return rest_ensure_response( array( 'cancelled'=>self::cancel( $token ) ) ); } /** One waiting request per product and address; repeats return the same request. */ public static function subscribe( array $data, string $ip ): array { if ( ! self::settings()['enabled'] ) { throw new \RuntimeException( I18n::t( 'Back-in-stock emails are not available.' ) ); } if ( trim( (string) ( $data['website'] ?? '' ) ) !== '' ) { return array( 'ok'=>true, 'token'=>'' ); } // Bots filling the hidden field get no record. if ( empty( $data['consent'] ) ) { throw new \InvalidArgumentException( I18n::t( 'Tick the box to confirm the email.' ) ); } $email = sanitize_email( (string) ( $data['email'] ?? '' ) ); if ( ! is_email( $email ) || strlen( $email ) > 190 ) { throw new \InvalidArgumentException( I18n::t( 'Enter a valid email address.' ) ); } $product = wc_get_product( absint( $data['product'] ?? 0 ) ); if ( ! self::eligible( $product ) ) { throw new \InvalidArgumentException( I18n::t( 'This product is available now; no request is needed.' ) ); } // 10 an hour from one visitor network: an IPv4 address, or a whole IPv6 /64 (one connection's addresses), counted in // one atomic statement (security review S09). $network = Store::network( $ip ); if ( ! Store::throttle( 'stock-alert-network', $network !== '' ? $network : 'unknown', 10, HOUR_IN_SECONDS ) ) { throw new \RuntimeException( I18n::t( 'Too many requests. Please try again later.' ) ); } global $wpdb; $table = self::table(); $hash = hash( 'sha256', strtolower( $email ) ); $row = $wpdb->get_row( $wpdb->prepare( 'SELECT id,status,token FROM %i WHERE product_id=%d AND email_hash=%s', $table, $product->get_id(), $hash ), ARRAY_A ); // A repeat gets no cancel key: whoever knows an address must not be able to cancel that shopper's request. The same // inbox under another spelling (a +tag, Gmail's dots) is a repeat too: one email per inbox and product. if ( ( $row && $row['status'] === 'waiting' ) || self::waiting_mailbox( $product->get_id(), $email ) ) { return array( 'ok'=>true, 'token'=>'' ); } // At most 5 new or renewed requests a day for one inbox, whatever its spelling: nobody can sign someone else up for // every product, or again after each restock. if ( ! Store::throttle( 'stock-alert-mailbox', Store::mailbox( $email ), 5, DAY_IN_SECONDS ) ) { throw new \RuntimeException( I18n::t( 'Too many requests. Please try again later.' ) ); } $token = bin2hex( random_bytes( 16 ) ); $now = current_time( 'mysql', true ); if ( $row ) { $wpdb->update( $table, array( 'status'=>'waiting', 'token'=>$token, 'created'=>$now, 'notified'=>null, 'email'=>$email ), array( 'id'=>$row['id'] ) ); } else { $wpdb->insert( $table, array( 'product_id'=>$product->get_id(), 'email'=>$email, 'email_hash'=>$hash, 'customer_id'=>get_current_user_id(), 'status'=>'waiting', 'token'=>$token, 'created'=>$now ) ); } return array( 'ok'=>true, 'token'=>$token ); } /** Whether a waiting request for this product already goes to the same inbox as $email under another spelling. */ private static function waiting_mailbox( int $product_id, string $email ): bool { global $wpdb; $mailbox = Store::mailbox( $email ); $domain = (string) substr( (string) strrchr( $mailbox, '@' ), 1 ); if ( $domain === '' ) { return false; } $other = $domain === 'gmail.com' ? 'googlemail.com' : $domain; $emails = (array) $wpdb->get_col( $wpdb->prepare( "SELECT email FROM %i WHERE product_id=%d AND status='waiting' AND ( email LIKE %s OR email LIKE %s ) LIMIT 500", self::table(), $product_id, '%@' . $wpdb->esc_like( $domain ), '%@' . $wpdb->esc_like( $other ) ) ); foreach ( $emails as $address ) { if ( Store::mailbox( (string) $address ) === $mailbox ) { return true; } } return false; } public static function cancel( string $token ): bool { if ( ! preg_match( '/^[a-f0-9]{32}$/', $token ) ) { return false; } global $wpdb; return (bool) $wpdb->update( self::table(), array( 'status'=>'cancelled' ), array( 'token'=>$token, 'status'=>'waiting' ) ); } /* ---------- Sending ---------- */ public static function restocked( $product_id, $status ): void { $settings = self::settings(); if ( ! $settings['enabled'] || ! $settings['auto_send'] || $status !== 'instock' ) { return; } self::queue( (int) $product_id ); // Variations that use their parent's stock are switched by WooCommerce without an event of their own. global $wpdb; foreach ( (array) $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT a.product_id FROM %i a JOIN %i p ON p.ID=a.product_id WHERE a.status='waiting' AND p.post_parent=%d AND p.post_type='product_variation'", self::table(), $wpdb->posts, (int) $product_id ) ) as $child ) { self::queue( (int) $child ); } } /** * A product put back on sale (published again): its waiting requests for items already in stock are queued, as when the * stock comes back. While it was off sale nothing was sent (Codex SA-02), and nothing else would queue them later * (review of 0.57). For a variable product: its variations with waiting requests. */ public static function republished( $new, $old, $post ): void { if ( $new !== 'publish' || $old === 'publish' || ! $post instanceof \WP_Post || ! in_array( $post->post_type, array( 'product', 'product_variation' ), true ) ) { return; } $settings = self::settings(); if ( ! $settings['enabled'] || ! $settings['auto_send'] ) { return; } global $wpdb; $ids = (array) $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT a.product_id FROM %i a JOIN %i p ON p.ID=a.product_id WHERE a.status='waiting' AND ( p.ID=%d OR ( p.post_parent=%d AND p.post_type='product_variation' ) )", self::table(), $wpdb->posts, (int) $post->ID, (int) $post->ID ) ); foreach ( $ids as $id ) { $product = wc_get_product( (int) $id ); if ( $product && $product->is_in_stock() ) { self::queue( (int) $id ); } } } /** A follow-up batch is not unique: the job that schedules it is still running and would count as its duplicate. */ private static function queue( int $product_id, bool $follow_up = false ): void { if ( $follow_up && function_exists( 'as_schedule_single_action' ) ) { as_schedule_single_action( time() + 30, self::HOOK, array( $product_id ), 'storesuit-stock-alert-' . $product_id ); } elseif ( function_exists( 'as_enqueue_async_action' ) ) { as_enqueue_async_action( self::HOOK, array( $product_id ), 'storesuit-stock-alert-' . $product_id, true ); } else { wp_schedule_single_event( time() + ( $follow_up ? 30 : 1 ), self::HOOK, array( $product_id ) ); } } /** * Sends up to one batch; each request is marked before it is mailed so a retry never emails twice. Whether requests * are on is checked when the job runs, not when it was queued: a job left over from before the owner turned them * off sends nothing, and its waiting requests stay as they are. */ public static function send_batch( int $product_id ): int { if ( ! self::settings()['enabled'] ) { return 0; } $product = wc_get_product( $product_id ); // Checked when the job runs: a product taken off sale meanwhile (for a variation, its product) sends nothing, and the // waiting requests stay as they are (Codex SA-02). if ( ! $product || ! $product->is_in_stock() || ! self::published( $product ) ) { return 0; } global $wpdb; $table = self::table(); $rows = $wpdb->get_results( $wpdb->prepare( "SELECT id,email,token FROM %i WHERE product_id=%d AND status='waiting' ORDER BY id ASC LIMIT %d", $table, $product_id, self::BATCH ), ARRAY_A ); $mailer = WC()->mailer(); $emails = $mailer->get_emails(); $email = $emails['StoreSuit_Stock_Alert'] ?? null; $sent = 0; foreach ( $rows as $row ) { $claimed = $wpdb->update( $table, array( 'status'=>'sending' ), array( 'id'=>$row['id'], 'status'=>'waiting' ) ); if ( ! $claimed ) { continue; } $ok = $email && $email->is_enabled() ? $email->trigger( $row['email'], $product ) : false; $wpdb->update( $table, array( 'status'=>$ok ? 'sent' : 'waiting', 'notified'=>$ok ? current_time( 'mysql', true ) : null ), array( 'id'=>$row['id'] ) ); if ( $ok ) { ++$sent; } } if ( count( $rows ) === self::BATCH && $sent ) { self::queue( $product_id, true ); } return $sent; } /* ---------- Merchant view ---------- */ public static function read(): array { Store::require_cap( 'manage_woocommerce' ); global $wpdb; $table = self::table(); $top = $wpdb->get_results( $wpdb->prepare( "SELECT product_id, COUNT(*) AS waiting, MIN(created) AS since FROM %i WHERE status='waiting' GROUP BY product_id ORDER BY waiting DESC, since ASC LIMIT 30", $table ), ARRAY_A ); $rows = array(); foreach ( $top as $row ) { $product = wc_get_product( (int) $row['product_id'] ); if ( ! $product ) { continue; } $rows[] = array( 'id'=>$product->get_id(), 'parent'=>$product->get_parent_id() ?: $product->get_id(), 'name'=>$product->get_name(), 'sku'=>$product->get_sku(), 'waiting'=>(int) $row['waiting'], 'since'=>wp_date( get_option( 'date_format' ), strtotime( $row['since'] . ' UTC' ) ), 'in_stock'=>$product->is_in_stock() ); } $email = WC()->mailer()->get_emails()['StoreSuit_Stock_Alert'] ?? null; return array( 'settings' => self::settings(), 'waiting' => (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE status='waiting'", $table ) ), 'sent' => (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE status='sent' AND notified>=%s", $table, gmdate( 'Y-m-d H:i:s', time() - 30 * DAY_IN_SECONDS ) ) ), 'rows' => $rows, 'email_enabled' => $email ? $email->is_enabled() : false, 'email_settings' => admin_url( 'admin.php?page=wc-settings&tab=email§ion=' . strtolower( 'StoreSuit_Stock_Alert' ) ), ); } public static function save( array $data ): array { Store::require_cap( 'manage_woocommerce' ); $current = self::settings(); $enabled = ! empty( $data['enabled'] ) && $data['enabled'] !== '0'; if ( $enabled !== $current['enabled'] && empty( $data['confirmed'] ) ) { throw new \InvalidArgumentException( I18n::t( 'Confirm this change before saving.' ) ); } update_option( self::SETTINGS, array( 'enabled'=>$enabled, 'auto_send'=>! isset( $data['auto_send'] ) ? $current['auto_send'] : ( ! empty( $data['auto_send'] ) && $data['auto_send'] !== '0' ) ), false ); // Off: the emails already queued are cancelled and the waiting requests are kept. Turning requests on again // queues nothing, so no old request is emailed silently: waiting shoppers are emailed after a product comes // back in stock from then on, or when the owner sends by hand. if ( ! $enabled ) { self::unschedule(); } if ( $enabled !== $current['enabled'] ) { Store::audit( 'settings', 0, $enabled ? 'stock_alerts_enabled' : 'stock_alerts_disabled', array() ); } return self::read(); } private static function unschedule(): void { if ( function_exists( 'as_unschedule_all_actions' ) ) { as_unschedule_all_actions( self::HOOK ); } wp_unschedule_hook( self::HOOK ); } /** Adds how many shoppers wait for each product to purchase-suggestion rows (one query per page). */ public static function with_waiting( array $rows ): array { $ids = array_filter( array_map( static fn( $row ) => (int) ( $row['product_id'] ?? 0 ), $rows ) ); if ( ! $ids || ! get_option( self::SCHEMA ) ) { return $rows; } global $wpdb; $counts = $wpdb->get_results( $wpdb->prepare( "SELECT product_id, COUNT(*) AS waiting FROM %i WHERE status='waiting' AND product_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ') GROUP BY product_id', array_merge( array( self::table() ), $ids ) ), OBJECT_K ); foreach ( $rows as &$row ) { $row['waiting'] = isset( $counts[ (int) ( $row['product_id'] ?? 0 ) ] ) ? (int) $counts[ (int) $row['product_id'] ]->waiting : 0; } return $rows; } /** Manual send for a product that is in stock again (for stores that turn off automatic sending). */ public static function send_now( int $product_id ): array { Store::require_cap( 'manage_woocommerce' ); if ( ! self::settings()['enabled'] ) { throw new \RuntimeException( I18n::t( 'Back-in-stock requests are off.' ) ); } $product = wc_get_product( $product_id ); if ( ! $product || ! $product->is_in_stock() ) { throw new \InvalidArgumentException( I18n::t( 'Emails go out only when the product is in stock.' ) ); } self::queue( $product_id ); Store::audit( 'product', $product_id, 'stock_alerts_sent', array() ); return self::read(); } /* ---------- Privacy and retention ---------- */ public static function export( string $email, int $page = 1 ): array { global $wpdb; $data = array(); foreach ( (array) $wpdb->get_results( $wpdb->prepare( 'SELECT id,product_id,status,created FROM %i WHERE email_hash=%s', self::table(), hash( 'sha256', strtolower( $email ) ) ), ARRAY_A ) as $row ) { $product = wc_get_product( (int) $row['product_id'] ); $data[] = array( 'group_id'=>'storesuit-stock-alerts', 'group_label'=>I18n::t( 'Back-in-stock requests' ), 'item_id'=>'stock-alert-' . $row['id'], 'data'=>array( array( 'name'=>I18n::t( 'Product' ), 'value'=>$product ? $product->get_name() : '#' . $row['product_id'] ), array( 'name'=>I18n::t( 'Status' ), 'value'=>array( 'waiting'=>I18n::t( 'Waiting' ), 'sending'=>I18n::t( 'Sending' ), 'sent'=>I18n::t( 'Sent' ), 'cancelled'=>I18n::t( 'Cancelled' ) )[ $row['status'] ] ?? $row['status'] ), array( 'name'=>I18n::t( 'Requested' ), 'value'=>$row['created'] ), ) ); } return array( 'data'=>$data, 'done'=>true ); } public static function erase( string $email, int $page = 1 ): array { global $wpdb; $removed = (int) $wpdb->delete( self::table(), array( 'email_hash'=>hash( 'sha256', strtolower( $email ) ) ) ); return array( 'items_removed'=>$removed > 0, 'items_retained'=>false, 'messages'=>array(), 'done'=>true ); } public static function cleanup(): void { global $wpdb; $table = self::table(); // A request stuck in 'sending' (the process stopped mid-mail) is never retried, so it cannot be emailed twice; it just ages out. $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE status IN ('sent','cancelled','sending') AND created<%s", $table, gmdate( 'Y-m-d H:i:s', time() - 90 * DAY_IN_SECONDS ) ) ); $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE status='waiting' AND created<%s", $table, gmdate( 'Y-m-d H:i:s', time() - 365 * DAY_IN_SECONDS ) ) ); } }