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/Integration/AbstractFormIntegration.php +166 -13 5.5.0 → 5.13.0 View file →
@@ -15,8 +15,10 @@
15 15 use Forge12\DoubleOptIn\Events\Integration\FormSubmissionEvent;
16 16 use Forge12\DoubleOptIn\Events\Lifecycle\OptInConfirmedEvent;
17 17 use Forge12\DoubleOptIn\Events\Lifecycle\OptInCreatedEvent;
18 18 use Forge12\DoubleOptIn\Files\FileStorage;
19 +use Forge12\DoubleOptIn\FollowUp\FollowUpAttempt;
20 +use Forge12\DoubleOptIn\FollowUp\FollowUpCoordinator;
19 21 use Forge12\DoubleOptIn\Frontend\ErrorNotification;
20 22 use Forge12\DoubleOptIn\Service\RateLimiter;
21 23 use forge12\contactform7\CF7DoubleOptIn\CF7DoubleOptIn;
22 24 use forge12\contactform7\CF7DoubleOptIn\IPHelper;
@@ -81,8 +83,47 @@
81 83 * Get the validation status from the last validateOptIn() call.
82 84 *
83 85 * @return string One of: '', 'confirmed', 'already_confirmed', 'expired', 'not_found'.
84 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 +
85 126 public static function getValidationStatus(): string {
86 127 return self::$validationStatus;
87 128 }
88 129
@@ -171,12 +212,19 @@
171 212 /**
172 213 * {@inheritdoc}
173 214 */
174 215 public function isOptInEnabled( int $formId ): bool {
175 - // Disable if opt-in confirmation is in progress
176 - 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() ) {
177 225 $this->getLogger()->debug(
178 - 'Opt-in disabled due to optin flag in GET request',
226 + 'Opt-in disabled during post-confirmation replay',
179 227 array(
180 228 'plugin' => 'double-opt-in',
181 229 'class' => static::class,
182 230 )
@@ -312,9 +360,8 @@
312 360 $this->getLogger()->warning(
313 361 'Rate limit exceeded for IP',
314 362 array(
315 363 'plugin' => 'double-opt-in',
316 - 'ip' => $ip,
317 364 'form_id' => $formData->getFormId(),
318 365 )
319 366 );
320 367 do_action( 'f12_cf7_doubleoptin_rate_limited', 'ip', $ip, $formData->getFormId() );
@@ -335,9 +382,8 @@
335 382 $this->getLogger()->warning(
336 383 'Rate limit exceeded for email',
337 384 array(
338 385 'plugin' => 'double-opt-in',
339 - 'email' => $recipient,
340 386 'form_id' => $formData->getFormId(),
341 387 )
342 388 );
343 389 do_action( 'f12_cf7_doubleoptin_rate_limited', 'email', $recipient, $formData->getFormId() );
@@ -375,9 +421,8 @@
375 421 $this->getLogger()->warning(
376 422 'Recipient validation failed',
377 423 array(
378 424 'plugin' => 'double-opt-in',
379 - 'email' => $recipient,
380 425 'form_id' => $formData->getFormId(),
381 426 'reason' => $errorMsg,
382 427 )
383 428 );
@@ -439,8 +484,81 @@
439 484 return null;
440 485 }
441 486
442 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 + /**
443 561 * Validate the consent-acceptance gate (GDPR Art. 7).
444 562 *
445 563 * The decision itself lives in {@see ConsentGate} — this method only
446 564 * turns it into the return value `createOptIn()` expects and writes
@@ -607,8 +725,11 @@
607 725 'form' => $formData->getFormHtml(),
608 726 'email' => $recipient,
609 727 'consent_text' => (string) ( $formParameter['consent_text'] ?? '' ),
610 728 'consent_field' => (string) ( $formParameter['consent_field'] ?? '' ),
729 + // Instance of the form inside the form id (Elementor widget id).
730 + // Integrations that cannot tell instances apart leave it empty.
731 + 'form_ref' => (string) $formData->getMetaValue( 'form_ref', '' ),
611 732 );
612 733
613 734 /**
614 735 * Filter the OptIn properties array before the record is
@@ -1031,8 +1152,24 @@
1031 1152 self::setValidationStatus( 'already_confirmed' );
1032 1153 return false;
1033 1154 }
1034 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 +
1035 1172 // Confirm the opt-in
1036 1173 do_action( 'f12_cf7_doubleoptin_before_confirm', $hash, $optIn );
1037 1174
1038 1175 $optIn->set_doubleoptin( 1 );
@@ -1058,16 +1195,32 @@
1058 1195
1059 1196 // Dispatch event
1060 1197 $this->dispatchOptInConfirmedEvent( $optIn, $hash );
1061 1198
1062 - 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 );
1063 1203
1064 - // Send the original mail if enabled
1065 - if ( apply_filters( 'f12_cf7_doubleoptin_send_default_mail', true, $optIn->get_cf_form_id() ) ) {
1066 - do_action( 'f12_cf7_doubleoptin_before_send_default_mail', $optIn );
1067 - $this->sendConfirmationMail( $optIn );
1068 - do_action( 'f12_cf7_doubleoptin_after_send_default_mail', $optIn );
1069 - }
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 + );
1070 1223
1071 1224 $this->getLogger()->info(
1072 1225 'OptIn confirmed successfully',
1073 1226 array(