prefix . ( 'events' === $kind ? 'kconv_delivery_events' : 'kconv_delivery_jobs' ); } public static function boot() { add_filter( 'cron_schedules', array( __CLASS__, 'schedules' ) ); add_action( 'kconv_delivery_tick', array( __CLASS__, 'work' ) ); add_action( 'kconv_delivery_wake', array( __CLASS__, 'work' ) ); add_filter( 'kconv_entry_storage_errors', array( __CLASS__, 'store_with_entry' ), 10, 4 ); add_filter( 'kconv_entry_transaction_tables', array( __CLASS__, 'transaction_tables' ), 10, 2 ); add_action( 'kconv_entries_deleted', array( __CLASS__, 'entries_deleted' ) ); add_action( 'kconv_cleanup', array( __CLASS__, 'cleanup' ) ); add_action( 'admin_menu', array( __CLASS__, 'menu' ), 30 ); add_action( 'admin_post_kconv_delivery_replay', array( __CLASS__, 'admin_replay' ) ); add_action( 'admin_post_kconv_delivery_run', array( __CLASS__, 'admin_run' ) ); if ( get_option( 'kconv_delivery_db_version' ) !== self::DB_VERSION || ! KCONV_Free_Ownership::verify_table( self::table() ) || ! KCONV_Free_Ownership::verify_table( self::table( 'events' ) ) ) { self::install(); } add_action( 'init', array( __CLASS__, 'ensure_schedule' ) ); } public static function ensure_schedule() { if ( ! wp_next_scheduled( 'kconv_delivery_tick' ) ) { wp_schedule_event( time() + 60, 'kconv_delivery_minute', 'kconv_delivery_tick' ); } } public static function schedules( $schedules ) { $schedules['kconv_delivery_minute'] = array( 'interval' => 60, 'display' => __( 'Every minute (KillerConversions delivery)', 'killerconversions' ) ); return $schedules; } public static function deactivate() { wp_clear_scheduled_hook( 'kconv_delivery_tick' ); wp_clear_scheduled_hook( 'kconv_delivery_wake' ); } public static function install() { global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $jobs = self::table(); $events = self::table( 'events' ); $charset = $wpdb->get_charset_collate(); dbDelta( "CREATE TABLE $jobs ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, delivery_id varchar(36) NOT NULL, entry_id bigint(20) unsigned NOT NULL, form_id bigint(20) unsigned NOT NULL, destination varchar(20) NOT NULL, revision varchar(64) NOT NULL, dispatch_key varchar(36) NOT NULL DEFAULT '', payload_digest char(64) NOT NULL DEFAULT '', consent_digest char(64) NOT NULL DEFAULT '', status varchar(20) NOT NULL, attempts smallint unsigned NOT NULL DEFAULT 0, cycle_attempts smallint unsigned NOT NULL DEFAULT 0, replays smallint unsigned NOT NULL DEFAULT 0, available_at datetime NOT NULL, locked_until datetime NULL, lock_token varchar(36) NOT NULL DEFAULT '', reason varchar(40) NOT NULL DEFAULT '', http_code smallint unsigned NOT NULL DEFAULT 0, created_at datetime NOT NULL, updated_at datetime NOT NULL, PRIMARY KEY (id), UNIQUE KEY delivery_id (delivery_id), UNIQUE KEY entry_dispatch (entry_id,destination,revision,dispatch_key), KEY due_jobs (status,available_at), KEY entry_id (entry_id), KEY form_destination (form_id,destination), KEY lock_token (lock_token) ) ENGINE=InnoDB $charset;" ); dbDelta( "CREATE TABLE $events ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, job_id bigint(20) unsigned NOT NULL, entry_id bigint(20) unsigned NOT NULL, event varchar(24) NOT NULL, status varchar(20) NOT NULL, reason varchar(40) NOT NULL DEFAULT '', http_code smallint unsigned NOT NULL DEFAULT 0, attempt smallint unsigned NOT NULL DEFAULT 0, actor_id bigint(20) unsigned NOT NULL DEFAULT 0, created_at datetime NOT NULL, PRIMARY KEY (id), KEY job_events (job_id,id), KEY entry_id (entry_id) ) ENGINE=InnoDB $charset;" ); // Create the replacement index before removing the older, narrower unique key. // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- The durable queue uses plugin-owned fixed table names; leases, transactions, freshness and privacy deletes must see current state. Prepared values and integer-only ID lists are used. Report fragments come only from the fixed scoped-entry subquery. $new_index = $wpdb->get_results( "SHOW INDEX FROM $jobs WHERE Key_name='entry_dispatch'", ARRAY_A ); if ( count( $new_index ) !== 4 ) { return; } // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- The durable queue uses plugin-owned fixed table names; leases, transactions, freshness and privacy deletes must see current state. Prepared values and integer-only ID lists are used. Report fragments come only from the fixed scoped-entry subquery. if ( $wpdb->get_var( "SHOW INDEX FROM $jobs WHERE Key_name='entry_destination'" ) && false === $wpdb->query( "ALTER TABLE $jobs DROP INDEX entry_destination" ) ) { return; } // Historical attempts are preserved but never turned into a fresh send. $legacy = $wpdb->prefix . 'kconv_pro_delivery'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- The durable queue uses plugin-owned fixed table names; leases, transactions, freshness and privacy deletes must see current state. Prepared values and integer-only ID lists are used. Report fragments come only from the fixed scoped-entry subquery. if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $legacy ) ) ) === $legacy ) { // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- The durable queue uses plugin-owned fixed table names; leases, transactions, freshness and privacy deletes must see current state. Prepared values and integer-only ID lists are used. Report fragments come only from the fixed scoped-entry subquery. $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO %i (delivery_id,entry_id,form_id,destination,revision,status,attempts,cycle_attempts,available_at,reason,http_code,created_at,updated_at) SELECT CONCAT('legacy-',entry_id),entry_id,form_id,'webhook','legacy',IF(status='accepted','accepted','failed'),1,1,attempted_at,'legacy_import',http_code,attempted_at,attempted_at FROM %i", $jobs, $legacy ) ); } if ( ! $wpdb->last_error && KCONV_Free_Ownership::verify_table( self::table() ) && KCONV_Free_Ownership::verify_table( self::table( 'events' ) ) ) { update_option( 'kconv_delivery_db_version', self::DB_VERSION, false ); } } public static function allowed() { return current_user_can( 'manage_options' ) || current_user_can( 'kconv_manage_delivery' ); } public static function destinations() { return array_merge( KCONV_Integrations::destinations(), class_exists( 'KCONV_Mail' ) ? KCONV_Mail::destinations() : array() ); } public static function config( $form, $destination ) { return class_exists( 'KCONV_Mail' ) && KCONV_Mail::is_destination( $destination ) ? KCONV_Mail::config( $form, $destination ) : KCONV_Integrations::config( $form, $destination ); } public static function request( $destination, $config, $entry, $id ) { return KCONV_Mail::is_destination( $destination ) ? KCONV_Mail::request( $config, $entry, $id ) : KCONV_Integrations::request( $destination, $config, $entry, $id ); } public static function request_digest( $request ) { $parts = array( $request['body'], $request['to'] ?? '', $request['subject'] ?? '' ); if ( ! empty( $request['reply_to'] ) ) { $parts[] = $request['reply_to']; } if ( ! empty( $request['kconv_document'] ) ) { $parts[] = array( 'document_entry_id' => (int) $request['kconv_document']['entry_id'], 'document_sha256' => $request['kconv_document']['sha256'] ); } return hash_hmac( 'sha256', wp_json_encode( $parts ), wp_salt( 'auth' ) ); } public static function dispatch_committed( $job ) { self::changed( $job ); self::wake(); } private static function wake() { if ( ! wp_next_scheduled( 'kconv_delivery_wake' ) ) { wp_schedule_single_event( time() + 5, 'kconv_delivery_wake' ); } } public static function transaction_tables( $tables, $form ) { foreach ( self::destinations() as $destination => $label ) { $config = self::config( $form, $destination ); if ( ! is_wp_error( $config ) && $config['enabled'] ) { return array_merge( $tables, array( self::table(), self::table( 'events' ) ) ); } } return $tables; } public static function store_with_entry( $errors, $entry_id, $form, $values ) { $queued = self::enqueue( $entry_id, $form, $values ); if ( $queued->has_errors() ) { $errors->add( 'delivery_storage', __( 'We could not safely queue delivery. Please try submitting again.', 'killerconversions' ) ); } return $errors; } private static function transactional() { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- The durable queue uses plugin-owned fixed table names; leases, transactions, freshness and privacy deletes must see current state. Prepared values and integer-only ID lists are used. Report fragments come only from the fixed scoped-entry subquery. $rows = $wpdb->get_results( $wpdb->prepare( 'SHOW TABLE STATUS WHERE Name IN (%s,%s)', self::table(), self::table( 'events' ) ), ARRAY_A ); if ( count( $rows ) !== 2 ) { return false; } foreach ( $rows as $row ) { if ( 'innodb' !== strtolower( $row['Engine'] ) ) { return false; } } return true; } public static function enqueue( $entry_id, $form, $values = array() ) { global $wpdb; $jobs = self::table(); $entries = KCONV_Core::table( 'entries' ); $now = gmdate( 'Y-m-d H:i:s' ); $errors = new WP_Error(); $checked = false; foreach ( self::destinations() as $destination => $label ) { $config = self::config( $form, $destination ); if ( is_wp_error( $config ) || ! $config['enabled'] || ! $config['acknowledged'] ) { continue; } $action = 'email' === $destination || 0 === strpos( $destination, 'followup_' ) ? 'notification' : $destination; if ( ! apply_filters( 'kconv_action_allowed', true, $action, $form, $values ) ) { continue; } if ( ! $checked ) { if ( ! self::transactional() ) { $errors->add( 'delivery_engine', 'Delivery tables require InnoDB.' ); return $errors; } $checked = true; } $delivery_id = wp_generate_uuid4(); $delay = max( 0, min( 604800, (int) apply_filters( 'kconv_delivery_delay_seconds', 0, $destination, $config, $form, $values ) ) ); $available = gmdate( 'Y-m-d H:i:s', time() + $delay ); // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- The durable queue uses plugin-owned fixed table names; leases, transactions, freshness and privacy deletes must see current state. Prepared values and integer-only ID lists are used. Report fragments come only from the fixed scoped-entry subquery. $result = $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO %i (delivery_id,entry_id,form_id,destination,revision,consent_digest,status,available_at,created_at,updated_at) SELECT %s,id,form_id,%s,%s,%s,'queued',%s,%s,%s FROM %i WHERE id=%d AND form_id=%d AND expires_at>%s", $jobs, $delivery_id, $destination, $config['revision'], $config['consent_digest'] ?? '', $available, $now, $now, $entries, $entry_id, $form['id'], $now ) ); if ( false === $result ) { $errors->add( 'delivery_storage', 'Could not persist delivery.' ); } if ( 1 === $result ) { $job = self::get( $wpdb->insert_id ); if ( ! self::event( $job, 'enqueued' ) ) { $errors->add( 'delivery_audit', 'Could not persist delivery audit.' ); } self::changed( $job ); } } self::wake(); return $errors; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- The durable queue uses plugin-owned fixed table names; leases, transactions, freshness and privacy deletes must see current state. Prepared values and integer-only ID lists are used. Report fragments come only from the fixed scoped-entry subquery. public static function get( $id ) { global $wpdb; return $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE id=%d', self::table(), $id ), ARRAY_A ); } public static function event( $job, $event, $actor = 0 ) { if ( ! $job ) { return false; } global $wpdb; // An erased entry cannot be resurrected by a late worker audit write. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- The durable queue uses plugin-owned fixed table names; leases, transactions, freshness and privacy deletes must see current state. Prepared values and integer-only ID lists are used. Report fragments come only from the fixed scoped-entry subquery. return 1 === $wpdb->query( $wpdb->prepare( 'INSERT INTO %i (job_id,entry_id,event,status,reason,http_code,attempt,actor_id,created_at) SELECT j.id,j.entry_id,%s,j.status,j.reason,j.http_code,j.attempts,%d,%s FROM %i j INNER JOIN %i e ON e.id=j.entry_id WHERE j.id=%d', self::table( 'events' ), $event, $actor, gmdate( 'Y-m-d H:i:s' ), self::table(), KCONV_Core::table( 'entries' ), $job['id'] ) ); } private static function changed( $job ) { if ( $job ) { do_action( 'kconv_delivery_status_changed', (int) $job['id'], (int) $job['entry_id'], $job['destination'], $job['status'] ); } } /** Claim is one atomic conditional SQL statement; two workers cannot own the same lease. */ public static function claim() { global $wpdb; $now = gmdate( 'Y-m-d H:i:s' ); $token = wp_generate_uuid4(); $jobs = self::table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- The durable queue uses plugin-owned fixed table names; leases, transactions, freshness and privacy deletes must see current state. Prepared values and integer-only ID lists are used. Report fragments come only from the fixed scoped-entry subquery. $result = $wpdb->query( $wpdb->prepare( "UPDATE %i SET status='processing',lock_token=%s,locked_until=IF(destination IN ('email','confirmation') OR destination LIKE %s,%s,%s),attempts=attempts+1,cycle_attempts=cycle_attempts+1,updated_at=%s WHERE status IN ('queued','retry') AND available_at<=%s AND cycle_attempts<%d ORDER BY available_at,id LIMIT 1", $jobs, $token, $wpdb->esc_like( 'followup_' ) . '%', gmdate( 'Y-m-d H:i:s', time() + 900 ), gmdate( 'Y-m-d H:i:s', time() + 120 ), $now, $now, self::MAX_ATTEMPTS ) ); if ( 1 !== $result ) { return null; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- The durable queue uses plugin-owned fixed table names; leases, transactions, freshness and privacy deletes must see current state. Prepared values and integer-only ID lists are used. Report fragments come only from the fixed scoped-entry subquery. $job = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM %i WHERE lock_token=%s AND status='processing'", $jobs, $token ), ARRAY_A ); self::event( $job, 'attempt_started' ); return $job; } public static function recover() { global $wpdb; $jobs = self::table(); $now = gmdate( 'Y-m-d H:i:s' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- The durable queue uses plugin-owned fixed table names; leases, transactions, freshness and privacy deletes must see current state. Prepared values and integer-only ID lists are used. Report fragments come only from the fixed scoped-entry subquery. $stale = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE status='processing' AND locked_until<%s ORDER BY locked_until LIMIT 100", $jobs, $now ), ARRAY_A ); foreach ( $stale as $job ) { self::finish( $job, (int) $job['cycle_attempts'] < self::MAX_ATTEMPTS ? 'retry' : 'failed', 'lease_expired', 0, 60 ); } } public static function work() { if ( class_exists( 'KCONV_Delivery_Guard' ) && ! KCONV_Delivery_Guard::allowed() ) { return; } update_option( 'kconv_delivery_last_run', time(), false ); self::recover(); $processed = 0; for ( $i = 0; $i < 5; $i++ ) { $job = self::claim(); if ( ! $job ) { break; } self::process( $job ); $processed++; } update_option( 'kconv_delivery_last_finish', time(), false ); update_option( 'kconv_delivery_last_result', array( 'processed' => $processed ), false ); } private static function live_config( $job ) { $form = KCONV_Core::get_form( (int) $job['form_id'] ); if ( ! $form || is_wp_error( $form ) || 'publish' !== $form['status'] ) { return new WP_Error( 'form_unavailable', __( 'The form is not published.', 'killerconversions' ) ); } $config = self::config( $form, $job['destination'] ); if ( is_wp_error( $config ) || ! $config['enabled'] || ! $config['acknowledged'] ) { return new WP_Error( 'destination_disabled', __( 'The destination is disabled or invalid.', 'killerconversions' ) ); } if ( ! hash_equals( $job['revision'], $config['revision'] ) ) { return new WP_Error( 'destination_changed', __( 'The destination configuration has changed.', 'killerconversions' ) ); } return $config; } public static function process( $job ) { // A companion can pause its own destinations while ordinary delivery continues. try { $allowed = apply_filters( 'kconv_delivery_operation_allowed', true, $job ); if ( true !== $allowed ) { KCONV_Delivery_Guard::release( $job, 'pro_required' ); return; } self::process_guarded( $job ); } finally { do_action( 'kconv_delivery_operation_finished', $job ); } } private static function process_guarded( $job ) { global $wpdb; if ( class_exists( 'KCONV_Delivery_Guard' ) && ! KCONV_Delivery_Guard::allowed() ) { KCONV_Delivery_Guard::release( $job ); return; } if ( ! class_exists( 'KCONV_Pro' ) && ( ! isset( self::destinations()[ $job['destination'] ] ) || KCONV_Free_Ownership::premium( (int) $job['form_id'] ) ) ) { KCONV_Delivery_Guard::release( $job, 'pro_required' ); return; } $config = self::live_config( $job ); if ( is_wp_error( $config ) ) { self::finish( $job, 'cancelled', $config->get_error_code() ); return; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- The durable queue uses plugin-owned fixed table names; leases, transactions, freshness and privacy deletes must see current state. Prepared values and integer-only ID lists are used. Report fragments come only from the fixed scoped-entry subquery. $entry = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE id=%d AND expires_at>%s', KCONV_Core::table( 'entries' ), $job['entry_id'], gmdate( 'Y-m-d H:i:s' ) ), ARRAY_A ); if ( $wpdb->last_error ) { self::finish( $job, (int) $job['cycle_attempts'] < self::MAX_ATTEMPTS ? 'retry' : 'failed', 'entry_storage', 0, 60 ); return; } if ( ! $entry ) { self::entries_deleted( array( $job['entry_id'] ) ); return; } // Stored entry snapshots can require an extension even after form settings change. if ( true !== apply_filters( 'kconv_delivery_entry_allowed', true, $job, $entry ) ) { KCONV_Delivery_Guard::release( $job, 'pro_required' ); return; } $request = self::request( $job['destination'], $config, $entry, $job['delivery_id'] ); if ( is_wp_error( $request ) && 'pro_required' === $request->get_error_code() ) { KCONV_Delivery_Guard::release( $job, 'pro_required' ); return; } if ( is_wp_error( $request ) ) { self::finish( $job, in_array( $request->get_error_code(), array( 'consent_missing', 'mail_no_recipient' ), true ) ? 'skipped' : 'failed', $request->get_error_code() ); return; } if ( ! empty( $job['consent_digest'] ) && ! hash_equals( $job['consent_digest'], $config['consent_digest'] ?? '' ) ) { self::finish( $job, 'cancelled', 'consent_context_changed' ); return; } if ( ! empty( $job['payload_digest'] ) && ! hash_equals( $job['payload_digest'], self::request_digest( $request ) ) ) { self::finish( $job, 'cancelled', 'reviewed_payload_changed' ); return; } // Recheck ownership and existence immediately before I/O. A request already in flight cannot be recalled. $live = self::get( $job['id'] ); if ( ! $live || 'processing' !== $live['status'] || ! hash_equals( $job['lock_token'], $live['lock_token'] ) ) { return; } if ( class_exists( 'KCONV_Delivery_Guard' ) && ! KCONV_Delivery_Guard::allowed() ) { KCONV_Delivery_Guard::release( $job ); return; } try { /** Developers can supply an adapter transport; returning null uses the pinned HTTPS implementation. */ $result = KCONV_Mail::is_destination( $job['destination'] ) ? KCONV_Mail::send( $request ) : apply_filters( 'kconv_pre_delivery_request', null, $request, $job ); if ( null === $result ) { $result = KCONV_Integrations::send( $job['destination'], $request, $job ); } } catch ( Throwable $error ) { // Never store exception text: transport libraries may include credentials or answers. $result = array( 'status' => KCONV_Mail::is_destination( $job['destination'] ) ? 'mail_failed' : 'transport_failed', 'http_code' => 0 ); } $decision = self::classify( $result, (int) $job['cycle_attempts'], $job['delivery_id'] ); self::finish( $job, $decision['status'], $decision['reason'], $decision['http_code'], $decision['delay'] ); } public static function classify( $result, $attempt, $delivery_id = '' ) { $status = is_array( $result ) && isset( $result['status'] ) && is_string( $result['status'] ) ? $result['status'] : 'transport_failed'; $code = is_array( $result ) && isset( $result['http_code'] ) ? max( 0, min( 599, (int) $result['http_code'] ) ) : 0; if ( 'mail_accepted' === $status ) { return array( 'status' => 'accepted', 'reason' => 'mail_accepted', 'http_code' => 0, 'delay' => 0 ); } if ( 'accepted' === $status && $code >= 200 && $code < 300 ) { return array( 'status' => 'accepted', 'reason' => 'http_accepted', 'http_code' => $code, 'delay' => 0 ); } $transient = in_array( $status, array( 'transport_failed', 'dns_failed', 'mail_failed' ), true ) || 408 === $code || 429 === $code || $code >= 500; if ( $code >= 300 && $code < 500 && ! in_array( $code, array( 408, 429 ), true ) ) { $transient = false; } $allowed = array( 'mail_failed', 'mail_configuration', 'blocked_url', 'invalid_payload', 'invalid_headers', 'external_http_blocked', 'transport_unavailable', 'blocked_address', 'tls_ca_unavailable', 'transport_configuration', 'transport_address', 'dns_failed', 'transport_failed' ); $reason = 401 === $code || 403 === $code ? 'http_auth' : ( 429 === $code ? 'http_rate_limited' : ( $code >= 500 ? 'http_server_error' : ( $code >= 300 && $code < 400 ? 'http_redirect_blocked' : ( $code >= 400 ? 'http_client_error' : ( in_array( $status, $allowed, true ) ? $status : 'transport_failed' ) ) ) ) ); $backoff = array( 60, 300, 900, 3600, 14400, 86400 ); $delay = $backoff[ min( 5, max( 0, $attempt - 1 ) ) ] + ( hexdec( substr( hash( 'sha256', $delivery_id . ':' . $attempt ), 0, 4 ) ) % 31 ); $retry_after = is_array( $result ) && isset( $result['retry_after'] ) ? min( 86400, max( 0, (int) $result['retry_after'] ) ) : 0; return array( 'status' => $transient && $attempt < self::MAX_ATTEMPTS ? 'retry' : 'failed', 'reason' => $reason, 'http_code' => $code, 'delay' => max( $delay, $retry_after ) ); } public static function finish( $job, $status, $reason, $code = 0, $delay = 0 ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- The durable queue uses plugin-owned fixed table names; leases, transactions, freshness and privacy deletes must see current state. Prepared values and integer-only ID lists are used. Report fragments come only from the fixed scoped-entry subquery. $result = $wpdb->query( $wpdb->prepare( "UPDATE %i SET status=%s,reason=%s,http_code=%d,available_at=%s,updated_at=%s,lock_token='',locked_until=NULL WHERE id=%d AND status='processing' AND lock_token=%s", self::table(), $status, $reason, $code, gmdate( 'Y-m-d H:i:s', time() + $delay ), gmdate( 'Y-m-d H:i:s' ), $job['id'], $job['lock_token'] ) ); if ( 1 === $result ) { $fresh = self::get( $job['id'] ); self::event( $fresh, $status ); self::changed( $fresh ); } } public static function cancel_changed( $form_id, $destination, $revision ) { global $wpdb; // Include failed jobs: replay must not send old entries after any destination change. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- The durable queue uses plugin-owned fixed table names; leases, transactions, freshness and privacy deletes must see current state. Prepared values and integer-only ID lists are used. Report fragments come only from the fixed scoped-entry subquery. $jobs = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE form_id=%d AND destination=%s AND revision<>%s AND status IN ('queued','retry','processing','failed')", self::table(), $form_id, $destination, $revision ), ARRAY_A ); if ( $wpdb->last_error ) { self::authoring_error(); return false; } foreach ( $jobs as $job ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- The durable queue uses plugin-owned fixed table names; leases, transactions, freshness and privacy deletes must see current state. Prepared values and integer-only ID lists are used. Report fragments come only from the fixed scoped-entry subquery. $count = $wpdb->query( $wpdb->prepare( "UPDATE %i SET status='cancelled',reason='destination_changed',lock_token='',locked_until=NULL,updated_at=%s WHERE id=%d AND status IN ('queued','retry','processing','failed')", self::table(), gmdate( 'Y-m-d H:i:s' ), $job['id'] ) ); if ( false === $count ) { self::authoring_error(); return false; } if ( 1 === $count ) { $fresh = self::get( $job['id'] ); if ( ! $fresh || ! self::event( $fresh, 'cancelled', get_current_user_id() ) ) { self::authoring_error(); return false; } self::changed( $fresh ); } } return true; } private static function authoring_error() { if ( class_exists( 'KCONV_Authoring' ) && KCONV_Authoring::transaction_active() ) { do_action( 'kconv_authoring_storage_error', 'Delivery changes could not be saved.' ); } } /** A state compare-and-swap inside a transaction binds the replay audit to the reset. */ public static function replay( $id, $nonce ) { if ( ! self::allowed() ) { return new WP_Error( 'forbidden', __( 'You cannot replay this delivery.', 'killerconversions' ) ); } if ( ! is_string( $nonce ) ) { return new WP_Error( 'forbidden', __( 'You cannot replay this delivery.', 'killerconversions' ) ); } if ( ! wp_verify_nonce( sanitize_text_field( $nonce ), 'kconv_delivery_replay_' . (int) $id ) ) { return new WP_Error( 'forbidden', __( 'You cannot replay this delivery.', 'killerconversions' ) ); } global $wpdb; $job = self::get( $id ); if ( $wpdb->last_error ) { return new WP_Error( 'replay_storage', __( 'Could not read the delivery. Repair database access and retry.', 'killerconversions' ) ); } if ( ! $job || 'failed' !== $job['status'] || (int) $job['replays'] >= self::MAX_REPLAYS ) { return new WP_Error( 'not_replayable', __( 'Only failed deliveries with fewer than three manual replays can be replayed.', 'killerconversions' ) ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- The durable queue uses plugin-owned fixed table names; leases, transactions, freshness and privacy deletes must see current state. Prepared values and integer-only ID lists are used. Report fragments come only from the fixed scoped-entry subquery. $entry = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE id=%d', KCONV_Core::table( 'entries' ), $job['entry_id'] ), ARRAY_A ); if ( $wpdb->last_error ) { return new WP_Error( 'replay_storage', __( 'Could not read the entry. Repair database access and retry.', 'killerconversions' ) ); } if ( ! $entry || ! KCONV_Entry_Access::allowed( $entry, 'delivery' ) ) { return new WP_Error( 'forbidden', __( 'You cannot replay this delivery.', 'killerconversions' ) ); } $config = self::live_config( $job ); if ( is_wp_error( $config ) ) { return new WP_Error( 'destination_changed', __( 'The destination changed or was disabled. Use Review a new send to inspect and approve delivery to the current configuration.', 'killerconversions' ) ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- The durable queue uses plugin-owned fixed table names; leases, transactions, freshness and privacy deletes must see current state. Prepared values and integer-only ID lists are used. Report fragments come only from the fixed scoped-entry subquery. $exists = $wpdb->get_var( $wpdb->prepare( 'SELECT id FROM %i WHERE id=%d AND expires_at>%s', KCONV_Core::table( 'entries' ), $job['entry_id'], gmdate( 'Y-m-d H:i:s' ) ) ); if ( $wpdb->last_error ) { return new WP_Error( 'replay_storage', __( 'Could not check the entry. Repair database access and retry.', 'killerconversions' ) ); } if ( ! $exists ) { return new WP_Error( 'entry_missing', __( 'The entry was deleted or expired.', 'killerconversions' ) ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- The durable queue uses plugin-owned fixed table names; leases, transactions, freshness and privacy deletes must see current state. Prepared values and integer-only ID lists are used. Report fragments come only from the fixed scoped-entry subquery. if ( false === $wpdb->query( 'START TRANSACTION' ) ) { return new WP_Error( 'replay_storage', __( 'Could not start the replay transaction. No replay was queued; repair storage and retry.', 'killerconversions' ) ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- The durable queue uses plugin-owned fixed table names; leases, transactions, freshness and privacy deletes must see current state. Prepared values and integer-only ID lists are used. Report fragments come only from the fixed scoped-entry subquery. $entry = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE id=%d FOR UPDATE', KCONV_Core::table( 'entries' ), $job['entry_id'] ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- The durable queue uses plugin-owned fixed table names; leases, transactions, freshness and privacy deletes must see current state. Prepared values and integer-only ID lists are used. Report fragments come only from the fixed scoped-entry subquery. if ( $wpdb->last_error ) { $wpdb->query( 'ROLLBACK' ); return new WP_Error( 'replay_storage', __( 'Could not lock the entry. Repair database access and retry.', 'killerconversions' ) ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Roll back this replay transaction when its authoritative storage read or write fails. if ( ! $entry || ! KCONV_Entry_Access::allowed( $entry, 'delivery' ) ) { $wpdb->query( 'ROLLBACK' ); return new WP_Error( 'forbidden', __( 'Your access to this delivery changed. Reload before retrying.', 'killerconversions' ) ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- The durable queue uses plugin-owned fixed table names; leases, transactions, freshness and privacy deletes must see current state. Prepared values and integer-only ID lists are used. Report fragments come only from the fixed scoped-entry subquery. $count = $wpdb->query( $wpdb->prepare( "UPDATE %i SET status='queued',reason='manual_replay',cycle_attempts=0,replays=replays+1,available_at=%s,updated_at=%s,lock_token='',locked_until=NULL WHERE id=%d AND status='failed' AND replays=%d", self::table(), gmdate( 'Y-m-d H:i:s' ), gmdate( 'Y-m-d H:i:s' ), $id, $job['replays'] ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- The durable queue uses plugin-owned fixed table names; leases, transactions, freshness and privacy deletes must see current state. Prepared values and integer-only ID lists are used. Report fragments come only from the fixed scoped-entry subquery. if ( false === $count ) { $wpdb->query( 'ROLLBACK' ); return new WP_Error( 'replay_storage', __( 'Could not save the replay. Repair database access and retry.', 'killerconversions' ) ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Roll back this replay transaction when its authoritative storage read or write fails. if ( 1 !== $count ) { $wpdb->query( 'ROLLBACK' ); return new WP_Error( 'replay_conflict', __( 'Another action already changed this delivery.', 'killerconversions' ) ); } $fresh = self::get( $id ); $audited = self::event( $fresh, 'manual_replay', get_current_user_id() ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- The durable queue uses plugin-owned fixed table names; leases, transactions, freshness and privacy deletes must see current state. Prepared values and integer-only ID lists are used. Report fragments come only from the fixed scoped-entry subquery. if ( ! $audited ) { $wpdb->query( 'ROLLBACK' ); return new WP_Error( 'replay_storage', __( 'Could not save the replay audit.', 'killerconversions' ) ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- The durable queue uses plugin-owned fixed table names; leases, transactions, freshness and privacy deletes must see current state. Prepared values and integer-only ID lists are used. Report fragments come only from the fixed scoped-entry subquery. if ( false === $wpdb->query( 'COMMIT' ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- A failed replay commit cannot be reported as queued; discard this uncommitted reset and its audit together. $wpdb->query( 'ROLLBACK' ); return new WP_Error( 'replay_storage', __( 'Could not commit the replay. No replay was confirmed; reload its status and retry after storage is repaired.', 'killerconversions' ) ); } self::changed( $fresh ); self::wake(); return true; } public static function health() { global $wpdb; $now = gmdate( 'Y-m-d H:i:s' ); $scope = self::scope_sql(); // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- The durable queue uses plugin-owned fixed table names; leases, transactions, freshness and privacy deletes must see current state. Prepared values and integer-only ID lists are used. Report fragments come only from the fixed scoped-entry subquery. $counts = $wpdb->get_results( $wpdb->prepare( "SELECT status,COUNT(*) total FROM %i WHERE $scope GROUP BY status", self::table() ), ARRAY_A ); $statuses = array(); foreach ( $counts as $row ) { $statuses[ $row['status'] ] = (int) $row['total']; } // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- The durable queue uses plugin-owned fixed table names; leases, transactions, freshness and privacy deletes must see current state. Prepared values and integer-only ID lists are used. Report fragments come only from the fixed scoped-entry subquery. $due = $wpdb->get_row( $wpdb->prepare( "SELECT COUNT(*) total,MIN(available_at) oldest FROM %i WHERE $scope AND status IN ('queued','retry') AND available_at<=%s", self::table(), $now ), ARRAY_A ); // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- The durable queue uses plugin-owned fixed table names; leases, transactions, freshness and privacy deletes must see current state. Prepared values and integer-only ID lists are used. Report fragments come only from the fixed scoped-entry subquery. $stuck = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE $scope AND status='processing' AND locked_until<%s", self::table(), $now ) ); return array( 'statuses' => $statuses, 'due' => (int) $due['total'], 'oldest_due' => $due['oldest'], 'expired_leases' => $stuck, 'last_started' => (int) get_option( 'kconv_delivery_last_run', 0 ), 'last_finished' => (int) get_option( 'kconv_delivery_last_finish', 0 ), 'next_worker' => wp_next_scheduled( 'kconv_delivery_tick' ), 'wp_cron_disabled' => defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON, 'storage_version_ok' => self::DB_VERSION === get_option( 'kconv_delivery_db_version' ) ); } public static function run_authorized( $nonce ) { if ( ! current_user_can( 'manage_options' ) ) { return new WP_Error( 'forbidden', __( 'You cannot run delivery jobs.', 'killerconversions' ) ); } if ( ! is_string( $nonce ) ) { return new WP_Error( 'forbidden', __( 'You cannot run delivery jobs.', 'killerconversions' ) ); } if ( ! wp_verify_nonce( sanitize_text_field( $nonce ), 'kconv_delivery_run' ) ) { return new WP_Error( 'forbidden', __( 'You cannot run delivery jobs.', 'killerconversions' ) ); } self::work(); return true; } public static function admin_run() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You cannot run delivery jobs.', 'killerconversions' ), '', array( 'response' => 403 ) ); } if ( ! isset( $_POST['_wpnonce'] ) || ! is_string( $_POST['_wpnonce'] ) ) { wp_die( esc_html__( 'You cannot run delivery jobs.', 'killerconversions' ), '', array( 'response' => 403 ) ); } $nonce = sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ); if ( ! wp_verify_nonce( $nonce, 'kconv_delivery_run' ) ) { wp_die( esc_html__( 'You cannot run delivery jobs.', 'killerconversions' ), '', array( 'response' => 403 ) ); } $result = self::run_authorized( $nonce ); if ( is_wp_error( $result ) ) { wp_die( esc_html( $result->get_error_message() ), '', array( 'response' => 403 ) ); } wp_safe_redirect( admin_url( 'admin.php?page=kconv-pro-delivery&worker_ran=1' ) ); exit; } public static function health_panel() { if ( class_exists( 'KCONV_Delivery_Guard' ) ) { KCONV_Delivery_Guard::panel(); } $health = self::health(); $date = function ( $value ) { return $value ? gmdate( 'Y-m-d H:i:s', $value ) : __( 'Not recorded', 'killerconversions' ); }; $cards = array( 'waiting' => array( __( 'Waiting', 'killerconversions' ), ( $health['statuses']['queued'] ?? 0 ) + ( $health['statuses']['retry'] ?? 0 ), __( 'Queued or awaiting a retry', 'killerconversions' ) ), 'processing' => array( __( 'Processing', 'killerconversions' ), $health['statuses']['processing'] ?? 0, __( 'Currently held by a worker', 'killerconversions' ) ), 'accepted' => array( __( 'Accepted for sending', 'killerconversions' ), $health['statuses']['accepted'] ?? 0, __( 'Transport accepted the request', 'killerconversions' ) ), 'failed' => array( __( 'Needs attention', 'killerconversions' ), $health['statuses']['failed'] ?? 0, __( 'Failed jobs to review', 'killerconversions' ) ), ); echo '
' . esc_html__( 'All retained jobs, across forms and destinations. Accepted does not confirm inbox delivery or downstream processing.', 'killerconversions' ) . '
' . esc_html__( 'Queue storage upgrade is incomplete. Ask the site administrator to check database permissions and the WordPress error log before sending.', 'killerconversions' ) . '
' . esc_html__( 'A worker stopped before confirming delivery. Running due jobs recovers expired leases. Remote acceptance may be uncertain, so retries can duplicate a notification.', 'killerconversions' ) . '
' . esc_html__( 'Some jobs are more than five minutes overdue. Check that your server runs WordPress due cron events every minute, then process a batch below.', 'killerconversions' ) . '
' . esc_html__( 'Visitor-triggered WP-Cron is disabled or the worker schedule is missing. A configured server scheduler must run due WordPress events; disabling WP-Cron alone does not prove that scheduler is working.', 'killerconversions' ) . '
'; } echo '' . esc_html__( 'A WordPress mail acceptance is not an inbox receipt. For missing notifications, inspect the job history and your SMTP provider logs. Fix recipient, key or mapping problems in form settings, then review a new send for retained entries.', 'killerconversions' ) . '
' . esc_html__( 'See what was accepted, what is waiting and what needs your attention.', 'killerconversions' ) . '
' . esc_html__( 'The reviewed new send is queued. Repeated confirmation of the same review does not create another job.', 'killerconversions' ) . '
' . esc_html__( 'The worker finished a bounded batch. Check individual job outcomes below.', 'killerconversions' ) . '
' . esc_html__( 'Retry queued. Its delivery ID is unchanged for receiver deduplication.', 'killerconversions' ) . '
' . esc_html__( 'Waiting jobs already have automatic attempts scheduled. Check the next-attempt time before taking another action. Acceptance confirms only the recorded transport response, not inbox receipt or downstream processing.', 'killerconversions' ) . '
| ' . esc_html( $title ) . ' | '; } echo '|||||
|---|---|---|---|---|---|
' . esc_html__( 'Entry', 'killerconversions' ) . ' #' . esc_html( $row['entry_id'] ) . '' . esc_html__( 'Form', 'killerconversions' ) . ' #' . esc_html( $row['form_id'] ) . '' . esc_html__( 'Delivery reference', 'killerconversions' ) . '' . esc_html( $row['delivery_id'] ) . ' | ' . esc_html( isset( self::destinations()[ $row['destination'] ] ) ? self::destinations()[ $row['destination'] ] : $row['destination'] ) . ' | ' . esc_html( self::status_label( $row['status'] ) ) . ' ' . esc_html( $row['http_code'] ? 'HTTP ' . $row['http_code'] : '' ) . ' ' . esc_html( self::diagnostic( $row['reason'] ) ) . ' '; $next_step = self::diagnostic_next_step( $row['reason'] ); if ( $next_step && ! in_array( $row['status'], array( 'accepted', 'processing', 'queued' ), true ) ) { echo ''; } echo ' | ' . esc_html( $row['attempts'] ) . '' . esc_html( $row['replays'] ) . ' ' . esc_html__( 'replays', 'killerconversions' ) . ' | ' . esc_html( in_array( $row['status'], array( 'queued', 'retry' ), true ) ? $row['available_at'] : '—' ) . ' | ';
if ( KCONV_Core::can( 'kconv_view_entries' ) ) { echo ' ' . esc_html__( 'View retained entry', 'killerconversions' ) . ' '; } echo '' . esc_html__( 'History', 'killerconversions' ) . ''; $settings_url = self::settings_url( (int) $row['form_id'], $row['destination'] ); if ( $settings_url ) { echo '' . esc_html__( 'Review setup', 'killerconversions' ) . ' '; } if ( class_exists( 'KCONV_Delivery_Recovery' ) ) { echo '' . esc_html__( 'Review a new send', 'killerconversions' ) . ' '; } if ( 'failed' === $row['status'] && 'legacy' !== $row['revision'] && (int) $row['replays'] < self::MAX_REPLAYS ) { echo ''; } echo ' |
| ' . esc_html( $empty_title ) . ' ' . esc_html( $empty_detail ) . ' '; if ( $filtered ) { echo '' . esc_html__( 'Show all delivery activity', 'killerconversions' ) . ' '; } echo ' | |||||
' . esc_html__( 'Temporary network errors, HTTP 408/429 and 5xx retry up to six attempts per cycle. Other 4xx and unsafe destinations stop immediately. Notification email also uses six bounded attempts.', 'killerconversions' ) . '
' . esc_html__( 'Queue and history contain identifiers and diagnostic codes only, never answer bodies, URLs, API keys or response text. Entry deletion removes its jobs and audit history. A request already in flight cannot be recalled; manage remote data with the destination provider.', 'killerconversions' ) . '
' . esc_html__( 'A delivery can be attempted more than once; retries do not guarantee receipt. Signed webhook receivers should deduplicate the stable X-KillerConversions-Delivery ID. Slack and email may duplicate on uncertain failures. Manual retry is limited to three cycles and is recorded with the operator ID.', 'killerconversions' ) . '
' . wp_kses_post( (string) paginate_links( array( 'base' => add_query_arg( array_merge( array( 'page' => 'kconv-pro-delivery' ), $filters, array( 'delivery_page' => '%#%' ) ), admin_url( 'admin.php' ) ), 'current' => $page, 'total' => max( 1, (int) ceil( $total / 100 ) ) ) ) ) . '
'; if ( $history_id ) { echo '| ' . esc_html( $heading ) . ' | '; } echo '||||||
|---|---|---|---|---|---|---|
| ' . esc_html( $value ) . ' | '; } echo '||||||
| ' . esc_html__( 'No detailed history available.', 'killerconversions' ) . ' | ||||||