false, 'show_ui' => false, 'supports' => array( 'title' ), 'rewrite' => false ) );
wp_register_script( 'sendtim-form-block', plugins_url( 'assets/form-block.js', SENDTIM_SIGNUP_FILE ), array( 'wp-blocks', 'wp-element', 'wp-components', 'wp-block-editor' ), SENDTIM_SIGNUP_VERSION, true );
wp_localize_script( 'sendtim-form-block', 'sendtimFormChoices', self::choices() );
register_block_type( 'sendtim/signup-form', array(
'api_version' => 3, 'editor_script' => 'sendtim-form-block',
'attributes' => array( 'formId' => array( 'type' => 'string', 'default' => '' ) ),
'render_callback' => function ( $attributes ) { return self::shortcode( array( 'id' => $attributes['formId'] ?? '' ) ); },
) );
}
public static function settings() {
return wp_parse_args( get_option( self::OPTIONS, array() ), array( 'popups' => true, 'double_opt_in' => false ) );
}
public static function posts() {
return get_posts( array( 'post_type' => self::TYPE, 'post_status' => 'publish', 'numberposts' => -1, 'orderby' => 'title', 'order' => 'ASC' ) );
}
public static function choices() {
return array_map( function ( $post ) { return array( 'value' => (string) $post->ID, 'label' => $post->post_title ); }, self::posts() );
}
/** Cache is bound to the saved credential, so changing workspaces cannot reuse forms. */
public static function remote( $fresh = false ) {
$key = Sendtim_Signup_Settings::api_key();
if ( '' === $key ) { return new WP_Error( 'not_connected', 'Connect your Sendtim account first.' ); }
$cache = 'sendtim_forms_' . hash( 'sha256', $key );
$rows = $fresh ? false : get_transient( $cache );
if ( false !== $rows ) { return $rows; }
$rows = array();
$page = 1;
do {
$result = ( new Sendtim_Signup_Api() )->forms( $page );
if ( is_wp_error( $result ) ) { set_transient( $cache, $result, 30 ); return $result; }
if ( ! isset( $result['data'] ) || ! is_array( $result['data'] ) ) { return new WP_Error( 'invalid_forms', 'Invalid Sendtim forms response.' ); }
$rows = array_merge( $rows, $result['data'] );
$page++;
} while ( ! empty( $result['hasMore'] ) && $page <= 100 );
if ( ! empty( $result['hasMore'] ) ) { return new WP_Error( 'form_limit', 'Too many forms to synchronize.' ); }
set_transient( $cache, $rows, MINUTE_IN_SECONDS );
return $rows;
}
public static function remote_form( $id, $fresh = false ) {
$rows = self::remote( $fresh );
if ( is_wp_error( $rows ) ) { return $rows; }
foreach ( $rows as $row ) {
if ( ( $row['id'] ?? '' ) === $id && 'published' === ( $row['status'] ?? '' ) ) {
$origin = wp_parse_url( home_url( '/' ) );
$origin = $origin['scheme'] . '://' . $origin['host'] . ( isset( $origin['port'] ) ? ':' . $origin['port'] : '' );
if ( ! empty( $row['allowedOrigins'] ) && ! in_array( $origin, $row['allowedOrigins'], true ) ) { return new WP_Error( 'origin', 'This website is not allowed to display the form.' ); }
return array( 'title' => $row['name'], 'definition' => $row['definition'], 'list_id' => $row['listId'] ?? '', 'list_double_opt_in' => ! empty( $row['listDoubleOptIn'] ), 'interests' => array(), 'remote_id' => $id );
}
}
return new WP_Error( 'unavailable', 'This form is no longer published.' );
}
public static function resolve( $ref, $fresh = false ) {
if ( 0 === strpos( $ref, 'remote:' ) ) { return self::remote_form( substr( $ref, 7 ), $fresh ); }
$post = get_post( absint( $ref ) );
if ( ! $post || self::TYPE !== $post->post_type || 'publish' !== $post->post_status ) { return new WP_Error( 'unavailable', 'Form unavailable.' ); }
$form = get_post_meta( $post->ID, self::META, true );
if ( ! is_array( $form ) ) { return new WP_Error( 'unavailable', 'Form unavailable.' ); }
// A credential change requires deliberately reconnecting saved forms.
if ( ! hash_equals( $form['account_hash'] ?? '', hash( 'sha256', Sendtim_Signup_Settings::api_key() ) ) ) { return new WP_Error( 'account', 'Reconnect this form to your current account.' ); }
if ( ! empty( $form['remote_id'] ) ) {
return self::remote_form( $form['remote_id'], $fresh );
}
$form['title'] = $post->post_title;
return $form;
}
public static function assets() {
wp_enqueue_style( 'sendtim-forms', plugins_url( 'assets/forms.css', SENDTIM_SIGNUP_FILE ), array(), SENDTIM_SIGNUP_VERSION );
if ( self::settings()['popups'] && '' !== Sendtim_Signup_Settings::api_key() ) {
wp_enqueue_script( 'sendtim-forms', plugins_url( 'assets/forms.js', SENDTIM_SIGNUP_FILE ), array(), SENDTIM_SIGNUP_VERSION, true );
}
}
public static function shortcode( $attributes ) {
$attributes = shortcode_atts( array( 'id' => '' ), $attributes );
$ref = (string) absint( $attributes['id'] );
$form = self::resolve( $ref );
if ( is_wp_error( $form ) || '' === Sendtim_Signup_Settings::api_key() ) { return ''; }
// Account pop-ups are injected once globally, never again through an embed.
if ( ! empty( $form['remote_id'] ) && 'inline' !== ( $form['definition']['settings']['display']['mode'] ?? 'inline' ) ) { return ''; }
return self::render( $form, $ref );
}
public static function render( $form, $ref ) {
$d = $form['definition'];
$s = $d['style'];
$uid = wp_unique_id( 'sendtim-' );
$accent = sanitize_hex_color( $s['accentColor'] ?? '#4f46e5' ) ?: '#4f46e5';
$text = sanitize_hex_color( $s['textColor'] ?? '#172033' ) ?: '#172033';
$background = sanitize_hex_color( $s['backgroundColor'] ?? '#ffffff' ) ?: '#ffffff';
$style = '--st-accent:' . $accent . ';--st-text:' . $text . ';--st-bg:' . $background . ';--st-radius:' . min( 24, absint( $s['borderRadius'] ?? 12 ) ) . 'px;max-width:' . min( 720, max( 240, absint( $s['width'] ?? 480 ) ) ) . 'px';
$fonts = array( 'system' => 'system-ui,sans-serif', 'sans' => 'Arial,sans-serif', 'serif' => 'Georgia,serif', 'mono' => 'monospace' );
$style .= ';font-family:' . ( $fonts[ $s['fontFamily'] ?? 'system' ] ?? $fonts['system'] );
$status = self::input( $_GET, 'sendtim_result' );
$matches = self::input( $_GET, 'sendtim_form' ) === $ref;
ob_start();
?>
', esc_attr( $name ), esc_attr( $field['value'] ?? '' ) ); return; }
$check = in_array( $type, array( 'checkbox', 'consent' ), true );
echo '
';
printf( '', esc_attr( $id ), $hide_label && ! $check ? 'screen-reader-text' : '', esc_html( $field['label'] ), ! empty( $field['required'] ) ? ' *' : '' );
$common = ' id="' . esc_attr( $id ) . '" name="' . esc_attr( $name ) . '"' . ( ! empty( $field['required'] ) ? ' required' : '' );
if ( ! empty( $field['helpText'] ) ) { $common .= ' aria-describedby="' . esc_attr( $id . '-help' ) . '"'; }
$common .= ' placeholder="' . esc_attr( $field['placeholder'] ?? '' ) . '"';
// All values in $common are escaped above. Element names are fixed below.
if ( 'textarea' === $type ) {
echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
} elseif ( 'select' === $type ) {
echo '';
} else {
$input_type = $check ? 'checkbox' : ( 'phone' === $type ? 'tel' : ( in_array( $type, array( 'email', 'number' ), true ) ? $type : 'text' ) );
$autocomplete = array( 'email' => 'email', 'first_name' => 'given-name', 'last_name' => 'family-name', 'phone' => 'tel' );
$auto = $autocomplete[ $field['mapping']['key'] ?? $type ] ?? 'off';
echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
if ( ! empty( $field['helpText'] ) ) { echo '' . esc_html( $field['helpText'] ) . ''; }
echo '
';
}
public static function popups() {
if ( ! self::settings()['popups'] || '' === Sendtim_Signup_Settings::api_key() || is_admin() || is_feed() ) { return; }
$rows = self::remote();
if ( is_wp_error( $rows ) ) { return; }
foreach ( $rows as $row ) {
$display = $row['definition']['settings']['display'] ?? array( 'mode' => 'inline' );
if ( ! in_array( $display['mode'], array( 'popup', 'slide_in' ), true ) ) { continue; }
$form = self::remote_form( $row['id'] );
if ( is_wp_error( $form ) ) { continue; }
$ref = 'remote:' . $row['id'];
printf( '', esc_attr( $row['id'] ), esc_attr( $display['mode'] ), absint( $display['delaySeconds'] ?? 5 ), absint( $display['scrollPercent'] ?? 50 ), ! empty( $display['exitIntent'] ) ? '1' : '0', esc_attr( $row['name'] ), esc_attr__( 'Close', 'sendtim-signup-forms' ), self::render( $form, $ref ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- renderer escapes every field.
}
}
public static function input( $input, $key ) { return isset( $input[ $key ] ) && is_scalar( $input[ $key ] ) ? sanitize_text_field( wp_unslash( (string) $input[ $key ] ) ) : ''; }
/** Validate only the stored definition; visitors cannot supply fields, mapping or lists. */
public static function contact( $form, $input ) {
$contact = array( 'source' => substr( $form['definition']['settings']['sourceLabel'] ?? 'wordpress:signup', 0, 64 ), 'consentGiven' => true, 'consentSource' => 'form' );
$mapping = array( 'first_name' => 'firstName', 'last_name' => 'lastName', 'company' => 'company', 'job_title' => 'jobTitle', 'phone' => 'phone', 'country' => 'country', 'city' => 'city' );
$limits = array( 'first_name' => 120, 'last_name' => 120, 'company' => 200, 'job_title' => 200, 'phone' => 50, 'country' => 100, 'city' => 120 );
foreach ( $form['definition']['fields'] as $field ) {
$type = $field['type'];
$value = 'hidden' === $type ? ( $field['value'] ?? '' ) : self::input( $input, $field['id'] );
$is_check = in_array( $type, array( 'checkbox', 'consent' ), true );
if ( $is_check ) { $value = in_array( $value, array( '1', 'on', 'true' ), true ); }
if ( ( ! empty( $field['required'] ) && ( '' === $value || false === $value ) ) || ( 'consent' === $type && ! $value ) ) { return new WP_Error( 'field', 'Required field missing.' ); }
if ( strlen( (string) $value ) > ( 'textarea' === $type ? 1000 : 500 ) ) { return new WP_Error( 'field', 'Field is too long.' ); }
if ( 'email' === $type ) {
if ( ! is_email( $value ) || strlen( $value ) > 254 ) { return new WP_Error( 'email', 'Invalid email.' ); }
$contact['email'] = strtolower( $value ); continue;
}
if ( 'select' === $type && '' !== $value && ! in_array( $value, array_column( $field['options'], 'value' ), true ) ) { return new WP_Error( 'field', 'Invalid choice.' ); }
if ( 'number' === $type && '' !== $value ) {
if ( ! is_numeric( $value ) || ! is_finite( (float) $value ) ) { return new WP_Error( 'field', 'Invalid number.' ); }
$value = (float) $value;
}
$map = $field['mapping'] ?? array( 'kind' => 'none' );
if ( 'contact_field' === $map['kind'] && isset( $mapping[ $map['key'] ] ) && '' !== $value ) {
if ( strlen( (string) $value ) > $limits[ $map['key'] ] ) { return new WP_Error( 'field', 'Field is too long.' ); }
$contact[ $mapping[ $map['key'] ] ] = (string) $value;
} elseif ( 'custom_property' === $map['kind'] && preg_match( '/^[a-zA-Z0-9_][a-zA-Z0-9_.-]{0,63}$/', $map['key'] ) ) {
$contact['customProperties'][ $map['key'] ] = $value;
}
if ( 'consent' === $type ) { $contact['customProperties']['sendtim_consent_text'] = $field['label']; }
}
return isset( $contact['email'] ) ? $contact : new WP_Error( 'email', 'Email field missing.' );
}
public static function signup() {
$ref = self::input( $_POST, 'form' );
if ( ! wp_verify_nonce( self::input( $_POST, '_wpnonce' ), 'sendtim_form_' . $ref ) || (int) ( $_SERVER['CONTENT_LENGTH'] ?? 0 ) > 32768 ) { self::redirect( $ref, 'error' ); }
$form = self::resolve( $ref, true );
if ( is_wp_error( $form ) || '' === Sendtim_Signup_Settings::api_key() ) { self::redirect( $ref, 'error' ); }
if ( '' !== self::input( $_POST, 'website' ) ) { self::redirect( $ref, 'confirm' ); }
$contact = self::contact( $form, isset( $_POST['fields'] ) && is_array( $_POST['fields'] ) ? $_POST['fields'] : array() );
if ( is_wp_error( $contact ) ) { self::redirect( $ref, 'error' ); }
foreach ( array( array( strtolower( $contact['email'] ), 5 ), array( $_SERVER['REMOTE_ADDR'] ?? 'unknown', 30 ) ) as $limit ) {
$key = 'sendtim_forms_limit_' . hash_hmac( 'sha256', $limit[0], wp_salt( 'nonce' ) );
$count = (int) get_transient( $key );
if ( $count >= $limit[1] ) { self::redirect( $ref, 'error' ); }
set_transient( $key, $count + 1, HOUR_IN_SECONDS );
}
$lists = empty( $form['list_id'] ) ? array() : array( $form['list_id'] );
$chosen = isset( $_POST['interests'] ) && is_array( $_POST['interests'] ) ? array_filter( $_POST['interests'], 'is_string' ) : array();
foreach ( $form['interests'] ?? array() as $interest ) { if ( in_array( $interest['id'], $chosen, true ) ) { $lists[] = $interest['id']; } }
$rule = $form['definition']['settings']['doubleOptIn'] ?? 'inherit';
$double = empty( $form['remote_id'] ) ? self::settings()['double_opt_in'] : ( 'on' === $rule || ( 'inherit' === $rule && ! empty( $form['list_double_opt_in'] ) ) );
$payload = array( 'contact' => $contact, 'lists' => array_values( array_unique( $lists ) ), 'ref' => $ref, 'return' => wp_validate_redirect( wp_get_referer(), home_url( '/' ) ), 'account' => hash( 'sha256', Sendtim_Signup_Settings::api_key() ) );
if ( $double ) {
$result = self::send_confirmation( $payload );
self::redirect( $ref, is_wp_error( $result ) ? 'error' : 'confirm' );
}
$result = self::subscribe( $payload );
self::redirect( $ref, is_wp_error( $result ) ? 'error' : 'success', '', $form );
}
public static function subscribe( $payload ) {
if ( ! hash_equals( $payload['account'], hash( 'sha256', Sendtim_Signup_Settings::api_key() ) ) ) { return new WP_Error( 'account', 'Account changed.' ); }
$api = new Sendtim_Signup_Api();
$lists = $payload['lists'];
$result = $api->subscribe_contact( $payload['contact'], array_shift( $lists ) ?? '' );
if ( is_wp_error( $result ) ) { return $result; }
if ( $lists ) { return $api->add_lists( $result['id'], $lists ); }
return $result;
}
public static function send_confirmation( $payload ) {
try { $token = bin2hex( random_bytes( 24 ) ); } catch ( Throwable $e ) { return new WP_Error( 'token', 'Could not generate confirmation.' ); }
$key = 'sendtim_forms_confirm_' . hash( 'sha256', $token );
if ( ! set_transient( $key, $payload, 2 * DAY_IN_SECONDS ) ) { return new WP_Error( 'token', 'Could not save confirmation.' ); }
$link = add_query_arg( array( 'action' => 'sendtim_forms_confirm', 'token' => $token ), admin_url( 'admin-post.php' ) );
$message = sprintf(
// translators: %s: Link to confirm the email subscription.
__( 'Confirm your email subscription within 48 hours: %s', 'sendtim-signup-forms' ),
$link
);
$sent = wp_mail( $payload['contact']['email'], __( 'Confirm your subscription', 'sendtim-signup-forms' ), $message );
if ( ! $sent ) { delete_transient( $key ); return new WP_Error( 'mail', 'Confirmation could not be sent.' ); }
return true;
}
public static function confirm() {
nocache_headers();
header( 'Referrer-Policy: no-referrer' );
$token = self::input( 'POST' === ( $_SERVER['REQUEST_METHOD'] ?? '' ) ? $_POST : $_GET, 'token' );
if ( ! preg_match( '/^[a-f0-9]{48}$/', $token ) ) { self::redirect( '', 'error' ); }
$key = 'sendtim_forms_confirm_' . hash( 'sha256', $token );
$payload = get_transient( $key );
if ( ! is_array( $payload ) ) { self::redirect( '', 'error' ); }
$form = self::resolve( $payload['ref'], true );
if ( is_wp_error( $form ) ) { self::redirect( $payload['ref'], 'error', $payload['return'] ); }
if ( 'POST' !== ( $_SERVER['REQUEST_METHOD'] ?? '' ) ) {
$html = '';
wp_die( $html, esc_html__( 'Confirm subscription', 'sendtim-signup-forms' ), array( 'response' => 200 ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
$payload['contact']['consentSource'] = 'double_opt_in';
$result = self::subscribe( $payload );
if ( ! is_wp_error( $result ) ) { delete_transient( $key ); }
self::redirect( $payload['ref'], is_wp_error( $result ) ? 'error' : 'success', $payload['return'], $form );
}
private static function redirect( $ref, $status, $url = '', $form = array() ) {
$url = $url ?: ( wp_get_referer() ?: home_url( '/' ) );
$success = $form['definition']['settings']['successBehavior'] ?? array();
if ( 'success' === $status && 'redirect' === ( $success['kind'] ?? '' ) && 0 === strpos( $success['url'] ?? '', 'https://' ) ) {
// Explicit destination from the administrator-owned definition; never from POST.
wp_redirect( esc_url_raw( $success['url'] ), 303 ); exit;
}
wp_safe_redirect( add_query_arg( array( 'sendtim_form' => $ref, 'sendtim_result' => $status ), remove_query_arg( array( 'sendtim_form', 'sendtim_result' ), $url ) ), 303 ); exit;
}
}