PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.11.0
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.11.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/Integration/AbstractFormIntegration.php +260 -81 5.1.6 → 5.11.0 View file →
@@ -7,8 +7,9 @@
7 7 */
8 8
9 9 namespace Forge12\DoubleOptIn\Integration;
10 10
11 +use Forge12\DoubleOptIn\Consent\ConsentGate;
11 12 use Forge12\DoubleOptIn\Container\Container;
12 13 use Forge12\DoubleOptIn\EmailTemplates\PlaceholderMapper;
13 14 use Forge12\DoubleOptIn\EventSystem\EventDispatcherInterface;
14 15 use Forge12\DoubleOptIn\Events\Integration\FormSubmissionEvent;
@@ -14,8 +15,10 @@
14 15 use Forge12\DoubleOptIn\Events\Integration\FormSubmissionEvent;
15 16 use Forge12\DoubleOptIn\Events\Lifecycle\OptInConfirmedEvent;
16 17 use Forge12\DoubleOptIn\Events\Lifecycle\OptInCreatedEvent;
17 18 use Forge12\DoubleOptIn\Files\FileStorage;
19 +use Forge12\DoubleOptIn\FollowUp\FollowUpAttempt;
20 +use Forge12\DoubleOptIn\FollowUp\FollowUpCoordinator;
18 21 use Forge12\DoubleOptIn\Frontend\ErrorNotification;
19 22 use Forge12\DoubleOptIn\Service\RateLimiter;
20 23 use forge12\contactform7\CF7DoubleOptIn\CF7DoubleOptIn;
21 24 use forge12\contactform7\CF7DoubleOptIn\IPHelper;
@@ -80,8 +83,47 @@
80 83 * Get the validation status from the last validateOptIn() call.
81 84 *
82 85 * @return string One of: '', 'confirmed', 'already_confirmed', 'expired', 'not_found'.
83 86 */
87 + /**
88 + * Nesting depth of post-confirmation replays in this request.
89 + *
90 + * @var int
91 + */
92 + private static $replayDepth = 0;
93 +
94 + /**
95 + * True while a post-confirmation replay (re-submitting the stored
96 + * data to the form plugin) runs in this request. Set only by server
97 + * code via {@see runAsReplay()} — never derived from request input.
98 + *
99 + * @since 5.6.0
100 + */
101 + public static function isReplaying(): bool {
102 + return self::$replayDepth > 0;
103 + }
104 +
105 + /**
106 + * Run $fn as a post-confirmation replay: form-submit hooks that fire
107 + * inside it (wpcf7_before_send_mail, gform_after_submission, …) see
108 + * {@see isReplaying()} and do not create a new opt-in.
109 + *
110 + * @template T
111 + * @param callable():T $fn
112 + *
113 + * @return T
114 + *
115 + * @since 5.6.0
116 + */
117 + public static function runAsReplay( callable $fn ) {
118 + self::$replayDepth++;
119 + try {
120 + return $fn();
121 + } finally {
122 + self::$replayDepth--;
123 + }
124 + }
125 +
84 126 public static function getValidationStatus(): string {
85 127 return self::$validationStatus;
86 128 }
87 129
@@ -170,12 +212,19 @@
170 212 /**
171 213 * {@inheritdoc}
172 214 */
173 215 public function isOptInEnabled( int $formId ): bool {
174 - // Disable if opt-in confirmation is in progress
175 - if ( isset( $_GET['optin'] ) ) {
216 + // Disable while our own post-confirmation replay runs — the stored
217 + // submission must be processed, not turned into a new opt-in.
218 + //
219 + // This used to test `isset( $_GET['optin'] )`. That flag is set by
220 + // whoever sends the request: `…/feedback?optin=1` on the CF7 REST
221 + // route submitted a DOI form with every mail and no confirmation.
222 + // It also broke every replay outside the confirmation request
223 + // (cron, admin retry), which has no `?optin` in its URL.
224 + if ( self::isReplaying() ) {
176 225 $this->getLogger()->debug(
177 - 'Opt-in disabled due to optin flag in GET request',
226 + 'Opt-in disabled during post-confirmation replay',
178 227 array(
179 228 'plugin' => 'double-opt-in',
180 229 'class' => static::class,
181 230 )
@@ -311,9 +360,8 @@
311 360 $this->getLogger()->warning(
312 361 'Rate limit exceeded for IP',
313 362 array(
314 363 'plugin' => 'double-opt-in',
315 - 'ip' => $ip,
316 364 'form_id' => $formData->getFormId(),
317 365 )
318 366 );
319 367 do_action( 'f12_cf7_doubleoptin_rate_limited', 'ip', $ip, $formData->getFormId() );
@@ -334,9 +382,8 @@
334 382 $this->getLogger()->warning(
335 383 'Rate limit exceeded for email',
336 384 array(
337 385 'plugin' => 'double-opt-in',
338 - 'email' => $recipient,
339 386 'form_id' => $formData->getFormId(),
340 387 )
341 388 );
342 389 do_action( 'f12_cf7_doubleoptin_rate_limited', 'email', $recipient, $formData->getFormId() );
@@ -374,9 +421,8 @@
374 421 $this->getLogger()->warning(
375 422 'Recipient validation failed',
376 423 array(
377 424 'plugin' => 'double-opt-in',
378 - 'email' => $recipient,
379 425 'form_id' => $formData->getFormId(),
380 426 'reason' => $errorMsg,
381 427 )
382 428 );
@@ -438,26 +484,102 @@
438 484 return null;
439 485 }
440 486
441 487 /**
488 + * The consent gate, asked at the form plugin's own validation stage.
489 + *
490 + * For integrations whose submit hook runs after the form plugin has
491 + * already accepted the submission (WPForms `wpforms_process_complete`,
492 + * Gravity Forms `gform_after_submission`). By then the form has been
493 + * replaced by its confirmation, and a refused consent could only be
494 + * reported in a toast over an empty page (5.6.2 click test). Asked from
495 + * `wpforms_process` / `gform_validation` instead, the form plugin marks
496 + * the checkbox like a missed required field and keeps the input.
497 + *
498 + * Only a refusal that would stand is returned: DOI on for the form, not
499 + * skipped by `f12_cf7_doubleoptin_skip_option`, verdict NOT_GIVEN, gate
500 + * enforced. Everything else — a stale field name, the gate switched off
501 + * by filter — is left to createOptIn(), which logs it as before.
502 + *
503 + * @param FormDataInterface $formData The submission, normalized the same
504 + * way the submit hook will normalize it.
505 + * @param mixed $rawFields What the skip filter receives in the
506 + * submit hook of this integration.
507 + *
508 + * @return OptInError|null The refusal, or null to let the form through.
509 + *
510 + * @since 5.6.2
511 + */
512 + public function refusedConsentBeforeSubmit( FormDataInterface $formData, $rawFields = array() ): ?OptInError {
513 + $formId = $formData->getFormId();
514 +
515 + if ( ! $this->isOptInEnabled( $formId ) ) {
516 + return null;
517 + }
518 +
519 + if ( apply_filters( 'f12_cf7_doubleoptin_skip_option', false, $formId, $rawFields, $this->getIdentifier() ) ) {
520 + return null;
521 + }
522 +
523 + $consentField = (string) ( $this->getFormParameter( $formId )['consent_field'] ?? '' );
524 + if ( $consentField === '' ) {
525 + return null;
526 + }
527 +
528 + $verdict = ConsentGate::evaluate(
529 + $consentField,
530 + $formData->getFields(),
531 + $this->getKnownFieldNames( $formId )
532 + );
533 +
534 + if ( $verdict !== ConsentGate::NOT_GIVEN || ! ConsentGate::isEnforced( $formId, $this->getIdentifier() ) ) {
535 + return null;
536 + }
537 +
538 + $this->getLogger()->info(
539 + 'Consent acceptance not given, rejecting submission at validation',
540 + array(
541 + 'plugin' => 'double-opt-in',
542 + 'form_id' => $formId,
543 + 'integration' => $this->getIdentifier(),
544 + 'consent_field' => $consentField,
545 + )
546 + );
547 +
548 + /** This action is documented in createOptIn(). */
549 + do_action( 'f12_cf7_doubleoptin_consent_not_given', $formId, $consentField );
550 +
551 + return OptInError::fromCode(
552 + OptInError::CONSENT_NOT_GIVEN,
553 + array(
554 + 'form_id' => $formId,
555 + 'consent_field' => $consentField,
556 + )
557 + );
558 + }
559 +
560 + /**
442 561 * Validate the consent-acceptance gate (GDPR Art. 7).
443 562 *
444 - * When the form has a configured `consent_field`, the user must
445 - * have actively confirmed it. Otherwise we'd be storing a
446 - * `consent_text` snapshot the user never saw — fabricated audit
447 - * evidence. Empty `consent_field` means "no gate"
448 - * (backward-compat; admins who haven't migrated yet keep working).
563 + * The decision itself lives in {@see ConsentGate} — this method only
564 + * turns it into the return value `createOptIn()` expects and writes
565 + * the log lines. Both halves of the plugin share that class now: the
566 + * integrations that extend this base, and the legacy path in
567 + * `OptInFrontend::maybeCreateOptIn()` that serves Elementor and the
568 + * CF7/Avada shims.
449 569 *
450 - * Extracted into its own method so the gate behavior is testable
451 - * in isolation — the full createOptIn() flow has too many
452 - * side-effects (rate-limiting, file storage, container access)
453 - * for clean unit testing of just this branch.
570 + * `ConsentGate::FIELD_UNKNOWN` — the configured field is not on this
571 + * form — deliberately does NOT reject. Until 5.4.0 it did, which took
572 + * a site's registrations offline over a settings mistake the visitor
573 + * could neither see nor fix (customer report 2026-08-27). The admin
574 + * now hears about it through the log, the Site Health check and the
575 + * banner on the form's settings tab instead.
454 576 *
455 577 * @param FormDataInterface $formData The submitted form data.
456 578 * @param array<string, mixed> $formParameter The form-settings snapshot.
457 579 *
458 - * @return OptInError|null Error when the gate fails; null when it
459 - * passes (gate disabled or value truthy).
580 + * @return OptInError|null Error when the gate rejects; null when the
581 + * submission may proceed.
460 582 */
461 583 protected function validateConsentAcceptance( FormDataInterface $formData, array $formParameter ): ?OptInError {
462 584 $consentField = (string) ( $formParameter['consent_field'] ?? '' );
463 585 if ( $consentField === '' ) {
@@ -463,70 +585,65 @@
463 585 if ( $consentField === '' ) {
464 586 return null;
465 587 }
466 588
467 - $consentValue = $formData->getField( $consentField );
589 + $verdict = ConsentGate::evaluate(
590 + $consentField,
591 + $formData->getFields(),
592 + $this->getKnownFieldNames( $formData->getFormId() )
593 + );
468 594
469 - // Diagnostic — 2026-05-13 user report: WPForms Checkbox field
470 - // ticked, gate still rejects. Need to see the actual shape of
471 - // `$consentValue` to know whether `! empty()` is the wrong
472 - // predicate for WPForms checkbox payloads (e.g. array with
473 - // empty string, scalar 0, etc.).
474 - $this->getLogger()->info(
475 - 'Consent gate evaluation',
476 - array(
477 - 'plugin' => 'double-opt-in',
478 - 'form_id' => $formData->getFormId(),
479 - 'consent_field' => $consentField,
480 - 'value_type' => gettype( $consentValue ),
481 - 'value_preview' => is_scalar( $consentValue )
482 - ? (string) $consentValue
483 - : wp_json_encode( $consentValue ),
484 - 'is_empty' => empty( $consentValue ),
485 - )
486 - );
595 + if ( $verdict === ConsentGate::PASSED ) {
596 + return null;
597 + }
487 598
488 - if ( ! empty( $consentValue ) ) {
599 + if ( $verdict === ConsentGate::FIELD_UNKNOWN ) {
600 + // Never a rejection — see the ConsentGate docblock. Logged as
601 + // a warning all the same: until someone fixes the setting, the
602 + // consent proof stored with every opt-in of this form is
603 + // worthless.
604 + $this->getLogger()->warning(
605 + 'Consent field is not on this form — opt-in accepted without provable consent',
606 + array(
607 + 'plugin' => 'double-opt-in',
608 + 'form_id' => $formData->getFormId(),
609 + 'integration' => $this->getIdentifier(),
610 + 'consent_field' => $consentField,
611 + )
612 + );
613 +
614 + /**
615 + * Fires when a form's configured acceptance field cannot be
616 + * found on the form itself. The submission is accepted.
617 + *
618 + * @since 5.4.0
619 + *
620 + * @param int $formId The form the submission came from.
621 + * @param string $consentField The configured field name.
622 + * @param string $integration The integration identifier.
623 + */
624 + do_action(
625 + 'f12_doi_consent_field_unknown',
626 + $formData->getFormId(),
627 + $consentField,
628 + $this->getIdentifier()
629 + );
630 +
489 631 return null;
490 632 }
491 633
492 - // Fallback for WPForms checkbox shape: when the user ticked
493 - // the box, the bare-id key may carry the joined-string `value`
494 - // while the truthful "did the user actually tick anything"
495 - // signal lives in the `field_{id}` mirror's `value_raw`
496 - // (array of internal slugs). If `value_raw` is a non-empty
497 - // array with at least one non-empty entry, treat as consent
498 - // given — `empty()` over the joined string is a false
499 - // negative when the checkbox's display labels are empty.
500 - $mirror = $formData->getField( 'field_' . $consentField );
501 - if ( is_array( $mirror ) ) {
502 - $valueRaw = $mirror['value_raw'] ?? null;
503 - $value = $mirror['value'] ?? null;
504 - $hasTicked = false;
505 - foreach ( array( $valueRaw, $value ) as $candidate ) {
506 - if ( is_array( $candidate ) ) {
507 - foreach ( $candidate as $entry ) {
508 - if ( is_scalar( $entry ) && (string) $entry !== '' ) {
509 - $hasTicked = true;
510 - break 2;
511 - }
512 - }
513 - } elseif ( is_scalar( $candidate ) && (string) $candidate !== '' ) {
514 - $hasTicked = true;
515 - break;
516 - }
517 - }
518 - if ( $hasTicked ) {
519 - $this->getLogger()->info(
520 - 'Consent gate passed via field_{id} mirror fallback',
521 - array(
522 - 'plugin' => 'double-opt-in',
523 - 'form_id' => $formData->getFormId(),
524 - 'consent_field' => $consentField,
525 - )
526 - );
527 - return null;
528 - }
634 + if ( ! ConsentGate::isEnforced( $formData->getFormId(), $this->getIdentifier() ) ) {
635 + $this->getLogger()->warning(
636 + 'Consent gate disabled by filter — accepting an unconfirmed submission',
637 + array(
638 + 'plugin' => 'double-opt-in',
639 + 'form_id' => $formData->getFormId(),
640 + 'integration' => $this->getIdentifier(),
641 + 'consent_field' => $consentField,
642 + )
643 + );
644 +
645 + return null;
529 646 }
530 647
531 648 return OptInError::fromCode(
532 649 OptInError::CONSENT_NOT_GIVEN,
@@ -537,8 +654,38 @@
537 654 );
538 655 }
539 656
540 657 /**
658 + * The names of the fields this form actually declares.
659 + *
660 + * Needed to tell an unticked checkbox — which the browser leaves out
661 + * of the payload entirely — from a `consent_field` pointing at a
662 + * field the admin has since renamed or deleted. An integration that
663 + * cannot answer yields an empty list, and the gate then stays on the
664 + * cautious side and rejects nothing it cannot prove.
665 + *
666 + * @param int $formId The form to inspect.
667 + *
668 + * @return array<int,string>
669 + */
670 + protected function getKnownFieldNames( int $formId ): array {
671 + try {
672 + return ConsentGate::normalizeFieldNames( $this->getFormFields( $formId ) );
673 + } catch ( \Throwable $e ) {
674 + $this->getLogger()->warning(
675 + 'Could not read the form field inventory for the consent gate',
676 + array(
677 + 'plugin' => 'double-opt-in',
678 + 'form_id' => $formId,
679 + 'error' => $e->getMessage(),
680 + )
681 + );
682 +
683 + return array();
684 + }
685 + }
686 +
687 + /**
541 688 * Build the OptIn properties array that will be persisted on
542 689 * record creation. Extracted from {@see createOptIn()} so the
543 690 * field-coverage contract is testable in isolation — the full
544 691 * createOptIn() flow has too many side-effects (rate-limiting,
@@ -1002,8 +1149,24 @@
1002 1149 self::setValidationStatus( 'already_confirmed' );
1003 1150 return false;
1004 1151 }
1005 1152
1153 + /**
1154 + * Enable / Disable default mail.
1155 + *
1156 + * @param bool $status Enable (true) or disable (false) the default mail.
1157 + * @param int $postId The ID of the Post / Form.
1158 + *
1159 + * @since 2.3.3
1160 + */
1161 + $sendDefaultMail = (bool) apply_filters( 'f12_cf7_doubleoptin_send_default_mail', true, $optIn->get_cf_form_id() );
1162 +
1163 + // Bind the follow-up plan BEFORE the confirmation is saved, so a
1164 + // request that dies in between leaves rows the sweep can finish.
1165 + // False = no adapter for this integration → previous behaviour.
1166 + $coordinator = FollowUpCoordinator::instance();
1167 + $managed = $coordinator !== null && $coordinator->plan( $optIn, $sendDefaultMail );
1168 +
1006 1169 // Confirm the opt-in
1007 1170 do_action( 'f12_cf7_doubleoptin_before_confirm', $hash, $optIn );
1008 1171
1009 1172 $optIn->set_doubleoptin( 1 );
@@ -1029,16 +1192,32 @@
1029 1192
1030 1193 // Dispatch event
1031 1194 $this->dispatchOptInConfirmedEvent( $optIn, $hash );
1032 1195
1033 - do_action( 'f12_cf7_doubleoptin_after_confirm', $hash, $optIn );
1196 + // Everything from here on re-processes the stored submission.
1197 + self::runAsReplay(
1198 + function () use ( $hash, $optIn, $managed, $coordinator, $sendDefaultMail ) {
1199 + do_action( 'f12_cf7_doubleoptin_after_confirm', $hash, $optIn );
1034 1200
1035 - // Send the original mail if enabled
1036 - if ( apply_filters( 'f12_cf7_doubleoptin_send_default_mail', true, $optIn->get_cf_form_id() ) ) {
1037 - do_action( 'f12_cf7_doubleoptin_before_send_default_mail', $optIn );
1038 - $this->sendConfirmationMail( $optIn );
1039 - do_action( 'f12_cf7_doubleoptin_after_send_default_mail', $optIn );
1040 - }
1201 + if ( $managed ) {
1202 + // The coordinator runs every planned action — entry and
1203 + // mail — and records a result per action. The before/after
1204 + // hooks keep firing for listeners that depend on them.
1205 + if ( $sendDefaultMail ) {
1206 + do_action( 'f12_cf7_doubleoptin_before_send_default_mail', $optIn );
1207 + }
1208 + $coordinator->run( $optIn, FollowUpAttempt::TRIGGER_CONFIRM );
1209 + if ( $sendDefaultMail ) {
1210 + do_action( 'f12_cf7_doubleoptin_after_send_default_mail', $optIn );
1211 + }
1212 + } elseif ( $sendDefaultMail ) {
1213 + // Send the original mail if enabled
1214 + do_action( 'f12_cf7_doubleoptin_before_send_default_mail', $optIn );
1215 + $this->sendConfirmationMail( $optIn );
1216 + do_action( 'f12_cf7_doubleoptin_after_send_default_mail', $optIn );
1217 + }
1218 + }
1219 + );
1041 1220
1042 1221 $this->getLogger()->info(
1043 1222 'OptIn confirmed successfully',
1044 1223 array(