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 +258 -96 5.3.2 → 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,87 +585,65 @@
463 585 if ( $consentField === '' ) {
464 586 return null;
465 587 }
466 588
467 - // Reconcile the configured name with what the form actually
468 - // submitted. Settings saved before 5.3.2 ran through
469 - // sanitize_key(), which lowercased them — a CF7 field named
470 - // `Datenschutz` was stored as `datenschutz`, was never found
471 - // here, and the gate then rejected EVERY submission with
472 - // "consent not given" (customer report 2026-08-27).
473 - //
474 - // An exact match always wins, so a form carrying both spellings
475 - // still resolves to the one the admin configured. When nothing
476 - // matches at all the original name is kept and the rejection
477 - // below carries it into the log — that case is a genuinely
478 - // misconfigured form and has to stay visible.
479 - $resolvedField = SubmittedContent::matchFieldName( $consentField, array_keys( $formData->getFields() ) );
480 - if ( $resolvedField !== '' ) {
481 - $consentField = $resolvedField;
589 + $verdict = ConsentGate::evaluate(
590 + $consentField,
591 + $formData->getFields(),
592 + $this->getKnownFieldNames( $formData->getFormId() )
593 + );
594 +
595 + if ( $verdict === ConsentGate::PASSED ) {
596 + return null;
482 597 }
483 598
484 - $consentValue = $formData->getField( $consentField );
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 + );
485 613
486 - // Diagnostic — 2026-05-13 user report: WPForms Checkbox field
487 - // ticked, gate still rejects. Need to see the actual shape of
488 - // `$consentValue` to know whether `! empty()` is the wrong
489 - // predicate for WPForms checkbox payloads (e.g. array with
490 - // empty string, scalar 0, etc.).
491 - $this->getLogger()->info(
492 - 'Consent gate evaluation',
493 - array(
494 - 'plugin' => 'double-opt-in',
495 - 'form_id' => $formData->getFormId(),
496 - 'consent_field' => $consentField,
497 - 'value_type' => gettype( $consentValue ),
498 - 'value_preview' => is_scalar( $consentValue )
499 - ? (string) $consentValue
500 - : wp_json_encode( $consentValue ),
501 - 'is_empty' => empty( $consentValue ),
502 - )
503 - );
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 + );
504 630
505 - if ( ! empty( $consentValue ) ) {
506 631 return null;
507 632 }
508 633
509 - // Fallback for WPForms checkbox shape: when the user ticked
510 - // the box, the bare-id key may carry the joined-string `value`
511 - // while the truthful "did the user actually tick anything"
512 - // signal lives in the `field_{id}` mirror's `value_raw`
513 - // (array of internal slugs). If `value_raw` is a non-empty
514 - // array with at least one non-empty entry, treat as consent
515 - // given — `empty()` over the joined string is a false
516 - // negative when the checkbox's display labels are empty.
517 - $mirror = $formData->getField( 'field_' . $consentField );
518 - if ( is_array( $mirror ) ) {
519 - $valueRaw = $mirror['value_raw'] ?? null;
520 - $value = $mirror['value'] ?? null;
521 - $hasTicked = false;
522 - foreach ( array( $valueRaw, $value ) as $candidate ) {
523 - if ( is_array( $candidate ) ) {
524 - foreach ( $candidate as $entry ) {
525 - if ( is_scalar( $entry ) && (string) $entry !== '' ) {
526 - $hasTicked = true;
527 - break 2;
528 - }
529 - }
530 - } elseif ( is_scalar( $candidate ) && (string) $candidate !== '' ) {
531 - $hasTicked = true;
532 - break;
533 - }
534 - }
535 - if ( $hasTicked ) {
536 - $this->getLogger()->info(
537 - 'Consent gate passed via field_{id} mirror fallback',
538 - array(
539 - 'plugin' => 'double-opt-in',
540 - 'form_id' => $formData->getFormId(),
541 - 'consent_field' => $consentField,
542 - )
543 - );
544 - return null;
545 - }
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;
546 646 }
547 647
548 648 return OptInError::fromCode(
549 649 OptInError::CONSENT_NOT_GIVEN,
@@ -554,8 +654,38 @@
554 654 );
555 655 }
556 656
557 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 + /**
558 688 * Build the OptIn properties array that will be persisted on
559 689 * record creation. Extracted from {@see createOptIn()} so the
560 690 * field-coverage contract is testable in isolation — the full
561 691 * createOptIn() flow has too many side-effects (rate-limiting,
@@ -1019,8 +1149,24 @@
1019 1149 self::setValidationStatus( 'already_confirmed' );
1020 1150 return false;
1021 1151 }
1022 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 +
1023 1169 // Confirm the opt-in
1024 1170 do_action( 'f12_cf7_doubleoptin_before_confirm', $hash, $optIn );
1025 1171
1026 1172 $optIn->set_doubleoptin( 1 );
@@ -1046,16 +1192,32 @@
1046 1192
1047 1193 // Dispatch event
1048 1194 $this->dispatchOptInConfirmedEvent( $optIn, $hash );
1049 1195
1050 - 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 );
1051 1200
1052 - // Send the original mail if enabled
1053 - if ( apply_filters( 'f12_cf7_doubleoptin_send_default_mail', true, $optIn->get_cf_form_id() ) ) {
1054 - do_action( 'f12_cf7_doubleoptin_before_send_default_mail', $optIn );
1055 - $this->sendConfirmationMail( $optIn );
1056 - do_action( 'f12_cf7_doubleoptin_after_send_default_mail', $optIn );
1057 - }
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 + );
1058 1220
1059 1221 $this->getLogger()->info(
1060 1222 'OptIn confirmed successfully',
1061 1223 array(