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 '
'; foreach ( $cards as $key => $card ) { echo '

' . esc_html( $card[0] ) . '

' . esc_html( number_format_i18n( $card[1] ) ) . '' . esc_html( $card[2] ) . '
'; } echo '

' . esc_html__( 'All retained jobs, across forms and destinations. Accepted does not confirm inbox delivery or downstream processing.', 'killerconversions' ) . '

' . esc_html__( 'Background processing', 'killerconversions' ) . '

' . esc_html__( 'Queue health', 'killerconversions' ) . '

' . esc_html( $health['due'] ) . ' ' . esc_html__( 'due now', 'killerconversions' ) . '
' . esc_html__( 'Last worker run', 'killerconversions' ) . '
' . esc_html( $date( $health['last_started'] ) ) . '
' . esc_html__( 'Last completion', 'killerconversions' ) . '
' . esc_html( $date( $health['last_finished'] ) ) . '
' . esc_html__( 'Next scheduled run', 'killerconversions' ) . '
' . esc_html( $date( $health['next_worker'] ) ) . '
' . esc_html__( 'Expired worker leases', 'killerconversions' ) . '
' . esc_html( $health['expired_leases'] ) . '
'; if ( ! $health['storage_version_ok'] ) { echo '

' . esc_html__( 'Queue storage upgrade is incomplete. Ask the site administrator to check database permissions and the WordPress error log before sending.', 'killerconversions' ) . '

'; } if ( $health['expired_leases'] ) { echo '

' . 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' ) . '

'; } if ( $health['oldest_due'] && strtotime( $health['oldest_due'] . ' UTC' ) < time() - 300 ) { echo '

' . 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' ) . '

'; } if ( $health['wp_cron_disabled'] || ! $health['next_worker'] ) { echo '

' . 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' ) . '

'; wp_nonce_field( 'kconv_delivery_run' ); echo ' ' . esc_html__( 'This can send configured notifications now; it does not bypass consent, backoff or revision checks.', 'killerconversions' ) . '
'; } public static function admin_replay() { if ( ! self::allowed() ) { wp_die( esc_html__( 'You cannot replay this delivery.', 'killerconversions' ), '', array( 'response' => 403 ) ); } if ( ! isset( $_POST['_wpnonce'] ) || ! is_string( $_POST['_wpnonce'] ) ) { wp_die( esc_html__( 'You cannot replay this delivery.', 'killerconversions' ), '', array( 'response' => 403 ) ); } $id = isset( $_POST['job_id'] ) && is_scalar( $_POST['job_id'] ) ? absint( $_POST['job_id'] ) : 0; $nonce = sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ); if ( ! wp_verify_nonce( $nonce, 'kconv_delivery_replay_' . $id ) ) { wp_die( esc_html__( 'You cannot replay this delivery.', 'killerconversions' ), '', array( 'response' => 403 ) ); } $result = self::replay( $id, $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&replayed=1' ) ); exit; } public static function entries_deleted( $ids ) { global $wpdb; $ids = array_values( array_filter( array_map( 'absint', (array) $ids ) ) ); if ( ! $ids ) { return; } $list = implode( ',', $ids ); // 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. foreach ( self::cleanup_tables() as $table ) { $wpdb->query( "DELETE FROM $table WHERE entry_id IN ($list)" ); } } public static function cleanup() { global $wpdb; $entries = KCONV_Core::table( 'entries' ); // 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. foreach ( self::cleanup_tables() as $table ) { $wpdb->query( $wpdb->prepare( "DELETE d FROM %i d LEFT JOIN %i e ON d.entry_id=e.id WHERE e.id IS NULL", $table, $entries ) ); } } /** Clean retained legacy records even when Pro is inactive, without assuming its table exists. */ private static function cleanup_tables() { global $wpdb; $tables = array( self::table( 'events' ), self::table() ); $legacy = $wpdb->prefix . 'kconv_pro_delivery'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Inspect the fixed optional legacy table afresh; Free-only installations never created it. $found = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $legacy ) ) ); if ( $wpdb->last_error ) { return array(); } if ( $found === $legacy ) { $tables[] = $legacy; } return $tables; } public static function menu() { add_submenu_page( 'killerconversions', __( 'Delivery', 'killerconversions' ), __( 'Delivery', 'killerconversions' ), current_user_can( 'manage_options' ) ? 'manage_options' : 'kconv_manage_delivery', 'kconv-pro-delivery', array( __CLASS__, 'report' ) ); } public static function diagnostic( $reason ) { $messages = array( 'http_accepted' => __( 'Destination returned HTTP 2xx; downstream processing is not verified.', 'killerconversions' ), 'mail_accepted' => __( 'WordPress mail transport accepted the request. Inbox delivery is not verified; review your SMTP provider logs if the notification is missing.', 'killerconversions' ), 'entry_storage' => __( 'The stored entry could not be read. Nothing was sent by this attempt; the job and its history were retained. Temporary storage failures retry within the existing attempt limit.', 'killerconversions' ), 'mail_failed' => __( 'WordPress mail transport failed or threw an error. Check your SMTP plugin, credentials and hosting mail restrictions. Temporary failures retry automatically; review a new send after correcting settings.', 'killerconversions' ), 'mail_configuration' => __( 'Review the notification address and routing rules in Connect.', 'killerconversions' ), 'mail_reply_to' => __( 'The retained Reply-To email is invalid. Review the mapped email field and this entry before requesting another send.', 'killerconversions' ), 'origin_changed' => __( 'This site address or environment changed. An administrator must review and authorize outbound delivery from this site. Pending jobs are retained.', 'killerconversions' ), 'mail_no_recipient' => __( 'No routing rule matched and the default notification address is blank. The entry remains in Inbox.', 'killerconversions' ), 'consent_context_changed' => __( 'The permission wording, message or mapping changed. Obtain a fresh visitor choice; historical answers cannot be repurposed.', 'killerconversions' ), 'reviewed_resend' => __( 'An authorized operator reviewed the current destination and selected payload, then requested this new dispatch.', 'killerconversions' ), 'reviewed_payload_changed' => __( 'Cancelled because the retained payload changed after approval. Review the entry again before creating another dispatch.', 'killerconversions' ), 'http_auth' => __( 'Destination rejected authentication or access. Verify its credential and permissions.', 'killerconversions' ), 'http_rate_limited' => __( 'Destination rate limited the request. Retry-After is honored up to 24 hours.', 'killerconversions' ), 'http_server_error' => __( 'Destination reported a temporary server failure.', 'killerconversions' ), 'http_client_error' => __( 'Destination rejected the request. Review field mapping, list and endpoint.', 'killerconversions' ), 'http_redirect_blocked' => __( 'Redirect refused. Configure the final HTTPS endpoint.', 'killerconversions' ), 'transport_failed' => __( 'Network or TLS exchange failed. Delivery may have reached the destination; retry can duplicate it.', 'killerconversions' ), 'dns_failed' => __( 'The destination hostname could not be resolved.', 'killerconversions' ), 'blocked_url' => __( 'Destination must be a public HTTPS DNS hostname on port 443.', 'killerconversions' ), 'blocked_address' => __( 'DNS returned a private, reserved or unsupported address; nothing was sent.', 'killerconversions' ), 'external_http_blocked' => __( 'WordPress external HTTP is disabled on this installation.', 'killerconversions' ), 'transport_unavailable' => __( 'PHP cURL with DNS pinning is required.', 'killerconversions' ), 'tls_ca_unavailable' => __( 'The WordPress CA bundle is unavailable; TLS verification cannot proceed.', 'killerconversions' ), 'transport_configuration' => __( 'Could not configure the secure transport.', 'killerconversions' ), 'transport_address' => __( 'Connected address did not match the pinned address.', 'killerconversions' ), 'invalid_payload' => __( 'The stored entry could not be encoded within the delivery size limit.', 'killerconversions' ), 'invalid_headers' => __( 'The destination credential format is invalid.', 'killerconversions' ), 'destination_changed' => __( 'Cancelled because destination settings changed. Use Review a new send to approve the current destination and selected payload; ordinary replay never reroutes it.', 'killerconversions' ), 'destination_disabled' => __( 'Destination is disabled or its configuration is invalid.', 'killerconversions' ), 'form_unavailable' => __( 'The form is no longer published.', 'killerconversions' ), 'consent_missing' => __( 'Skipped: this entry did not give the required subscriber consent.', 'killerconversions' ), 'email_missing' => __( 'The email mapping was empty or hidden on this entry.', 'killerconversions' ), 'manual_replay' => __( 'An administrator requested another bounded delivery cycle.', 'killerconversions' ), 'lease_expired' => __( 'A worker stopped before confirming the result. Retry may duplicate a remote delivery.', 'killerconversions' ), 'legacy_import' => __( 'Imported 0.1 delivery history. Historical entries cannot be replayed.', 'killerconversions' ), ); if ( '' === $reason ) { return 'Waiting for background processing.'; } return isset( $messages[ $reason ] ) ? $messages[ $reason ] : __( 'No recognized diagnostic was recorded. Review the status and history before choosing a recovery action.', 'killerconversions' ); } /** Static, non-sensitive guidance: never render provider response bodies or credential-bearing URLs. */ public static function diagnostic_next_step( $reason ) { $groups = array( 'entry_storage' => __( 'Ask the site host to repair database access. Waiting jobs retry automatically; retry an exhausted failure after storage is available again.', 'killerconversions' ), 'http_auth,invalid_headers' => __( 'Ask an administrator to replace the credential or correct its provider permissions. Saving a change cancels earlier jobs; review a new send for any retained entry you still want to send.', 'killerconversions' ), 'http_client_error,email_missing,mail_configuration,destination_disabled' => __( 'Review the form’s selected fields, recipient and destination configuration. After saving a correction, use Review a new send for a retained entry.', 'killerconversions' ), 'http_redirect_blocked,blocked_url' => __( 'Ask an administrator to configure the final public HTTPS endpoint on port 443. Redirects and local endpoints are not followed.', 'killerconversions' ), 'http_rate_limited,http_server_error' => __( 'Check the next-attempt time and the provider’s status. A waiting job retries automatically. Retry an exhausted failure only after the provider is ready.', 'killerconversions' ), 'transport_failed,lease_expired,transport_address' => __( 'Check the receiver’s logs for this delivery reference before replaying. A lost response can hide successful receipt, so another attempt may duplicate it.', 'killerconversions' ), 'mail_failed' => __( 'Ask an administrator to check the SMTP plugin and hosting mail service. After restoring service, retry a failed job if its saved recipient is still correct.', 'killerconversions' ), 'dns_failed,blocked_address' => __( 'Ask the destination owner to check public DNS and hosting. Delivery requires a public IPv4 address; do not bypass private-address protections.', 'killerconversions' ), 'external_http_blocked,transport_unavailable,tls_ca_unavailable,transport_configuration' => __( 'Ask the site host to review outbound HTTPS policy, PHP cURL support and the WordPress certificate bundle. Keep certificate and address checks enabled.', 'killerconversions' ), 'consent_missing,consent_context_changed' => __( 'Do not override missing or changed consent. Ask the person to submit the current form with its optional subscriber consent if they wish to join the list.', 'killerconversions' ), 'destination_changed,reviewed_payload_changed' => __( 'Open Review a new send to inspect and approve the current destination and payload. Ordinary replay cannot send an old job to a changed destination.', 'killerconversions' ), 'form_unavailable' => __( 'Ask an administrator to check that the original form still exists and is published before considering another dispatch.', 'killerconversions' ), 'invalid_payload' => __( 'Review the retained entry. Correct the form or integration mapping for future submissions; do not edit historical answers merely to force delivery.', 'killerconversions' ), 'http_accepted,mail_accepted' => __( 'If the result is missing, check the destination or mail provider’s logs using this delivery reference. Acceptance alone does not confirm the final result.', 'killerconversions' ), 'legacy_import' => __( 'Keep this record for reference. Imported historical jobs cannot be replayed.', 'killerconversions' ), ); foreach ( $groups as $reasons => $guidance ) { if ( in_array( $reason, explode( ',', $reasons ), true ) ) { return $guidance; } } return ''; } /** Read-only report filters are normalized separately from queue/replay operations. */ public static function report_filters( $input ) { $input = is_array( $input ) ? $input : array(); $statuses = array( 'waiting', 'queued', 'retry', 'processing', 'accepted', 'failed', 'skipped', 'cancelled' ); $result = array( 'delivery_status' => '', 'delivery_form' => 0, 'delivery_destination' => '', 'delivery_page' => 1 ); if ( isset( $input['delivery_status'] ) && is_string( $input['delivery_status'] ) && in_array( $input['delivery_status'], $statuses, true ) ) { $result['delivery_status'] = $input['delivery_status']; } if ( isset( $input['delivery_destination'] ) && is_string( $input['delivery_destination'] ) && isset( self::destinations()[ $input['delivery_destination'] ] ) ) { $result['delivery_destination'] = $input['delivery_destination']; } foreach ( array( 'delivery_form', 'delivery_page' ) as $key ) { if ( isset( $input[ $key ] ) && ( is_string( $input[ $key ] ) || is_int( $input[ $key ] ) ) && preg_match( '/^[0-9]{1,10}$/D', (string) $input[ $key ] ) ) { $result[ $key ] = max( 'delivery_page' === $key ? 1 : 0, (int) $input[ $key ] ); } } return $result; } public static function scope_sql() { return 'entry_id IN (SELECT id FROM ' . KCONV_Core::table( 'entries' ) . ' scope_entry WHERE ' . KCONV_Entry_Access::where( 'scope_entry', 'delivery' ) . ')'; } public static function report_where( $input ) { global $wpdb; $filter = self::report_filters( $input ); $clauses = array( self::scope_sql() ); if ( 'waiting' === $filter['delivery_status'] ) { $clauses[] = "status IN ('queued','retry')"; } elseif ( $filter['delivery_status'] ) { $clauses[] = $wpdb->prepare( 'status=%s', $filter['delivery_status'] ); } if ( $filter['delivery_form'] ) { $clauses[] = $wpdb->prepare( 'form_id=%d', $filter['delivery_form'] ); } if ( $filter['delivery_destination'] ) { $clauses[] = $wpdb->prepare( 'destination=%s', $filter['delivery_destination'] ); } return implode( ' AND ', $clauses ); } public static function report_url( $input, $changes = array() ) { $filters = self::report_filters( array_merge( self::report_filters( $input ), $changes ) ); return add_query_arg( array_merge( array( 'page' => 'kconv-pro-delivery' ), $filters ), admin_url( 'admin.php' ) ); } public static function settings_url( $form_id, $destination ) { if ( ! current_user_can( 'manage_options' ) || ! isset( self::destinations()[ $destination ] ) || ! $form_id ) { return ''; } $post = get_post( $form_id ); if ( ! $post || 'kconv_form' !== $post->post_type ) { return ''; } return admin_url( 'admin.php?page=killerconversions&edit=' . (int) $form_id ) . '#kconv-connect-' . $destination; } public static function status_label( $status ) { $labels = array( 'waiting' => __( 'Waiting', 'killerconversions' ), 'queued' => __( 'Queued', 'killerconversions' ), 'retry' => __( 'Retry scheduled', 'killerconversions' ), 'processing' => __( 'Processing', 'killerconversions' ), 'accepted' => __( 'Accepted for sending', 'killerconversions' ), 'failed' => __( 'Failed', 'killerconversions' ), 'skipped' => __( 'Skipped', 'killerconversions' ), 'cancelled' => __( 'Cancelled', 'killerconversions' ), 'pending' => __( 'Pending', 'killerconversions' ), 'disabled' => __( 'Disabled', 'killerconversions' ) ); return isset( $labels[ $status ] ) ? $labels[ $status ] : $status; } /** A history identifier never grants access beyond its retained entry. */ public static function history_entry( $id ) { if ( ! self::allowed() ) { return false; } if ( ! is_int( $id ) || $id < 1 ) { return false; } global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Read the current job/entry binding and retention before private history access; identifiers and scalar values are prepared. $entry = $wpdb->get_row( $wpdb->prepare( 'SELECT e.* FROM %i e INNER JOIN %i j ON j.entry_id=e.id WHERE j.id=%d AND e.expires_at>%s', KCONV_Core::table( 'entries' ), self::table(), $id, gmdate( 'Y-m-d H:i:s' ) ), ARRAY_A ); if ( $wpdb->last_error || ! $entry ) { return false; } if ( ! KCONV_Entry_Access::allowed( $entry, 'delivery' ) ) { return false; } return $entry; } public static function history_events( $id ) { if ( ! self::history_entry( $id ) ) { return new WP_Error( 'forbidden', __( 'This delivery history is unavailable or outside your access.', 'killerconversions' ) ); } global $wpdb; $scope = KCONV_Entry_Access::where( 'e', 'delivery' ); // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Entry_Access::where receives only literal alias e and ability delivery; it allowlists modes/abilities, normalizes every form ID with absint, and prepares the assigned-user value. The outer query prepares identifiers, job ID and expiry, and repeats current entry scope and event-entry binding. $events = $wpdb->get_results( $wpdb->prepare( "SELECT h.created_at,h.event,h.status,h.reason,h.http_code,h.attempt,h.actor_id FROM %i h INNER JOIN %i j ON j.id=h.job_id AND j.entry_id=h.entry_id INNER JOIN %i e ON e.id=j.entry_id WHERE j.id=%d AND e.expires_at>%s AND $scope ORDER BY h.id ASC LIMIT 150", self::table( 'events' ), self::table(), KCONV_Core::table( 'entries' ), $id, gmdate( 'Y-m-d H:i:s' ) ), ARRAY_A ); if ( $wpdb->last_error || ! is_array( $events ) ) { return new WP_Error( 'storage', __( 'Delivery history could not be read. Reload the page to try again.', 'killerconversions' ) ); } return $events; } public static function report() { if ( ! self::allowed() ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only history navigation. Access is checked against the current retained entry before any report output; this request does not change state. $history_id = isset( $_GET['job'] ) && is_scalar( $_GET['job'] ) ? absint( $_GET['job'] ) : 0; $history = $history_id ? self::history_events( $history_id ) : array(); if ( is_wp_error( $history ) ) { wp_die( esc_html( $history->get_error_message() ), '', array( 'response' => 'forbidden' === $history->get_error_code() ? 403 : 503 ) ); } wp_enqueue_style( 'kconv-delivery-workspace', KCONV_URL . 'assets/delivery-workspace.css', array( 'kconv-workspace' ), KCONV_VERSION ); wp_enqueue_style( 'kconv-connection-review', KCONV_URL . 'assets/connection-review.css', array( 'kconv-delivery-workspace' ), KCONV_VERSION ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only report() navigation/filter input; values are normalized or allowlisted and escaped at output. No state change occurs from this read; mutations use separate capability/nonce guards. if ( isset( $_GET['review_entry'] ) && is_scalar( $_GET['review_entry'] ) && class_exists( 'KCONV_Delivery_Recovery' ) ) { echo '
'; if ( method_exists( 'KCONV_Admin', 'navigation' ) ) { KCONV_Admin::navigation( 'delivery' ); } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only report() navigation/filter input; values are normalized or allowlisted and escaped at output. No state change occurs from this read; mutations use separate capability/nonce guards. echo '
'; KCONV_Delivery_Recovery::render( absint( $_GET['review_entry'] ) ); echo '
'; return; } global $wpdb; $table = self::table(); $now = gmdate( 'Y-m-d H:i:s' ); $statuses = array( 'waiting', 'queued', 'retry', 'processing', 'accepted', 'failed', 'skipped', 'cancelled' ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only report() navigation/filter input; values are normalized or allowlisted and escaped at output. No state change occurs from this read; mutations use separate capability/nonce guards. $filters = self::report_filters( $_GET ); $filter = $filters['delivery_status']; $page = $filters['delivery_page']; $where = self::report_where( $filters ); // 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. $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM $table WHERE $where" ); $page = min( $page, max( 1, (int) ceil( $total / 100 ) ) ); // 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. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE $where ORDER BY updated_at DESC,id DESC LIMIT 100 OFFSET %d", $table, ( $page - 1 ) * 100 ), ARRAY_A ); echo '
'; if ( method_exists( 'KCONV_Admin', 'navigation' ) ) { KCONV_Admin::navigation( 'delivery' ); } echo '
' . esc_html__( 'From submission to destination', 'killerconversions' ) . '

' . esc_html__( 'Keep every next step in sight.', 'killerconversions' ) . '

' . esc_html__( 'See what was accepted, what is waiting and what needs your attention.', 'killerconversions' ) . '

' . esc_html__( 'Delivery monitor', 'killerconversions' ) . '
'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only report() navigation/filter input; values are normalized or allowlisted and escaped at output. No state change occurs from this read; mutations use separate capability/nonce guards. if ( isset( $_GET['resend_queued'] ) ) { echo '

' . esc_html__( 'The reviewed new send is queued. Repeated confirmation of the same review does not create another job.', 'killerconversions' ) . '

'; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only report() navigation/filter input; values are normalized or allowlisted and escaped at output. No state change occurs from this read; mutations use separate capability/nonce guards. if ( isset( $_GET['worker_ran'] ) ) { echo '

' . esc_html__( 'The worker finished a bounded batch. Check individual job outcomes below.', 'killerconversions' ) . '

'; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only report() navigation/filter input; values are normalized or allowlisted and escaped at output. No state change occurs from this read; mutations use separate capability/nonce guards. if ( isset( $_GET['replayed'] ) ) { echo '

' . esc_html__( 'Retry queued. Its delivery ID is unchanged for receiver deduplication.', 'killerconversions' ) . '

'; } echo ''; self::health_panel(); echo '
' . esc_html__( 'Choose the right recovery action', 'killerconversions' ) . '
' . esc_html__( 'Retry this failed delivery', 'killerconversions' ) . '
' . esc_html__( 'Starts another bounded cycle for the same failed job and saved destination. Use it after restoring the same service. Its reference stays the same; another attempt can still duplicate a message.', 'killerconversions' ) . '
' . esc_html__( 'Review a new send', 'killerconversions' ) . '
' . esc_html__( 'Inspect the current destination and selected answers before approving a new job. Use this after a recipient or mapping change, or for a retained entry without a retryable job. Original history and consent requirements remain intact.', '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' ) . '

'; echo '

' . esc_html__( 'Delivery activity', 'killerconversions' ) . '

' . esc_html( number_format_i18n( $total ) ) . ' ' . esc_html__( 'matching jobs · 100 per page · times in UTC', 'killerconversions' ) . '

' . esc_html__( 'Clear', 'killerconversions' ) . '
'; echo '
'; foreach ( array( __( 'Entry / form', 'killerconversions' ), __( 'Destination', 'killerconversions' ), __( 'Status / diagnosis', 'killerconversions' ), __( 'Attempts', 'killerconversions' ), __( 'Next attempt (UTC)', 'killerconversions' ), __( 'Actions', 'killerconversions' ) ) as $title ) { echo ''; } echo ''; foreach ( $rows as $row ) { echo ''; } if ( ! $rows ) { $filtered = $filter || $filters['delivery_form'] || $filters['delivery_destination']; $empty_title = 'failed' === $filter ? __( 'No failed jobs in this view.', 'killerconversions' ) : __( 'No jobs in this view yet.', 'killerconversions' ); $empty_detail = $filtered ? __( 'No retained jobs match these filters. Clear the filters to inspect other activity; this view does not establish inbox receipt.', 'killerconversions' ) : __( 'Configured destinations queue jobs when a form receives a submission. Use a published test form to check the complete workflow; an editor preview does not send a notification.', 'killerconversions' ); echo ''; } echo '
' . esc_html( $title ) . '
' . 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 '
' . esc_html__( 'Next step', 'killerconversions' ) . '

' . esc_html( $next_step ) . '

'; } 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 '
'; wp_nonce_field( 'kconv_delivery_replay_' . $row['id'] ); 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__( 'Delivery behavior, privacy and retry limits', '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' ) . '

'; echo '

' . 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__( 'Job history', 'killerconversions' ) . ' #' . esc_html( $history_id ) . '

'; foreach ( array( __( 'Time (UTC)', 'killerconversions' ), __( 'Event', 'killerconversions' ), __( 'Status', 'killerconversions' ), __( 'Reason', 'killerconversions' ), __( 'HTTP', 'killerconversions' ), __( 'Attempt', 'killerconversions' ), __( 'Operator ID', 'killerconversions' ) ) as $heading ) { echo ''; } echo ''; foreach ( $history as $event ) { echo ''; foreach ( $event as $value ) { echo ''; } echo ''; } if ( ! $history ) { echo ''; } echo '
' . esc_html( $heading ) . '
' . esc_html( $value ) . '
' . esc_html__( 'No detailed history available.', 'killerconversions' ) . '
'; } echo '
'; } }