PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.6.0
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.6.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
double-opt-in / compatibility / OptInFrontend.class.php

OptInFrontend.class.php in Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification 5.6.0, at compatibility/OptInFrontend.class.php

1,540 lines 48.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace forge12\contactform7\CF7DoubleOptIn;
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
19 if ( ! defined( 'ABSPATH' ) ) {
20 exit;
21 }
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 */
32 abstract class OptInFrontend {
33 /**
34 * The Type of the OptIn Form System
35 *
36 * @var string
37 */
38 protected string $type = '';
39
40 private LoggerInterface $logger;
41
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 /**
89 * Constructor for the class.
90 *
91 * This constructor registers the necessary actions to be performed,
92 * by hooking methods of the current class, for the following events:
93 * - 'f12_cf7_doubleoptin_before_send_default_mail'
94 * - 'f12_cf7_doubleoptin_after_send_default_mail'
95 * - 'f12_cf7_doubleoptin_trigger_default_mail'
96 * - 'shutdown'
97 *
98 * @return void
99 */
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
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
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
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
126 add_action( 'shutdown', [ $this, 'removeFiles' ] );
127 $this->get_logger()->debug( 'Hook shutdown registered for removeFiles', [
128 'plugin' => 'double-opt-in',
129 ] );
130
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
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 ] );
162 }
163
164
165 public function get_logger() {
166 return $this->logger;
167 }
168
169 /**
170 * Retrieves the recipient for a given recipient name, form parameters, and post parameters.
171 *
172 * @param string $recipient The name of the recipient to retrieve.
173 * @param array $formParameter The array of form parameters.
174 * @param array $postParameter The array of post parameters.
175 *
176 * @return string The recipient for the given parameters.
177 */
178 abstract public function getRecipient( string $recipient, array $formParameter, array $postParameter ): string;
179
180 /**
181 * Sends a default mail for the given OptIn object.
182 *
183 * This method is declared as abstract, meaning that it must be implemented
184 * by any child class that extends the current class. The method takes
185 * a single parameter, $OptIn, of type OptIn. This parameter represents
186 * the OptIn object for which the default mail needs to be sent.
187 *
188 * This method should be overridden by child classes to define the specific
189 * logic for sending the default mail for the given OptIn object.
190 *
191 * Note that the implementation details of this method vary depending on the
192 * specific subclass. Therefore, the implementation code is not provided here.
193 *
194 * @param OptIn $OptIn The OptIn object for which the default mail needs to be sent.
195 *
196 * @return void
197 */
198 abstract public function sendDefaultMail( OptIn $OptIn ): void;
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
209 /**
210 * This method is used to perform necessary actions before sending the default mail.
211 *
212 * This method removes the filter for the forge12 spam captcha if the class
213 * '\forge12\contactform7\CF7Captcha\TimerValidatorCF7' exists. It removes the filters 'wpcf7_spam' for the methods
214 * '\forge12\contactform7\CF7Captcha::isSpam' and
215 * '\forge12\contactform7\CF7Captcha\CF7IPLog::isSpam', and also removes the action 'wpcf7_mail_sent' for the
216 * method
217 * '\forge12\contactform7\CF7Captcha\CF7IPLog::doLogIP', if these filters and action exist.
218 *
219 * Additionally, this method removes the filter 'wpcf7_spam' for the method 'wpcf7_recaptcha_verify_response' with
220 * a priority of 9.
221 *
222 * @return void
223 */
224 public function beforeSendDefaultMail() {
225 $this->get_logger()->debug( 'beforeSendDefaultMail called', [
226 'plugin' => 'double-opt-in',
227 'class' => __CLASS__,
228 'method' => __METHOD__,
229 ] );
230
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
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();
246 }
247
248
249 /**
250 * Performs actions after sending the default mail.
251 *
252 * In this method, two filters and an action are added to the WordPress hooks system.
253 *
254 * The first filter is added with the hook name 'wpcf7_spam' and the callback
255 * function is set to 'wpcf7_recaptcha_verify_response'. The priority is set to 9
256 * and the number of accepted arguments for the callback function is 2.
257 * This filter is added only if the function 'wpcf7_recaptcha_verifiy_response'
258 * exists.
259 *
260 * The second filter is added with the hook name 'wpcf7_spam' and the callback
261 * function is set to '\forge12\contactform7\CF7Captcha\TimerValidatorCF7::isSpam'.
262 * The priority is set to 100 and the number of accepted arguments for the callback
263 * function is 2. This filter is added only if the class '\forge12\contactform7\CF7Captcha\TimerValidatorCF7'
264 * exists.
265 *
266 * The third filter is added with the hook name 'wpcf7_spam' and the callback
267 * function is set to '\forge12\contactform7\CF7Captcha\CF7IPLog::isSpam'.
268 * The priority is set to 100 and the number of accepted arguments for the callback
269 * function is 2. This filter is added only if the class '\forge12\contactform7\CF7Captcha\CF7IPLog'
270 * exists.
271 *
272 * An action is added with the hook name 'wpcf7_mail_sent' and the callback function
273 * is set to '\forge12\contactform7\CF7Captcha\CF7IPLog::doLogIP'. The priority is set
274 * to 100 and the number of accepted arguments for the callback function is 1.
275 * This action is added only if the class '\forge12\contactform7\CF7Captcha\CF7IPLog' exists.
276 *
277 * @return void
278 */
279 public function afterSendDefaultMail() {
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' ) ) {
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 ] );
292 }
293
294 // re-add the filter for the Forge12 spam captcha
295 if ( class_exists( '\forge12\contactform7\CF7Captcha\TimerValidatorCF7' ) ) {
296 add_filter( 'wpcf7_spam', '\forge12\contactform7\CF7Captcha\TimerValidatorCF7::isSpam', 100, 2 );
297 add_filter( 'wpcf7_spam', '\forge12\contactform7\CF7Captcha\CF7IPLog::isSpam', 100, 2 );
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 ] );
303 }
304
305 // re-add hCaptcha validation filter
306 $this->restoreHCaptchaFilter();
307 }
308
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 /**
357 * Updates the opt-in status by hash.
358 *
359 * @param string $hash The opt-in hash.
360 * @param int $value The opt-in value to set.
361 * @param OptIn|null $OptIn The opt-in object. Optional.
362 *
363 * @return int Returns 0 if the opt-in is already confirmed, 1 if the opt-in is successfully updated.
364 */
365 protected function updateOptInByHash( string $hash, int $value, ?OptIn $OptIn = null ): int {
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
374 $OptIn = $OptIn ?? OptIn::get_by_hash( $hash );
375
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
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
391 do_action( 'f12_cf7_doubleoptin_already_confirmed', $hash, $OptIn );
392 return 0;
393 }
394
395 do_action( 'f12_cf7_doubleoptin_before_confirm', $hash, $OptIn );
396
397 $OptIn->set_doubleoptin( $value );
398 $OptIn->set_updatetime( time() );
399 $OptIn->set_ipaddr_confirmation( IPHelper::getIPAdress() );
400
401 $telemetry = new Telemetry( $this->get_logger() );
402
403 $result = $OptIn->save();
404
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 ] );
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 ] );
425 }
426
427 return (int) $result;
428 }
429
430
431 /**
432 * Add additional placeholder like time, date, subject
433 *
434 * @formatter:off
435 *
436 * @param string $body The content containing the placeholder that will be replaced.
437 * @param OptIn $OptIn The OptIn Object.
438 *
439 * @param array $parameter {
440 * @type string $formUrl The URL where the form is displayed.
441 * @type string $subject The Subject of the Form
442 * }
443 * #
444 * @formatter:on
445 */
446 protected function addPlaceholders( string $body, OptIn $OptIn, array $parameter ): 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 ] );
454
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' ) ),
464 'doubleoptin_form_email' => get_option( 'admin_email' ),
465 'doubleoptinlink' => $OptIn->get_link_optin( $parameter ),
466 'doubleoptoutlink' => $OptIn->get_link_optout(),
467 'doubleoptin_privacy_url' => $this->getPrivacyPolicyUrl(),
468 ];
469
470
471 foreach ( $placeholder as $key => $value ) {
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 ] );
487 }
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
530 return $body;
531 }
532
533
534 /**
535 * Add Stylesheets
536 */
537 public function validateOptIn(): bool {
538 $this->get_logger()->debug( 'validateOptIn started', [
539 'plugin' => 'double-opt-in',
540 ] );
541
542 /**
543 * Skip if the hash has not been submitted.
544 */
545 if ( ! isset( $_GET['optin'] ) ) {
546 $this->get_logger()->debug( 'No optin hash found in request, skipping', [
547 'plugin' => 'double-opt-in',
548 ] );
549 return false;
550 }
551
552 /**
553 * Get the Hash
554 */
555 $hash = sanitize_text_field( $_GET['optin'] );
556
557 /**
558 * Load the OptIn
559 */
560 $OptIn = OptIn::get_by_hash( $hash );
561
562 /**
563 * Skip if the OptIn does not exist
564 */
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' );
571 return false;
572 }
573
574 /**
575 * Skip if the OptIn is not from Type cf7.
576 */
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 ] );
584 return false;
585 }
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
594 /**
595 * Check if the token has expired.
596 */
597 $settings = CF7DoubleOptIn::getInstance()->getSettings();
598 $expiryHours = (int) ( $settings['token_expiry_hours'] ?? 48 );
599 if ( $expiryHours > 0 && ( time() - (int) $OptIn->get_createtime() ) > ( $expiryHours * 3600 ) ) {
600 $this->get_logger()->info( 'OptIn token expired', [
601 'plugin' => 'double-opt-in',
602 'hash' => $hash,
603 'optin_id' => $OptIn->get_id(),
604 'expiry_hours' => $expiryHours,
605 ] );
606 do_action( 'f12_cf7_doubleoptin_token_expired', $hash, $OptIn );
607 $this->setValidationStatus( 'expired' );
608 return false;
609 }
610
611 /**
612 * Check if already confirmed (before calling updateOptInByHash).
613 */
614 if ( $OptIn->is_confirmed() ) {
615 $this->get_logger()->info( 'OptIn already confirmed', [
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 * Enable / Disable default mail.
627 *
628 * @param bool $status Enable (true) or disable (false) the default mail.
629 * @param int $postId The ID of the Post / Form.
630 *
631 * @since 2.3.3
632 */
633 $sendDefaultMail = (bool) apply_filters( 'f12_cf7_doubleoptin_send_default_mail', true, $OptIn->get_cf_form_id() );
634
635 /**
636 * Bind the follow-up plan before the confirmation is saved (see
637 * AbstractFormIntegration::validateOptIn). False when no adapter
638 * handles this integration → previous behaviour below.
639 */
640 $coordinator = \Forge12\DoubleOptIn\FollowUp\FollowUpCoordinator::instance();
641 $managed = $coordinator !== null && $coordinator->plan( $OptIn, $sendDefaultMail );
642
643 /**
644 * Confirm the OptIn.
645 */
646 if ( $this->updateOptInByHash( $hash, 1, $OptIn ) <= 0 ) {
647 $this->get_logger()->info( 'OptIn update failed', [
648 'plugin' => 'double-opt-in',
649 'hash' => $hash,
650 'optin_id' => $OptIn->get_id(),
651 ] );
652 return false;
653 }
654
655 $this->setValidationStatus( 'confirmed' );
656
657 if ( $managed ) {
658 // Planned actions (skipped ones included) are recorded by the
659 // coordinator; trigger_default_mail is not fired for managed
660 // opt-ins, so no listener can replay them a second time.
661 if ( $sendDefaultMail ) {
662 do_action( 'f12_cf7_doubleoptin_before_send_default_mail', $OptIn );
663 }
664 $coordinator->run( $OptIn, \Forge12\DoubleOptIn\FollowUp\FollowUpAttempt::TRIGGER_CONFIRM );
665 if ( $sendDefaultMail ) {
666 do_action( 'f12_cf7_doubleoptin_after_send_default_mail', $OptIn );
667 }
668 return true;
669 }
670
671 if ( ! $sendDefaultMail ) {
672 $this->get_logger()->info( 'Default mail disabled for OptIn', [
673 'plugin' => 'double-opt-in',
674 'form_id' => $OptIn->get_cf_form_id(),
675 'optin_id' => $OptIn->get_id(),
676 ] );
677 return false;
678 }
679
680 $this->get_logger()->debug( 'Triggering before_send_default_mail hook', [
681 'plugin' => 'double-opt-in',
682 'optin_id' => $OptIn->get_id(),
683 ] );
684 do_action( 'f12_cf7_doubleoptin_before_send_default_mail', $OptIn );
685
686 $this->get_logger()->info( 'Triggering send_default_mail hook', [
687 'plugin' => 'double-opt-in',
688 'optin_id' => $OptIn->get_id(),
689 ] );
690 do_action( 'f12_cf7_doubleoptin_trigger_default_mail', $OptIn );
691
692 $this->get_logger()->debug( 'Triggering after_send_default_mail hook', [
693 'plugin' => 'double-opt-in',
694 'optin_id' => $OptIn->get_id(),
695 ] );
696 do_action( 'f12_cf7_doubleoptin_after_send_default_mail', $OptIn );
697
698 return true;
699 }
700
701
702
703 /**
704 * Store the files
705 *
706 * @param array $inFiles
707 *
708 * @return array
709 */
710 private function maybeStoreFiles( array $inFiles ): array {
711 $this->get_logger()->debug( 'maybeStoreFiles called', [
712 'plugin' => 'double-opt-in',
713 'class' => __CLASS__,
714 'method' => __METHOD__,
715 'files_in' => $inFiles,
716 ] );
717
718 $outFiles = [];
719
720 if ( empty( $inFiles ) ) {
721 $this->get_logger()->debug( 'No files provided to maybeStoreFiles', [
722 'plugin' => 'double-opt-in',
723 ] );
724 return $outFiles;
725 }
726
727 foreach ( $inFiles as $key => $subfiles ) {
728 foreach ( $subfiles as $file ) {
729 $newFile = $this->copyAndRenameFile( $file );
730 if ( $newFile ) {
731 $outFiles[] = $newFile;
732 $this->get_logger()->debug( 'File stored successfully', [
733 'plugin' => 'double-opt-in',
734 'original' => $file,
735 'new' => $newFile,
736 ] );
737 } else {
738 $this->get_logger()->warning( 'File could not be stored', [
739 'plugin' => 'double-opt-in',
740 'original' => $file,
741 ] );
742 }
743 }
744 }
745
746 $this->get_logger()->debug( 'maybeStoreFiles completed', [
747 'plugin' => 'double-opt-in',
748 'files_out' => $outFiles,
749 ] );
750
751 return $outFiles;
752 }
753
754
755 /**
756 * Copy and rename a file
757 *
758 * @param string $file The path to the file to copy and rename
759 *
760 * @return string|null The path to the copied and renamed file, or null if the copy operation failed
761 */
762 private function copyAndRenameFile( string $file ): ?string {
763 $this->get_logger()->debug( 'copyAndRenameFile called', [
764 'plugin' => 'double-opt-in',
765 'class' => __CLASS__,
766 'method' => __METHOD__,
767 'file' => $file,
768 ] );
769
770 $newFile = explode( '/', $file );
771 $name = $newFile[ count( $newFile ) - 1 ];
772 $name = time() . '_' . $name;
773 $newFile[ count( $newFile ) - 1 ] = $name;
774 $newFile = implode( "/", $newFile );
775
776 if ( copy( $file, $newFile ) ) {
777 $this->get_logger()->info( 'File copied and renamed successfully', [
778 'plugin' => 'double-opt-in',
779 'original' => $file,
780 'new_file' => $newFile,
781 ] );
782 return $newFile;
783 }
784
785 $this->get_logger()->error( 'Failed to copy and rename file', [
786 'plugin' => 'double-opt-in',
787 'original' => $file,
788 'target' => $newFile,
789 ] );
790
791 return null;
792 }
793
794
795 /**
796 * Create the OptIn
797 *
798 * @param int $formId The identifier of the form
799 * @param string $formHtml The HTML code of the form
800 * @param array $parameter The Post Parameter of the form.
801 * @param array $files The Files attached to the form.
802 * @param array $knownFields The fields this form declares, `name => label`
803 * or a plain list. Only the consent gate uses
804 * them, to tell an unticked checkbox from a
805 * setting that points at a field which no
806 * longer exists. A shim that knows its form
807 * better than the registry does (Elementor
808 * has the Form_Record in hand) passes them in;
809 * everyone else leaves it empty and
810 * {@see self::resolveKnownFieldNames()} asks
811 * the integration.
812 *
813 * @return OptIn|null
814 */
815 protected function maybeCreateOptIn( int $formId, string $formHtml, array $parameter, array $files = array(), array $knownFields = array() ): ?OptIn {
816 $this->get_logger()->debug( 'maybeCreateOptIn called', [
817 'plugin' => 'double-opt-in',
818 'class' => __CLASS__,
819 'method' => __METHOD__,
820 'formId' => $formId,
821 'formHtml' => substr( $formHtml, 0, 200 ),
822 'files' => $files,
823 ] );
824
825 /**
826 * Mögliche Dateien speichern, um sie während der Opt-In-Bestätigung vorzuhalten
827 */
828 $files = $this->maybeStoreFiles( $files );
829 $this->get_logger()->debug( 'Files checked and possibly stored', [
830 'plugin' => 'double-opt-in',
831 'files' => $files,
832 ] );
833
834 /**
835 * Filter, um die Parameter vor dem Speichern in der Datenbank zu manipulieren
836 */
837 $parameter = \apply_filters( 'f12_cf7_doubleoptin_add_request_parameter', $parameter );
838 $this->get_logger()->debug( 'Request parameters filtered', [
839 'plugin' => 'double-opt-in',
840 'parameter' => $parameter,
841 ] );
842
843 /**
844 * Globale Einstellungen für das Formular abrufen
845 */
846 $formParameter = CF7DoubleOptIn::getInstance()->getParameter( $formId );
847
848 /**
849 * Filter, um den Empfänger zu ermitteln, bevor das OptIn-Objekt erstellt wird
850 */
851 $recipient = \apply_filters( 'f12_cf7_doubleoptin_get_recipient_' . $this->type, '', $formParameter, $parameter );
852
853 /**
854 * Wenn keine E-Mail-Adresse gefunden wurde, Abbruch
855 */
856 if ( empty( $recipient ) ) {
857 $this->get_logger()->warning( 'No recipient found, skipping OptIn creation', [
858 'plugin' => 'double-opt-in',
859 ] );
860 ErrorNotification::store(
861 OptInError::fromCode( OptInError::NO_RECIPIENT, [ 'form_id' => $formId ] ),
862 $formId
863 );
864 return null;
865 }
866
867 /**
868 * Consent gate (GDPR Art. 7) — same position in the flow as in
869 * AbstractFormIntegration::createOptIn(): after the recipient is
870 * known, before rate limiting.
871 *
872 * Until 5.4.0 this path had no gate at all. Everything that does
873 * not extend AbstractFormIntegration comes through here —
874 * Elementor, plus the CF7 and Avada legacy shims — so on those
875 * forms the acceptance checkbox was stored as consent proof
876 * without ever having been enforced. The banner in the admin UI
877 * said as much since 5.3.2; this closes it.
878 *
879 * ConsentGate::FIELD_UNKNOWN never rejects. See the class for why
880 * that matters: a stale `consent_field` must not take a site's
881 * registrations offline.
882 */
883 $consentSnapshot = $this->loadConsentSnapshot( $formId, $formParameter );
884 $consentField = $consentSnapshot['field'];
885 $consentGate = ConsentGate::evaluate(
886 $consentField,
887 $parameter,
888 $this->resolveKnownFieldNames( $formId, $knownFields )
889 );
890
891 if ( $consentGate === ConsentGate::NOT_GIVEN && ! ConsentGate::isEnforced( $formId, $this->type ) ) {
892 $this->get_logger()->warning( 'Consent gate disabled by filter — accepting an unconfirmed submission', [
893 'plugin' => 'double-opt-in',
894 'form_id' => $formId,
895 'integration' => $this->type,
896 'consent_field' => $consentField,
897 ] );
898 $consentGate = ConsentGate::PASSED;
899 }
900
901 if ( $consentGate === ConsentGate::NOT_GIVEN ) {
902 $this->get_logger()->info( 'Consent acceptance not given, rejecting OptIn', [
903 'plugin' => 'double-opt-in',
904 'form_id' => $formId,
905 'integration' => $this->type,
906 'consent_field' => $consentField,
907 ] );
908 do_action( 'f12_cf7_doubleoptin_consent_not_given', $formId, $consentField );
909 ErrorNotification::store(
910 OptInError::fromCode(
911 OptInError::CONSENT_NOT_GIVEN,
912 [ 'form_id' => $formId, 'consent_field' => $consentField ]
913 ),
914 $formId
915 );
916 return null;
917 }
918
919 if ( $consentGate === ConsentGate::FIELD_UNKNOWN ) {
920 $this->get_logger()->warning( 'Consent field is not on this form — opt-in accepted without provable consent', [
921 'plugin' => 'double-opt-in',
922 'form_id' => $formId,
923 'integration' => $this->type,
924 'consent_field' => $consentField,
925 ] );
926 do_action( 'f12_doi_consent_field_unknown', $formId, $consentField, $this->type );
927 }
928
929 /**
930 * Rate-Limiting: Check IP and email limits before creating OptIn.
931 */
932 $rateLimiter = new RateLimiter();
933 $ratSettings = CF7DoubleOptIn::getInstance()->getSettings();
934 $rateLimitIp = (int) ( $ratSettings['rate_limit_ip'] ?? 5 );
935 $rateLimitEmail = (int) ( $ratSettings['rate_limit_email'] ?? 3 );
936 $rateLimitWindow = (int) ( $ratSettings['rate_limit_window'] ?? 60 );
937
938 $ip = IPHelper::getIPAdress();
939 if ( ! $rateLimiter->isAllowed( 'ip', $ip, $rateLimitIp, $rateLimitWindow ) ) {
940 $this->get_logger()->warning( 'Rate limit exceeded for IP', [
941 'plugin' => 'double-opt-in',
942 'ip' => $ip,
943 'formId' => $formId,
944 ] );
945 do_action( 'f12_cf7_doubleoptin_rate_limited', 'ip', $ip, $formId );
946 ErrorNotification::store(
947 OptInError::fromCode( OptInError::RATE_LIMIT_IP, [ 'ip' => $ip, 'form_id' => $formId ] ),
948 $formId
949 );
950 return null;
951 }
952
953 if ( ! $rateLimiter->isAllowed( 'email', $recipient, $rateLimitEmail, $rateLimitWindow ) ) {
954 $this->get_logger()->warning( 'Rate limit exceeded for email', [
955 'plugin' => 'double-opt-in',
956 'email' => $recipient,
957 'formId' => $formId,
958 ] );
959 do_action( 'f12_cf7_doubleoptin_rate_limited', 'email', $recipient, $formId );
960 ErrorNotification::store(
961 OptInError::fromCode( OptInError::RATE_LIMIT_EMAIL, [ 'email' => $recipient, 'form_id' => $formId ] ),
962 $formId
963 );
964 return null;
965 }
966
967 /**
968 * Validate recipient (extensible via Pro plugin: MX check, unique
969 * email, etc.). Uses a lightweight proxy so filters that expect
970 * FormDataInterface::getFormId() keep working.
971 *
972 * IMPORTANT: `getFormType()` must be set to `$this->type` — that's
973 * the integration identifier ("elementor", "cf7" via legacy path,
974 * "avada" via legacy path). The Unique Email validator's
975 * resolver matches conditions on the (integration, form_id) pair;
976 * a proxy without getFormType returns '' and breaks the
977 * `selected` and `all_except` modes for every form that flows
978 * through this legacy path. User-reported 2026-05-13. Pinned by
979 * `LegacyFormDataProxyTest` in core tests.
980 */
981 $formDataProxy = new class( $formId, $this->type ) {
982 private int $formId;
983 private string $formType;
984 public function __construct( int $formId, string $formType ) {
985 $this->formId = $formId;
986 $this->formType = $formType;
987 }
988 public function getFormId(): int { return $this->formId; }
989 public function getFormType(): string { return $this->formType; }
990 };
991
992 $recipientValid = \apply_filters(
993 'f12_cf7_doubleoptin_validate_recipient',
994 true,
995 $recipient,
996 $formDataProxy
997 );
998
999 if ( $recipientValid !== true ) {
1000 $errorMsg = is_string( $recipientValid ) ? $recipientValid : '';
1001 $errorCode = OptInError::RECIPIENT_INVALID;
1002
1003 if ( $errorMsg === 'unique_email_rejected' ) {
1004 $errorCode = OptInError::UNIQUE_EMAIL_DUPLICATE;
1005 $errorMsg = OptInError::fromCode( OptInError::UNIQUE_EMAIL_DUPLICATE )->getMessage();
1006 }
1007
1008 $this->get_logger()->warning( 'Recipient validation failed', [
1009 'plugin' => 'double-opt-in',
1010 'email' => $recipient,
1011 'form_id' => $formId,
1012 'reason' => $errorMsg,
1013 ] );
1014
1015 $this->lastCreationError = new OptInError(
1016 $errorCode,
1017 ! empty( $errorMsg ) ? $errorMsg : OptInError::fromCode( OptInError::RECIPIENT_INVALID )->getMessage(),
1018 [ 'email' => $recipient, 'form_id' => $formId ]
1019 );
1020
1021 ErrorNotification::store( $this->lastCreationError, $formId );
1022 return null;
1023 }
1024
1025 /**
1026 * Consent-Snapshot aus den Form-Settings laden. Both the wording
1027 * shown to the user (`consent_text`) AND the form-field key the
1028 * user had to tick (`consent_field`) need to be persisted, or
1029 * the Consent Evidence panel reports "No acknowledgment field
1030 * configured — consent text is stored but not legally provable"
1031 * even when the admin wired up the checkbox. The modern
1032 * AbstractFormIntegration::buildOptInProperties() path includes
1033 * both; this legacy path (used by Elementor + the CF7/Avada
1034 * legacy compat shims) used to only carry consent_text.
1035 */
1036 $consentText = $consentSnapshot['text'];
1037 $consentField = $consentSnapshot['field'];
1038
1039 /**
1040 * Eigenschaften des OptIn-Objekts festlegen
1041 */
1042 $properties = $this->buildLegacyOptInProperties(
1043 $formId,
1044 $formHtml,
1045 $parameter,
1046 $files,
1047 $formParameter,
1048 $recipient,
1049 $consentText,
1050 $consentField
1051 );
1052
1053 $this->get_logger()->debug( 'OptIn properties created', [
1054 'plugin' => 'double-opt-in',
1055 'properties' => $properties,
1056 ] );
1057
1058 /**
1059 * OptIn-Objekt erstellen
1060 */
1061 $OptIn = new OptIn($this->get_logger(), $properties );
1062 $this->get_logger()->debug( 'OptIn object instantiated', [
1063 'plugin' => 'double-opt-in',
1064 'OptIn' => $OptIn,
1065 ] );
1066
1067 /**
1068 * OptIn speichern
1069 */
1070 if ( $OptIn->save() ) {
1071 $this->get_logger()->info( 'OptIn object saved successfully', [
1072 'plugin' => 'double-opt-in',
1073 'OptIn' => $OptIn,
1074 ] );
1075
1076 // Dispatch typed event for new event-driven architecture
1077 $this->dispatchOptInCreatedEvent( $OptIn, $formId );
1078
1079 return $OptIn;
1080 }
1081
1082 $this->get_logger()->error( 'Failed to save OptIn object', [
1083 'plugin' => 'double-opt-in',
1084 ] );
1085
1086 do_action( 'f12_cf7_doubleoptin_creation_failed', $formId, $recipient );
1087
1088 ErrorNotification::store(
1089 OptInError::fromCode( OptInError::SAVE_FAILED, [ 'form_id' => $formId ] ),
1090 $formId
1091 );
1092
1093 return null;
1094 }
1095
1096
1097 /**
1098 * Validate if the optin is enabled.
1099 */
1100 protected function isOptinEnabled( int $formId ): bool {
1101 // Disable optin sending while our own post-confirmation replay runs.
1102 // Not `isset( $_GET['optin'] )`: that is client input and let a
1103 // submitter switch the double opt-in off.
1104 if ( \Forge12\DoubleOptIn\Integration\AbstractFormIntegration::isReplaying() ) {
1105 $this->get_logger()->debug( 'Optin disabled during post-confirmation replay', [
1106 'plugin' => 'double-opt-in',
1107 'class' => __CLASS__,
1108 'method' => __METHOD__,
1109 ] );
1110 return false;
1111 }
1112
1113 $parameter = CF7DoubleOptIn::getInstance()->getParameter( $formId );
1114
1115 if ( (int) $parameter['enable'] != 1 ) {
1116 $this->get_logger()->debug( 'Optin not enabled in form parameter', [
1117 'plugin' => 'double-opt-in',
1118 'class' => __CLASS__,
1119 'method' => __METHOD__,
1120 'parameter' => $parameter,
1121 ] );
1122 return false;
1123 }
1124
1125 // Check the custom condition
1126 if ( isset( $parameter['conditions'] ) ) {
1127 $condition = sanitize_text_field( $parameter['conditions'] );
1128
1129 if ( ( $condition != 'disable' && $condition !== 'disabled' ) && ( ! isset( $_POST[ $condition ] ) || empty( $_POST[ $condition ] ) ) ) {
1130 $this->get_logger()->debug( 'Optin disabled due to unmet custom condition', [
1131 'plugin' => 'double-opt-in',
1132 'class' => __CLASS__,
1133 'method' => __METHOD__,
1134 'condition' => $condition,
1135 'post_keys' => array_keys( $_POST ),
1136 ] );
1137 return false;
1138 }
1139 }
1140
1141 $this->get_logger()->debug( 'Optin enabled', [
1142 'plugin' => 'double-opt-in',
1143 'class' => __CLASS__,
1144 'method' => __METHOD__,
1145 ] );
1146
1147 return true;
1148 }
1149
1150
1151 /**
1152 * Render validation feedback in the frontend via wp_footer.
1153 *
1154 * Fires the 'f12_cf7_doubleoptin_validation_feedback' action for customization,
1155 * and provides a default inline notice for non-confirmed statuses.
1156 *
1157 * @return void
1158 */
1159 public function renderValidationFeedback(): void {
1160 $status = self::getValidationStatus();
1161 if ( empty( $status ) ) {
1162 return;
1163 }
1164
1165 /**
1166 * Allow themes/plugins to handle the validation feedback display.
1167 *
1168 * @param string $status The validation status.
1169 *
1170 * @since 3.3.0
1171 */
1172 do_action( 'f12_cf7_doubleoptin_validation_feedback', $status );
1173 }
1174
1175 /**
1176 * Removes files associated with the optin parameter.
1177 *
1178 * This method checks if the optin parameter is set and
1179 * loads the OptIn object based on the hash value. If
1180 * the OptIn does not exist or no files are found,
1181 * the method will return. Otherwise, it will iterate
1182 * through the files and delete each one.
1183 *
1184 * @return void
1185 */
1186 public function removeFiles(): void {
1187 /**
1188 * Skip if the optin parameter is not set.
1189 */
1190 if ( ! isset( $_GET['optin'] ) ) {
1191 $this->get_logger()->debug( 'No optin parameter found, skipping file removal', [
1192 'plugin' => 'double-opt-in',
1193 'class' => __CLASS__,
1194 'method' => __METHOD__,
1195 ] );
1196 return;
1197 }
1198
1199 $hash = sanitize_text_field( wp_unslash( $_GET['optin'] ) );
1200
1201 /**
1202 * Load the OptIn
1203 */
1204 $OptIn = OptIn::get_by_hash( $hash );
1205
1206 /**
1207 * Skip if the OptIn does not exist
1208 */
1209 if ( null == $OptIn ) {
1210 $this->get_logger()->warning( 'OptIn not found, skipping file removal', [
1211 'plugin' => 'double-opt-in',
1212 'class' => __CLASS__,
1213 'method' => __METHOD__,
1214 'hash' => $hash,
1215 ] );
1216 return;
1217 }
1218
1219 /**
1220 * Only for this integration's own opt-in, only right after it was
1221 * confirmed in this request, and only when no follow-up adapter
1222 * owns the files. Previously any `?optin=` request — an expired
1223 * link, a second click, another integration's hash — deleted the
1224 * stored files, including ones a pending action still needed.
1225 */
1226 if ( ! $OptIn->isType( $this->type ) || self::$validationStatus !== 'confirmed' ) {
1227 return;
1228 }
1229 $coordinator = \Forge12\DoubleOptIn\FollowUp\FollowUpCoordinator::instance();
1230 if ( $coordinator !== null && $coordinator->adapterFor( $OptIn ) !== null ) {
1231 return;
1232 }
1233
1234 /**
1235 * Load all files
1236 */
1237 $files = maybe_unserialize( $OptIn->get_files() );
1238
1239 /**
1240 * Skip if no files found
1241 */
1242 if ( empty( $files ) ) {
1243 $this->get_logger()->debug( 'No files found in OptIn, skipping removal', [
1244 'plugin' => 'double-opt-in',
1245 'class' => __CLASS__,
1246 'method' => __METHOD__,
1247 'hash' => $hash,
1248 ] );
1249 return;
1250 }
1251
1252 foreach ( $files as $file ) {
1253 /**
1254 * Skip if empty
1255 */
1256 if ( empty( $file ) ) {
1257 continue;
1258 }
1259
1260 /**
1261 * Skip if no file found
1262 */
1263 if ( ! is_file( $file ) ) {
1264 $this->get_logger()->warning( 'File not found, skipping', [
1265 'plugin' => 'double-opt-in',
1266 'class' => __CLASS__,
1267 'method' => __METHOD__,
1268 'file' => $file,
1269 ] );
1270 continue;
1271 }
1272
1273 /**
1274 * Delete the file
1275 */
1276 if ( unlink( $file ) ) {
1277 $this->get_logger()->info( 'File deleted successfully', [
1278 'plugin' => 'double-opt-in',
1279 'class' => __CLASS__,
1280 'method' => __METHOD__,
1281 'file' => $file,
1282 ] );
1283 } else {
1284 $this->get_logger()->error( 'Could not delete file', [
1285 'plugin' => 'double-opt-in',
1286 'class' => __CLASS__,
1287 'method' => __METHOD__,
1288 'file' => $file,
1289 ] );
1290 }
1291 }
1292 }
1293
1294 /**
1295 * Get the privacy policy URL.
1296 *
1297 * Fallback chain: Plugin setting → WordPress Privacy Policy page → empty string.
1298 *
1299 * @return string
1300 */
1301 private function getPrivacyPolicyUrl(): string {
1302 $settings = CF7DoubleOptIn::getInstance()->getSettings();
1303 $pageId = (int) ( $settings['privacy_policy_page'] ?? 0 );
1304
1305 if ( $pageId > 0 ) {
1306 $url = get_permalink( $pageId );
1307 if ( $url ) {
1308 return $url;
1309 }
1310 }
1311
1312 // Fallback to WordPress privacy policy page
1313 $wpPrivacyPageId = (int) get_option( 'wp_page_for_privacy_policy', 0 );
1314 if ( $wpPrivacyPageId > 0 ) {
1315 $url = get_permalink( $wpPrivacyPageId );
1316 if ( $url ) {
1317 return $url;
1318 }
1319 }
1320
1321 return '';
1322 }
1323
1324 /**
1325 * Dispatch OptInCreatedEvent via the new event system.
1326 *
1327 * @param OptIn $optIn The created OptIn object.
1328 * @param int $formId The form ID.
1329 *
1330 * @since 4.0.0
1331 */
1332 protected function dispatchOptInCreatedEvent( OptIn $optIn, int $formId ): void {
1333 try {
1334 $container = Container::getInstance();
1335 if ( $container->has( EventDispatcherInterface::class ) ) {
1336 $dispatcher = $container->get( EventDispatcherInterface::class );
1337 $event = new OptInCreatedEvent(
1338 $optIn->get_id(),
1339 $formId,
1340 $this->type,
1341 $optIn->get_email(),
1342 $optIn->get_hash()
1343 );
1344 $dispatcher->dispatch( $event );
1345
1346 $this->get_logger()->debug( 'OptInCreatedEvent dispatched', [
1347 'plugin' => 'double-opt-in',
1348 'optin_id' => $optIn->get_id(),
1349 'form_id' => $formId,
1350 ] );
1351 }
1352 } catch ( \Exception $e ) {
1353 $this->get_logger()->warning( 'Failed to dispatch OptInCreatedEvent', [
1354 'plugin' => 'double-opt-in',
1355 'error' => $e->getMessage(),
1356 ] );
1357 }
1358 }
1359
1360 /**
1361 * Dispatch OptInConfirmedEvent via the new event system.
1362 *
1363 * @param OptIn $optIn The confirmed OptIn object.
1364 * @param string $hash The opt-in hash.
1365 *
1366 * @since 4.0.0
1367 */
1368 protected function dispatchOptInConfirmedEvent( OptIn $optIn, string $hash ): void {
1369 try {
1370 $container = Container::getInstance();
1371 if ( $container->has( EventDispatcherInterface::class ) ) {
1372 $dispatcher = $container->get( EventDispatcherInterface::class );
1373
1374 $formData = maybe_unserialize( $optIn->get_content() );
1375
1376 $event = new OptInConfirmedEvent(
1377 $optIn->get_id(),
1378 $hash,
1379 $optIn->get_email(),
1380 $optIn->get_ipaddr_confirmation(),
1381 (int) $optIn->get_cf_form_id(),
1382 is_array( $formData ) ? $formData : []
1383 );
1384 $dispatcher->dispatch( $event );
1385
1386 $this->get_logger()->debug( 'OptInConfirmedEvent dispatched', [
1387 'plugin' => 'double-opt-in',
1388 'optin_id' => $optIn->get_id(),
1389 'hash' => $hash,
1390 ] );
1391 }
1392 } catch ( \Exception $e ) {
1393 $this->get_logger()->warning( 'Failed to dispatch OptInConfirmedEvent', [
1394 'plugin' => 'double-opt-in',
1395 'error' => $e->getMessage(),
1396 ] );
1397 }
1398 }
1399
1400 /**
1401 * Load the consent snapshot (wording + acceptance field) for a form.
1402 *
1403 * The authoritative source is `FormSettingsService`, not the
1404 * `$formParameter` array this legacy path carries — the settings the
1405 * admin edits in the React UI land in post_meta and only some of them
1406 * make it into `getParameter()`. `$formParameter` is used purely as a
1407 * fallback for the case where the container is not available.
1408 *
1409 * Read once per submission and used twice: by the consent gate before
1410 * the opt-in is created, and by the snapshot that is persisted with
1411 * it. They must agree — a gate that reads a different field than the
1412 * record stores would produce a proof of the wrong checkbox.
1413 *
1414 * @param int $formId The form being submitted.
1415 * @param array $formParameter The legacy form-parameter array.
1416 *
1417 * @return array{text:string,field:string}
1418 */
1419 protected function loadConsentSnapshot( int $formId, array $formParameter = array() ): array {
1420 $snapshot = [
1421 'text' => (string) ( $formParameter['consent_text'] ?? '' ),
1422 'field' => (string) ( $formParameter['consent_field'] ?? '' ),
1423 ];
1424
1425 try {
1426 $container = Container::getInstance();
1427 $settingsService = $container->get( \Forge12\DoubleOptIn\FormSettings\FormSettingsService::class );
1428 $formSettings = $settingsService->getSettings( $formId );
1429
1430 $snapshot['text'] = (string) ( $formSettings->consentText ?? '' );
1431 $snapshot['field'] = (string) ( $formSettings->consentField ?? '' );
1432 } catch ( \Throwable $e ) {
1433 $this->get_logger()->debug( 'Could not load consent snapshot from FormSettings', [
1434 'plugin' => 'double-opt-in',
1435 'form_id' => $formId,
1436 'error' => $e->getMessage(),
1437 ] );
1438 }
1439
1440 return $snapshot;
1441 }
1442
1443 /**
1444 * The field names this form declares, for the consent gate.
1445 *
1446 * An unticked checkbox never reaches the server, so the payload alone
1447 * cannot distinguish "the visitor left the box alone" from "the
1448 * configured field does not exist any more". The form's own
1449 * definition can, and every integration exposes it through
1450 * `FormIntegrationInterface::getFormFields()`.
1451 *
1452 * A shim may pass the inventory in directly — Elementor does, because
1453 * its `Form_Record` lists every declared field including the empty
1454 * ones, while the composite form ID its `getFormFields()` wants
1455 * (`{postId}_{widgetId}`) is not what this legacy path carries.
1456 * Otherwise we ask the registry for the integration behind
1457 * `$this->type`.
1458 *
1459 * Returning an empty array is a valid answer and means "unknown" —
1460 * the gate then rejects nothing it cannot prove.
1461 *
1462 * @param int $formId The form being submitted.
1463 * @param array $explicit Inventory supplied by the caller, if any.
1464 *
1465 * @return array<int,string>
1466 */
1467 protected function resolveKnownFieldNames( int $formId, array $explicit = array() ): array {
1468 if ( $explicit !== array() ) {
1469 return ConsentGate::normalizeFieldNames( $explicit );
1470 }
1471
1472 if ( $this->type === '' || ! class_exists( FormIntegrationRegistry::class ) ) {
1473 return array();
1474 }
1475
1476 try {
1477 $integration = FormIntegrationRegistry::getInstance()->get( $this->type );
1478 if ( $integration === null ) {
1479 return array();
1480 }
1481
1482 return ConsentGate::normalizeFieldNames( $integration->getFormFields( $formId ) );
1483 } catch ( \Throwable $e ) {
1484 $this->get_logger()->warning( 'Could not read the form field inventory for the consent gate', [
1485 'plugin' => 'double-opt-in',
1486 'form_id' => $formId,
1487 'error' => $e->getMessage(),
1488 ] );
1489
1490 return array();
1491 }
1492 }
1493
1494 /**
1495 * Assemble the legacy OptIn properties array.
1496 *
1497 * Extracted from {@see maybeCreateOptIn()} so the column inventory
1498 * is unit-testable without standing up the whole submit pipeline
1499 * (rate limiter, FormSettingsService, $wpdb save, etc.). The
1500 * modern {@see \Forge12\DoubleOptIn\Integration\AbstractFormIntegration::buildOptInProperties()}
1501 * has a sibling helper; the two MUST stay in sync — every column
1502 * the modern path snapshots, this legacy path also has to snapshot,
1503 * otherwise integrations on the legacy compat path (Elementor +
1504 * the CF7/Avada legacy shims) lose the column silently.
1505 *
1506 * The 2026-05-13 incident this guards against: `consent_field`
1507 * was missing here, so Elementor opt-ins shipped with an empty
1508 * acknowledgment field even when the admin configured one. The
1509 * Consent Evidence panel then rendered "No acknowledgment field
1510 * configured — consent text is stored but not legally provable"
1511 * on a record that was, in fact, ticked through a configured
1512 * checkbox.
1513 *
1514 * @return array<string, mixed>
1515 */
1516 protected function buildLegacyOptInProperties(
1517 int $formId,
1518 string $formHtml,
1519 array $parameter,
1520 array $files,
1521 array $formParameter,
1522 string $recipient,
1523 string $consentText,
1524 string $consentField
1525 ): array {
1526 return [
1527 'cf_form_id' => $formId,
1528 'doubleoptin' => 0,
1529 'createtime' => time(),
1530 'content' => maybe_serialize( $parameter ),
1531 'files' => maybe_serialize( $files ),
1532 'ipaddr_register' => IPHelper::getIPAdress(),
1533 'category' => (int) ( $formParameter['category'] ?? 0 ),
1534 'form' => $formHtml,
1535 'email' => $recipient,
1536 'consent_text' => $consentText,
1537 'consent_field' => $consentField,
1538 ];
1539 }
1540 }