300, 'display' => __( 'Every five minutes (SubmitSignal)', 'submitsignal' ) ); return $schedules; } private static function table() { global $wpdb; return $wpdb->prefix . 'submitsignal_events'; } public static function activate() { global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; // WordPress supplies the charset/collation SQL clause, not request data. $charset = $wpdb->get_charset_collate(); dbDelta( $wpdb->prepare( "CREATE TABLE %i (id varchar(36) NOT NULL, payload text NOT NULL, created_at bigint(20) unsigned NOT NULL, PRIMARY KEY (id), KEY created_at (created_at)) $charset;", self::table() ) ); update_option( 'submitsignal_db_version', self::VERSION, false ); } public static function upgrade() { if ( get_option( 'submitsignal_db_version' ) !== self::VERSION ) { self::activate(); } if ( self::settings() && ! wp_next_scheduled( self::CRON ) ) { wp_schedule_event( time() + 60, 'submitsignal_five_minutes', self::CRON ); } } public static function deactivate() { wp_clear_scheduled_hook( self::CRON ); } private static function settings() { $settings = get_option( self::SETTINGS, array() ); return is_array( $settings ) && ! empty( $settings['consent'] ) && ! empty( $settings['token'] ) ? $settings : array(); } public static function menu() { add_options_page( 'SubmitSignal', 'SubmitSignal', 'manage_options', 'submitsignal', array( __CLASS__, 'page' ) ); } public static function page() { if ( ! current_user_can( 'manage_options' ) ) { return; } $settings = self::settings(); $forms = self::forms(); $notice = get_transient( 'submitsignal_notice_' . get_current_user_id() ); delete_transient( 'submitsignal_notice_' . get_current_user_id() ); ?>
'GET', 'permission_callback' => '__return_true', 'callback' => function () { $token = get_transient( 'submitsignal_verification' ); $response = new WP_REST_Response( $token ? array( 'token' => $token ) : array( 'error' => 'No active pairing request.' ), $token ? 200 : 404 ); $response->header( 'Cache-Control', 'no-store, max-age=0' ); return $response; }, ) ); } public static function connect() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Administrator access required.', 'submitsignal' ), '', array( 'response' => 403 ) ); } check_admin_referer( 'submitsignal_connect' ); if ( self::settings() ) { self::notice( __( 'Disconnect the existing account before pairing again.', 'submitsignal' ) ); } if ( empty( $_POST['consent'] ) ) { self::notice( __( 'Consent is required before any data is transferred.', 'submitsignal' ) ); } $code = isset( $_POST['code'] ) ? sanitize_text_field( wp_unslash( $_POST['code'] ) ) : ''; if ( ! preg_match( '/^[A-Za-z0-9_-]{32,128}$/', $code ) ) { self::notice( __( 'Copy a new, complete pairing code from your dashboard.', 'submitsignal' ) ); } if ( 'https' !== wp_parse_url( home_url(), PHP_URL_SCHEME ) ) { self::notice( __( 'This website must use HTTPS before connecting.', 'submitsignal' ) ); } $verification = wp_generate_password( 48, false, false ); set_transient( 'submitsignal_verification', $verification, 300 ); $result = self::request( '/api/plugin/connect', array( 'code' => $code, 'siteUrl' => home_url( '/' ), 'siteName' => get_bloginfo( 'name' ), 'verificationToken' => $verification, 'forms' => self::forms() ), '', true ); delete_transient( 'submitsignal_verification' ); if ( is_wp_error( $result ) ) { self::notice( __( 'Connection failed. Confirm that the pairing code is current and your public WordPress REST API is reachable, then try again.', 'submitsignal' ) ); } $body = json_decode( wp_remote_retrieve_body( $result ), true ); if ( wp_remote_retrieve_response_code( $result ) < 200 || wp_remote_retrieve_response_code( $result ) >= 300 || empty( $body['siteId'] ) || empty( $body['token'] ) || ! is_string( $body['token'] ) || strlen( $body['token'] ) > 512 ) { self::notice( __( 'The service could not verify this website. Check the pairing code, HTTPS and REST API access. No connection token was saved.', 'submitsignal' ) ); } update_option( self::SETTINGS, array( 'token' => $body['token'], 'site_id' => sanitize_text_field( $body['siteId'] ), 'consent' => true, 'connected_at' => time() ), false ); self::upgrade(); self::notice( __( 'Website connected. Choose an individual form and authorize its test in the SubmitSignal dashboard.', 'submitsignal' ) ); } public static function disconnect() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Administrator access required.', 'submitsignal' ), '', array( 'response' => 403 ) ); } check_admin_referer( 'submitsignal_disconnect' ); self::purge(); self::notice( __( 'Disconnected. The connection token and queued events were removed, and metadata transfer has stopped. Temporary mail-correlation records may remain in a persistent cache until they expire within seven days. Delete the site in your hosted dashboard to revoke the server token and delete hosted data.', 'submitsignal' ) ); } public static function receipt_settings() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Administrator access required.', 'submitsignal' ), '', array( 'response' => 403 ) ); } check_admin_referer( 'submitsignal_receipts' ); $settings = self::settings(); if ( ! $settings ) { self::notice( __( 'Connect this website before enabling a test inbox.', 'submitsignal' ) ); } $settings['receipt_copies'] = isset( $_POST['receipt_copies'] ) && '1' === $_POST['receipt_copies']; update_option( self::SETTINGS, $settings, false ); self::notice( __( 'Test inbox preference saved. Hosted monitor consent is also required; normal visitor submissions are never copied.', 'submitsignal' ) ); } public static function purge() { global $wpdb; delete_option( self::SETTINGS ); delete_option( 'submitsignal_sync_status' ); delete_transient( 'submitsignal_verification' ); delete_transient( 'submitsignal_sync_lock' ); wp_clear_scheduled_hook( self::CRON ); $wpdb->query( $wpdb->prepare( 'DELETE FROM %i', self::table() ) ); $like = $wpdb->esc_like( '_transient_submitsignal_mail_' ) . '%'; $timeout = $wpdb->esc_like( '_transient_timeout_submitsignal_mail_' ) . '%'; $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE option_name LIKE %s OR option_name LIKE %s', $wpdb->options, $like, $timeout ) ); } public static function supported( $data ) { if ( ! is_array( $data ) || empty( $data['fields'] ) || ! empty( $data['payments'] ) || ! empty( $data['settings']['registration_enable'] ) || ! empty( $data['settings']['user_registration_enable'] ) ) { return false; } $labeled = false; foreach ( $data['fields'] as $field ) { $type = isset( $field['type'] ) ? $field['type'] : ''; if ( ! in_array( $type, array( 'name', 'email', 'text', 'textarea', 'hidden', 'html', 'divider' ), true ) ) { return false; } if ( in_array( $type, array( 'name', 'text', 'textarea' ), true ) ) { $labeled = true; } } return $labeled; } public static function forms() { if ( ! function_exists( 'wpforms' ) ) { return array(); } $forms = array(); $posts = get_posts( array( 'post_type' => 'wpforms', 'post_status' => 'publish', 'numberposts' => 100, 'orderby' => 'ID', 'order' => 'ASC', 'suppress_filters' => true ) ); foreach ( $posts as $post ) { $data = json_decode( $post->post_content, true ); if ( self::supported( $data ) ) { $forms[] = array( 'id' => (string) $post->ID, 'name' => sanitize_text_field( $post->post_title ) ); } } return $forms; } private static function run_id() { // WPForms owns public submission validation. This optional correlation // value never authorizes a connection, browser run or privileged action. // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Read only optional metadata from WPForms callbacks. if ( ! isset( $_POST['wpforms']['submitsignal_run_id'] ) || ! is_string( $_POST['wpforms']['submitsignal_run_id'] ) ) { return ''; } // Reject malformed identifiers instead of sanitizing them into another ID. // phpcs:ignore WordPress.Security.NonceVerification.Missing -- WPForms owns validation of the public form submission. $id = filter_var( wp_unslash( $_POST['wpforms']['submitsignal_run_id'] ), FILTER_VALIDATE_REGEXP, array( 'options' => array( 'regexp' => '/\A[A-Za-z0-9_-]{16,80}\z/' ) ) ); return is_string( $id ) ? $id : ''; } public static function accepted( $fields, $entry, $form_data, $entry_id ) { if ( self::supported( $form_data ) ) { self::enqueue( $form_data['id'], 'accepted', self::run_id() ); } } private static function synthetic_values_only( $values, $run_id, &$count, &$labels, $depth = 0 ) { if ( $depth > 4 || ! is_array( $values ) || count( $values ) > 100 ) { return false; } $message = 'SubmitSignal automated test — no action required. This synthetic inquiry checks that your contact form still works. Test reference: ' . $run_id; foreach ( $values as $value ) { if ( is_array( $value ) ) { if ( ! self::synthetic_values_only( $value, $run_id, $count, $labels, $depth + 1 ) ) { return false; } continue; } if ( ! is_string( $value ) || ++$count > 100 ) { return false; } if ( '' === $value || 'automated-test@example.com' === $value ) { continue; } if ( 'SubmitSignal automated test' !== $value && $message !== $value ) { return false; } ++$labels; } return true; } private static function receipt_recipient( $email, $data ) { $settings = self::settings(); if ( ! $settings || empty( $settings['receipt_copies'] ) || empty( $settings['site_id'] ) || ! empty( $data['attachments'] ) || empty( $email->fields ) || ! is_array( $email->fields ) ) { return ''; } // The public run ID is correlation metadata, never authorization. A short-lived // signed ticket and an authenticated one-use server claim are both required. // phpcs:ignore WordPress.Security.NonceVerification.Missing -- HMAC ticket plus paired-token claim authorize only this synthetic copy. $posted = isset( $_POST['wpforms'] ) && is_array( $_POST['wpforms'] ) ? wp_unslash( $_POST['wpforms'] ) : array(); $ticket = isset( $posted['submitsignal_receipt_ticket'] ) ? $posted['submitsignal_receipt_ticket'] : ''; $run_id = self::run_id(); if ( ! $run_id || ! is_string( $ticket ) || strlen( $ticket ) > 1600 || ! preg_match( '/\A([A-Za-z0-9_-]+)\.([a-f0-9]{64})\z/', $ticket, $parts ) ) { return ''; } $expected = hash_hmac( 'sha256', 'submitsignal-test-receipt-v1.' . $parts[1], hash( 'sha256', $settings['token'] ) ); if ( ! hash_equals( $expected, $parts[2] ) ) { return ''; } $payload = json_decode( base64_decode( strtr( $parts[1], '-_', '+/' ), true ), true, 10 ); if ( ! is_array( $payload ) ) { return ''; } $keys = array_keys( $payload ); sort( $keys ); if ( array( 'expires', 'formId', 'recipient', 'runId', 'siteId', 'v' ) !== $keys || 1 !== $payload['v'] || $settings['site_id'] !== $payload['siteId'] || (string) $email->form_data['id'] !== $payload['formId'] || $run_id !== $payload['runId'] || ! is_int( $payload['expires'] ) || $payload['expires'] <= time() || $payload['expires'] > time() + 660 || ! is_string( $payload['recipient'] ) || ! preg_match( '/\A[a-f0-9]{64}@[a-z0-9.-]{3,180}\z/', $payload['recipient'] ) ) { return ''; } if ( ! isset( $posted['id'], $posted['fields'] ) || ! is_scalar( $posted['id'] ) || (string) $posted['id'] !== $payload['formId'] ) { return ''; } $count = 0; $labels = 0; if ( ! self::synthetic_values_only( $posted['fields'], $run_id, $count, $labels ) || ! $labels ) { return ''; } // Check processed values too. A formatter or additional real value fails closed. foreach ( $email->fields as $field ) { if ( ! is_array( $field ) || ! isset( $field['value'] ) || ! is_string( $field['value'] ) ) { return ''; } $value = $field['value']; if ( 'name' === ( isset( $field['type'] ) ? $field['type'] : '' ) && preg_match( '/\ASubmitSignal automated test(?: SubmitSignal automated test){0,2}\z/', $value ) ) { continue; } $processed_count = 0; $processed_labels = 0; if ( ! self::synthetic_values_only( array( $value ), $run_id, $processed_count, $processed_labels ) ) { return ''; } } $response = self::request( '/api/plugin/receipt-claim', array( 'ticket' => $ticket ), $settings['token'] ); if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { return ''; } $claim = json_decode( wp_remote_retrieve_body( $response ), true ); return is_array( $claim ) && isset( $claim['recipient'] ) && $payload['recipient'] === $claim['recipient'] ? $payload['recipient'] : ''; } public static function mark_email( $data, $email ) { if ( ! self::settings() || ! is_object( $email ) || empty( $email->form_data ) || ! self::supported( $email->form_data ) ) { return $data; } $marker = wp_generate_uuid4(); // A transient persists across WPForms' asynchronous mail tasks. Only // opaque metadata is stored; recipient/content is never inspected. set_transient( 'submitsignal_mail_' . $marker, array( 'formId' => (string) $email->form_data['id'], 'runId' => self::run_id() ), 7 * DAY_IN_SECONDS ); $headers = isset( $data['headers'] ) ? $data['headers'] : ''; if ( is_array( $headers ) ) { $headers[] = 'X-SubmitSignal-Event: ' . $marker; } else { $headers = rtrim( (string) $headers ) . "\r\nX-SubmitSignal-Event: " . $marker; } $data['headers'] = $headers; $recipient = self::receipt_recipient( $email, $data ); if ( $recipient ) { if ( is_array( $data['headers'] ) ) { $data['headers'][] = 'Bcc: ' . $recipient; } else { $data['headers'] .= "\r\nBcc: " . $recipient; } } return $data; } private static function mail_event( $data, $kind ) { if ( ! is_array( $data ) || empty( $data['headers'] ) || ! self::settings() ) { return; } $headers = $data['headers']; if ( is_array( $headers ) ) { $lines = array(); // Core normalizes custom headers into name => value pairs before // firing wp_mail_succeeded/failed. Some mail plugins keep raw lines. foreach ( $headers as $name => $value ) { $lines[] = is_string( $name ) ? $name . ': ' . $value : $value; } $headers = implode( "\n", $lines ); } else { $headers = (string) $headers; } if ( ! preg_match( '/(?:^|[\r\n])X-SubmitSignal-Event:\s*([a-f0-9-]{36})(?:\s|$)/i', $headers, $matches ) ) { return; } $key = 'submitsignal_mail_' . strtolower( $matches[1] ); $context = get_transient( $key ); if ( ! is_array( $context ) ) { return; } delete_transient( $key ); self::enqueue( $context['formId'], $kind, $context['runId'] ); } public static function mail_succeeded( $data ) { self::mail_event( $data, 'mail_handoff' ); } public static function mail_failed( $error ) { if ( is_wp_error( $error ) ) { self::mail_event( $error->get_error_data(), 'mail_failed' ); } } private static function enqueue( $form_id, $kind, $run_id ) { if ( ! self::settings() ) { return; } global $wpdb; $id = wp_generate_uuid4(); $event = array( 'id' => $id, 'formId' => (string) absint( $form_id ), 'kind' => $kind, 'at' => gmdate( 'c' ) ); if ( $run_id ) { $event['runId'] = $run_id; } $wpdb->insert( self::table(), array( 'id' => $id, 'payload' => wp_json_encode( $event ), 'created_at' => time() ), array( '%s', '%s', '%d' ) ); self::$queued = true; } private static function request( $path, $payload, $token = '', $blocking = true ) { $headers = array( 'Content-Type' => 'application/json', 'Accept' => 'application/json' ); if ( $token ) { $headers['Authorization'] = 'Bearer ' . $token; } return wp_safe_remote_post( self::SERVICE . $path, array( 'headers' => $headers, 'body' => wp_json_encode( $payload ), 'timeout' => $blocking ? 20 : 1, 'redirection' => 0, 'blocking' => $blocking, 'sslverify' => true, 'limit_response_size' => 65536 ) ); } private static function batch() { global $wpdb; // Queue reads must reflect acknowledgements and expirations immediately. return $wpdb->get_results( $wpdb->prepare( 'SELECT id, payload FROM %i ORDER BY created_at ASC LIMIT 50', self::table() ), ARRAY_A ); } public static function send_fast() { if ( ! self::$queued ) { return; } $settings = self::settings(); if ( ! $settings ) { return; } $events = array(); foreach ( self::batch() as $row ) { $events[] = json_decode( $row['payload'], true ); } if ( $events ) { self::request( '/api/plugin/events', array( 'events' => $events ), $settings['token'], false ); } // Fire-and-forget is never acknowledged locally. Durable queue entries // remain until a later blocking sync confirms acceptance; server dedupes. } public static function sync() { $settings = self::settings(); if ( ! $settings || get_transient( 'submitsignal_sync_lock' ) ) { return; } set_transient( 'submitsignal_sync_lock', 1, 120 ); global $wpdb; $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE created_at < %d', self::table(), time() - 7 * DAY_IN_SECONDS ) ); $rows = self::batch(); if ( $rows ) { $events = array(); foreach ( $rows as $row ) { $events[] = json_decode( $row['payload'], true ); } $response = self::request( '/api/plugin/events', array( 'events' => $events ), $settings['token'] ); if ( ! is_wp_error( $response ) && wp_remote_retrieve_response_code( $response ) >= 200 && wp_remote_retrieve_response_code( $response ) < 300 ) { foreach ( $rows as $row ) { $wpdb->delete( self::table(), array( 'id' => $row['id'] ), array( '%s' ) ); } } } $heartbeat = self::request( '/api/plugin/heartbeat', array( 'forms' => self::forms() ), $settings['token'] ); $ok = ! is_wp_error( $heartbeat ) && wp_remote_retrieve_response_code( $heartbeat ) >= 200 && wp_remote_retrieve_response_code( $heartbeat ) < 300; update_option( 'submitsignal_sync_status', $ok ? 'Last successful heartbeat: ' . gmdate( 'Y-m-d H:i:s' ) . ' UTC.' : 'Heartbeat failed. Queued events will retry on later WP-Cron visits for up to seven days. Check your dashboard connection.', false ); delete_transient( 'submitsignal_sync_lock' ); } } SubmitSignal_Plugin::init(); register_activation_hook( __FILE__, array( 'SubmitSignal_Plugin', 'activate' ) ); register_deactivation_hook( __FILE__, array( 'SubmitSignal_Plugin', 'deactivate' ) );