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 | compatibility/OptInFrontend.class.php +1150 -114 3.0.3 → 5.6.3 View file →
@@ -1,12 +1,35 @@
1 1 <?php
2 2
3 3 namespace forge12\contactform7\CF7DoubleOptIn;
4 4
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;
16 +use Forge12\Shared\Logger;
17 +use Forge12\Shared\LoggerInterface;
18 +
5 19 if ( ! defined( 'ABSPATH' ) ) {
6 20 exit;
7 21 }
8 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 + */
9 32 abstract class OptInFrontend {
10 33 /**
11 34 * The Type of the OptIn Form System
12 35 *
@@ -13,9 +36,83 @@
13 36 * @var string
14 37 */
15 38 protected string $type = '';
16 39
40 + private LoggerInterface $logger;
41 +
17 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 + * Why the last maybeCreateOptIn() returned null, or null if it did not.
72 + *
73 + * Callers use it to answer in the form plugin's own words — Elementor
74 + * adds the message to its AJAX response when the error must reach the
75 + * visitor (OptInError::shouldShowToVisitor()).
76 + *
77 + * @return OptInError|null
78 + */
79 + public function getLastCreationError(): ?OptInError {
80 + return $this->lastCreationError;
81 + }
82 +
83 + /**
84 + * Remember the refusal and hand it to the frontend toast.
85 + *
86 + * @param OptInError $error The reason.
87 + * @param int $formId The form.
88 + *
89 + * @return void
90 + */
91 + protected function storeCreationError( OptInError $error, int $formId ): void {
92 + $this->lastCreationError = $error;
93 + ErrorNotification::store( $error, $formId );
94 + }
95 +
96 + /**
97 + * Get the validation status from the last validateOptIn() call.
98 + *
99 + * @return string One of: '', 'confirmed', 'already_confirmed', 'expired', 'not_found'.
100 + */
101 + public static function getValidationStatus(): string {
102 + return self::$validationStatus;
103 + }
104 +
105 + /**
106 + * Set the validation status.
107 + *
108 + * @param string $status The validation status.
109 + */
110 + private function setValidationStatus( string $status ): void {
111 + self::$validationStatus = $status;
112 + }
113 +
114 + /**
18 115 * Constructor for the class.
19 116 *
20 117 * This constructor registers the necessary actions to be performed,
21 118 * by hooking methods of the current class, for the following events:
@@ -25,18 +122,77 @@
25 122 * - 'shutdown'
26 123 *
27 124 * @return void
28 125 */
29 - public function __construct( string $type ) {
30 - $this->type = $type;
126 + public function __construct( LoggerInterface $logger, string $type ) {
127 + $this->logger = $logger;
128 + $this->type = $type;
129 +
130 + $this->get_logger()->debug( 'Base integration constructor called', [
131 + 'plugin' => 'double-opt-in',
132 + 'class' => __CLASS__,
133 + 'method' => __METHOD__,
134 + 'type' => $type,
135 + ] );
136 +
31 137 add_action( 'f12_cf7_doubleoptin_before_send_default_mail', [ $this, 'beforeSendDefaultMail' ], 10, 1 );
138 + $this->get_logger()->debug( 'Hook f12_cf7_doubleoptin_before_send_default_mail registered', [
139 + 'plugin' => 'double-opt-in',
140 + ] );
141 +
32 142 add_action( 'f12_cf7_doubleoptin_after_send_default_mail', [ $this, 'afterSendDefaultMail' ], 10, 1 );
143 + $this->get_logger()->debug( 'Hook f12_cf7_doubleoptin_after_send_default_mail registered', [
144 + 'plugin' => 'double-opt-in',
145 + ] );
146 +
33 147 add_action( 'f12_cf7_doubleoptin_trigger_default_mail', [ $this, 'sendDefaultMail' ], 10, 1 );
148 + $this->get_logger()->debug( 'Hook f12_cf7_doubleoptin_trigger_default_mail registered', [
149 + 'plugin' => 'double-opt-in',
150 + ] );
151 +
34 152 add_action( 'shutdown', [ $this, 'removeFiles' ] );
153 + $this->get_logger()->debug( 'Hook shutdown registered for removeFiles', [
154 + 'plugin' => 'double-opt-in',
155 + ] );
156 +
35 157 add_action( 'init', [ $this, 'validateOptIn' ] );
158 + $this->get_logger()->debug( 'Hook init registered for validateOptIn', [
159 + 'plugin' => 'double-opt-in',
160 + ] );
161 +
162 + add_action( 'wp_footer', [ $this, 'renderValidationFeedback' ] );
163 +
164 + // Default feedback handler for validation statuses
165 + add_action( 'f12_cf7_doubleoptin_validation_feedback', function ( $status ) {
166 + if ( $status === 'confirmed' ) {
167 + return;
168 + }
169 +
170 + $messages = [
171 + 'already_confirmed' => __( 'Your opt-in has already been confirmed.', 'double-opt-in' ),
172 + 'expired' => __( 'This confirmation link has expired. Please submit the form again.', 'double-opt-in' ),
173 + 'not_found' => __( 'This confirmation link is invalid.', 'double-opt-in' ),
174 + ];
175 +
176 + $message = $messages[ $status ] ?? '';
177 + if ( ! empty( $message ) ) {
178 + echo '<div class="doi-validation-notice doi-notice-' . esc_attr( $status ) . '">';
179 + echo '<p>' . esc_html( $message ) . '</p>';
180 + echo '</div>';
181 + }
182 + }, 10, 1 );
183 +
36 184 add_filter( 'f12_cf7_doubleoptin_get_recipient_' . $this->type, [ $this, 'getRecipient' ], 10, 3 );
185 + $this->get_logger()->debug( 'Filter f12_cf7_doubleoptin_get_recipient_' . $this->type . ' registered', [
186 + 'plugin' => 'double-opt-in',
187 + ] );
37 188 }
38 189
190 +
191 + public function get_logger() {
192 + return $this->logger;
193 + }
194 +
39 195 /**
40 196 * Retrieves the recipient for a given recipient name, form parameters, and post parameters.
41 197 *
42 198 * @param string $recipient The name of the recipient to retrieve.
@@ -66,8 +222,17 @@
66 222 * @return void
67 223 */
68 224 abstract public function sendDefaultMail( OptIn $OptIn ): void;
69 225
226 + public function disable_contact_form_7_captcha($is_active){
227 + if($is_active){
228 + $this->get_logger()->debug( 'Forge12 CF7Captcha filters and actions removed', ['plugin' => 'double-opt-in']);
229 + return false;
230 + }
231 + $this->get_logger()->debug( 'Forge12 CF7Captcha filters and actions removed', ['plugin' => 'double-opt-in']);
232 + return $is_active;
233 + }
234 +
70 235 /**
71 236 * This method is used to perform necessary actions before sending the default mail.
72 237 *
73 238 * This method removes the filter for the forge12 spam captcha if the class
@@ -82,19 +247,32 @@
82 247 *
83 248 * @return void
84 249 */
85 250 public function beforeSendDefaultMail() {
86 - // Remove the filter for the forge12 spam captcha
87 - if ( class_exists( '\forge12\contactform7\CF7Captcha\TimerValidatorCF7' ) ) {
88 - remove_filter( 'wpcf7_spam', '\forge12\contactform7\CF7Captcha::isSpam' );
89 - remove_filter( 'wpcf7_spam', '\forge12\contactform7\CF7Captcha\CF7IPLog::isSpam' );
90 - remove_action( 'wpcf7_mail_sent', '\forge12\contactform7\CF7Captcha\CF7IPLog::doLogIP' );
91 - }
251 + $this->get_logger()->debug( 'beforeSendDefaultMail called', [
252 + 'plugin' => 'double-opt-in',
253 + 'class' => __CLASS__,
254 + 'method' => __METHOD__,
255 + ] );
92 256
93 - // Remove the filter for the google repatcha validation
257 + // Remove the filter for the Forge12 spam captcha
258 + add_filter('f12_cf7_captcha_is_installed_cf7', [$this, 'disable_contact_form_7_captcha'], 999, 1);
259 + $this->get_logger()->debug( 'Forge12 CF7Captcha filters and actions removed', [
260 + 'plugin' => 'double-opt-in',
261 + ] );
262 +
263 + // Remove the filter for the Google reCAPTCHA validation
94 264 remove_filter( 'wpcf7_spam', 'wpcf7_recaptcha_verify_response', 9 );
265 +
266 + $this->get_logger()->debug( 'Google reCAPTCHA filter removed', [
267 + 'plugin' => 'double-opt-in',
268 + ] );
269 +
270 + // Remove hCaptcha validation filter
271 + $this->removeHCaptchaFilter();
95 272 }
96 273
274 +
97 275 /**
98 276 * Performs actions after sending the default mail.
99 277 *
100 278 * In this method, two filters and an action are added to the WordPress hooks system.
@@ -124,22 +302,85 @@
124 302 *
125 303 * @return void
126 304 */
127 305 public function afterSendDefaultMail() {
128 - // re add the filter to ensure for all other forms the recaptcha is used
129 - if ( function_exists( 'wpcf7_recaptcha_verifiy_response' ) ) {
306 + $this->get_logger()->debug( 'afterSendDefaultMail called', [
307 + 'plugin' => 'double-opt-in',
308 + 'class' => __CLASS__,
309 + 'method' => __METHOD__,
310 + ] );
311 +
312 + // re-add the filter to ensure for all other forms the reCAPTCHA is used
313 + if ( function_exists( 'wpcf7_recaptcha_verify_response' ) ) {
130 314 add_filter( 'wpcf7_spam', 'wpcf7_recaptcha_verify_response', 9, 2 );
315 + $this->get_logger()->debug( 'Google reCAPTCHA filter re-added', [
316 + 'plugin' => 'double-opt-in',
317 + ] );
131 318 }
132 319
133 - // re add the filter for the forge12 spam captcha
320 + // re-add the filter for the Forge12 spam captcha
134 321 if ( class_exists( '\forge12\contactform7\CF7Captcha\TimerValidatorCF7' ) ) {
135 322 add_filter( 'wpcf7_spam', '\forge12\contactform7\CF7Captcha\TimerValidatorCF7::isSpam', 100, 2 );
136 323 add_filter( 'wpcf7_spam', '\forge12\contactform7\CF7Captcha\CF7IPLog::isSpam', 100, 2 );
137 324 add_action( 'wpcf7_mail_sent', '\forge12\contactform7\CF7Captcha\CF7IPLog::doLogIP', 100, 1 );
325 +
326 + $this->get_logger()->debug( 'Forge12 CF7Captcha filters and actions re-added', [
327 + 'plugin' => 'double-opt-in',
328 + ] );
138 329 }
330 +
331 + // re-add hCaptcha validation filter
332 + $this->restoreHCaptchaFilter();
139 333 }
140 334
141 335 /**
336 + * Remove hCaptcha CF7 validation filter and store the instance for later restore.
337 + *
338 + * @return void
339 + */
340 + private function removeHCaptchaFilter(): void {
341 + if ( ! class_exists( '\HCaptcha\CF7\CF7' ) ) {
342 + return;
343 + }
344 +
345 + global $wp_filter;
346 +
347 + if ( ! isset( $wp_filter['wpcf7_validate'] ) ) {
348 + return;
349 + }
350 +
351 + foreach ( $wp_filter['wpcf7_validate']->callbacks as $priority => $hooks ) {
352 + foreach ( $hooks as $key => $hook ) {
353 + if ( is_array( $hook['function'] ) && $hook['function'][0] instanceof \HCaptcha\CF7\CF7 ) {
354 + $this->hcaptchaCf7Instance = $hook['function'][0];
355 + $this->hcaptchaCf7Priority = $priority;
356 + remove_filter( 'wpcf7_validate', $hook['function'], $priority );
357 + $this->get_logger()->debug( 'hCaptcha CF7 validation filter removed', [
358 + 'plugin' => 'double-opt-in',
359 + ] );
360 +
361 + return;
362 + }
363 + }
364 + }
365 + }
366 +
367 + /**
368 + * Re-add hCaptcha CF7 validation filter if it was previously removed.
369 + *
370 + * @return void
371 + */
372 + private function restoreHCaptchaFilter(): void {
373 + if ( isset( $this->hcaptchaCf7Instance ) ) {
374 + add_filter( 'wpcf7_validate', [ $this->hcaptchaCf7Instance, 'verify_hcaptcha' ], $this->hcaptchaCf7Priority, 2 );
375 + $this->get_logger()->debug( 'hCaptcha CF7 validation filter re-added', [
376 + 'plugin' => 'double-opt-in',
377 + ] );
378 + $this->hcaptchaCf7Instance = null;
379 + }
380 + }
381 +
382 + /**
142 383 * Updates the opt-in status by hash.
143 384 *
144 385 * @param string $hash The opt-in hash.
145 386 * @param int $value The opt-in value to set.
@@ -147,25 +388,37 @@
147 388 *
148 389 * @return int Returns 0 if the opt-in is already confirmed, 1 if the opt-in is successfully updated.
149 390 */
150 391 protected function updateOptInByHash( string $hash, int $value, ?OptIn $OptIn = null ): int {
151 - /**
152 - * Init the optIn is not defined yet.
153 - */
392 + $this->get_logger()->debug( 'updateOptInByHash called', [
393 + 'plugin' => 'double-opt-in',
394 + 'class' => __CLASS__,
395 + 'method' => __METHOD__,
396 + 'hash' => $hash,
397 + 'value' => $value,
398 + ] );
399 +
154 400 $OptIn = $OptIn ?? OptIn::get_by_hash( $hash );
155 401
156 - /**
157 - * If the OptIn is already confirmed - we skip the update.
158 - */
402 + if ( ! $OptIn ) {
403 + $this->get_logger()->warning( 'No OptIn found for hash', [
404 + 'plugin' => 'double-opt-in',
405 + 'hash' => $hash,
406 + ] );
407 + return 0;
408 + }
409 +
159 410 if ( $OptIn->is_confirmed() ) {
411 + $this->get_logger()->info( 'OptIn already confirmed, skipping update', [
412 + 'plugin' => 'double-opt-in',
413 + 'hash' => $hash,
414 + 'optin_id' => $OptIn->get_id(),
415 + ] );
416 +
160 417 do_action( 'f12_cf7_doubleoptin_already_confirmed', $hash, $OptIn );
161 -
162 418 return 0;
163 419 }
164 420
165 - /**
166 - * Hook
167 - */
168 421 do_action( 'f12_cf7_doubleoptin_before_confirm', $hash, $OptIn );
169 422
170 423 $OptIn->set_doubleoptin( $value );
171 424 $OptIn->set_updatetime( time() );
@@ -170,17 +423,38 @@
170 423 $OptIn->set_doubleoptin( $value );
171 424 $OptIn->set_updatetime( time() );
172 425 $OptIn->set_ipaddr_confirmation( IPHelper::getIPAdress() );
173 426
427 + $telemetry = new Telemetry( $this->get_logger() );
428 +
174 429 $result = $OptIn->save();
175 430
176 431 if ( $result ) {
432 + if ( (int)$value === 1 ) {
433 + $telemetry->increment( 'confirmed_optins' );
434 +
435 + // Dispatch typed event for new event-driven architecture
436 + $this->dispatchOptInConfirmedEvent( $OptIn, $hash );
437 + }
438 +
439 + $this->get_logger()->info( 'OptIn updated successfully', [
440 + 'plugin' => 'double-opt-in',
441 + 'hash' => $hash,
442 + 'optin_id' => $OptIn->get_id(),
443 + ] );
177 444 do_action( 'f12_cf7_doubleoptin_after_confirm', $hash, $OptIn );
445 + } else {
446 + $this->get_logger()->error( 'Failed to update OptIn', [
447 + 'plugin' => 'double-opt-in',
448 + 'hash' => $hash,
449 + 'optin_id' => $OptIn->get_id(),
450 + ] );
178 451 }
179 452
180 453 return (int) $result;
181 454 }
182 455
456 +
183 457 /**
184 458 * Add additional placeholder like time, date, subject
185 459 *
186 460 * @formatter:off
@@ -195,42 +469,110 @@
195 469 * #
196 470 * @formatter:on
197 471 */
198 472 protected function addPlaceholders( string $body, OptIn $OptIn, array $parameter ): string {
199 - # set the default timezone
200 - $timezone = get_option( 'timezone_string' );
473 + $this->get_logger()->debug( 'addPlaceholders called', [
474 + 'plugin' => 'double-opt-in',
475 + 'class' => __CLASS__,
476 + 'method' => __METHOD__,
477 + 'optin_id' => $OptIn->get_id(),
478 + 'parameter' => $parameter,
479 + ] );
201 480
202 - # set fallback timezone
203 - if ( empty( $timezone ) ) {
204 - $timezone = 'Europe/Berlin';
205 - }
206 -
207 - date_default_timezone_set( $timezone );
208 - $placeholder = array(
209 - 'doubleoptin_form_url' => $parameter['formUrl'],
210 - 'doubleoptin_form_subject' => $parameter['subject'],
211 - 'doubleoptin_form_date' => date( get_option( 'date_format' ) ),
212 - 'doubleoptin_form_time' => date( get_option( 'time_format' ) ),
481 + $placeholder = [
482 + // User-influenced submit URL — sanitise so a crafted query string
483 + // can't reflect into the mail HTML (esc_url_raw keeps text-mail intact).
484 + 'doubleoptin_form_url' => esc_url_raw( (string) ( $parameter['formUrl'] ?? '' ) ),
485 + 'doubleoptin_form_subject' => $parameter['subject'] ?? '',
486 + // wp_date() formats in the site's timezone without mutating PHP's
487 + // global timezone (the old date() + date_default_timezone_set() did).
488 + 'doubleoptin_form_date' => wp_date( get_option( 'date_format' ) ),
489 + 'doubleoptin_form_time' => wp_date( get_option( 'time_format' ) ),
213 490 'doubleoptin_form_email' => get_option( 'admin_email' ),
214 491 'doubleoptinlink' => $OptIn->get_link_optin( $parameter ),
215 - 'doubleoptoutlink' => $OptIn->get_link_optout()
216 - );
492 + 'doubleoptoutlink' => $OptIn->get_link_optout(),
493 + 'doubleoptin_privacy_url' => $this->getPrivacyPolicyUrl(),
494 + ];
217 495
496 +
218 497 foreach ( $placeholder as $key => $value ) {
219 - $body = str_replace( '[' . $key . ']', $value, $body );
498 + if ( is_array( $value ) || is_object( $value ) ) {
499 + // Array/Objekte serialisieren oder in JSON umwandeln
500 + $value = wp_json_encode( $value );
501 + }
502 +
503 + $replacement = (string) ( $value ?? '' );
504 +
505 + $body = str_replace( '[' . $key . ']', $replacement, $body );
506 +
507 + // Logging je Platzhalter
508 + $this->get_logger()->debug( 'Placeholder replaced', [
509 + 'plugin' => 'double-opt-in',
510 + 'placeholder' => '[' . $key . ']',
511 + 'value' => $replacement,
512 + ] );
220 513 }
221 514
515 + // Logging nach allen Ersetzungen
516 + $this->get_logger()->debug( 'System placeholders replaced in body', [
517 + 'plugin' => 'double-opt-in',
518 + 'placeholders' => array_keys( $placeholder ),
519 + 'body_length' => strlen( $body ),
520 + ] );
521 +
522 + // Replace standard placeholders (doi_email, doi_name, etc.)
523 + $formData = maybe_unserialize( $OptIn->get_content() );
524 + if ( is_array( $formData ) ) {
525 + // Per-integration nesting unwrap. The serialized OptIn content
526 + // is whatever each frontend stored, and that differs by
527 + // integration — see SubmittedContent for the table.
528 + // Without this, PlaceholderMapper::replacePlaceholders looks
529 + // for `$formData[$mappedField]` at the top level and finds
530 + // nothing for Elementor — every `[doi_*]` placeholder renders
531 + // as an empty string in the confirmation mail.
532 + //
533 + // The chain used to be spelled out here. It now lives in
534 + // SubmittedContent because the audit reader needs the same
535 + // knowledge, and the copy it had was one shape short — every
536 + // Elementor opt-in reported its consent checkbox as unticked
537 + // (customer report 2026-08-27).
538 + $fieldData = \Forge12\DoubleOptIn\Integration\SubmittedContent::unwrapFields( $formData );
539 +
540 + $body = PlaceholderMapper::replacePlaceholders(
541 + $body,
542 + $fieldData,
543 + $OptIn->get_cf_form_id(),
544 + [],
545 + $this->type
546 + );
547 +
548 + $this->get_logger()->debug( 'Standard placeholders replaced', [
549 + 'plugin' => 'double-opt-in',
550 + 'form_id' => $OptIn->get_cf_form_id(),
551 + 'field_data_keys' => array_keys( $fieldData ),
552 + 'unwrapped_from' => \Forge12\DoubleOptIn\Integration\SubmittedContent::describeShape( $formData ),
553 + ] );
554 + }
555 +
222 556 return $body;
223 557 }
224 558
559 +
225 560 /**
226 561 * Add Stylesheets
227 562 */
228 563 public function validateOptIn(): bool {
564 + $this->get_logger()->debug( 'validateOptIn started', [
565 + 'plugin' => 'double-opt-in',
566 + ] );
567 +
229 568 /**
230 569 * Skip if the hash has not been submitted.
231 570 */
232 571 if ( ! isset( $_GET['optin'] ) ) {
572 + $this->get_logger()->debug( 'No optin hash found in request, skipping', [
573 + 'plugin' => 'double-opt-in',
574 + ] );
233 575 return false;
234 576 }
235 577
236 578 /**
@@ -246,8 +588,13 @@
246 588 /**
247 589 * Skip if the OptIn does not exist
248 590 */
249 591 if ( null == $OptIn ) {
592 + $this->get_logger()->warning( 'OptIn not found for hash', [
593 + 'plugin' => 'double-opt-in',
594 + 'hash' => $hash,
595 + ] );
596 + $this->setValidationStatus( 'not_found' );
250 597 return false;
251 598 }
252 599
253 600 /**
@@ -253,63 +600,126 @@
253 600 /**
254 601 * Skip if the OptIn is not from Type cf7.
255 602 */
256 603 if ( ! $OptIn->isType( $this->type ) ) {
604 + $this->get_logger()->warning( 'OptIn type mismatch', [
605 + 'plugin' => 'double-opt-in',
606 + 'hash' => $hash,
607 + 'type' => $this->type,
608 + 'optin_id' => $OptIn->get_id(),
609 + ] );
257 610 return false;
258 611 }
259 612
613 + $this->get_logger()->debug( 'OptIn type found', [
614 + 'plugin' => 'double-opt-in',
615 + 'hash' => $hash,
616 + 'type' => $this->type,
617 + 'optin_id' => $OptIn->get_id(),
618 + ] );
619 +
260 620 /**
261 - * Skip if the OptIn has been confirmed already or could not be updated.
621 + * Check if the token has expired.
262 622 */
263 - if ( $this->updateOptInByHash( $hash, 1 ) <= 0 ) {
623 + $settings = CF7DoubleOptIn::getInstance()->getSettings();
624 + $expiryHours = (int) ( $settings['token_expiry_hours'] ?? 48 );
625 + if ( $expiryHours > 0 && ( time() - (int) $OptIn->get_createtime() ) > ( $expiryHours * 3600 ) ) {
626 + $this->get_logger()->info( 'OptIn token expired', [
627 + 'plugin' => 'double-opt-in',
628 + 'hash' => $hash,
629 + 'optin_id' => $OptIn->get_id(),
630 + 'expiry_hours' => $expiryHours,
631 + ] );
632 + do_action( 'f12_cf7_doubleoptin_token_expired', $hash, $OptIn );
633 + $this->setValidationStatus( 'expired' );
264 634 return false;
265 635 }
266 636
267 637 /**
638 + * Check if already confirmed (before calling updateOptInByHash).
639 + */
640 + if ( $OptIn->is_confirmed() ) {
641 + $this->get_logger()->info( 'OptIn already confirmed', [
642 + 'plugin' => 'double-opt-in',
643 + 'hash' => $hash,
644 + 'optin_id' => $OptIn->get_id(),
645 + ] );
646 + do_action( 'f12_cf7_doubleoptin_already_confirmed', $hash, $OptIn );
647 + $this->setValidationStatus( 'already_confirmed' );
648 + return false;
649 + }
650 +
651 + /**
268 652 * Enable / Disable default mail.
269 653 *
270 - * Filter to allow developer to enable / disable the default mail.
271 - *
272 654 * @param bool $status Enable (true) or disable (false) the default mail.
273 655 * @param int $postId The ID of the Post / Form.
274 656 *
275 657 * @since 2.3.3
276 658 */
277 - if ( ! apply_filters( 'f12_cf7_doubleoptin_send_default_mail', true, $OptIn->get_cf_form_id() ) ) {
278 - return false;
279 - }
659 + $sendDefaultMail = (bool) apply_filters( 'f12_cf7_doubleoptin_send_default_mail', true, $OptIn->get_cf_form_id() );
280 660
281 661 /**
282 - * Hook triggers before the default mail will be send.
283 - *
284 - * Action to allow developers to do custom actions before the default mail will be triggered.
285 - *
286 - * @param OptIn $OptIn
287 - *
288 - * @since 2.3.3
662 + * Bind the follow-up plan before the confirmation is saved (see
663 + * AbstractFormIntegration::validateOptIn). False when no adapter
664 + * handles this integration → previous behaviour below.
289 665 */
290 - do_action( 'f12_cf7_doubleoptin_before_send_default_mail', $OptIn );
666 + $coordinator = \Forge12\DoubleOptIn\FollowUp\FollowUpCoordinator::instance();
667 + $managed = $coordinator !== null && $coordinator->plan( $OptIn, $sendDefaultMail );
291 668
292 669 /**
293 - * Hook triggers the default mail
294 - *
295 - * Action to allow developers to do custom actions before the default mail will be triggered.
296 - *
297 - * @param OptIn $OptIn
298 - *
299 - * @since 2.3.3
670 + * Confirm the OptIn.
300 671 */
672 + if ( $this->updateOptInByHash( $hash, 1, $OptIn ) <= 0 ) {
673 + $this->get_logger()->info( 'OptIn update failed', [
674 + 'plugin' => 'double-opt-in',
675 + 'hash' => $hash,
676 + 'optin_id' => $OptIn->get_id(),
677 + ] );
678 + return false;
679 + }
680 +
681 + $this->setValidationStatus( 'confirmed' );
682 +
683 + if ( $managed ) {
684 + // Planned actions (skipped ones included) are recorded by the
685 + // coordinator; trigger_default_mail is not fired for managed
686 + // opt-ins, so no listener can replay them a second time.
687 + if ( $sendDefaultMail ) {
688 + do_action( 'f12_cf7_doubleoptin_before_send_default_mail', $OptIn );
689 + }
690 + $coordinator->run( $OptIn, \Forge12\DoubleOptIn\FollowUp\FollowUpAttempt::TRIGGER_CONFIRM );
691 + if ( $sendDefaultMail ) {
692 + do_action( 'f12_cf7_doubleoptin_after_send_default_mail', $OptIn );
693 + }
694 + return true;
695 + }
696 +
697 + if ( ! $sendDefaultMail ) {
698 + $this->get_logger()->info( 'Default mail disabled for OptIn', [
699 + 'plugin' => 'double-opt-in',
700 + 'form_id' => $OptIn->get_cf_form_id(),
701 + 'optin_id' => $OptIn->get_id(),
702 + ] );
703 + return false;
704 + }
705 +
706 + $this->get_logger()->debug( 'Triggering before_send_default_mail hook', [
707 + 'plugin' => 'double-opt-in',
708 + 'optin_id' => $OptIn->get_id(),
709 + ] );
710 + do_action( 'f12_cf7_doubleoptin_before_send_default_mail', $OptIn );
711 +
712 + $this->get_logger()->info( 'Triggering send_default_mail hook', [
713 + 'plugin' => 'double-opt-in',
714 + 'optin_id' => $OptIn->get_id(),
715 + ] );
301 716 do_action( 'f12_cf7_doubleoptin_trigger_default_mail', $OptIn );
302 717
303 - /**
304 - * Hook triggers after the default mail has been sent
305 - *
306 - * Action to allow developers to do custom actions after the default mail has been triggered.
307 - *
308 - * @param OptIn $OptIn
309 - *
310 - * @since 2.3.3
311 - */
718 + $this->get_logger()->debug( 'Triggering after_send_default_mail hook', [
719 + 'plugin' => 'double-opt-in',
720 + 'optin_id' => $OptIn->get_id(),
721 + ] );
312 722 do_action( 'f12_cf7_doubleoptin_after_send_default_mail', $OptIn );
313 723
314 724 return true;
315 725 }
@@ -314,8 +724,9 @@
314 724 return true;
315 725 }
316 726
317 727
728 +
318 729 /**
319 730 * Store the files
320 731 *
321 732 * @param array $inFiles
@@ -322,26 +733,52 @@
322 733 *
323 734 * @return array
324 735 */
325 736 private function maybeStoreFiles( array $inFiles ): array {
326 - $outFiles = array();
737 + $this->get_logger()->debug( 'maybeStoreFiles called', [
738 + 'plugin' => 'double-opt-in',
739 + 'class' => __CLASS__,
740 + 'method' => __METHOD__,
741 + 'files_in' => $inFiles,
742 + ] );
327 743
328 - if ( empty( $files ) ) {
744 + $outFiles = [];
745 +
746 + if ( empty( $inFiles ) ) {
747 + $this->get_logger()->debug( 'No files provided to maybeStoreFiles', [
748 + 'plugin' => 'double-opt-in',
749 + ] );
329 750 return $outFiles;
330 751 }
331 752
332 - foreach ( $files as $key => $subfiles ) {
753 + foreach ( $inFiles as $key => $subfiles ) {
333 754 foreach ( $subfiles as $file ) {
334 755 $newFile = $this->copyAndRenameFile( $file );
335 756 if ( $newFile ) {
336 757 $outFiles[] = $newFile;
758 + $this->get_logger()->debug( 'File stored successfully', [
759 + 'plugin' => 'double-opt-in',
760 + 'original' => $file,
761 + 'new' => $newFile,
762 + ] );
763 + } else {
764 + $this->get_logger()->warning( 'File could not be stored', [
765 + 'plugin' => 'double-opt-in',
766 + 'original' => $file,
767 + ] );
337 768 }
338 769 }
339 770 }
340 771
772 + $this->get_logger()->debug( 'maybeStoreFiles completed', [
773 + 'plugin' => 'double-opt-in',
774 + 'files_out' => $outFiles,
775 + ] );
776 +
341 777 return $outFiles;
342 778 }
343 779
780 +
344 781 /**
345 782 * Copy and rename a file
346 783 *
347 784 * @param string $file The path to the file to copy and rename
@@ -348,98 +785,357 @@
348 785 *
349 786 * @return string|null The path to the copied and renamed file, or null if the copy operation failed
350 787 */
351 788 private function copyAndRenameFile( string $file ): ?string {
352 - $newFile = explode( '/', $file );
353 - $name = $newFile[ count( $newFile ) - 1 ];
354 - $name = time() . '_' . $name;
789 + $this->get_logger()->debug( 'copyAndRenameFile called', [
790 + 'plugin' => 'double-opt-in',
791 + 'class' => __CLASS__,
792 + 'method' => __METHOD__,
793 + 'file' => $file,
794 + ] );
795 +
796 + $newFile = explode( '/', $file );
797 + $name = $newFile[ count( $newFile ) - 1 ];
798 + $name = time() . '_' . $name;
355 799 $newFile[ count( $newFile ) - 1 ] = $name;
356 - $newFile = implode( "/", $newFile );
800 + $newFile = implode( "/", $newFile );
801 +
357 802 if ( copy( $file, $newFile ) ) {
803 + $this->get_logger()->info( 'File copied and renamed successfully', [
804 + 'plugin' => 'double-opt-in',
805 + 'original' => $file,
806 + 'new_file' => $newFile,
807 + ] );
358 808 return $newFile;
359 809 }
360 810
811 + $this->get_logger()->error( 'Failed to copy and rename file', [
812 + 'plugin' => 'double-opt-in',
813 + 'original' => $file,
814 + 'target' => $newFile,
815 + ] );
816 +
361 817 return null;
362 818 }
363 819
820 +
364 821 /**
365 822 * Create the OptIn
366 823 *
367 - * @param int $formId The identifier of the form
368 - * @param string $formHtml The HTML code of the form
369 - * @param array $parameter The Post Parameter of the form.
370 - * @param array $files The Files attached to the form.
824 + * @param int $formId The identifier of the form
825 + * @param string $formHtml The HTML code of the form
826 + * @param array $parameter The Post Parameter of the form.
827 + * @param array $files The Files attached to the form.
828 + * @param array $knownFields The fields this form declares, `name => label`
829 + * or a plain list. Only the consent gate uses
830 + * them, to tell an unticked checkbox from a
831 + * setting that points at a field which no
832 + * longer exists. A shim that knows its form
833 + * better than the registry does (Elementor
834 + * has the Form_Record in hand) passes them in;
835 + * everyone else leaves it empty and
836 + * {@see self::resolveKnownFieldNames()} asks
837 + * the integration.
371 838 *
372 839 * @return OptIn|null
373 840 */
374 - protected function maybeCreateOptIn( int $formId, string $formHtml, array $parameter, array $files = array() ): ?OptIn {
841 + protected function maybeCreateOptIn( int $formId, string $formHtml, array $parameter, array $files = array(), array $knownFields = array() ): ?OptIn {
842 + $this->lastCreationError = null;
843 +
844 + $this->get_logger()->debug( 'maybeCreateOptIn called', [
845 + 'plugin' => 'double-opt-in',
846 + 'class' => __CLASS__,
847 + 'method' => __METHOD__,
848 + 'formId' => $formId,
849 + 'formHtml' => substr( $formHtml, 0, 200 ),
850 + 'files' => $files,
851 + ] );
852 +
375 853 /**
376 - * Maybe copy the files to store them while waiting for the optin confirmation
854 + * Mögliche Dateien speichern, um sie während der Opt-In-Bestätigung vorzuhalten
377 855 */
378 856 $files = $this->maybeStoreFiles( $files );
857 + $this->get_logger()->debug( 'Files checked and possibly stored', [
858 + 'plugin' => 'double-opt-in',
859 + 'files' => $files,
860 + ] );
379 861
380 862 /**
381 - * Filter to manipulate the content parameter before storing them in the database
382 - *
383 - * @param array $parameter
384 - *
385 - * @since 2.3.3
863 + * Filter, um die Parameter vor dem Speichern in der Datenbank zu manipulieren
386 864 */
387 865 $parameter = \apply_filters( 'f12_cf7_doubleoptin_add_request_parameter', $parameter );
866 + $this->get_logger()->debug( 'Request parameters filtered', [
867 + 'plugin' => 'double-opt-in',
868 + 'parameter' => $parameter,
869 + ] );
388 870
389 871 /**
390 - * Get the global settings for the formular.
872 + * Globale Einstellungen für das Formular abrufen
391 873 */
392 874 $formParameter = CF7DoubleOptIn::getInstance()->getParameter( $formId );
393 875
394 876 /**
395 - * Filter to fetch the recipient before creating the optin object.
877 + * Filter, um den Empfänger zu ermitteln, bevor das OptIn-Objekt erstellt wird
878 + */
879 + $recipient = \apply_filters( 'f12_cf7_doubleoptin_get_recipient_' . $this->type, '', $formParameter, $parameter );
880 +
881 + /**
882 + * Wenn keine E-Mail-Adresse gefunden wurde, Abbruch
883 + */
884 + if ( empty( $recipient ) ) {
885 + $this->get_logger()->warning( 'No recipient found, skipping OptIn creation', [
886 + 'plugin' => 'double-opt-in',
887 + ] );
888 + $this->storeCreationError(
889 + OptInError::fromCode( OptInError::NO_RECIPIENT, [ 'form_id' => $formId ] ),
890 + $formId
891 + );
892 + return null;
893 + }
894 +
895 + /**
896 + * Consent gate (GDPR Art. 7) — same position in the flow as in
897 + * AbstractFormIntegration::createOptIn(): after the recipient is
898 + * known, before rate limiting.
396 899 *
397 - * @param string $recipient The Default recipient
398 - * @param array $formParameter @see CF7DoubleOptIn::getParameter() for details
399 - * @param array $parameter The Post Parameter submitted by the visitor.
900 + * Until 5.4.0 this path had no gate at all. Everything that does
901 + * not extend AbstractFormIntegration comes through here —
902 + * Elementor, plus the CF7 and Avada legacy shims — so on those
903 + * forms the acceptance checkbox was stored as consent proof
904 + * without ever having been enforced. The banner in the admin UI
905 + * said as much since 5.3.2; this closes it.
400 906 *
401 - * @since 2.3.3
907 + * ConsentGate::FIELD_UNKNOWN never rejects. See the class for why
908 + * that matters: a stale `consent_field` must not take a site's
909 + * registrations offline.
402 910 */
403 - $recipient = \apply_filters( 'f12_cf7_doubleoptin_get_recipient_' . $this->type, '', $formParameter, $parameter );
911 + $consentSnapshot = $this->loadConsentSnapshot( $formId, $formParameter );
912 + $consentField = $consentSnapshot['field'];
913 + $consentGate = ConsentGate::evaluate(
914 + $consentField,
915 + $parameter,
916 + $this->resolveKnownFieldNames( $formId, $knownFields )
917 + );
404 918
919 + if ( $consentGate === ConsentGate::NOT_GIVEN && ! ConsentGate::isEnforced( $formId, $this->type ) ) {
920 + $this->get_logger()->warning( 'Consent gate disabled by filter — accepting an unconfirmed submission', [
921 + 'plugin' => 'double-opt-in',
922 + 'form_id' => $formId,
923 + 'integration' => $this->type,
924 + 'consent_field' => $consentField,
925 + ] );
926 + $consentGate = ConsentGate::PASSED;
927 + }
928 +
929 + if ( $consentGate === ConsentGate::NOT_GIVEN ) {
930 + $this->get_logger()->info( 'Consent acceptance not given, rejecting OptIn', [
931 + 'plugin' => 'double-opt-in',
932 + 'form_id' => $formId,
933 + 'integration' => $this->type,
934 + 'consent_field' => $consentField,
935 + ] );
936 + do_action( 'f12_cf7_doubleoptin_consent_not_given', $formId, $consentField );
937 + $this->storeCreationError(
938 + OptInError::fromCode(
939 + OptInError::CONSENT_NOT_GIVEN,
940 + [ 'form_id' => $formId, 'consent_field' => $consentField ]
941 + ),
942 + $formId
943 + );
944 + return null;
945 + }
946 +
947 + if ( $consentGate === ConsentGate::FIELD_UNKNOWN ) {
948 + $this->get_logger()->warning( 'Consent field is not on this form — opt-in accepted without provable consent', [
949 + 'plugin' => 'double-opt-in',
950 + 'form_id' => $formId,
951 + 'integration' => $this->type,
952 + 'consent_field' => $consentField,
953 + ] );
954 + do_action( 'f12_doi_consent_field_unknown', $formId, $consentField, $this->type );
955 + }
956 +
405 957 /**
406 - * Skip if no Mail has been found.
958 + * Rate-Limiting: Check IP and email limits before creating OptIn.
407 959 */
408 - if ( empty( $recipient ) ) {
960 + $rateLimiter = new RateLimiter();
961 + $ratSettings = CF7DoubleOptIn::getInstance()->getSettings();
962 + $rateLimitIp = (int) ( $ratSettings['rate_limit_ip'] ?? 5 );
963 + $rateLimitEmail = (int) ( $ratSettings['rate_limit_email'] ?? 3 );
964 + $rateLimitWindow = (int) ( $ratSettings['rate_limit_window'] ?? 60 );
965 +
966 + $ip = IPHelper::getIPAdress();
967 + if ( ! $rateLimiter->isAllowed( 'ip', $ip, $rateLimitIp, $rateLimitWindow ) ) {
968 + $this->get_logger()->warning( 'Rate limit exceeded for IP', [
969 + 'plugin' => 'double-opt-in',
970 + 'ip' => $ip,
971 + 'formId' => $formId,
972 + ] );
973 + do_action( 'f12_cf7_doubleoptin_rate_limited', 'ip', $ip, $formId );
974 + $this->storeCreationError(
975 + OptInError::fromCode( OptInError::RATE_LIMIT_IP, [ 'ip' => $ip, 'form_id' => $formId ] ),
976 + $formId
977 + );
409 978 return null;
410 979 }
411 980
981 + if ( ! $rateLimiter->isAllowed( 'email', $recipient, $rateLimitEmail, $rateLimitWindow ) ) {
982 + $this->get_logger()->warning( 'Rate limit exceeded for email', [
983 + 'plugin' => 'double-opt-in',
984 + 'email' => $recipient,
985 + 'formId' => $formId,
986 + ] );
987 + do_action( 'f12_cf7_doubleoptin_rate_limited', 'email', $recipient, $formId );
988 + $this->storeCreationError(
989 + OptInError::fromCode( OptInError::RATE_LIMIT_EMAIL, [ 'email' => $recipient, 'form_id' => $formId ] ),
990 + $formId
991 + );
992 + return null;
993 + }
994 +
412 995 /**
413 - * Set the Properties of the OptIn Object
996 + * Validate recipient (extensible via Pro plugin: MX check, unique
997 + * email, etc.). Uses a lightweight proxy so filters that expect
998 + * FormDataInterface::getFormId() keep working.
999 + *
1000 + * IMPORTANT: `getFormType()` must be set to `$this->type` — that's
1001 + * the integration identifier ("elementor", "cf7" via legacy path,
1002 + * "avada" via legacy path). The Unique Email validator's
1003 + * resolver matches conditions on the (integration, form_id) pair;
1004 + * a proxy without getFormType returns '' and breaks the
1005 + * `selected` and `all_except` modes for every form that flows
1006 + * through this legacy path. User-reported 2026-05-13. Pinned by
1007 + * `LegacyFormDataProxyTest` in core tests.
414 1008 */
415 - $properties = array(
416 - 'cf_form_id' => $formId,
417 - 'doubleoptin' => 0,
418 - 'createtime' => time(),
419 - 'content' => maybe_serialize( $parameter ),
420 - 'files' => maybe_serialize( $files ),
421 - 'ipaddr_register' => IPHelper::getIPAdress(),
422 - 'category' => (int) $formParameter['category'],
423 - 'form' => $formHtml,
424 - 'email' => $recipient
1009 + $formDataProxy = new class( $formId, $this->type ) {
1010 + private int $formId;
1011 + private string $formType;
1012 + public function __construct( int $formId, string $formType ) {
1013 + $this->formId = $formId;
1014 + $this->formType = $formType;
1015 + }
1016 + public function getFormId(): int { return $this->formId; }
1017 + public function getFormType(): string { return $this->formType; }
1018 + };
1019 +
1020 + $recipientValid = \apply_filters(
1021 + 'f12_cf7_doubleoptin_validate_recipient',
1022 + true,
1023 + $recipient,
1024 + $formDataProxy
425 1025 );
426 1026
427 - $OptIn = new OptIn( $properties );
1027 + if ( $recipientValid !== true ) {
1028 + $errorMsg = is_string( $recipientValid ) ? $recipientValid : '';
1029 + $errorCode = OptInError::RECIPIENT_INVALID;
428 1030
1031 + if ( $errorMsg === 'unique_email_rejected' ) {
1032 + $errorCode = OptInError::UNIQUE_EMAIL_DUPLICATE;
1033 + $errorMsg = OptInError::fromCode( OptInError::UNIQUE_EMAIL_DUPLICATE )->getMessage();
1034 + }
1035 +
1036 + $this->get_logger()->warning( 'Recipient validation failed', [
1037 + 'plugin' => 'double-opt-in',
1038 + 'email' => $recipient,
1039 + 'form_id' => $formId,
1040 + 'reason' => $errorMsg,
1041 + ] );
1042 +
1043 + $this->lastCreationError = new OptInError(
1044 + $errorCode,
1045 + ! empty( $errorMsg ) ? $errorMsg : OptInError::fromCode( OptInError::RECIPIENT_INVALID )->getMessage(),
1046 + [ 'email' => $recipient, 'form_id' => $formId ]
1047 + );
1048 +
1049 + $this->storeCreationError( $this->lastCreationError, $formId );
1050 + return null;
1051 + }
1052 +
1053 + /**
1054 + * Consent-Snapshot aus den Form-Settings laden. Both the wording
1055 + * shown to the user (`consent_text`) AND the form-field key the
1056 + * user had to tick (`consent_field`) need to be persisted, or
1057 + * the Consent Evidence panel reports "No acknowledgment field
1058 + * configured — consent text is stored but not legally provable"
1059 + * even when the admin wired up the checkbox. The modern
1060 + * AbstractFormIntegration::buildOptInProperties() path includes
1061 + * both; this legacy path (used by Elementor + the CF7/Avada
1062 + * legacy compat shims) used to only carry consent_text.
1063 + */
1064 + $consentText = $consentSnapshot['text'];
1065 + $consentField = $consentSnapshot['field'];
1066 +
1067 + /**
1068 + * Eigenschaften des OptIn-Objekts festlegen
1069 + */
1070 + $properties = $this->buildLegacyOptInProperties(
1071 + $formId,
1072 + $formHtml,
1073 + $parameter,
1074 + $files,
1075 + $formParameter,
1076 + $recipient,
1077 + $consentText,
1078 + $consentField
1079 + );
1080 +
1081 + $this->get_logger()->debug( 'OptIn properties created', [
1082 + 'plugin' => 'double-opt-in',
1083 + 'properties' => $properties,
1084 + ] );
1085 +
1086 + /**
1087 + * OptIn-Objekt erstellen
1088 + */
1089 + $OptIn = new OptIn($this->get_logger(), $properties );
1090 + $this->get_logger()->debug( 'OptIn object instantiated', [
1091 + 'plugin' => 'double-opt-in',
1092 + 'OptIn' => $OptIn,
1093 + ] );
1094 +
1095 + /**
1096 + * OptIn speichern
1097 + */
429 1098 if ( $OptIn->save() ) {
1099 + $this->get_logger()->info( 'OptIn object saved successfully', [
1100 + 'plugin' => 'double-opt-in',
1101 + 'OptIn' => $OptIn,
1102 + ] );
1103 +
1104 + // Dispatch typed event for new event-driven architecture
1105 + $this->dispatchOptInCreatedEvent( $OptIn, $formId );
1106 +
430 1107 return $OptIn;
431 1108 }
432 1109
1110 + $this->get_logger()->error( 'Failed to save OptIn object', [
1111 + 'plugin' => 'double-opt-in',
1112 + ] );
1113 +
1114 + do_action( 'f12_cf7_doubleoptin_creation_failed', $formId, $recipient );
1115 +
1116 + $this->storeCreationError(
1117 + OptInError::fromCode( OptInError::SAVE_FAILED, [ 'form_id' => $formId ] ),
1118 + $formId
1119 + );
1120 +
433 1121 return null;
434 1122 }
435 1123
1124 +
436 1125 /**
437 1126 * Validate if the optin is enabled.
438 1127 */
439 1128 protected function isOptinEnabled( int $formId ): bool {
440 - // Disable optin sending if the optin flag is set.
441 - if ( isset( $_GET['optin'] ) ) {
1129 + // Disable optin sending while our own post-confirmation replay runs.
1130 + // Not `isset( $_GET['optin'] )`: that is client input and let a
1131 + // submitter switch the double opt-in off.
1132 + if ( \Forge12\DoubleOptIn\Integration\AbstractFormIntegration::isReplaying() ) {
1133 + $this->get_logger()->debug( 'Optin disabled during post-confirmation replay', [
1134 + 'plugin' => 'double-opt-in',
1135 + 'class' => __CLASS__,
1136 + 'method' => __METHOD__,
1137 + ] );
442 1138 return false;
443 1139 }
444 1140
445 1141 $parameter = CF7DoubleOptIn::getInstance()->getParameter( $formId );
@@ -444,8 +1140,14 @@
444 1140
445 1141 $parameter = CF7DoubleOptIn::getInstance()->getParameter( $formId );
446 1142
447 1143 if ( (int) $parameter['enable'] != 1 ) {
1144 + $this->get_logger()->debug( 'Optin not enabled in form parameter', [
1145 + 'plugin' => 'double-opt-in',
1146 + 'class' => __CLASS__,
1147 + 'method' => __METHOD__,
1148 + 'parameter' => $parameter,
1149 + ] );
448 1150 return false;
449 1151 }
450 1152
451 1153 // Check the custom condition
@@ -452,16 +1154,54 @@
452 1154 if ( isset( $parameter['conditions'] ) ) {
453 1155 $condition = sanitize_text_field( $parameter['conditions'] );
454 1156
455 1157 if ( ( $condition != 'disable' && $condition !== 'disabled' ) && ( ! isset( $_POST[ $condition ] ) || empty( $_POST[ $condition ] ) ) ) {
1158 + $this->get_logger()->debug( 'Optin disabled due to unmet custom condition', [
1159 + 'plugin' => 'double-opt-in',
1160 + 'class' => __CLASS__,
1161 + 'method' => __METHOD__,
1162 + 'condition' => $condition,
1163 + 'post_keys' => array_keys( $_POST ),
1164 + ] );
456 1165 return false;
457 1166 }
458 1167 }
459 1168
1169 + $this->get_logger()->debug( 'Optin enabled', [
1170 + 'plugin' => 'double-opt-in',
1171 + 'class' => __CLASS__,
1172 + 'method' => __METHOD__,
1173 + ] );
1174 +
460 1175 return true;
461 1176 }
462 1177
1178 +
463 1179 /**
1180 + * Render validation feedback in the frontend via wp_footer.
1181 + *
1182 + * Fires the 'f12_cf7_doubleoptin_validation_feedback' action for customization,
1183 + * and provides a default inline notice for non-confirmed statuses.
1184 + *
1185 + * @return void
1186 + */
1187 + public function renderValidationFeedback(): void {
1188 + $status = self::getValidationStatus();
1189 + if ( empty( $status ) ) {
1190 + return;
1191 + }
1192 +
1193 + /**
1194 + * Allow themes/plugins to handle the validation feedback display.
1195 + *
1196 + * @param string $status The validation status.
1197 + *
1198 + * @since 3.3.0
1199 + */
1200 + do_action( 'f12_cf7_doubleoptin_validation_feedback', $status );
1201 + }
1202 +
1203 + /**
464 1204 * Removes files associated with the optin parameter.
465 1205 *
466 1206 * This method checks if the optin parameter is set and
467 1207 * loads the OptIn object based on the hash value. If
@@ -475,12 +1215,17 @@
475 1215 /**
476 1216 * Skip if the optin parameter is not set.
477 1217 */
478 1218 if ( ! isset( $_GET['optin'] ) ) {
1219 + $this->get_logger()->debug( 'No optin parameter found, skipping file removal', [
1220 + 'plugin' => 'double-opt-in',
1221 + 'class' => __CLASS__,
1222 + 'method' => __METHOD__,
1223 + ] );
479 1224 return;
480 1225 }
481 1226
482 - $hash = esc_sql( $_GET['optin'] );
1227 + $hash = sanitize_text_field( wp_unslash( $_GET['optin'] ) );
483 1228
484 1229 /**
485 1230 * Load the OptIn
486 1231 */
@@ -489,12 +1234,33 @@
489 1234 /**
490 1235 * Skip if the OptIn does not exist
491 1236 */
492 1237 if ( null == $OptIn ) {
1238 + $this->get_logger()->warning( 'OptIn not found, skipping file removal', [
1239 + 'plugin' => 'double-opt-in',
1240 + 'class' => __CLASS__,
1241 + 'method' => __METHOD__,
1242 + 'hash' => $hash,
1243 + ] );
493 1244 return;
494 1245 }
495 1246
496 1247 /**
1248 + * Only for this integration's own opt-in, only right after it was
1249 + * confirmed in this request, and only when no follow-up adapter
1250 + * owns the files. Previously any `?optin=` request — an expired
1251 + * link, a second click, another integration's hash — deleted the
1252 + * stored files, including ones a pending action still needed.
1253 + */
1254 + if ( ! $OptIn->isType( $this->type ) || self::$validationStatus !== 'confirmed' ) {
1255 + return;
1256 + }
1257 + $coordinator = \Forge12\DoubleOptIn\FollowUp\FollowUpCoordinator::instance();
1258 + if ( $coordinator !== null && $coordinator->adapterFor( $OptIn ) !== null ) {
1259 + return;
1260 + }
1261 +
1262 + /**
497 1263 * Load all files
498 1264 */
499 1265 $files = maybe_unserialize( $OptIn->get_files() );
500 1266
@@ -501,8 +1267,14 @@
501 1267 /**
502 1268 * Skip if no files found
503 1269 */
504 1270 if ( empty( $files ) ) {
1271 + $this->get_logger()->debug( 'No files found in OptIn, skipping removal', [
1272 + 'plugin' => 'double-opt-in',
1273 + 'class' => __CLASS__,
1274 + 'method' => __METHOD__,
1275 + 'hash' => $hash,
1276 + ] );
505 1277 return;
506 1278 }
507 1279
508 1280 foreach ( $files as $file ) {
@@ -508,9 +1280,9 @@
508 1280 foreach ( $files as $file ) {
509 1281 /**
510 1282 * Skip if empty
511 1283 */
512 - if ( ! empty( $file ) ) {
1284 + if ( empty( $file ) ) {
513 1285 continue;
514 1286 }
515 1287
516 1288 /**
@@ -516,8 +1288,14 @@
516 1288 /**
517 1289 * Skip if no file found
518 1290 */
519 1291 if ( ! is_file( $file ) ) {
1292 + $this->get_logger()->warning( 'File not found, skipping', [
1293 + 'plugin' => 'double-opt-in',
1294 + 'class' => __CLASS__,
1295 + 'method' => __METHOD__,
1296 + 'file' => $file,
1297 + ] );
520 1298 continue;
521 1299 }
522 1300
523 1301 /**
@@ -522,11 +1300,269 @@
522 1300
523 1301 /**
524 1302 * Delete the file
525 1303 */
526 - if ( ! unlink( $file ) ) {
527 - error_log( "Could not delete file " . $file . "!" );
1304 + if ( unlink( $file ) ) {
1305 + $this->get_logger()->info( 'File deleted successfully', [
1306 + 'plugin' => 'double-opt-in',
1307 + 'class' => __CLASS__,
1308 + 'method' => __METHOD__,
1309 + 'file' => $file,
1310 + ] );
1311 + } else {
1312 + $this->get_logger()->error( 'Could not delete file', [
1313 + 'plugin' => 'double-opt-in',
1314 + 'class' => __CLASS__,
1315 + 'method' => __METHOD__,
1316 + 'file' => $file,
1317 + ] );
528 1318 }
529 1319 }
530 1320 }
531 1321
1322 + /**
1323 + * Get the privacy policy URL.
1324 + *
1325 + * Fallback chain: Plugin setting → WordPress Privacy Policy page → empty string.
1326 + *
1327 + * @return string
1328 + */
1329 + private function getPrivacyPolicyUrl(): string {
1330 + $settings = CF7DoubleOptIn::getInstance()->getSettings();
1331 + $pageId = (int) ( $settings['privacy_policy_page'] ?? 0 );
1332 +
1333 + if ( $pageId > 0 ) {
1334 + $url = get_permalink( $pageId );
1335 + if ( $url ) {
1336 + return $url;
1337 + }
1338 + }
1339 +
1340 + // Fallback to WordPress privacy policy page
1341 + $wpPrivacyPageId = (int) get_option( 'wp_page_for_privacy_policy', 0 );
1342 + if ( $wpPrivacyPageId > 0 ) {
1343 + $url = get_permalink( $wpPrivacyPageId );
1344 + if ( $url ) {
1345 + return $url;
1346 + }
1347 + }
1348 +
1349 + return '';
1350 + }
1351 +
1352 + /**
1353 + * Dispatch OptInCreatedEvent via the new event system.
1354 + *
1355 + * @param OptIn $optIn The created OptIn object.
1356 + * @param int $formId The form ID.
1357 + *
1358 + * @since 4.0.0
1359 + */
1360 + protected function dispatchOptInCreatedEvent( OptIn $optIn, int $formId ): void {
1361 + try {
1362 + $container = Container::getInstance();
1363 + if ( $container->has( EventDispatcherInterface::class ) ) {
1364 + $dispatcher = $container->get( EventDispatcherInterface::class );
1365 + $event = new OptInCreatedEvent(
1366 + $optIn->get_id(),
1367 + $formId,
1368 + $this->type,
1369 + $optIn->get_email(),
1370 + $optIn->get_hash()
1371 + );
1372 + $dispatcher->dispatch( $event );
1373 +
1374 + $this->get_logger()->debug( 'OptInCreatedEvent dispatched', [
1375 + 'plugin' => 'double-opt-in',
1376 + 'optin_id' => $optIn->get_id(),
1377 + 'form_id' => $formId,
1378 + ] );
1379 + }
1380 + } catch ( \Exception $e ) {
1381 + $this->get_logger()->warning( 'Failed to dispatch OptInCreatedEvent', [
1382 + 'plugin' => 'double-opt-in',
1383 + 'error' => $e->getMessage(),
1384 + ] );
1385 + }
1386 + }
1387 +
1388 + /**
1389 + * Dispatch OptInConfirmedEvent via the new event system.
1390 + *
1391 + * @param OptIn $optIn The confirmed OptIn object.
1392 + * @param string $hash The opt-in hash.
1393 + *
1394 + * @since 4.0.0
1395 + */
1396 + protected function dispatchOptInConfirmedEvent( OptIn $optIn, string $hash ): void {
1397 + try {
1398 + $container = Container::getInstance();
1399 + if ( $container->has( EventDispatcherInterface::class ) ) {
1400 + $dispatcher = $container->get( EventDispatcherInterface::class );
1401 +
1402 + $formData = maybe_unserialize( $optIn->get_content() );
1403 +
1404 + $event = new OptInConfirmedEvent(
1405 + $optIn->get_id(),
1406 + $hash,
1407 + $optIn->get_email(),
1408 + $optIn->get_ipaddr_confirmation(),
1409 + (int) $optIn->get_cf_form_id(),
1410 + is_array( $formData ) ? $formData : []
1411 + );
1412 + $dispatcher->dispatch( $event );
1413 +
1414 + $this->get_logger()->debug( 'OptInConfirmedEvent dispatched', [
1415 + 'plugin' => 'double-opt-in',
1416 + 'optin_id' => $optIn->get_id(),
1417 + 'hash' => $hash,
1418 + ] );
1419 + }
1420 + } catch ( \Exception $e ) {
1421 + $this->get_logger()->warning( 'Failed to dispatch OptInConfirmedEvent', [
1422 + 'plugin' => 'double-opt-in',
1423 + 'error' => $e->getMessage(),
1424 + ] );
1425 + }
1426 + }
1427 +
1428 + /**
1429 + * Load the consent snapshot (wording + acceptance field) for a form.
1430 + *
1431 + * The authoritative source is `FormSettingsService`, not the
1432 + * `$formParameter` array this legacy path carries — the settings the
1433 + * admin edits in the React UI land in post_meta and only some of them
1434 + * make it into `getParameter()`. `$formParameter` is used purely as a
1435 + * fallback for the case where the container is not available.
1436 + *
1437 + * Read once per submission and used twice: by the consent gate before
1438 + * the opt-in is created, and by the snapshot that is persisted with
1439 + * it. They must agree — a gate that reads a different field than the
1440 + * record stores would produce a proof of the wrong checkbox.
1441 + *
1442 + * @param int $formId The form being submitted.
1443 + * @param array $formParameter The legacy form-parameter array.
1444 + *
1445 + * @return array{text:string,field:string}
1446 + */
1447 + protected function loadConsentSnapshot( int $formId, array $formParameter = array() ): array {
1448 + $snapshot = [
1449 + 'text' => (string) ( $formParameter['consent_text'] ?? '' ),
1450 + 'field' => (string) ( $formParameter['consent_field'] ?? '' ),
1451 + ];
1452 +
1453 + try {
1454 + $container = Container::getInstance();
1455 + $settingsService = $container->get( \Forge12\DoubleOptIn\FormSettings\FormSettingsService::class );
1456 + $formSettings = $settingsService->getSettings( $formId );
1457 +
1458 + $snapshot['text'] = (string) ( $formSettings->consentText ?? '' );
1459 + $snapshot['field'] = (string) ( $formSettings->consentField ?? '' );
1460 + } catch ( \Throwable $e ) {
1461 + $this->get_logger()->debug( 'Could not load consent snapshot from FormSettings', [
1462 + 'plugin' => 'double-opt-in',
1463 + 'form_id' => $formId,
1464 + 'error' => $e->getMessage(),
1465 + ] );
1466 + }
1467 +
1468 + return $snapshot;
1469 + }
1470 +
1471 + /**
1472 + * The field names this form declares, for the consent gate.
1473 + *
1474 + * An unticked checkbox never reaches the server, so the payload alone
1475 + * cannot distinguish "the visitor left the box alone" from "the
1476 + * configured field does not exist any more". The form's own
1477 + * definition can, and every integration exposes it through
1478 + * `FormIntegrationInterface::getFormFields()`.
1479 + *
1480 + * A shim may pass the inventory in directly — Elementor does, because
1481 + * its `Form_Record` lists every declared field including the empty
1482 + * ones, while the composite form ID its `getFormFields()` wants
1483 + * (`{postId}_{widgetId}`) is not what this legacy path carries.
1484 + * Otherwise we ask the registry for the integration behind
1485 + * `$this->type`.
1486 + *
1487 + * Returning an empty array is a valid answer and means "unknown" —
1488 + * the gate then rejects nothing it cannot prove.
1489 + *
1490 + * @param int $formId The form being submitted.
1491 + * @param array $explicit Inventory supplied by the caller, if any.
1492 + *
1493 + * @return array<int,string>
1494 + */
1495 + protected function resolveKnownFieldNames( int $formId, array $explicit = array() ): array {
1496 + if ( $explicit !== array() ) {
1497 + return ConsentGate::normalizeFieldNames( $explicit );
1498 + }
1499 +
1500 + if ( $this->type === '' || ! class_exists( FormIntegrationRegistry::class ) ) {
1501 + return array();
1502 + }
1503 +
1504 + try {
1505 + $integration = FormIntegrationRegistry::getInstance()->get( $this->type );
1506 + if ( $integration === null ) {
1507 + return array();
1508 + }
1509 +
1510 + return ConsentGate::normalizeFieldNames( $integration->getFormFields( $formId ) );
1511 + } catch ( \Throwable $e ) {
1512 + $this->get_logger()->warning( 'Could not read the form field inventory for the consent gate', [
1513 + 'plugin' => 'double-opt-in',
1514 + 'form_id' => $formId,
1515 + 'error' => $e->getMessage(),
1516 + ] );
1517 +
1518 + return array();
1519 + }
1520 + }
1521 +
1522 + /**
1523 + * Assemble the legacy OptIn properties array.
1524 + *
1525 + * Extracted from {@see maybeCreateOptIn()} so the column inventory
1526 + * is unit-testable without standing up the whole submit pipeline
1527 + * (rate limiter, FormSettingsService, $wpdb save, etc.). The
1528 + * modern {@see \Forge12\DoubleOptIn\Integration\AbstractFormIntegration::buildOptInProperties()}
1529 + * has a sibling helper; the two MUST stay in sync — every column
1530 + * the modern path snapshots, this legacy path also has to snapshot,
1531 + * otherwise integrations on the legacy compat path (Elementor +
1532 + * the CF7/Avada legacy shims) lose the column silently.
1533 + *
1534 + * The 2026-05-13 incident this guards against: `consent_field`
1535 + * was missing here, so Elementor opt-ins shipped with an empty
1536 + * acknowledgment field even when the admin configured one. The
1537 + * Consent Evidence panel then rendered "No acknowledgment field
1538 + * configured — consent text is stored but not legally provable"
1539 + * on a record that was, in fact, ticked through a configured
1540 + * checkbox.
1541 + *
1542 + * @return array<string, mixed>
1543 + */
1544 + protected function buildLegacyOptInProperties(
1545 + int $formId,
1546 + string $formHtml,
1547 + array $parameter,
1548 + array $files,
1549 + array $formParameter,
1550 + string $recipient,
1551 + string $consentText,
1552 + string $consentField
1553 + ): array {
1554 + return [
1555 + 'cf_form_id' => $formId,
1556 + 'doubleoptin' => 0,
1557 + 'createtime' => time(),
1558 + 'content' => maybe_serialize( $parameter ),
1559 + 'files' => maybe_serialize( $files ),
1560 + 'ipaddr_register' => IPHelper::getIPAdress(),
1561 + 'category' => (int) ( $formParameter['category'] ?? 0 ),
1562 + 'form' => $formHtml,
1563 + 'email' => $recipient,
1564 + 'consent_text' => $consentText,
1565 + 'consent_field' => $consentField,
1566 + ];
1567 + }
532 1568 }