PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.6.2
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.6.2
5.6.2 5.6.3 5.6.1 5.6.0 5.5.0 5.4.0 5.3.2 5.3.1 5.1.6 5.1.5 trunk 2.1.5 2.11 2.12 2.13 2.15 3.0.0 3.0.1 3.0.2 3.0.3 3.0.5 3.0.51 3.0.60 3.0.61 3.0.62 All 38 releases
← All changes | src/Admin/AdminRestController.php +48 -21 5.1.5 → 5.6.2 View file →
@@ -14,8 +14,9 @@
14 14 use Forge12\DoubleOptIn\Audit\AuditLogger;
15 15 use Forge12\DoubleOptIn\FormSettings\FormSettingsDTO;
16 16 use Forge12\DoubleOptIn\FormSettings\FormSettingsService;
17 17 use Forge12\DoubleOptIn\FormSettings\FormSettingsValidator;
18 +use Forge12\DoubleOptIn\Integration\SubmittedContent;
18 19 use Forge12\Shared\LoggerInterface;
19 20
20 21 if ( ! defined( 'ABSPATH' ) ) {
21 22 exit;
@@ -736,8 +737,18 @@
736 737 $where[] = 'cf_form_id = %d';
737 738 $params[] = (int) $formId;
738 739 }
739 740
741 + // Confirmed opt-ins whose follow-up actions failed or have an
742 + // unknown outcome — the admin's "needs attention" list.
743 + if ( sanitize_text_field( (string) ( $request->get_param( 'follow_up' ) ?? '' ) ) === 'problem' ) {
744 + $followUpTable = $wpdb->prefix . \Forge12\DoubleOptIn\Repository\FollowUpSchema::TABLE_NAME;
745 + $problems = \Forge12\DoubleOptIn\FollowUp\FollowUpStatus::problematic();
746 + $where[] = "EXISTS (SELECT 1 FROM {$followUpTable} fu WHERE fu.optin_id = {$table}.id AND fu.status IN ("
747 + . implode( ', ', array_fill( 0, count( $problems ), '%s' ) ) . '))';
748 + $params = array_merge( $params, $problems );
749 + }
750 +
740 751 $whereClause = implode( ' AND ', $where );
741 752
742 753 // Count
743 754 $countQuery = "SELECT COUNT(*) FROM {$table} WHERE {$whereClause}";
@@ -1439,8 +1450,13 @@
1439 1450
1440 1451 public function getSettings( \WP_REST_Request $request ): \WP_REST_Response {
1441 1452 $defaults = array(
1442 1453 'telemetry' => 1,
1454 + // Optional "Double Opt-In by Forge12" credit on the confirmation
1455 + // page. Defaults to 0 and must stay that way: wordpress.org
1456 + // guideline 10 requires credit links to be off unless the site
1457 + // owner explicitly turns them on.
1458 + 'credit_link' => 0,
1443 1459 'delete' => 12,
1444 1460 'delete_unconfirmed' => 7,
1445 1461 'delete_period' => 'months',
1446 1462 'delete_unconfirmed_period' => 'months',
@@ -1515,8 +1531,13 @@
1515 1531 'type' => 'int',
1516 1532 'min' => 0,
1517 1533 'max' => 1,
1518 1534 ),
1535 + 'credit_link' => array(
1536 + 'type' => 'int',
1537 + 'min' => 0,
1538 + 'max' => 1,
1539 + ),
1519 1540 'privacy_policy_page' => array(
1520 1541 'type' => 'int',
1521 1542 'min' => 0,
1522 1543 ),
@@ -2080,10 +2101,13 @@
2080 2101
2081 2102 // 2. Title collision — a page literally titled "Opt-Out" but
2082 2103 // without the shortcode is the user's own content. Refuse
2083 2104 // to silently modify it.
2084 - $desiredTitle = __( 'Opt-Out', 'double-opt-in' );
2085 - $collisionId = (int) get_page_by_path( sanitize_title( $desiredTitle ), OBJECT, 'page' )?->ID;
2105 + $desiredTitle = __( 'Opt-Out', 'double-opt-in' );
2106 + $collisionPage = get_page_by_path( sanitize_title( $desiredTitle ), OBJECT, 'page' );
2107 + // Plain null check, not instanceof: this replaces `?->ID`, which only
2108 + // short-circuits on null and does not care about the concrete class.
2109 + $collisionId = is_object( $collisionPage ) ? (int) $collisionPage->ID : 0;
2086 2110 if ( $collisionId > 0 ) {
2087 2111 return new \WP_REST_Response(
2088 2112 array(
2089 2113 'success' => false,
@@ -2405,28 +2429,31 @@
2405 2429 // configured, look up the value the user actually submitted.
2406 2430 // Truthy = explicit acknowledgment captured. Falsy = either
2407 2431 // gate wasn't enforced or this is a legacy record.
2408 2432 //
2409 - // Storage shape varies per integration:
2410 - // - CF7 / WPForms / GF (default path) store fields flat
2411 - // at the top level: $content[fieldName] = value.
2412 - // - Avada wraps fields under a `data` sub-key alongside
2413 - // metadata (field_labels, field_types, form_parameter)
2414 - // — its OnSubmit overrides the flat content set by
2415 - // createOptIn(). For Avada records, $content[fieldName]
2416 - // is undefined; the value lives at $content['data'][fieldName].
2433 + // Where that value sits differs per integration, and this
2434 + // reader got the list wrong twice:
2417 2435 //
2418 - // Pre-2026-05-01 we only checked the flat shape, so every
2419 - // Avada opt-in showed "User acknowledged: ✗ No" even when
2420 - // the user explicitly checked the GDPR box. The fallback
2421 - // below recognises the Avada shape too — adding a third
2422 - // shape would be the next addition.
2423 - $data['consentAcknowledged'] = ! empty( $data['consentField'] )
2424 - && is_array( $content )
2425 - && (
2426 - ! empty( $content[ $data['consentField'] ] )
2427 - || ! empty( $content['data'][ $data['consentField'] ] ?? null )
2428 - );
2436 + // 2026-05-01 Avada wraps its fields under `data`, so the
2437 + // flat lookup missed and every Avada opt-in
2438 + // showed "User acknowledged: ✗ No" even with
2439 + // the GDPR box explicitly checked.
2440 + // 2026-08-27 Elementor stores the whole $_POST parameter
2441 + // dict, so its fields sit under `form_fields`
2442 + // — the same symptom, one integration further
2443 + // on. The docblock added after the Avada fix
2444 + // had predicted exactly this ("adding a third
2445 + // shape would be the next addition").
2446 + //
2447 + // The shape list now lives in SubmittedContent, shared with
2448 + // OptInFrontend::addPlaceholders() — the other consumer that
2449 + // already knew all of them. A fourth integration with a
2450 + // fourth layout is taught to both at once.
2451 + //
2452 + // The lookup also tolerates a consent_field that the
2453 + // pre-5.3.2 sanitize_key() lowercased, so installations
2454 + // recover from the update without re-saving every form.
2455 + $data['consentAcknowledged'] = SubmittedContent::hasValue( $content, (string) $data['consentField'] );
2429 2456
2430 2457 // Parse mail_optin
2431 2458 $mailOptin = maybe_unserialize( $row['mail_optin'] );
2432 2459 $data['mailOptin'] = is_array( $mailOptin ) ? $mailOptin : array();