PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.13.0
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.13.0
5.12.0 5.13.0 5.13.1 5.11.0 5.10.0 5.9.0 5.8.0 5.8.1 5.7.0 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 All 47 releases
← All changes | src/Admin/AdminRestController.php +159 -2 5.8.0 → 5.13.0 View file →
@@ -17,8 +17,9 @@
17 17 use Forge12\DoubleOptIn\FormSettings\FormSettingsValidator;
18 18 use Forge12\DoubleOptIn\Integration\SubmittedContent;
19 19 use Forge12\DoubleOptIn\Service\ConfirmationMailResender;
20 20 use Forge12\DoubleOptIn\Service\ResendResult;
21 +use Forge12\DoubleOptIn\Subscription\SubscriptionGroups;
21 22 use Forge12\Shared\LoggerInterface;
22 23
23 24 if ( ! defined( 'ABSPATH' ) ) {
24 25 exit;
@@ -32,8 +33,14 @@
32 33 class AdminRestController {
33 34
34 35 const API_NAMESPACE = 'f12-doi/v1';
35 36
37 + /**
38 + * SQL form of OptIn::isOptedOut(): not confirmed, withdrawal IP and time
39 + * recorded. A re-opt-in clears the time, so the row counts as confirmed again.
40 + */
41 + private const REVOKED_SQL = "(doubleoptin = 0 AND ipaddr_optout IS NOT NULL AND ipaddr_optout <> '' AND optouttime IS NOT NULL AND optouttime NOT IN ('', '0'))";
42 +
36 43 private LoggerInterface $logger;
37 44 private FormSettingsService $formService;
38 45 private FormSettingsValidator $formValidator;
39 46
@@ -107,8 +114,19 @@
107 114 'permission_callback' => array( $this, 'checkPermission' ),
108 115 )
109 116 );
110 117
118 + // ── Subscription groups (read-only; managed by an add-on) ──
119 + register_rest_route(
120 + self::API_NAMESPACE,
121 + '/subscription-groups',
122 + array(
123 + 'methods' => \WP_REST_Server::READABLE,
124 + 'callback' => array( $this, 'getSubscriptionGroups' ),
125 + 'permission_callback' => array( $this, 'checkPermission' ),
126 + )
127 + );
128 +
111 129 // ── Opt-Ins ────────────────────────────────────────────────
112 130 register_rest_route(
113 131 self::API_NAMESPACE,
114 132 '/optins',
@@ -617,9 +635,10 @@
617 635 $table = $wpdb->prefix . 'f12_cf7_doubleoptin';
618 636
619 637 $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" );
620 638 $confirmed = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE doubleoptin = 1" );
621 - $pending = $total - $confirmed;
639 + $revoked = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE " . self::REVOKED_SQL ); // phpcs:ignore WordPress.DB.PreparedSQL -- fixed SQL, no input.
640 + $pending = max( 0, $total - $confirmed - $revoked );
622 641 $rate = $total > 0 ? round( ( $confirmed / $total ) * 100, 1 ) : 0;
623 642
624 643 // Recent opt-ins (raw activity feed — not analytics).
625 644 // Time-bucketed activity, top-forms breakdown and the big
@@ -625,9 +644,9 @@
625 644 // Time-bucketed activity, top-forms breakdown and the big
626 645 // conversion-rate card moved into addon-analytics, which
627 646 // renders them at the `dashboard.widget` mount point.
628 647 $recent = $wpdb->get_results(
629 - "SELECT id, email, cf_form_id, doubleoptin, createtime FROM {$table} ORDER BY id DESC LIMIT 5",
648 + "SELECT id, email, cf_form_id, doubleoptin, createtime, ipaddr_optout, optouttime FROM {$table} ORDER BY id DESC LIMIT 5",
630 649 ARRAY_A
631 650 );
632 651
633 652 foreach ( $recent as &$row ) {
@@ -633,14 +652,18 @@
633 652 foreach ( $recent as &$row ) {
634 653 $post = get_post( (int) $row['cf_form_id'] );
635 654 $row['formName'] = $post ? $post->post_title : sprintf( '#%d', $row['cf_form_id'] );
636 655 $row['confirmed'] = (int) $row['doubleoptin'] === 1;
656 + $row['revoked'] = self::isRevokedRow( $row );
657 + unset( $row['ipaddr_optout'], $row['optouttime'] );
637 658 }
659 + unset( $row );
638 660
639 661 $data = array(
640 662 'totalOptins' => $total,
641 663 'confirmed' => $confirmed,
642 664 'pending' => $pending,
665 + 'revoked' => $revoked,
643 666 'conversionRate' => $rate,
644 667 'recentOptins' => $recent ?: array(),
645 668 );
646 669
@@ -692,8 +715,35 @@
692 715 // ═══════════════════════════════════════════════════════════════
693 716 // OPT-INS
694 717 // ═══════════════════════════════════════════════════════════════
695 718
719 + /**
720 + * Subscription groups for the filter in the opt-in list. `available`
721 + * is false when no add-on provides groups, so the SPA hides the
722 + * controls instead of showing an empty filter.
723 + */
724 + public function getSubscriptionGroups( \WP_REST_Request $request ): \WP_REST_Response {
725 + $groups = array();
726 + foreach ( SubscriptionGroups::resolver()->groups() as $group ) {
727 + $groups[] = array(
728 + 'key' => $group->getKey(),
729 + 'label' => $group->getLabel(),
730 + 'memberCount' => count( $group->getMembers() ),
731 + );
732 + }
733 +
734 + return new \WP_REST_Response(
735 + array(
736 + 'success' => true,
737 + 'data' => array(
738 + 'available' => SubscriptionGroups::isAvailable(),
739 + 'groups' => $groups,
740 + ),
741 + ),
742 + 200
743 + );
744 + }
745 +
696 746 public function getOptins( \WP_REST_Request $request ): \WP_REST_Response {
697 747 $page = max( 1, (int) $request->get_param( 'page' ) ?: 1 );
698 748 $perPage = max( 1, min( 100, (int) $request->get_param( 'per_page' ) ?: 20 ) );
699 749 $search = sanitize_text_field( $request->get_param( 'search' ) ?? '' );
@@ -721,8 +771,11 @@
721 771 if ( $status === 'confirmed' ) {
722 772 $where[] = 'doubleoptin = 1';
723 773 } elseif ( $status === 'pending' ) {
724 774 $where[] = '(doubleoptin = 0 OR doubleoptin IS NULL)';
775 + $where[] = 'NOT ' . self::REVOKED_SQL;
776 + } elseif ( $status === 'revoked' ) {
777 + $where[] = self::REVOKED_SQL;
725 778 }
726 779
727 780 if ( $formId !== null && $formId !== '' ) {
728 781 $where[] = 'cf_form_id = %d';
@@ -728,8 +781,22 @@
728 781 $where[] = 'cf_form_id = %d';
729 782 $params[] = (int) $formId;
730 783 }
731 784
785 + // Subscription group (5.12.0). An unknown key selects nothing, so a
786 + // stale link can never widen into the unfiltered list.
787 + $groupKey = sanitize_text_field( (string) ( $request->get_param( 'group' ) ?? '' ) );
788 + if ( $groupKey !== '' ) {
789 + $group = SubscriptionGroups::resolver()->findGroup( $groupKey );
790 + if ( $group === null ) {
791 + $where[] = '1 = 0';
792 + } else {
793 + list( $groupSql, $groupParams ) = $group->toSqlCondition();
794 + $where[] = $groupSql;
795 + $params = array_merge( $params, $groupParams );
796 + }
797 + }
798 +
732 799 // Opt-ins whose confirmation mail could not be sent (5.8.0).
733 800 if ( sanitize_text_field( (string) ( $request->get_param( 'mail' ) ?? '' ) ) === 'failed' ) {
734 801 $where[] = 'mail_status = %s';
735 802 $params[] = \Forge12\DoubleOptIn\Repository\OptInMailStatusRepository::FAILED;
@@ -798,8 +865,12 @@
798 865 }
799 866
800 867 $data = $this->formatOptinRow( $row, true );
801 868
869 + // Other sign-ups of the same address in the same subscription
870 + // group (5.12.0). The records stay separate; this only links them.
871 + $data['linkedOptIns'] = $this->linkedOptIns( $row );
872 +
802 873 // Dev-mode UI hint: surface whether the reset-confirmation
803 874 // endpoint is reachable for this request, so the React detail
804 875 // page can show/hide the "Reset to pending" button without
805 876 // having to probe the endpoint and handle a 403. Mirrors
@@ -2170,8 +2241,21 @@
2170 2241 // HELPERS
2171 2242 // ═══════════════════════════════════════════════════════════════
2172 2243
2173 2244 /**
2245 + * PHP form of REVOKED_SQL for a raw table row.
2246 + *
2247 + * @param array<string, mixed> $row The database row.
2248 + */
2249 + private static function isRevokedRow( array $row ): bool {
2250 + $optOutTime = (string) ( $row['optouttime'] ?? '' );
2251 +
2252 + return (int) ( $row['doubleoptin'] ?? 0 ) !== 1
2253 + && (string) ( $row['ipaddr_optout'] ?? '' ) !== ''
2254 + && $optOutTime !== '' && $optOutTime !== '0';
2255 + }
2256 +
2257 + /**
2174 2258 * Format an opt-in database row for the API response.
2175 2259 *
2176 2260 * @param array $row The database row.
2177 2261 * @param bool $detailed Whether to include full detail (content, mail data).
@@ -2177,8 +2261,74 @@
2177 2261 * @param bool $detailed Whether to include full detail (content, mail data).
2178 2262 *
2179 2263 * @return array Formatted data.
2180 2264 */
2265 + /**
2266 + * @param array<string, mixed> $row A database row.
2267 + *
2268 + * @return array{key:string, label:string}|null
2269 + */
2270 + private function subscriptionOfRow( array $row ): ?array {
2271 + $group = SubscriptionGroups::resolver()->groupForForm(
2272 + (int) ( $row['cf_form_id'] ?? 0 ),
2273 + (string) ( $row['form_ref'] ?? '' )
2274 + );
2275 +
2276 + return $group === null ? null : array(
2277 + 'key' => $group->getKey(),
2278 + 'label' => $group->getLabel(),
2279 + );
2280 + }
2281 +
2282 + /**
2283 + * The other records of the same address that belong to the same
2284 + * subscription group as the given record.
2285 + *
2286 + * @param array<string, mixed> $row A database row.
2287 + *
2288 + * @return array<int, array<string, mixed>>
2289 + */
2290 + private function linkedOptIns( array $row ): array {
2291 + $group = SubscriptionGroups::resolver()->groupForForm(
2292 + (int) ( $row['cf_form_id'] ?? 0 ),
2293 + (string) ( $row['form_ref'] ?? '' )
2294 + );
2295 + $email = (string) ( $row['email'] ?? '' );
2296 + if ( $group === null || $email === '' ) {
2297 + return array();
2298 + }
2299 +
2300 + global $wpdb;
2301 + $table = $wpdb->prefix . 'f12_cf7_doubleoptin';
2302 + list( $groupSql, $groupParams ) = $group->toSqlCondition();
2303 +
2304 + // The group condition is built from fixed column names and `%d`/`%s`
2305 + // placeholders only; every value travels in the parameter list.
2306 + $found = $wpdb->get_results(
2307 + $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- placeholders come from SubscriptionGroup::toSqlCondition().
2308 + "SELECT * FROM {$table} WHERE email = %s AND id <> %d AND {$groupSql} ORDER BY id DESC LIMIT 50", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
2309 + array_merge( array( $email, (int) $row['id'] ), $groupParams )
2310 + ),
2311 + ARRAY_A
2312 + );
2313 +
2314 + $linked = array();
2315 + foreach ( (array) $found as $other ) {
2316 + $data = $this->formatOptinRow( $other );
2317 + $linked[] = array(
2318 + 'id' => $data['id'],
2319 + 'formId' => $data['formId'],
2320 + 'formName' => $data['formName'],
2321 + 'formRef' => $data['formRef'],
2322 + 'confirmed' => $data['confirmed'],
2323 + 'revoked' => $data['revoked'],
2324 + 'createtime' => $data['createtime'],
2325 + );
2326 + }
2327 +
2328 + return $linked;
2329 + }
2330 +
2181 2331 private function formatOptinRow( array $row, bool $detailed = false ): array {
2182 2332 $post = get_post( (int) $row['cf_form_id'] );
2183 2333
2184 2334 $data = array(
@@ -2188,8 +2338,10 @@
2188 2338 'formId' => (int) $row['cf_form_id'],
2189 2339 'formName' => $post ? $post->post_title : sprintf( '#%d', $row['cf_form_id'] ),
2190 2340 'category' => (int) $row['category'],
2191 2341 'confirmed' => (int) $row['doubleoptin'] === 1,
2342 + // Consent withdrawn via the opt-out (5.9.0). Not "pending".
2343 + 'revoked' => self::isRevokedRow( $row ),
2192 2344 // Confirmation mail: 'sent' (handed to the mail server), 'failed',
2193 2345 // or '' (recorded before 5.8.0). Since 5.8.0.
2194 2346 'mailStatus' => (string) ( $row['mail_status'] ?? '' ),
2195 2347 'createtime' => $this->toSiteLocalTime( $row['createtime'] ),
@@ -2194,8 +2346,13 @@
2194 2346 'mailStatus' => (string) ( $row['mail_status'] ?? '' ),
2195 2347 'createtime' => $this->toSiteLocalTime( $row['createtime'] ),
2196 2348 'updatetime' => $this->toSiteLocalTime( $row['updatetime'] ),
2197 2349 );
2350 +
2351 + // Instance inside the form id, e.g. the Elementor widget (5.12.0).
2352 + $data['formRef'] = (string) ( $row['form_ref'] ?? '' );
2353 + // Subscription group of the record, or null (5.12.0).
2354 + $data['subscription'] = $this->subscriptionOfRow( $row );
2198 2355
2199 2356 if ( $detailed ) {
2200 2357 $data['ipRegister'] = $row['ipaddr_register'];
2201 2358 $data['ipConfirmation'] = $row['ipaddr_confirmation'];