prefix . 'emails404_retry_queue'; } public static function create_table() { global $wpdb; $table_name = self::table_name(); $charset_collate = $wpdb->get_charset_collate(); require_once ABSPATH . 'wp-admin/includes/upgrade.php'; // `payload` stores only the already-minimal Signup_Dto fields // (email, name, consent, source reference, destination, and the // idempotency key that makes a retry safe) - never a raw API // response, never an Authorization header. See Part N/O. $sql = "CREATE TABLE {$table_name} ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, created_at DATETIME NOT NULL, next_attempt_at DATETIME NOT NULL, attempts SMALLINT UNSIGNED NOT NULL DEFAULT 0, provider VARCHAR(50) NOT NULL, payload LONGTEXT NOT NULL, PRIMARY KEY (id), KEY next_attempt_at (next_attempt_at) ) {$charset_collate};"; dbDelta( $sql ); } public static function drop_table() { global $wpdb; $table_name = self::table_name(); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from a trusted constant, no user input. $wpdb->query( "DROP TABLE IF EXISTS {$table_name}" ); } public static function schedule_cron() { if ( self::using_action_scheduler() ) { return; } if ( ! wp_next_scheduled( self::CRON_HOOK ) ) { wp_schedule_event( time() + self::BASE_DELAY_SECONDS, 'hourly', self::CRON_HOOK ); } } public static function unschedule_cron() { $timestamp = wp_next_scheduled( self::CRON_HOOK ); if ( $timestamp ) { wp_unschedule_event( $timestamp, self::CRON_HOOK ); } } /** Action Scheduler (bundled with WooCommerce and several other plugins) is preferred when present - runs more reliably than WP-Cron on low-traffic sites and has its own retry/backoff-aware runner. Never installed as a new dependency by this plugin itself. */ public static function using_action_scheduler() { return function_exists( 'as_schedule_single_action' ) && function_exists( 'as_next_scheduled_action' ); } public static function enqueue( Signup_Dto $dto, $provider_slug ) { global $wpdb; $payload = wp_json_encode( array( 'email' => $dto->email, 'first_name' => $dto->first_name, 'last_name' => $dto->last_name, 'consent' => $dto->consent, 'source_reference' => $dto->source_reference, 'destination_form_public_id' => $dto->destination_form_public_id, 'idempotency_key' => $dto->idempotency_key, 'consent_text' => $dto->consent_text, ) ); $wpdb->insert( self::table_name(), array( 'created_at' => current_time( 'mysql', true ), 'next_attempt_at' => gmdate( 'Y-m-d H:i:s', time() + self::BASE_DELAY_SECONDS ), 'attempts' => 0, 'provider' => sanitize_key( $provider_slug ), 'payload' => $payload, ), array( '%s', '%s', '%d', '%s', '%s' ) ); $row_id = (int) $wpdb->insert_id; if ( self::using_action_scheduler() && $row_id ) { as_schedule_single_action( time() + self::BASE_DELAY_SECONDS, self::AS_HOOK, array( 'row_id' => $row_id ), '404-emails' ); } return $row_id; } public static function pending_count() { global $wpdb; $table_name = self::table_name(); return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table_name}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from a trusted constant, no dynamic input. } /** WP-Cron entry point - processes every row whose next_attempt_at has passed. No-op when Action Scheduler is handling retries instead (each row was already individually scheduled at enqueue time). */ public static function process_due( Sync_Service $sync_service ) { if ( self::using_action_scheduler() ) { return; } global $wpdb; $table_name = self::table_name(); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from a trusted constant; the only dynamic value is passed via prepare(). $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table_name} WHERE next_attempt_at <= %s ORDER BY next_attempt_at ASC LIMIT 20", current_time( 'mysql', true ) ) ); foreach ( (array) $rows as $row ) { self::attempt_row( $row, $sync_service ); } } /** Action Scheduler entry point for a single row. */ public static function process_row( $row_id, Sync_Service $sync_service ) { global $wpdb; $table_name = self::table_name(); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from a trusted constant; $row_id passed via prepare(). $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table_name} WHERE id = %d", $row_id ) ); if ( $row ) { self::attempt_row( $row, $sync_service ); } } private static function attempt_row( $row, Sync_Service $sync_service ) { global $wpdb; $payload = json_decode( $row->payload, true ); if ( ! is_array( $payload ) ) { self::delete_row( $row->id ); return; } $dto = new Signup_Dto( $payload['email'], $payload['first_name'], $payload['last_name'], $payload['consent'], $payload['source_reference'], $payload['destination_form_public_id'], $payload['idempotency_key'], isset( $payload['consent_text'] ) ? $payload['consent_text'] : null ); // Sync_Service::attempt() (not send()) - this row already *is* the // retry-queue's own bookkeeping, so it owns update/delete itself // below rather than letting Sync_Service create a second, // independent queue row for the same logical retry. $response = $sync_service->attempt( $dto, $row->provider ); if ( $response->success || ! $response->retryable ) { self::delete_row( $row->id ); return; } $attempts = (int) $row->attempts + 1; if ( $attempts >= self::MAX_ATTEMPTS ) { // Bounded - never retried forever (Part M). The failure is // already recorded by Sync_Service::send() as OUTCOME_FAILED // on this same call once retryable stops mattering; here we // just stop scheduling further attempts. self::delete_row( $row->id ); return; } $delay = min( self::BASE_DELAY_SECONDS * ( 2 ** $attempts ), self::MAX_DELAY_SECONDS ); $wpdb->update( self::table_name(), array( 'attempts' => $attempts, 'next_attempt_at' => gmdate( 'Y-m-d H:i:s', time() + $delay ), ), array( 'id' => $row->id ), array( '%d', '%s' ), array( '%d' ) ); if ( self::using_action_scheduler() ) { as_schedule_single_action( time() + $delay, self::AS_HOOK, array( 'row_id' => $row->id ), '404-emails' ); } } private static function delete_row( $id ) { global $wpdb; $wpdb->delete( self::table_name(), array( 'id' => $id ), array( '%d' ) ); } }