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/Integration/AbstractFormIntegration.php +258 -93 5.3.2 → 5.6.2 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,87 +588,65 @@
463 588 if ( $consentField === '' ) {
464 589 return null;
465 590 }
466 591
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;
592 + $verdict = ConsentGate::evaluate(
593 + $consentField,
594 + $formData->getFields(),
595 + $this->getKnownFieldNames( $formData->getFormId() )
596 + );
597 +
598 + if ( $verdict === ConsentGate::PASSED ) {
599 + return null;
482 600 }
483 601
484 - $consentValue = $formData->getField( $consentField );
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 + );
485 616
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 - );
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 + );
504 633
505 - if ( ! empty( $consentValue ) ) {
506 634 return null;
507 635 }
508 636
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 - }
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;
546 649 }
547 650
548 651 return OptInError::fromCode(
549 652 OptInError::CONSENT_NOT_GIVEN,
@@ -554,8 +657,38 @@
554 657 );
555 658 }
556 659
557 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 + /**
558 691 * Build the OptIn properties array that will be persisted on
559 692 * record creation. Extracted from {@see createOptIn()} so the
560 693 * field-coverage contract is testable in isolation — the full
561 694 * createOptIn() flow has too many side-effects (rate-limiting,
@@ -1019,8 +1152,24 @@
1019 1152 self::setValidationStatus( 'already_confirmed' );
1020 1153 return false;
1021 1154 }
1022 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 +
1023 1172 // Confirm the opt-in
1024 1173 do_action( 'f12_cf7_doubleoptin_before_confirm', $hash, $optIn );
1025 1174
1026 1175 $optIn->set_doubleoptin( 1 );
@@ -1046,16 +1195,32 @@
1046 1195
1047 1196 // Dispatch event
1048 1197 $this->dispatchOptInConfirmedEvent( $optIn, $hash );
1049 1198
1050 - 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 );
1051 1203
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 - }
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 + );
1058 1223
1059 1224 $this->getLogger()->info(
1060 1225 'OptIn confirmed successfully',
1061 1226 array(