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.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 3.0.70 3.0.71 3.0.72 All 35 releases
← All changes | compatibility/OptInFrontend.class.php +1079 -112 3.0.35.5.0 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,57 @@
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 + * 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 + /**
18 89 * Constructor for the class.
19 90 *
20 91 * This constructor registers the necessary actions to be performed,
21 92 * by hooking methods of the current class, for the following events:
@@ -25,18 +96,77 @@
25 96 * - 'shutdown'
26 97 *
27 98 * @return void
28 99 */
29 - public function __construct( string $type ) {
30 - $this->type = $type;
100 + public function __construct( LoggerInterface $logger, string $type ) {
101 + $this->logger = $logger;
102 + $this->type = $type;
103 +
104 + $this->get_logger()->debug( 'Base integration constructor called', [
105 + 'plugin' => 'double-opt-in',
106 + 'class' => __CLASS__,
107 + 'method' => __METHOD__,
108 + 'type' => $type,
109 + ] );
110 +
31 111 add_action( 'f12_cf7_doubleoptin_before_send_default_mail', [ $this, 'beforeSendDefaultMail' ], 10, 1 );
112 + $this->get_logger()->debug( 'Hook f12_cf7_doubleoptin_before_send_default_mail registered', [
113 + 'plugin' => 'double-opt-in',
114 + ] );
115 +
32 116 add_action( 'f12_cf7_doubleoptin_after_send_default_mail', [ $this, 'afterSendDefaultMail' ], 10, 1 );
117 + $this->get_logger()->debug( 'Hook f12_cf7_doubleoptin_after_send_default_mail registered', [
118 + 'plugin' => 'double-opt-in',
119 + ] );
120 +
33 121 add_action( 'f12_cf7_doubleoptin_trigger_default_mail', [ $this, 'sendDefaultMail' ], 10, 1 );
122 + $this->get_logger()->debug( 'Hook f12_cf7_doubleoptin_trigger_default_mail registered', [
123 + 'plugin' => 'double-opt-in',
124 + ] );
125 +
34 126 add_action( 'shutdown', [ $this, 'removeFiles' ] );
127 + $this->get_logger()->debug( 'Hook shutdown registered for removeFiles', [
128 + 'plugin' => 'double-opt-in',
129 + ] );
130 +
35 131 add_action( 'init', [ $this, 'validateOptIn' ] );
132 + $this->get_logger()->debug( 'Hook init registered for validateOptIn', [
133 + 'plugin' => 'double-opt-in',
134 + ] );
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 +
36 158 add_filter( 'f12_cf7_doubleoptin_get_recipient_' . $this->type, [ $this, 'getRecipient' ], 10, 3 );
159 + $this->get_logger()->debug( 'Filter f12_cf7_doubleoptin_get_recipient_' . $this->type . ' registered', [
160 + 'plugin' => 'double-opt-in',
161 + ] );
37 162 }
38 163
164 +
165 + public function get_logger() {
166 + return $this->logger;
167 + }
168 +
39 169 /**
40 170 * Retrieves the recipient for a given recipient name, form parameters, and post parameters.
41 171 *
42 172 * @param string $recipient The name of the recipient to retrieve.
@@ -66,8 +196,17 @@
66 196 * @return void
67 197 */
68 198 abstract public function sendDefaultMail( OptIn $OptIn ): void;
69 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 +
70 209 /**
71 210 * This method is used to perform necessary actions before sending the default mail.
72 211 *
73 212 * This method removes the filter for the forge12 spam captcha if the class
@@ -82,19 +221,32 @@
82 221 *
83 222 * @return void
84 223 */
85 224 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 - }
225 + $this->get_logger()->debug( 'beforeSendDefaultMail called', [
226 + 'plugin' => 'double-opt-in',
227 + 'class' => __CLASS__,
228 + 'method' => __METHOD__,
229 + ] );
92 230
93 - // Remove the filter for the google repatcha validation
231 + // Remove the filter for the Forge12 spam captcha
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 + ] );
236 +
237 + // Remove the filter for the Google reCAPTCHA validation
94 238 remove_filter( 'wpcf7_spam', 'wpcf7_recaptcha_verify_response', 9 );
239 +
240 + $this->get_logger()->debug( 'Google reCAPTCHA filter removed', [
241 + 'plugin' => 'double-opt-in',
242 + ] );
243 +
244 + // Remove hCaptcha validation filter
245 + $this->removeHCaptchaFilter();
95 246 }
96 247
248 +
97 249 /**
98 250 * Performs actions after sending the default mail.
99 251 *
100 252 * In this method, two filters and an action are added to the WordPress hooks system.
@@ -124,22 +276,85 @@
124 276 *
125 277 * @return void
126 278 */
127 279 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' ) ) {
280 + $this->get_logger()->debug( 'afterSendDefaultMail called', [
281 + 'plugin' => 'double-opt-in',
282 + 'class' => __CLASS__,
283 + 'method' => __METHOD__,
284 + ] );
285 +
286 + // re-add the filter to ensure for all other forms the reCAPTCHA is used
287 + if ( function_exists( 'wpcf7_recaptcha_verify_response' ) ) {
130 288 add_filter( 'wpcf7_spam', 'wpcf7_recaptcha_verify_response', 9, 2 );
289 + $this->get_logger()->debug( 'Google reCAPTCHA filter re-added', [
290 + 'plugin' => 'double-opt-in',
291 + ] );
131 292 }
132 293
133 - // re add the filter for the forge12 spam captcha
294 + // re-add the filter for the Forge12 spam captcha
134 295 if ( class_exists( '\forge12\contactform7\CF7Captcha\TimerValidatorCF7' ) ) {
135 296 add_filter( 'wpcf7_spam', '\forge12\contactform7\CF7Captcha\TimerValidatorCF7::isSpam', 100, 2 );
136 297 add_filter( 'wpcf7_spam', '\forge12\contactform7\CF7Captcha\CF7IPLog::isSpam', 100, 2 );
137 298 add_action( 'wpcf7_mail_sent', '\forge12\contactform7\CF7Captcha\CF7IPLog::doLogIP', 100, 1 );
299 +
300 + $this->get_logger()->debug( 'Forge12 CF7Captcha filters and actions re-added', [
301 + 'plugin' => 'double-opt-in',
302 + ] );
138 303 }
304 +
305 + // re-add hCaptcha validation filter
306 + $this->restoreHCaptchaFilter();
139 307 }
140 308
141 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 + /**
142 357 * Updates the opt-in status by hash.
143 358 *
144 359 * @param string $hash The opt-in hash.
145 360 * @param int $value The opt-in value to set.
@@ -147,25 +362,37 @@
147 362 *
148 363 * @return int Returns 0 if the opt-in is already confirmed, 1 if the opt-in is successfully updated.
149 364 */
150 365 protected function updateOptInByHash( string $hash, int $value, ?OptIn $OptIn = null ): int {
151 - /**
152 - * Init the optIn is not defined yet.
153 - */
366 + $this->get_logger()->debug( 'updateOptInByHash called', [
367 + 'plugin' => 'double-opt-in',
368 + 'class' => __CLASS__,
369 + 'method' => __METHOD__,
370 + 'hash' => $hash,
371 + 'value' => $value,
372 + ] );
373 +
154 374 $OptIn = $OptIn ?? OptIn::get_by_hash( $hash );
155 375
156 - /**
157 - * If the OptIn is already confirmed - we skip the update.
158 - */
376 + if ( ! $OptIn ) {
377 + $this->get_logger()->warning( 'No OptIn found for hash', [
378 + 'plugin' => 'double-opt-in',
379 + 'hash' => $hash,
380 + ] );
381 + return 0;
382 + }
383 +
159 384 if ( $OptIn->is_confirmed() ) {
385 + $this->get_logger()->info( 'OptIn already confirmed, skipping update', [
386 + 'plugin' => 'double-opt-in',
387 + 'hash' => $hash,
388 + 'optin_id' => $OptIn->get_id(),
389 + ] );
390 +
160 391 do_action( 'f12_cf7_doubleoptin_already_confirmed', $hash, $OptIn );
161 -
162 392 return 0;
163 393 }
164 394
165 - /**
166 - * Hook
167 - */
168 395 do_action( 'f12_cf7_doubleoptin_before_confirm', $hash, $OptIn );
169 396
170 397 $OptIn->set_doubleoptin( $value );
171 398 $OptIn->set_updatetime( time() );
@@ -170,17 +397,38 @@
170 397 $OptIn->set_doubleoptin( $value );
171 398 $OptIn->set_updatetime( time() );
172 399 $OptIn->set_ipaddr_confirmation( IPHelper::getIPAdress() );
173 400
401 + $telemetry = new Telemetry( $this->get_logger() );
402 +
174 403 $result = $OptIn->save();
175 404
176 405 if ( $result ) {
406 + if ( (int)$value === 1 ) {
407 + $telemetry->increment( 'confirmed_optins' );
408 +
409 + // Dispatch typed event for new event-driven architecture
410 + $this->dispatchOptInConfirmedEvent( $OptIn, $hash );
411 + }
412 +
413 + $this->get_logger()->info( 'OptIn updated successfully', [
414 + 'plugin' => 'double-opt-in',
415 + 'hash' => $hash,
416 + 'optin_id' => $OptIn->get_id(),
417 + ] );
177 418 do_action( 'f12_cf7_doubleoptin_after_confirm', $hash, $OptIn );
419 + } else {
420 + $this->get_logger()->error( 'Failed to update OptIn', [
421 + 'plugin' => 'double-opt-in',
422 + 'hash' => $hash,
423 + 'optin_id' => $OptIn->get_id(),
424 + ] );
178 425 }
179 426
180 427 return (int) $result;
181 428 }
182 429
430 +
183 431 /**
184 432 * Add additional placeholder like time, date, subject
185 433 *
186 434 * @formatter:off
@@ -195,42 +443,110 @@
195 443 * #
196 444 * @formatter:on
197 445 */
198 446 protected function addPlaceholders( string $body, OptIn $OptIn, array $parameter ): string {
199 - # set the default timezone
200 - $timezone = get_option( 'timezone_string' );
447 + $this->get_logger()->debug( 'addPlaceholders called', [
448 + 'plugin' => 'double-opt-in',
449 + 'class' => __CLASS__,
450 + 'method' => __METHOD__,
451 + 'optin_id' => $OptIn->get_id(),
452 + 'parameter' => $parameter,
453 + ] );
201 454
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' ) ),
455 + $placeholder = [
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'] ?? '' ) ),
459 + 'doubleoptin_form_subject' => $parameter['subject'] ?? '',
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' ) ),
213 464 'doubleoptin_form_email' => get_option( 'admin_email' ),
214 465 'doubleoptinlink' => $OptIn->get_link_optin( $parameter ),
215 - 'doubleoptoutlink' => $OptIn->get_link_optout()
216 - );
466 + 'doubleoptoutlink' => $OptIn->get_link_optout(),
467 + 'doubleoptin_privacy_url' => $this->getPrivacyPolicyUrl(),
468 + ];
217 469
470 +
218 471 foreach ( $placeholder as $key => $value ) {
219 - $body = str_replace( '[' . $key . ']', $value, $body );
472 + if ( is_array( $value ) || is_object( $value ) ) {
473 + // Array/Objekte serialisieren oder in JSON umwandeln
474 + $value = wp_json_encode( $value );
475 + }
476 +
477 + $replacement = (string) ( $value ?? '' );
478 +
479 + $body = str_replace( '[' . $key . ']', $replacement, $body );
480 +
481 + // Logging je Platzhalter
482 + $this->get_logger()->debug( 'Placeholder replaced', [
483 + 'plugin' => 'double-opt-in',
484 + 'placeholder' => '[' . $key . ']',
485 + 'value' => $replacement,
486 + ] );
220 487 }
221 488
489 + // Logging nach allen Ersetzungen
490 + $this->get_logger()->debug( 'System placeholders replaced in body', [
491 + 'plugin' => 'double-opt-in',
492 + 'placeholders' => array_keys( $placeholder ),
493 + 'body_length' => strlen( $body ),
494 + ] );
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 +
222 530 return $body;
223 531 }
224 532
533 +
225 534 /**
226 535 * Add Stylesheets
227 536 */
228 537 public function validateOptIn(): bool {
538 + $this->get_logger()->debug( 'validateOptIn started', [
539 + 'plugin' => 'double-opt-in',
540 + ] );
541 +
229 542 /**
230 543 * Skip if the hash has not been submitted.
231 544 */
232 545 if ( ! isset( $_GET['optin'] ) ) {
546 + $this->get_logger()->debug( 'No optin hash found in request, skipping', [
547 + 'plugin' => 'double-opt-in',
548 + ] );
233 549 return false;
234 550 }
235 551
236 552 /**
@@ -246,8 +562,13 @@
246 562 /**
247 563 * Skip if the OptIn does not exist
248 564 */
249 565 if ( null == $OptIn ) {
566 + $this->get_logger()->warning( 'OptIn not found for hash', [
567 + 'plugin' => 'double-opt-in',
568 + 'hash' => $hash,
569 + ] );
570 + $this->setValidationStatus( 'not_found' );
250 571 return false;
251 572 }
252 573
253 574 /**
@@ -253,23 +574,72 @@
253 574 /**
254 575 * Skip if the OptIn is not from Type cf7.
255 576 */
256 577 if ( ! $OptIn->isType( $this->type ) ) {
578 + $this->get_logger()->warning( 'OptIn type mismatch', [
579 + 'plugin' => 'double-opt-in',
580 + 'hash' => $hash,
581 + 'type' => $this->type,
582 + 'optin_id' => $OptIn->get_id(),
583 + ] );
257 584 return false;
258 585 }
259 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 +
260 594 /**
261 - * Skip if the OptIn has been confirmed already or could not be updated.
595 + * Check if the token has expired.
262 596 */
263 - if ( $this->updateOptInByHash( $hash, 1 ) <= 0 ) {
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' );
264 608 return false;
265 609 }
266 610
267 611 /**
612 + * Check if already confirmed (before calling updateOptInByHash).
613 + */
614 + if ( $OptIn->is_confirmed() ) {
615 + $this->get_logger()->info( 'OptIn already confirmed', [
616 + 'plugin' => 'double-opt-in',
617 + 'hash' => $hash,
618 + 'optin_id' => $OptIn->get_id(),
619 + ] );
620 + do_action( 'f12_cf7_doubleoptin_already_confirmed', $hash, $OptIn );
621 + $this->setValidationStatus( 'already_confirmed' );
622 + return false;
623 + }
624 +
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 + /**
268 640 * Enable / Disable default mail.
269 641 *
270 - * Filter to allow developer to enable / disable the default mail.
271 - *
272 642 * @param bool $status Enable (true) or disable (false) the default mail.
273 643 * @param int $postId The ID of the Post / Form.
274 644 *
275 645 * @since 2.3.3
@@ -274,42 +644,32 @@
274 644 *
275 645 * @since 2.3.3
276 646 */
277 647 if ( ! apply_filters( 'f12_cf7_doubleoptin_send_default_mail', true, $OptIn->get_cf_form_id() ) ) {
648 + $this->get_logger()->info( 'Default mail disabled for OptIn', [
649 + 'plugin' => 'double-opt-in',
650 + 'form_id' => $OptIn->get_cf_form_id(),
651 + 'optin_id' => $OptIn->get_id(),
652 + ] );
278 653 return false;
279 654 }
280 655
281 - /**
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
289 - */
656 + $this->get_logger()->debug( 'Triggering before_send_default_mail hook', [
657 + 'plugin' => 'double-opt-in',
658 + 'optin_id' => $OptIn->get_id(),
659 + ] );
290 660 do_action( 'f12_cf7_doubleoptin_before_send_default_mail', $OptIn );
291 661
292 - /**
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
300 - */
662 + $this->get_logger()->info( 'Triggering send_default_mail hook', [
663 + 'plugin' => 'double-opt-in',
664 + 'optin_id' => $OptIn->get_id(),
665 + ] );
301 666 do_action( 'f12_cf7_doubleoptin_trigger_default_mail', $OptIn );
302 667
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 - */
668 + $this->get_logger()->debug( 'Triggering after_send_default_mail hook', [
669 + 'plugin' => 'double-opt-in',
670 + 'optin_id' => $OptIn->get_id(),
671 + ] );
312 672 do_action( 'f12_cf7_doubleoptin_after_send_default_mail', $OptIn );
313 673
314 674 return true;
315 675 }
@@ -314,8 +674,9 @@
314 674 return true;
315 675 }
316 676
317 677
678 +
318 679 /**
319 680 * Store the files
320 681 *
321 682 * @param array $inFiles
@@ -322,26 +683,52 @@
322 683 *
323 684 * @return array
324 685 */
325 686 private function maybeStoreFiles( array $inFiles ): array {
326 - $outFiles = array();
687 + $this->get_logger()->debug( 'maybeStoreFiles called', [
688 + 'plugin' => 'double-opt-in',
689 + 'class' => __CLASS__,
690 + 'method' => __METHOD__,
691 + 'files_in' => $inFiles,
692 + ] );
327 693
328 - if ( empty( $files ) ) {
694 + $outFiles = [];
695 +
696 + if ( empty( $inFiles ) ) {
697 + $this->get_logger()->debug( 'No files provided to maybeStoreFiles', [
698 + 'plugin' => 'double-opt-in',
699 + ] );
329 700 return $outFiles;
330 701 }
331 702
332 - foreach ( $files as $key => $subfiles ) {
703 + foreach ( $inFiles as $key => $subfiles ) {
333 704 foreach ( $subfiles as $file ) {
334 705 $newFile = $this->copyAndRenameFile( $file );
335 706 if ( $newFile ) {
336 707 $outFiles[] = $newFile;
708 + $this->get_logger()->debug( 'File stored successfully', [
709 + 'plugin' => 'double-opt-in',
710 + 'original' => $file,
711 + 'new' => $newFile,
712 + ] );
713 + } else {
714 + $this->get_logger()->warning( 'File could not be stored', [
715 + 'plugin' => 'double-opt-in',
716 + 'original' => $file,
717 + ] );
337 718 }
338 719 }
339 720 }
340 721
722 + $this->get_logger()->debug( 'maybeStoreFiles completed', [
723 + 'plugin' => 'double-opt-in',
724 + 'files_out' => $outFiles,
725 + ] );
726 +
341 727 return $outFiles;
342 728 }
343 729
730 +
344 731 /**
345 732 * Copy and rename a file
346 733 *
347 734 * @param string $file The path to the file to copy and rename
@@ -348,92 +735,342 @@
348 735 *
349 736 * @return string|null The path to the copied and renamed file, or null if the copy operation failed
350 737 */
351 738 private function copyAndRenameFile( string $file ): ?string {
352 - $newFile = explode( '/', $file );
353 - $name = $newFile[ count( $newFile ) - 1 ];
354 - $name = time() . '_' . $name;
739 + $this->get_logger()->debug( 'copyAndRenameFile called', [
740 + 'plugin' => 'double-opt-in',
741 + 'class' => __CLASS__,
742 + 'method' => __METHOD__,
743 + 'file' => $file,
744 + ] );
745 +
746 + $newFile = explode( '/', $file );
747 + $name = $newFile[ count( $newFile ) - 1 ];
748 + $name = time() . '_' . $name;
355 749 $newFile[ count( $newFile ) - 1 ] = $name;
356 - $newFile = implode( "/", $newFile );
750 + $newFile = implode( "/", $newFile );
751 +
357 752 if ( copy( $file, $newFile ) ) {
753 + $this->get_logger()->info( 'File copied and renamed successfully', [
754 + 'plugin' => 'double-opt-in',
755 + 'original' => $file,
756 + 'new_file' => $newFile,
757 + ] );
358 758 return $newFile;
359 759 }
360 760
761 + $this->get_logger()->error( 'Failed to copy and rename file', [
762 + 'plugin' => 'double-opt-in',
763 + 'original' => $file,
764 + 'target' => $newFile,
765 + ] );
766 +
361 767 return null;
362 768 }
363 769
770 +
364 771 /**
365 772 * Create the OptIn
366 773 *
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.
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.
371 788 *
372 789 * @return OptIn|null
373 790 */
374 - 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 {
792 + $this->get_logger()->debug( 'maybeCreateOptIn called', [
793 + 'plugin' => 'double-opt-in',
794 + 'class' => __CLASS__,
795 + 'method' => __METHOD__,
796 + 'formId' => $formId,
797 + 'formHtml' => substr( $formHtml, 0, 200 ),
798 + 'files' => $files,
799 + ] );
800 +
375 801 /**
376 - * Maybe copy the files to store them while waiting for the optin confirmation
802 + * Mögliche Dateien speichern, um sie während der Opt-In-Bestätigung vorzuhalten
377 803 */
378 804 $files = $this->maybeStoreFiles( $files );
805 + $this->get_logger()->debug( 'Files checked and possibly stored', [
806 + 'plugin' => 'double-opt-in',
807 + 'files' => $files,
808 + ] );
379 809
380 810 /**
381 - * Filter to manipulate the content parameter before storing them in the database
382 - *
383 - * @param array $parameter
384 - *
385 - * @since 2.3.3
811 + * Filter, um die Parameter vor dem Speichern in der Datenbank zu manipulieren
386 812 */
387 813 $parameter = \apply_filters( 'f12_cf7_doubleoptin_add_request_parameter', $parameter );
814 + $this->get_logger()->debug( 'Request parameters filtered', [
815 + 'plugin' => 'double-opt-in',
816 + 'parameter' => $parameter,
817 + ] );
388 818
389 819 /**
390 - * Get the global settings for the formular.
820 + * Globale Einstellungen für das Formular abrufen
391 821 */
392 822 $formParameter = CF7DoubleOptIn::getInstance()->getParameter( $formId );
393 823
394 824 /**
395 - * Filter to fetch the recipient before creating the optin object.
825 + * Filter, um den Empfänger zu ermitteln, bevor das OptIn-Objekt erstellt wird
826 + */
827 + $recipient = \apply_filters( 'f12_cf7_doubleoptin_get_recipient_' . $this->type, '', $formParameter, $parameter );
828 +
829 + /**
830 + * Wenn keine E-Mail-Adresse gefunden wurde, Abbruch
831 + */
832 + if ( empty( $recipient ) ) {
833 + $this->get_logger()->warning( 'No recipient found, skipping OptIn creation', [
834 + 'plugin' => 'double-opt-in',
835 + ] );
836 + ErrorNotification::store(
837 + OptInError::fromCode( OptInError::NO_RECIPIENT, [ 'form_id' => $formId ] ),
838 + $formId
839 + );
840 + return null;
841 + }
842 +
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.
396 847 *
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.
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.
400 854 *
401 - * @since 2.3.3
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.
402 858 */
403 - $recipient = \apply_filters( 'f12_cf7_doubleoptin_get_recipient_' . $this->type, '', $formParameter, $parameter );
859 + $consentSnapshot = $this->loadConsentSnapshot( $formId, $formParameter );
860 + $consentField = $consentSnapshot['field'];
861 + $consentGate = ConsentGate::evaluate(
862 + $consentField,
863 + $parameter,
864 + $this->resolveKnownFieldNames( $formId, $knownFields )
865 + );
404 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 +
405 905 /**
406 - * Skip if no Mail has been found.
906 + * Rate-Limiting: Check IP and email limits before creating OptIn.
407 907 */
408 - if ( empty( $recipient ) ) {
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 + );
409 926 return null;
410 927 }
411 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 +
412 943 /**
413 - * Set the Properties of the OptIn Object
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.
414 956 */
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
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
425 973 );
426 974
427 - $OptIn = new OptIn( $properties );
975 + if ( $recipientValid !== true ) {
976 + $errorMsg = is_string( $recipientValid ) ? $recipientValid : '';
977 + $errorCode = OptInError::RECIPIENT_INVALID;
428 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 + /**
1016 + * Eigenschaften des OptIn-Objekts festlegen
1017 + */
1018 + $properties = $this->buildLegacyOptInProperties(
1019 + $formId,
1020 + $formHtml,
1021 + $parameter,
1022 + $files,
1023 + $formParameter,
1024 + $recipient,
1025 + $consentText,
1026 + $consentField
1027 + );
1028 +
1029 + $this->get_logger()->debug( 'OptIn properties created', [
1030 + 'plugin' => 'double-opt-in',
1031 + 'properties' => $properties,
1032 + ] );
1033 +
1034 + /**
1035 + * OptIn-Objekt erstellen
1036 + */
1037 + $OptIn = new OptIn($this->get_logger(), $properties );
1038 + $this->get_logger()->debug( 'OptIn object instantiated', [
1039 + 'plugin' => 'double-opt-in',
1040 + 'OptIn' => $OptIn,
1041 + ] );
1042 +
1043 + /**
1044 + * OptIn speichern
1045 + */
429 1046 if ( $OptIn->save() ) {
1047 + $this->get_logger()->info( 'OptIn object saved successfully', [
1048 + 'plugin' => 'double-opt-in',
1049 + 'OptIn' => $OptIn,
1050 + ] );
1051 +
1052 + // Dispatch typed event for new event-driven architecture
1053 + $this->dispatchOptInCreatedEvent( $OptIn, $formId );
1054 +
430 1055 return $OptIn;
431 1056 }
432 1057
1058 + $this->get_logger()->error( 'Failed to save OptIn object', [
1059 + 'plugin' => 'double-opt-in',
1060 + ] );
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 +
433 1069 return null;
434 1070 }
435 1071
1072 +
436 1073 /**
437 1074 * Validate if the optin is enabled.
438 1075 */
439 1076 protected function isOptinEnabled( int $formId ): bool {
@@ -438,8 +1075,13 @@
438 1075 */
439 1076 protected function isOptinEnabled( int $formId ): bool {
440 1077 // Disable optin sending if the optin flag is set.
441 1078 if ( isset( $_GET['optin'] ) ) {
1079 + $this->get_logger()->debug( 'Optin disabled due to optin flag in GET request', [
1080 + 'plugin' => 'double-opt-in',
1081 + 'class' => __CLASS__,
1082 + 'method' => __METHOD__,
1083 + ] );
442 1084 return false;
443 1085 }
444 1086
445 1087 $parameter = CF7DoubleOptIn::getInstance()->getParameter( $formId );
@@ -444,8 +1086,14 @@
444 1086
445 1087 $parameter = CF7DoubleOptIn::getInstance()->getParameter( $formId );
446 1088
447 1089 if ( (int) $parameter['enable'] != 1 ) {
1090 + $this->get_logger()->debug( 'Optin not enabled in form parameter', [
1091 + 'plugin' => 'double-opt-in',
1092 + 'class' => __CLASS__,
1093 + 'method' => __METHOD__,
1094 + 'parameter' => $parameter,
1095 + ] );
448 1096 return false;
449 1097 }
450 1098
451 1099 // Check the custom condition
@@ -452,16 +1100,54 @@
452 1100 if ( isset( $parameter['conditions'] ) ) {
453 1101 $condition = sanitize_text_field( $parameter['conditions'] );
454 1102
455 1103 if ( ( $condition != 'disable' && $condition !== 'disabled' ) && ( ! isset( $_POST[ $condition ] ) || empty( $_POST[ $condition ] ) ) ) {
1104 + $this->get_logger()->debug( 'Optin disabled due to unmet custom condition', [
1105 + 'plugin' => 'double-opt-in',
1106 + 'class' => __CLASS__,
1107 + 'method' => __METHOD__,
1108 + 'condition' => $condition,
1109 + 'post_keys' => array_keys( $_POST ),
1110 + ] );
456 1111 return false;
457 1112 }
458 1113 }
459 1114
1115 + $this->get_logger()->debug( 'Optin enabled', [
1116 + 'plugin' => 'double-opt-in',
1117 + 'class' => __CLASS__,
1118 + 'method' => __METHOD__,
1119 + ] );
1120 +
460 1121 return true;
461 1122 }
462 1123
1124 +
463 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 + /**
464 1150 * Removes files associated with the optin parameter.
465 1151 *
466 1152 * This method checks if the optin parameter is set and
467 1153 * loads the OptIn object based on the hash value. If
@@ -475,12 +1161,17 @@
475 1161 /**
476 1162 * Skip if the optin parameter is not set.
477 1163 */
478 1164 if ( ! isset( $_GET['optin'] ) ) {
1165 + $this->get_logger()->debug( 'No optin parameter found, skipping file removal', [
1166 + 'plugin' => 'double-opt-in',
1167 + 'class' => __CLASS__,
1168 + 'method' => __METHOD__,
1169 + ] );
479 1170 return;
480 1171 }
481 1172
482 - $hash = esc_sql( $_GET['optin'] );
1173 + $hash = sanitize_text_field( wp_unslash( $_GET['optin'] ) );
483 1174
484 1175 /**
485 1176 * Load the OptIn
486 1177 */
@@ -489,8 +1180,14 @@
489 1180 /**
490 1181 * Skip if the OptIn does not exist
491 1182 */
492 1183 if ( null == $OptIn ) {
1184 + $this->get_logger()->warning( 'OptIn not found, skipping file removal', [
1185 + 'plugin' => 'double-opt-in',
1186 + 'class' => __CLASS__,
1187 + 'method' => __METHOD__,
1188 + 'hash' => $hash,
1189 + ] );
493 1190 return;
494 1191 }
495 1192
496 1193 /**
@@ -501,8 +1198,14 @@
501 1198 /**
502 1199 * Skip if no files found
503 1200 */
504 1201 if ( empty( $files ) ) {
1202 + $this->get_logger()->debug( 'No files found in OptIn, skipping removal', [
1203 + 'plugin' => 'double-opt-in',
1204 + 'class' => __CLASS__,
1205 + 'method' => __METHOD__,
1206 + 'hash' => $hash,
1207 + ] );
505 1208 return;
506 1209 }
507 1210
508 1211 foreach ( $files as $file ) {
@@ -508,9 +1211,9 @@
508 1211 foreach ( $files as $file ) {
509 1212 /**
510 1213 * Skip if empty
511 1214 */
512 - if ( ! empty( $file ) ) {
1215 + if ( empty( $file ) ) {
513 1216 continue;
514 1217 }
515 1218
516 1219 /**
@@ -516,8 +1219,14 @@
516 1219 /**
517 1220 * Skip if no file found
518 1221 */
519 1222 if ( ! is_file( $file ) ) {
1223 + $this->get_logger()->warning( 'File not found, skipping', [
1224 + 'plugin' => 'double-opt-in',
1225 + 'class' => __CLASS__,
1226 + 'method' => __METHOD__,
1227 + 'file' => $file,
1228 + ] );
520 1229 continue;
521 1230 }
522 1231
523 1232 /**
@@ -522,11 +1231,269 @@
522 1231
523 1232 /**
524 1233 * Delete the file
525 1234 */
526 - if ( ! unlink( $file ) ) {
527 - error_log( "Could not delete file " . $file . "!" );
1235 + if ( unlink( $file ) ) {
1236 + $this->get_logger()->info( 'File deleted successfully', [
1237 + 'plugin' => 'double-opt-in',
1238 + 'class' => __CLASS__,
1239 + 'method' => __METHOD__,
1240 + 'file' => $file,
1241 + ] );
1242 + } else {
1243 + $this->get_logger()->error( 'Could not delete file', [
1244 + 'plugin' => 'double-opt-in',
1245 + 'class' => __CLASS__,
1246 + 'method' => __METHOD__,
1247 + 'file' => $file,
1248 + ] );
528 1249 }
529 1250 }
530 1251 }
531 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 );
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 + }
532 1499 }