PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.6.3
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.6.3
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/Integration/AbstractFormIntegration.php +260 -78 5.1.6 → 5.6.3 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 )
@@ -438,26 +487,102 @@
438 487 return null;
439 488 }
440 489
441 490 /**
491 + * The consent gate, asked at the form plugin's own validation stage.
492 + *
493 + * For integrations whose submit hook runs after the form plugin has
494 + * already accepted the submission (WPForms `wpforms_process_complete`,
495 + * Gravity Forms `gform_after_submission`). By then the form has been
496 + * replaced by its confirmation, and a refused consent could only be
497 + * reported in a toast over an empty page (5.6.2 click test). Asked from
498 + * `wpforms_process` / `gform_validation` instead, the form plugin marks
499 + * the checkbox like a missed required field and keeps the input.
500 + *
501 + * Only a refusal that would stand is returned: DOI on for the form, not
502 + * skipped by `f12_cf7_doubleoptin_skip_option`, verdict NOT_GIVEN, gate
503 + * enforced. Everything else — a stale field name, the gate switched off
504 + * by filter — is left to createOptIn(), which logs it as before.
505 + *
506 + * @param FormDataInterface $formData The submission, normalized the same
507 + * way the submit hook will normalize it.
508 + * @param mixed $rawFields What the skip filter receives in the
509 + * submit hook of this integration.
510 + *
511 + * @return OptInError|null The refusal, or null to let the form through.
512 + *
513 + * @since 5.6.2
514 + */
515 + public function refusedConsentBeforeSubmit( FormDataInterface $formData, $rawFields = array() ): ?OptInError {
516 + $formId = $formData->getFormId();
517 +
518 + if ( ! $this->isOptInEnabled( $formId ) ) {
519 + return null;
520 + }
521 +
522 + if ( apply_filters( 'f12_cf7_doubleoptin_skip_option', false, $formId, $rawFields, $this->getIdentifier() ) ) {
523 + return null;
524 + }
525 +
526 + $consentField = (string) ( $this->getFormParameter( $formId )['consent_field'] ?? '' );
527 + if ( $consentField === '' ) {
528 + return null;
529 + }
530 +
531 + $verdict = ConsentGate::evaluate(
532 + $consentField,
533 + $formData->getFields(),
534 + $this->getKnownFieldNames( $formId )
535 + );
536 +
537 + if ( $verdict !== ConsentGate::NOT_GIVEN || ! ConsentGate::isEnforced( $formId, $this->getIdentifier() ) ) {
538 + return null;
539 + }
540 +
541 + $this->getLogger()->info(
542 + 'Consent acceptance not given, rejecting submission at validation',
543 + array(
544 + 'plugin' => 'double-opt-in',
545 + 'form_id' => $formId,
546 + 'integration' => $this->getIdentifier(),
547 + 'consent_field' => $consentField,
548 + )
549 + );
550 +
551 + /** This action is documented in createOptIn(). */
552 + do_action( 'f12_cf7_doubleoptin_consent_not_given', $formId, $consentField );
553 +
554 + return OptInError::fromCode(
555 + OptInError::CONSENT_NOT_GIVEN,
556 + array(
557 + 'form_id' => $formId,
558 + 'consent_field' => $consentField,
559 + )
560 + );
561 + }
562 +
563 + /**
442 564 * Validate the consent-acceptance gate (GDPR Art. 7).
443 565 *
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).
566 + * The decision itself lives in {@see ConsentGate} — this method only
567 + * turns it into the return value `createOptIn()` expects and writes
568 + * the log lines. Both halves of the plugin share that class now: the
569 + * integrations that extend this base, and the legacy path in
570 + * `OptInFrontend::maybeCreateOptIn()` that serves Elementor and the
571 + * CF7/Avada shims.
449 572 *
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.
573 + * `ConsentGate::FIELD_UNKNOWN` — the configured field is not on this
574 + * form — deliberately does NOT reject. Until 5.4.0 it did, which took
575 + * a site's registrations offline over a settings mistake the visitor
576 + * could neither see nor fix (customer report 2026-08-27). The admin
577 + * now hears about it through the log, the Site Health check and the
578 + * banner on the form's settings tab instead.
454 579 *
455 580 * @param FormDataInterface $formData The submitted form data.
456 581 * @param array<string, mixed> $formParameter The form-settings snapshot.
457 582 *
458 - * @return OptInError|null Error when the gate fails; null when it
459 - * passes (gate disabled or value truthy).
583 + * @return OptInError|null Error when the gate rejects; null when the
584 + * submission may proceed.
460 585 */
461 586 protected function validateConsentAcceptance( FormDataInterface $formData, array $formParameter ): ?OptInError {
462 587 $consentField = (string) ( $formParameter['consent_field'] ?? '' );
463 588 if ( $consentField === '' ) {
@@ -463,70 +588,65 @@
463 588 if ( $consentField === '' ) {
464 589 return null;
465 590 }
466 591
467 - $consentValue = $formData->getField( $consentField );
592 + $verdict = ConsentGate::evaluate(
593 + $consentField,
594 + $formData->getFields(),
595 + $this->getKnownFieldNames( $formData->getFormId() )
596 + );
468 597
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 - );
598 + if ( $verdict === ConsentGate::PASSED ) {
599 + return null;
600 + }
487 601
488 - if ( ! empty( $consentValue ) ) {
602 + if ( $verdict === ConsentGate::FIELD_UNKNOWN ) {
603 + // Never a rejection — see the ConsentGate docblock. Logged as
604 + // a warning all the same: until someone fixes the setting, the
605 + // consent proof stored with every opt-in of this form is
606 + // worthless.
607 + $this->getLogger()->warning(
608 + 'Consent field is not on this form — opt-in accepted without provable consent',
609 + array(
610 + 'plugin' => 'double-opt-in',
611 + 'form_id' => $formData->getFormId(),
612 + 'integration' => $this->getIdentifier(),
613 + 'consent_field' => $consentField,
614 + )
615 + );
616 +
617 + /**
618 + * Fires when a form's configured acceptance field cannot be
619 + * found on the form itself. The submission is accepted.
620 + *
621 + * @since 5.4.0
622 + *
623 + * @param int $formId The form the submission came from.
624 + * @param string $consentField The configured field name.
625 + * @param string $integration The integration identifier.
626 + */
627 + do_action(
628 + 'f12_doi_consent_field_unknown',
629 + $formData->getFormId(),
630 + $consentField,
631 + $this->getIdentifier()
632 + );
633 +
489 634 return null;
490 635 }
491 636
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 - }
637 + if ( ! ConsentGate::isEnforced( $formData->getFormId(), $this->getIdentifier() ) ) {
638 + $this->getLogger()->warning(
639 + 'Consent gate disabled by filter — accepting an unconfirmed submission',
640 + array(
641 + 'plugin' => 'double-opt-in',
642 + 'form_id' => $formData->getFormId(),
643 + 'integration' => $this->getIdentifier(),
644 + 'consent_field' => $consentField,
645 + )
646 + );
647 +
648 + return null;
529 649 }
530 650
531 651 return OptInError::fromCode(
532 652 OptInError::CONSENT_NOT_GIVEN,
@@ -537,8 +657,38 @@
537 657 );
538 658 }
539 659
540 660 /**
661 + * The names of the fields this form actually declares.
662 + *
663 + * Needed to tell an unticked checkbox — which the browser leaves out
664 + * of the payload entirely — from a `consent_field` pointing at a
665 + * field the admin has since renamed or deleted. An integration that
666 + * cannot answer yields an empty list, and the gate then stays on the
667 + * cautious side and rejects nothing it cannot prove.
668 + *
669 + * @param int $formId The form to inspect.
670 + *
671 + * @return array<int,string>
672 + */
673 + protected function getKnownFieldNames( int $formId ): array {
674 + try {
675 + return ConsentGate::normalizeFieldNames( $this->getFormFields( $formId ) );
676 + } catch ( \Throwable $e ) {
677 + $this->getLogger()->warning(
678 + 'Could not read the form field inventory for the consent gate',
679 + array(
680 + 'plugin' => 'double-opt-in',
681 + 'form_id' => $formId,
682 + 'error' => $e->getMessage(),
683 + )
684 + );
685 +
686 + return array();
687 + }
688 + }
689 +
690 + /**
541 691 * Build the OptIn properties array that will be persisted on
542 692 * record creation. Extracted from {@see createOptIn()} so the
543 693 * field-coverage contract is testable in isolation — the full
544 694 * createOptIn() flow has too many side-effects (rate-limiting,
@@ -1002,8 +1152,24 @@
1002 1152 self::setValidationStatus( 'already_confirmed' );
1003 1153 return false;
1004 1154 }
1005 1155
1156 + /**
1157 + * Enable / Disable default mail.
1158 + *
1159 + * @param bool $status Enable (true) or disable (false) the default mail.
1160 + * @param int $postId The ID of the Post / Form.
1161 + *
1162 + * @since 2.3.3
1163 + */
1164 + $sendDefaultMail = (bool) apply_filters( 'f12_cf7_doubleoptin_send_default_mail', true, $optIn->get_cf_form_id() );
1165 +
1166 + // Bind the follow-up plan BEFORE the confirmation is saved, so a
1167 + // request that dies in between leaves rows the sweep can finish.
1168 + // False = no adapter for this integration → previous behaviour.
1169 + $coordinator = FollowUpCoordinator::instance();
1170 + $managed = $coordinator !== null && $coordinator->plan( $optIn, $sendDefaultMail );
1171 +
1006 1172 // Confirm the opt-in
1007 1173 do_action( 'f12_cf7_doubleoptin_before_confirm', $hash, $optIn );
1008 1174
1009 1175 $optIn->set_doubleoptin( 1 );
@@ -1029,16 +1195,32 @@
1029 1195
1030 1196 // Dispatch event
1031 1197 $this->dispatchOptInConfirmedEvent( $optIn, $hash );
1032 1198
1033 - do_action( 'f12_cf7_doubleoptin_after_confirm', $hash, $optIn );
1199 + // Everything from here on re-processes the stored submission.
1200 + self::runAsReplay(
1201 + function () use ( $hash, $optIn, $managed, $coordinator, $sendDefaultMail ) {
1202 + do_action( 'f12_cf7_doubleoptin_after_confirm', $hash, $optIn );
1034 1203
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 - }
1204 + if ( $managed ) {
1205 + // The coordinator runs every planned action — entry and
1206 + // mail — and records a result per action. The before/after
1207 + // hooks keep firing for listeners that depend on them.
1208 + if ( $sendDefaultMail ) {
1209 + do_action( 'f12_cf7_doubleoptin_before_send_default_mail', $optIn );
1210 + }
1211 + $coordinator->run( $optIn, FollowUpAttempt::TRIGGER_CONFIRM );
1212 + if ( $sendDefaultMail ) {
1213 + do_action( 'f12_cf7_doubleoptin_after_send_default_mail', $optIn );
1214 + }
1215 + } elseif ( $sendDefaultMail ) {
1216 + // Send the original mail if enabled
1217 + do_action( 'f12_cf7_doubleoptin_before_send_default_mail', $optIn );
1218 + $this->sendConfirmationMail( $optIn );
1219 + do_action( 'f12_cf7_doubleoptin_after_send_default_mail', $optIn );
1220 + }
1221 + }
1222 + );
1041 1223
1042 1224 $this->getLogger()->info(
1043 1225 'OptIn confirmed successfully',
1044 1226 array(