PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.5.0
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.5.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 | compatibility/OptInFrontend.class.php +732 -78 3.1.0 → 5.5.0 View file →
@@ -2,8 +2,18 @@
2 2
3 3 namespace forge12\contactform7\CF7DoubleOptIn;
4 4
5 5
6 +use Forge12\DoubleOptIn\Consent\ConsentGate;
7 +use Forge12\DoubleOptIn\Container\Container;
8 +use Forge12\DoubleOptIn\EmailTemplates\PlaceholderMapper;
9 +use Forge12\DoubleOptIn\EventSystem\EventDispatcherInterface;
10 +use Forge12\DoubleOptIn\Events\Lifecycle\OptInConfirmedEvent;
11 +use Forge12\DoubleOptIn\Events\Lifecycle\OptInCreatedEvent;
12 +use Forge12\DoubleOptIn\Frontend\ErrorNotification;
13 +use Forge12\DoubleOptIn\Integration\FormIntegrationRegistry;
14 +use Forge12\DoubleOptIn\Integration\OptInError;
15 +use Forge12\DoubleOptIn\Service\RateLimiter;
6 16 use Forge12\Shared\Logger;
7 17 use Forge12\Shared\LoggerInterface;
8 18
9 19 if ( ! defined( 'ABSPATH' ) ) {
@@ -9,8 +19,17 @@
9 19 if ( ! defined( 'ABSPATH' ) ) {
10 20 exit;
11 21 }
12 22
23 +/**
24 + * @deprecated 4.0.0 Use \Forge12\DoubleOptIn\Integration\AbstractFormIntegration instead.
25 + *
26 + * This class is maintained for backward compatibility only.
27 + * New integrations should extend AbstractFormIntegration and implement FormIntegrationInterface.
28 + *
29 + * @see \Forge12\DoubleOptIn\Integration\AbstractFormIntegration
30 + * @see \Forge12\DoubleOptIn\Integration\FormIntegrationInterface
31 + */
13 32 abstract class OptInFrontend {
14 33 /**
15 34 * The Type of the OptIn Form System
16 35 *
@@ -20,8 +39,54 @@
20 39
21 40 private LoggerInterface $logger;
22 41
23 42 /**
43 + * Stored hCaptcha CF7 instance for restore after mail send.
44 + *
45 + * @var object|null
46 + */
47 + private $hcaptchaCf7Instance = null;
48 +
49 + /**
50 + * Stored hCaptcha CF7 filter priority for restore after mail send.
51 + *
52 + * @var int
53 + */
54 + private int $hcaptchaCf7Priority = 20;
55 +
56 + /**
57 + * Validation status from the last validateOptIn() call.
58 + *
59 + * @var string
60 + */
61 + private static string $validationStatus = '';
62 +
63 + /**
64 + * Last error from maybeCreateOptIn(), if any.
65 + *
66 + * @var OptInError|null
67 + */
68 + protected ?OptInError $lastCreationError = null;
69 +
70 + /**
71 + * Get the validation status from the last validateOptIn() call.
72 + *
73 + * @return string One of: '', 'confirmed', 'already_confirmed', 'expired', 'not_found'.
74 + */
75 + public static function getValidationStatus(): string {
76 + return self::$validationStatus;
77 + }
78 +
79 + /**
80 + * Set the validation status.
81 + *
82 + * @param string $status The validation status.
83 + */
84 + private function setValidationStatus( string $status ): void {
85 + self::$validationStatus = $status;
86 + }
87 +
88 + /**
24 89 * Constructor for the class.
25 90 *
26 91 * This constructor registers the necessary actions to be performed,
27 92 * by hooking methods of the current class, for the following events:
@@ -67,8 +132,30 @@
67 132 $this->get_logger()->debug( 'Hook init registered for validateOptIn', [
68 133 'plugin' => 'double-opt-in',
69 134 ] );
70 135
136 + add_action( 'wp_footer', [ $this, 'renderValidationFeedback' ] );
137 +
138 + // Default feedback handler for validation statuses
139 + add_action( 'f12_cf7_doubleoptin_validation_feedback', function ( $status ) {
140 + if ( $status === 'confirmed' ) {
141 + return;
142 + }
143 +
144 + $messages = [
145 + 'already_confirmed' => __( 'Your opt-in has already been confirmed.', 'double-opt-in' ),
146 + 'expired' => __( 'This confirmation link has expired. Please submit the form again.', 'double-opt-in' ),
147 + 'not_found' => __( 'This confirmation link is invalid.', 'double-opt-in' ),
148 + ];
149 +
150 + $message = $messages[ $status ] ?? '';
151 + if ( ! empty( $message ) ) {
152 + echo '<div class="doi-validation-notice doi-notice-' . esc_attr( $status ) . '">';
153 + echo '<p>' . esc_html( $message ) . '</p>';
154 + echo '</div>';
155 + }
156 + }, 10, 1 );
157 +
71 158 add_filter( 'f12_cf7_doubleoptin_get_recipient_' . $this->type, [ $this, 'getRecipient' ], 10, 3 );
72 159 $this->get_logger()->debug( 'Filter f12_cf7_doubleoptin_get_recipient_' . $this->type . ' registered', [
73 160 'plugin' => 'double-opt-in',
74 161 ] );
@@ -109,8 +196,17 @@
109 196 * @return void
110 197 */
111 198 abstract public function sendDefaultMail( OptIn $OptIn ): void;
112 199
200 + public function disable_contact_form_7_captcha($is_active){
201 + if($is_active){
202 + $this->get_logger()->debug( 'Forge12 CF7Captcha filters and actions removed', ['plugin' => 'double-opt-in']);
203 + return false;
204 + }
205 + $this->get_logger()->debug( 'Forge12 CF7Captcha filters and actions removed', ['plugin' => 'double-opt-in']);
206 + return $is_active;
207 + }
208 +
113 209 /**
114 210 * This method is used to perform necessary actions before sending the default mail.
115 211 *
116 212 * This method removes the filter for the forge12 spam captcha if the class
@@ -132,18 +228,13 @@
132 228 'method' => __METHOD__,
133 229 ] );
134 230
135 231 // Remove the filter for the Forge12 spam captcha
136 - if ( class_exists( '\forge12\contactform7\CF7Captcha\TimerValidatorCF7' ) ) {
137 - remove_filter( 'wpcf7_spam', '\forge12\contactform7\CF7Captcha::isSpam' );
138 - remove_filter( 'wpcf7_spam', '\forge12\contactform7\CF7Captcha\CF7IPLog::isSpam' );
139 - remove_action( 'wpcf7_mail_sent', '\forge12\contactform7\CF7Captcha\CF7IPLog::doLogIP' );
232 + add_filter('f12_cf7_captcha_is_installed_cf7', [$this, 'disable_contact_form_7_captcha'], 999, 1);
233 + $this->get_logger()->debug( 'Forge12 CF7Captcha filters and actions removed', [
234 + 'plugin' => 'double-opt-in',
235 + ] );
140 236
141 - $this->get_logger()->debug( 'Forge12 CF7Captcha filters and actions removed', [
142 - 'plugin' => 'double-opt-in',
143 - ] );
144 - }
145 -
146 237 // Remove the filter for the Google reCAPTCHA validation
147 238 remove_filter( 'wpcf7_spam', 'wpcf7_recaptcha_verify_response', 9 );
148 239
149 240 $this->get_logger()->debug( 'Google reCAPTCHA filter removed', [
@@ -148,8 +239,11 @@
148 239
149 240 $this->get_logger()->debug( 'Google reCAPTCHA filter removed', [
150 241 'plugin' => 'double-opt-in',
151 242 ] );
243 +
244 + // Remove hCaptcha validation filter
245 + $this->removeHCaptchaFilter();
152 246 }
153 247
154 248
155 249 /**
@@ -189,9 +283,9 @@
189 283 'method' => __METHOD__,
190 284 ] );
191 285
192 286 // re-add the filter to ensure for all other forms the reCAPTCHA is used
193 - if ( function_exists( 'wpcf7_recaptcha_verifiy_response' ) ) {
287 + if ( function_exists( 'wpcf7_recaptcha_verify_response' ) ) {
194 288 add_filter( 'wpcf7_spam', 'wpcf7_recaptcha_verify_response', 9, 2 );
195 289 $this->get_logger()->debug( 'Google reCAPTCHA filter re-added', [
196 290 'plugin' => 'double-opt-in',
197 291 ] );
@@ -206,11 +300,61 @@
206 300 $this->get_logger()->debug( 'Forge12 CF7Captcha filters and actions re-added', [
207 301 'plugin' => 'double-opt-in',
208 302 ] );
209 303 }
304 +
305 + // re-add hCaptcha validation filter
306 + $this->restoreHCaptchaFilter();
210 307 }
211 308
212 309 /**
310 + * Remove hCaptcha CF7 validation filter and store the instance for later restore.
311 + *
312 + * @return void
313 + */
314 + private function removeHCaptchaFilter(): void {
315 + if ( ! class_exists( '\HCaptcha\CF7\CF7' ) ) {
316 + return;
317 + }
318 +
319 + global $wp_filter;
320 +
321 + if ( ! isset( $wp_filter['wpcf7_validate'] ) ) {
322 + return;
323 + }
324 +
325 + foreach ( $wp_filter['wpcf7_validate']->callbacks as $priority => $hooks ) {
326 + foreach ( $hooks as $key => $hook ) {
327 + if ( is_array( $hook['function'] ) && $hook['function'][0] instanceof \HCaptcha\CF7\CF7 ) {
328 + $this->hcaptchaCf7Instance = $hook['function'][0];
329 + $this->hcaptchaCf7Priority = $priority;
330 + remove_filter( 'wpcf7_validate', $hook['function'], $priority );
331 + $this->get_logger()->debug( 'hCaptcha CF7 validation filter removed', [
332 + 'plugin' => 'double-opt-in',
333 + ] );
334 +
335 + return;
336 + }
337 + }
338 + }
339 + }
340 +
341 + /**
342 + * Re-add hCaptcha CF7 validation filter if it was previously removed.
343 + *
344 + * @return void
345 + */
346 + private function restoreHCaptchaFilter(): void {
347 + if ( isset( $this->hcaptchaCf7Instance ) ) {
348 + add_filter( 'wpcf7_validate', [ $this->hcaptchaCf7Instance, 'verify_hcaptcha' ], $this->hcaptchaCf7Priority, 2 );
349 + $this->get_logger()->debug( 'hCaptcha CF7 validation filter re-added', [
350 + 'plugin' => 'double-opt-in',
351 + ] );
352 + $this->hcaptchaCf7Instance = null;
353 + }
354 + }
355 +
356 + /**
213 357 * Updates the opt-in status by hash.
214 358 *
215 359 * @param string $hash The opt-in hash.
216 360 * @param int $value The opt-in value to set.
@@ -260,8 +404,11 @@
260 404
261 405 if ( $result ) {
262 406 if ( (int)$value === 1 ) {
263 407 $telemetry->increment( 'confirmed_optins' );
408 +
409 + // Dispatch typed event for new event-driven architecture
410 + $this->dispatchOptInConfirmedEvent( $OptIn, $hash );
264 411 }
265 412
266 413 $this->get_logger()->info( 'OptIn updated successfully', [
267 414 'plugin' => 'double-opt-in',
@@ -304,29 +451,21 @@
304 451 'optin_id' => $OptIn->get_id(),
305 452 'parameter' => $parameter,
306 453 ] );
307 454
308 - // set the default timezone
309 - $timezone = get_option( 'timezone_string' );
310 -
311 - // set fallback timezone
312 - if ( empty( $timezone ) ) {
313 - $timezone = 'Europe/Berlin';
314 - $this->get_logger()->debug( 'No timezone set in options, using fallback Europe/Berlin', [
315 - 'plugin' => 'double-opt-in',
316 - ] );
317 - }
318 -
319 - date_default_timezone_set( $timezone );
320 -
321 455 $placeholder = [
322 - 'doubleoptin_form_url' => $parameter['formUrl'] ?? '',
456 + // User-influenced submit URL — sanitise so a crafted query string
457 + // can't reflect into the mail HTML (esc_url_raw keeps text-mail intact).
458 + 'doubleoptin_form_url' => esc_url_raw( (string) ( $parameter['formUrl'] ?? '' ) ),
323 459 'doubleoptin_form_subject' => $parameter['subject'] ?? '',
324 - 'doubleoptin_form_date' => date( get_option( 'date_format' ) ),
325 - 'doubleoptin_form_time' => date( get_option( 'time_format' ) ),
460 + // wp_date() formats in the site's timezone without mutating PHP's
461 + // global timezone (the old date() + date_default_timezone_set() did).
462 + 'doubleoptin_form_date' => wp_date( get_option( 'date_format' ) ),
463 + 'doubleoptin_form_time' => wp_date( get_option( 'time_format' ) ),
326 464 'doubleoptin_form_email' => get_option( 'admin_email' ),
327 465 'doubleoptinlink' => $OptIn->get_link_optin( $parameter ),
328 466 'doubleoptoutlink' => $OptIn->get_link_optout(),
467 + 'doubleoptin_privacy_url' => $this->getPrivacyPolicyUrl(),
329 468 ];
330 469
331 470
332 471 foreach ( $placeholder as $key => $value ) {
@@ -347,14 +486,48 @@
347 486 ] );
348 487 }
349 488
350 489 // Logging nach allen Ersetzungen
351 - $this->get_logger()->debug( 'Placeholders replaced in body', [
490 + $this->get_logger()->debug( 'System placeholders replaced in body', [
352 491 'plugin' => 'double-opt-in',
353 492 'placeholders' => array_keys( $placeholder ),
354 - 'body_length' => strlen( $body ), // nur Info, wie groß der Text ist
493 + 'body_length' => strlen( $body ),
355 494 ] );
356 495
496 + // Replace standard placeholders (doi_email, doi_name, etc.)
497 + $formData = maybe_unserialize( $OptIn->get_content() );
498 + if ( is_array( $formData ) ) {
499 + // Per-integration nesting unwrap. The serialized OptIn content
500 + // is whatever each frontend stored, and that differs by
501 + // integration — see SubmittedContent for the table.
502 + // Without this, PlaceholderMapper::replacePlaceholders looks
503 + // for `$formData[$mappedField]` at the top level and finds
504 + // nothing for Elementor — every `[doi_*]` placeholder renders
505 + // as an empty string in the confirmation mail.
506 + //
507 + // The chain used to be spelled out here. It now lives in
508 + // SubmittedContent because the audit reader needs the same
509 + // knowledge, and the copy it had was one shape short — every
510 + // Elementor opt-in reported its consent checkbox as unticked
511 + // (customer report 2026-08-27).
512 + $fieldData = \Forge12\DoubleOptIn\Integration\SubmittedContent::unwrapFields( $formData );
513 +
514 + $body = PlaceholderMapper::replacePlaceholders(
515 + $body,
516 + $fieldData,
517 + $OptIn->get_cf_form_id(),
518 + [],
519 + $this->type
520 + );
521 +
522 + $this->get_logger()->debug( 'Standard placeholders replaced', [
523 + 'plugin' => 'double-opt-in',
524 + 'form_id' => $OptIn->get_cf_form_id(),
525 + 'field_data_keys' => array_keys( $fieldData ),
526 + 'unwrapped_from' => \Forge12\DoubleOptIn\Integration\SubmittedContent::describeShape( $formData ),
527 + ] );
528 + }
529 +
357 530 return $body;
358 531 }
359 532
360 533
@@ -361,8 +534,12 @@
361 534 /**
362 535 * Add Stylesheets
363 536 */
364 537 public function validateOptIn(): bool {
538 + $this->get_logger()->debug( 'validateOptIn started', [
539 + 'plugin' => 'double-opt-in',
540 + ] );
541 +
365 542 /**
366 543 * Skip if the hash has not been submitted.
367 544 */
368 545 if ( ! isset( $_GET['optin'] ) ) {
@@ -389,8 +566,9 @@
389 566 $this->get_logger()->warning( 'OptIn not found for hash', [
390 567 'plugin' => 'double-opt-in',
391 568 'hash' => $hash,
392 569 ] );
570 + $this->setValidationStatus( 'not_found' );
393 571 return false;
394 572 }
395 573
396 574 /**
@@ -405,25 +583,63 @@
405 583 ] );
406 584 return false;
407 585 }
408 586
587 + $this->get_logger()->debug( 'OptIn type found', [
588 + 'plugin' => 'double-opt-in',
589 + 'hash' => $hash,
590 + 'type' => $this->type,
591 + 'optin_id' => $OptIn->get_id(),
592 + ] );
593 +
409 594 /**
410 - * Skip if the OptIn has been confirmed already or could not be updated.
595 + * Check if the token has expired.
411 596 */
412 - if ( $this->updateOptInByHash( $hash, 1 ) <= 0 ) {
413 - $this->get_logger()->info( 'OptIn already confirmed or update failed', [
597 + $settings = CF7DoubleOptIn::getInstance()->getSettings();
598 + $expiryHours = (int) ( $settings['token_expiry_hours'] ?? 48 );
599 + if ( $expiryHours > 0 && ( time() - (int) $OptIn->get_createtime() ) > ( $expiryHours * 3600 ) ) {
600 + $this->get_logger()->info( 'OptIn token expired', [
601 + 'plugin' => 'double-opt-in',
602 + 'hash' => $hash,
603 + 'optin_id' => $OptIn->get_id(),
604 + 'expiry_hours' => $expiryHours,
605 + ] );
606 + do_action( 'f12_cf7_doubleoptin_token_expired', $hash, $OptIn );
607 + $this->setValidationStatus( 'expired' );
608 + return false;
609 + }
610 +
611 + /**
612 + * Check if already confirmed (before calling updateOptInByHash).
613 + */
614 + if ( $OptIn->is_confirmed() ) {
615 + $this->get_logger()->info( 'OptIn already confirmed', [
414 616 'plugin' => 'double-opt-in',
415 617 'hash' => $hash,
416 618 'optin_id' => $OptIn->get_id(),
417 619 ] );
620 + do_action( 'f12_cf7_doubleoptin_already_confirmed', $hash, $OptIn );
621 + $this->setValidationStatus( 'already_confirmed' );
418 622 return false;
419 623 }
420 624
421 625 /**
626 + * Confirm the OptIn.
627 + */
628 + if ( $this->updateOptInByHash( $hash, 1, $OptIn ) <= 0 ) {
629 + $this->get_logger()->info( 'OptIn update failed', [
630 + 'plugin' => 'double-opt-in',
631 + 'hash' => $hash,
632 + 'optin_id' => $OptIn->get_id(),
633 + ] );
634 + return false;
635 + }
636 +
637 + $this->setValidationStatus( 'confirmed' );
638 +
639 + /**
422 640 * Enable / Disable default mail.
423 641 *
424 - * Filter to allow developer to enable / disable the default mail.
425 - *
426 642 * @param bool $status Enable (true) or disable (false) the default mail.
427 643 * @param int $postId The ID of the Post / Form.
428 644 *
429 645 * @since 2.3.3
@@ -436,17 +652,8 @@
436 652 ] );
437 653 return false;
438 654 }
439 655
440 - /**
441 - * Hook triggers before the default mail will be send.
442 - *
443 - * Action to allow developers to do custom actions before the default mail will be triggered.
444 - *
445 - * @param OptIn $OptIn
446 - *
447 - * @since 2.3.3
448 - */
449 656 $this->get_logger()->debug( 'Triggering before_send_default_mail hook', [
450 657 'plugin' => 'double-opt-in',
451 658 'optin_id' => $OptIn->get_id(),
452 659 ] );
@@ -451,17 +658,8 @@
451 658 'optin_id' => $OptIn->get_id(),
452 659 ] );
453 660 do_action( 'f12_cf7_doubleoptin_before_send_default_mail', $OptIn );
454 661
455 - /**
456 - * Hook triggers the default mail
457 - *
458 - * Action to allow developers to do custom actions before the default mail will be triggered.
459 - *
460 - * @param OptIn $OptIn
461 - *
462 - * @since 2.3.3
463 - */
464 662 $this->get_logger()->info( 'Triggering send_default_mail hook', [
465 663 'plugin' => 'double-opt-in',
466 664 'optin_id' => $OptIn->get_id(),
467 665 ] );
@@ -466,17 +664,8 @@
466 664 'optin_id' => $OptIn->get_id(),
467 665 ] );
468 666 do_action( 'f12_cf7_doubleoptin_trigger_default_mail', $OptIn );
469 667
470 - /**
471 - * Hook triggers after the default mail has been sent
472 - *
473 - * Action to allow developers to do custom actions after the default mail has been triggered.
474 - *
475 - * @param OptIn $OptIn
476 - *
477 - * @since 2.3.3
478 - */
479 668 $this->get_logger()->debug( 'Triggering after_send_default_mail hook', [
480 669 'plugin' => 'double-opt-in',
481 670 'optin_id' => $OptIn->get_id(),
482 671 ] );
@@ -581,16 +770,26 @@
581 770
582 771 /**
583 772 * Create the OptIn
584 773 *
585 - * @param int $formId The identifier of the form
586 - * @param string $formHtml The HTML code of the form
587 - * @param array $parameter The Post Parameter of the form.
588 - * @param array $files The Files attached to the form.
774 + * @param int $formId The identifier of the form
775 + * @param string $formHtml The HTML code of the form
776 + * @param array $parameter The Post Parameter of the form.
777 + * @param array $files The Files attached to the form.
778 + * @param array $knownFields The fields this form declares, `name => label`
779 + * or a plain list. Only the consent gate uses
780 + * them, to tell an unticked checkbox from a
781 + * setting that points at a field which no
782 + * longer exists. A shim that knows its form
783 + * better than the registry does (Elementor
784 + * has the Form_Record in hand) passes them in;
785 + * everyone else leaves it empty and
786 + * {@see self::resolveKnownFieldNames()} asks
787 + * the integration.
589 788 *
590 789 * @return OptIn|null
591 790 */
592 - protected function maybeCreateOptIn( int $formId, string $formHtml, array $parameter, array $files = array() ): ?OptIn {
791 + protected function maybeCreateOptIn( int $formId, string $formHtml, array $parameter, array $files = array(), array $knownFields = array() ): ?OptIn {
593 792 $this->get_logger()->debug( 'maybeCreateOptIn called', [
594 793 'plugin' => 'double-opt-in',
595 794 'class' => __CLASS__,
596 795 'method' => __METHOD__,
@@ -633,25 +832,200 @@
633 832 if ( empty( $recipient ) ) {
634 833 $this->get_logger()->warning( 'No recipient found, skipping OptIn creation', [
635 834 'plugin' => 'double-opt-in',
636 835 ] );
836 + ErrorNotification::store(
837 + OptInError::fromCode( OptInError::NO_RECIPIENT, [ 'form_id' => $formId ] ),
838 + $formId
839 + );
637 840 return null;
638 841 }
639 842
640 843 /**
844 + * Consent gate (GDPR Art. 7) — same position in the flow as in
845 + * AbstractFormIntegration::createOptIn(): after the recipient is
846 + * known, before rate limiting.
847 + *
848 + * Until 5.4.0 this path had no gate at all. Everything that does
849 + * not extend AbstractFormIntegration comes through here —
850 + * Elementor, plus the CF7 and Avada legacy shims — so on those
851 + * forms the acceptance checkbox was stored as consent proof
852 + * without ever having been enforced. The banner in the admin UI
853 + * said as much since 5.3.2; this closes it.
854 + *
855 + * ConsentGate::FIELD_UNKNOWN never rejects. See the class for why
856 + * that matters: a stale `consent_field` must not take a site's
857 + * registrations offline.
858 + */
859 + $consentSnapshot = $this->loadConsentSnapshot( $formId, $formParameter );
860 + $consentField = $consentSnapshot['field'];
861 + $consentGate = ConsentGate::evaluate(
862 + $consentField,
863 + $parameter,
864 + $this->resolveKnownFieldNames( $formId, $knownFields )
865 + );
866 +
867 + if ( $consentGate === ConsentGate::NOT_GIVEN && ! ConsentGate::isEnforced( $formId, $this->type ) ) {
868 + $this->get_logger()->warning( 'Consent gate disabled by filter — accepting an unconfirmed submission', [
869 + 'plugin' => 'double-opt-in',
870 + 'form_id' => $formId,
871 + 'integration' => $this->type,
872 + 'consent_field' => $consentField,
873 + ] );
874 + $consentGate = ConsentGate::PASSED;
875 + }
876 +
877 + if ( $consentGate === ConsentGate::NOT_GIVEN ) {
878 + $this->get_logger()->info( 'Consent acceptance not given, rejecting OptIn', [
879 + 'plugin' => 'double-opt-in',
880 + 'form_id' => $formId,
881 + 'integration' => $this->type,
882 + 'consent_field' => $consentField,
883 + ] );
884 + do_action( 'f12_cf7_doubleoptin_consent_not_given', $formId, $consentField );
885 + ErrorNotification::store(
886 + OptInError::fromCode(
887 + OptInError::CONSENT_NOT_GIVEN,
888 + [ 'form_id' => $formId, 'consent_field' => $consentField ]
889 + ),
890 + $formId
891 + );
892 + return null;
893 + }
894 +
895 + if ( $consentGate === ConsentGate::FIELD_UNKNOWN ) {
896 + $this->get_logger()->warning( 'Consent field is not on this form — opt-in accepted without provable consent', [
897 + 'plugin' => 'double-opt-in',
898 + 'form_id' => $formId,
899 + 'integration' => $this->type,
900 + 'consent_field' => $consentField,
901 + ] );
902 + do_action( 'f12_doi_consent_field_unknown', $formId, $consentField, $this->type );
903 + }
904 +
905 + /**
906 + * Rate-Limiting: Check IP and email limits before creating OptIn.
907 + */
908 + $rateLimiter = new RateLimiter();
909 + $ratSettings = CF7DoubleOptIn::getInstance()->getSettings();
910 + $rateLimitIp = (int) ( $ratSettings['rate_limit_ip'] ?? 5 );
911 + $rateLimitEmail = (int) ( $ratSettings['rate_limit_email'] ?? 3 );
912 + $rateLimitWindow = (int) ( $ratSettings['rate_limit_window'] ?? 60 );
913 +
914 + $ip = IPHelper::getIPAdress();
915 + if ( ! $rateLimiter->isAllowed( 'ip', $ip, $rateLimitIp, $rateLimitWindow ) ) {
916 + $this->get_logger()->warning( 'Rate limit exceeded for IP', [
917 + 'plugin' => 'double-opt-in',
918 + 'ip' => $ip,
919 + 'formId' => $formId,
920 + ] );
921 + do_action( 'f12_cf7_doubleoptin_rate_limited', 'ip', $ip, $formId );
922 + ErrorNotification::store(
923 + OptInError::fromCode( OptInError::RATE_LIMIT_IP, [ 'ip' => $ip, 'form_id' => $formId ] ),
924 + $formId
925 + );
926 + return null;
927 + }
928 +
929 + if ( ! $rateLimiter->isAllowed( 'email', $recipient, $rateLimitEmail, $rateLimitWindow ) ) {
930 + $this->get_logger()->warning( 'Rate limit exceeded for email', [
931 + 'plugin' => 'double-opt-in',
932 + 'email' => $recipient,
933 + 'formId' => $formId,
934 + ] );
935 + do_action( 'f12_cf7_doubleoptin_rate_limited', 'email', $recipient, $formId );
936 + ErrorNotification::store(
937 + OptInError::fromCode( OptInError::RATE_LIMIT_EMAIL, [ 'email' => $recipient, 'form_id' => $formId ] ),
938 + $formId
939 + );
940 + return null;
941 + }
942 +
943 + /**
944 + * Validate recipient (extensible via Pro plugin: MX check, unique
945 + * email, etc.). Uses a lightweight proxy so filters that expect
946 + * FormDataInterface::getFormId() keep working.
947 + *
948 + * IMPORTANT: `getFormType()` must be set to `$this->type` — that's
949 + * the integration identifier ("elementor", "cf7" via legacy path,
950 + * "avada" via legacy path). The Unique Email validator's
951 + * resolver matches conditions on the (integration, form_id) pair;
952 + * a proxy without getFormType returns '' and breaks the
953 + * `selected` and `all_except` modes for every form that flows
954 + * through this legacy path. User-reported 2026-05-13. Pinned by
955 + * `LegacyFormDataProxyTest` in core tests.
956 + */
957 + $formDataProxy = new class( $formId, $this->type ) {
958 + private int $formId;
959 + private string $formType;
960 + public function __construct( int $formId, string $formType ) {
961 + $this->formId = $formId;
962 + $this->formType = $formType;
963 + }
964 + public function getFormId(): int { return $this->formId; }
965 + public function getFormType(): string { return $this->formType; }
966 + };
967 +
968 + $recipientValid = \apply_filters(
969 + 'f12_cf7_doubleoptin_validate_recipient',
970 + true,
971 + $recipient,
972 + $formDataProxy
973 + );
974 +
975 + if ( $recipientValid !== true ) {
976 + $errorMsg = is_string( $recipientValid ) ? $recipientValid : '';
977 + $errorCode = OptInError::RECIPIENT_INVALID;
978 +
979 + if ( $errorMsg === 'unique_email_rejected' ) {
980 + $errorCode = OptInError::UNIQUE_EMAIL_DUPLICATE;
981 + $errorMsg = OptInError::fromCode( OptInError::UNIQUE_EMAIL_DUPLICATE )->getMessage();
982 + }
983 +
984 + $this->get_logger()->warning( 'Recipient validation failed', [
985 + 'plugin' => 'double-opt-in',
986 + 'email' => $recipient,
987 + 'form_id' => $formId,
988 + 'reason' => $errorMsg,
989 + ] );
990 +
991 + $this->lastCreationError = new OptInError(
992 + $errorCode,
993 + ! empty( $errorMsg ) ? $errorMsg : OptInError::fromCode( OptInError::RECIPIENT_INVALID )->getMessage(),
994 + [ 'email' => $recipient, 'form_id' => $formId ]
995 + );
996 +
997 + ErrorNotification::store( $this->lastCreationError, $formId );
998 + return null;
999 + }
1000 +
1001 + /**
1002 + * Consent-Snapshot aus den Form-Settings laden. Both the wording
1003 + * shown to the user (`consent_text`) AND the form-field key the
1004 + * user had to tick (`consent_field`) need to be persisted, or
1005 + * the Consent Evidence panel reports "No acknowledgment field
1006 + * configured — consent text is stored but not legally provable"
1007 + * even when the admin wired up the checkbox. The modern
1008 + * AbstractFormIntegration::buildOptInProperties() path includes
1009 + * both; this legacy path (used by Elementor + the CF7/Avada
1010 + * legacy compat shims) used to only carry consent_text.
1011 + */
1012 + $consentText = $consentSnapshot['text'];
1013 + $consentField = $consentSnapshot['field'];
1014 +
1015 + /**
641 1016 * Eigenschaften des OptIn-Objekts festlegen
642 1017 */
643 - $properties = [
644 - 'cf_form_id' => $formId,
645 - 'doubleoptin' => 0,
646 - 'createtime' => time(),
647 - 'content' => maybe_serialize( $parameter ),
648 - 'files' => maybe_serialize( $files ),
649 - 'ipaddr_register' => IPHelper::getIPAdress(),
650 - 'category' => (int) $formParameter['category'],
651 - 'form' => $formHtml,
652 - 'email' => $recipient,
653 - ];
1018 + $properties = $this->buildLegacyOptInProperties(
1019 + $formId,
1020 + $formHtml,
1021 + $parameter,
1022 + $files,
1023 + $formParameter,
1024 + $recipient,
1025 + $consentText,
1026 + $consentField
1027 + );
654 1028
655 1029 $this->get_logger()->debug( 'OptIn properties created', [
656 1030 'plugin' => 'double-opt-in',
657 1031 'properties' => $properties,
@@ -673,8 +1047,12 @@
673 1047 $this->get_logger()->info( 'OptIn object saved successfully', [
674 1048 'plugin' => 'double-opt-in',
675 1049 'OptIn' => $OptIn,
676 1050 ] );
1051 +
1052 + // Dispatch typed event for new event-driven architecture
1053 + $this->dispatchOptInCreatedEvent( $OptIn, $formId );
1054 +
677 1055 return $OptIn;
678 1056 }
679 1057
680 1058 $this->get_logger()->error( 'Failed to save OptIn object', [
@@ -680,8 +1058,15 @@
680 1058 $this->get_logger()->error( 'Failed to save OptIn object', [
681 1059 'plugin' => 'double-opt-in',
682 1060 ] );
683 1061
1062 + do_action( 'f12_cf7_doubleoptin_creation_failed', $formId, $recipient );
1063 +
1064 + ErrorNotification::store(
1065 + OptInError::fromCode( OptInError::SAVE_FAILED, [ 'form_id' => $formId ] ),
1066 + $formId
1067 + );
1068 +
684 1069 return null;
685 1070 }
686 1071
687 1072
@@ -720,9 +1105,9 @@
720 1105 'plugin' => 'double-opt-in',
721 1106 'class' => __CLASS__,
722 1107 'method' => __METHOD__,
723 1108 'condition' => $condition,
724 - 'post' => $_POST,
1109 + 'post_keys' => array_keys( $_POST ),
725 1110 ] );
726 1111 return false;
727 1112 }
728 1113 }
@@ -737,8 +1122,32 @@
737 1122 }
738 1123
739 1124
740 1125 /**
1126 + * Render validation feedback in the frontend via wp_footer.
1127 + *
1128 + * Fires the 'f12_cf7_doubleoptin_validation_feedback' action for customization,
1129 + * and provides a default inline notice for non-confirmed statuses.
1130 + *
1131 + * @return void
1132 + */
1133 + public function renderValidationFeedback(): void {
1134 + $status = self::getValidationStatus();
1135 + if ( empty( $status ) ) {
1136 + return;
1137 + }
1138 +
1139 + /**
1140 + * Allow themes/plugins to handle the validation feedback display.
1141 + *
1142 + * @param string $status The validation status.
1143 + *
1144 + * @since 3.3.0
1145 + */
1146 + do_action( 'f12_cf7_doubleoptin_validation_feedback', $status );
1147 + }
1148 +
1149 + /**
741 1150 * Removes files associated with the optin parameter.
742 1151 *
743 1152 * This method checks if the optin parameter is set and
744 1153 * loads the OptIn object based on the hash value. If
@@ -760,9 +1169,9 @@
760 1169 ] );
761 1170 return;
762 1171 }
763 1172
764 - $hash = esc_sql( $_GET['optin'] );
1173 + $hash = sanitize_text_field( wp_unslash( $_GET['optin'] ) );
765 1174
766 1175 /**
767 1176 * Load the OptIn
768 1177 */
@@ -840,6 +1249,251 @@
840 1249 }
841 1250 }
842 1251 }
843 1252
1253 + /**
1254 + * Get the privacy policy URL.
1255 + *
1256 + * Fallback chain: Plugin setting → WordPress Privacy Policy page → empty string.
1257 + *
1258 + * @return string
1259 + */
1260 + private function getPrivacyPolicyUrl(): string {
1261 + $settings = CF7DoubleOptIn::getInstance()->getSettings();
1262 + $pageId = (int) ( $settings['privacy_policy_page'] ?? 0 );
844 1263
1264 + if ( $pageId > 0 ) {
1265 + $url = get_permalink( $pageId );
1266 + if ( $url ) {
1267 + return $url;
1268 + }
1269 + }
1270 +
1271 + // Fallback to WordPress privacy policy page
1272 + $wpPrivacyPageId = (int) get_option( 'wp_page_for_privacy_policy', 0 );
1273 + if ( $wpPrivacyPageId > 0 ) {
1274 + $url = get_permalink( $wpPrivacyPageId );
1275 + if ( $url ) {
1276 + return $url;
1277 + }
1278 + }
1279 +
1280 + return '';
1281 + }
1282 +
1283 + /**
1284 + * Dispatch OptInCreatedEvent via the new event system.
1285 + *
1286 + * @param OptIn $optIn The created OptIn object.
1287 + * @param int $formId The form ID.
1288 + *
1289 + * @since 4.0.0
1290 + */
1291 + protected function dispatchOptInCreatedEvent( OptIn $optIn, int $formId ): void {
1292 + try {
1293 + $container = Container::getInstance();
1294 + if ( $container->has( EventDispatcherInterface::class ) ) {
1295 + $dispatcher = $container->get( EventDispatcherInterface::class );
1296 + $event = new OptInCreatedEvent(
1297 + $optIn->get_id(),
1298 + $formId,
1299 + $this->type,
1300 + $optIn->get_email(),
1301 + $optIn->get_hash()
1302 + );
1303 + $dispatcher->dispatch( $event );
1304 +
1305 + $this->get_logger()->debug( 'OptInCreatedEvent dispatched', [
1306 + 'plugin' => 'double-opt-in',
1307 + 'optin_id' => $optIn->get_id(),
1308 + 'form_id' => $formId,
1309 + ] );
1310 + }
1311 + } catch ( \Exception $e ) {
1312 + $this->get_logger()->warning( 'Failed to dispatch OptInCreatedEvent', [
1313 + 'plugin' => 'double-opt-in',
1314 + 'error' => $e->getMessage(),
1315 + ] );
1316 + }
1317 + }
1318 +
1319 + /**
1320 + * Dispatch OptInConfirmedEvent via the new event system.
1321 + *
1322 + * @param OptIn $optIn The confirmed OptIn object.
1323 + * @param string $hash The opt-in hash.
1324 + *
1325 + * @since 4.0.0
1326 + */
1327 + protected function dispatchOptInConfirmedEvent( OptIn $optIn, string $hash ): void {
1328 + try {
1329 + $container = Container::getInstance();
1330 + if ( $container->has( EventDispatcherInterface::class ) ) {
1331 + $dispatcher = $container->get( EventDispatcherInterface::class );
1332 +
1333 + $formData = maybe_unserialize( $optIn->get_content() );
1334 +
1335 + $event = new OptInConfirmedEvent(
1336 + $optIn->get_id(),
1337 + $hash,
1338 + $optIn->get_email(),
1339 + $optIn->get_ipaddr_confirmation(),
1340 + (int) $optIn->get_cf_form_id(),
1341 + is_array( $formData ) ? $formData : []
1342 + );
1343 + $dispatcher->dispatch( $event );
1344 +
1345 + $this->get_logger()->debug( 'OptInConfirmedEvent dispatched', [
1346 + 'plugin' => 'double-opt-in',
1347 + 'optin_id' => $optIn->get_id(),
1348 + 'hash' => $hash,
1349 + ] );
1350 + }
1351 + } catch ( \Exception $e ) {
1352 + $this->get_logger()->warning( 'Failed to dispatch OptInConfirmedEvent', [
1353 + 'plugin' => 'double-opt-in',
1354 + 'error' => $e->getMessage(),
1355 + ] );
1356 + }
1357 + }
1358 +
1359 + /**
1360 + * Load the consent snapshot (wording + acceptance field) for a form.
1361 + *
1362 + * The authoritative source is `FormSettingsService`, not the
1363 + * `$formParameter` array this legacy path carries — the settings the
1364 + * admin edits in the React UI land in post_meta and only some of them
1365 + * make it into `getParameter()`. `$formParameter` is used purely as a
1366 + * fallback for the case where the container is not available.
1367 + *
1368 + * Read once per submission and used twice: by the consent gate before
1369 + * the opt-in is created, and by the snapshot that is persisted with
1370 + * it. They must agree — a gate that reads a different field than the
1371 + * record stores would produce a proof of the wrong checkbox.
1372 + *
1373 + * @param int $formId The form being submitted.
1374 + * @param array $formParameter The legacy form-parameter array.
1375 + *
1376 + * @return array{text:string,field:string}
1377 + */
1378 + protected function loadConsentSnapshot( int $formId, array $formParameter = array() ): array {
1379 + $snapshot = [
1380 + 'text' => (string) ( $formParameter['consent_text'] ?? '' ),
1381 + 'field' => (string) ( $formParameter['consent_field'] ?? '' ),
1382 + ];
1383 +
1384 + try {
1385 + $container = Container::getInstance();
1386 + $settingsService = $container->get( \Forge12\DoubleOptIn\FormSettings\FormSettingsService::class );
1387 + $formSettings = $settingsService->getSettings( $formId );
1388 +
1389 + $snapshot['text'] = (string) ( $formSettings->consentText ?? '' );
1390 + $snapshot['field'] = (string) ( $formSettings->consentField ?? '' );
1391 + } catch ( \Throwable $e ) {
1392 + $this->get_logger()->debug( 'Could not load consent snapshot from FormSettings', [
1393 + 'plugin' => 'double-opt-in',
1394 + 'form_id' => $formId,
1395 + 'error' => $e->getMessage(),
1396 + ] );
1397 + }
1398 +
1399 + return $snapshot;
1400 + }
1401 +
1402 + /**
1403 + * The field names this form declares, for the consent gate.
1404 + *
1405 + * An unticked checkbox never reaches the server, so the payload alone
1406 + * cannot distinguish "the visitor left the box alone" from "the
1407 + * configured field does not exist any more". The form's own
1408 + * definition can, and every integration exposes it through
1409 + * `FormIntegrationInterface::getFormFields()`.
1410 + *
1411 + * A shim may pass the inventory in directly — Elementor does, because
1412 + * its `Form_Record` lists every declared field including the empty
1413 + * ones, while the composite form ID its `getFormFields()` wants
1414 + * (`{postId}_{widgetId}`) is not what this legacy path carries.
1415 + * Otherwise we ask the registry for the integration behind
1416 + * `$this->type`.
1417 + *
1418 + * Returning an empty array is a valid answer and means "unknown" —
1419 + * the gate then rejects nothing it cannot prove.
1420 + *
1421 + * @param int $formId The form being submitted.
1422 + * @param array $explicit Inventory supplied by the caller, if any.
1423 + *
1424 + * @return array<int,string>
1425 + */
1426 + protected function resolveKnownFieldNames( int $formId, array $explicit = array() ): array {
1427 + if ( $explicit !== array() ) {
1428 + return ConsentGate::normalizeFieldNames( $explicit );
1429 + }
1430 +
1431 + if ( $this->type === '' || ! class_exists( FormIntegrationRegistry::class ) ) {
1432 + return array();
1433 + }
1434 +
1435 + try {
1436 + $integration = FormIntegrationRegistry::getInstance()->get( $this->type );
1437 + if ( $integration === null ) {
1438 + return array();
1439 + }
1440 +
1441 + return ConsentGate::normalizeFieldNames( $integration->getFormFields( $formId ) );
1442 + } catch ( \Throwable $e ) {
1443 + $this->get_logger()->warning( 'Could not read the form field inventory for the consent gate', [
1444 + 'plugin' => 'double-opt-in',
1445 + 'form_id' => $formId,
1446 + 'error' => $e->getMessage(),
1447 + ] );
1448 +
1449 + return array();
1450 + }
1451 + }
1452 +
1453 + /**
1454 + * Assemble the legacy OptIn properties array.
1455 + *
1456 + * Extracted from {@see maybeCreateOptIn()} so the column inventory
1457 + * is unit-testable without standing up the whole submit pipeline
1458 + * (rate limiter, FormSettingsService, $wpdb save, etc.). The
1459 + * modern {@see \Forge12\DoubleOptIn\Integration\AbstractFormIntegration::buildOptInProperties()}
1460 + * has a sibling helper; the two MUST stay in sync — every column
1461 + * the modern path snapshots, this legacy path also has to snapshot,
1462 + * otherwise integrations on the legacy compat path (Elementor +
1463 + * the CF7/Avada legacy shims) lose the column silently.
1464 + *
1465 + * The 2026-05-13 incident this guards against: `consent_field`
1466 + * was missing here, so Elementor opt-ins shipped with an empty
1467 + * acknowledgment field even when the admin configured one. The
1468 + * Consent Evidence panel then rendered "No acknowledgment field
1469 + * configured — consent text is stored but not legally provable"
1470 + * on a record that was, in fact, ticked through a configured
1471 + * checkbox.
1472 + *
1473 + * @return array<string, mixed>
1474 + */
1475 + protected function buildLegacyOptInProperties(
1476 + int $formId,
1477 + string $formHtml,
1478 + array $parameter,
1479 + array $files,
1480 + array $formParameter,
1481 + string $recipient,
1482 + string $consentText,
1483 + string $consentField
1484 + ): array {
1485 + return [
1486 + 'cf_form_id' => $formId,
1487 + 'doubleoptin' => 0,
1488 + 'createtime' => time(),
1489 + 'content' => maybe_serialize( $parameter ),
1490 + 'files' => maybe_serialize( $files ),
1491 + 'ipaddr_register' => IPHelper::getIPAdress(),
1492 + 'category' => (int) ( $formParameter['category'] ?? 0 ),
1493 + 'form' => $formHtml,
1494 + 'email' => $recipient,
1495 + 'consent_text' => $consentText,
1496 + 'consent_field' => $consentField,
1497 + ];
1498 + }
845 1499 }