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.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 3.1.0 All 34 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.5.0, at compatibility/OptInFrontend.class.php

1,499 lines 46.6 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 * 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 /**
640 * Enable / Disable default mail.
641 *
642 * @param bool $status Enable (true) or disable (false) the default mail.
643 * @param int $postId The ID of the Post / Form.
644 *
645 * @since 2.3.3
646 */
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 ] );
653 return false;
654 }
655
656 $this->get_logger()->debug( 'Triggering before_send_default_mail hook', [
657 'plugin' => 'double-opt-in',
658 'optin_id' => $OptIn->get_id(),
659 ] );
660 do_action( 'f12_cf7_doubleoptin_before_send_default_mail', $OptIn );
661
662 $this->get_logger()->info( 'Triggering send_default_mail hook', [
663 'plugin' => 'double-opt-in',
664 'optin_id' => $OptIn->get_id(),
665 ] );
666 do_action( 'f12_cf7_doubleoptin_trigger_default_mail', $OptIn );
667
668 $this->get_logger()->debug( 'Triggering after_send_default_mail hook', [
669 'plugin' => 'double-opt-in',
670 'optin_id' => $OptIn->get_id(),
671 ] );
672 do_action( 'f12_cf7_doubleoptin_after_send_default_mail', $OptIn );
673
674 return true;
675 }
676
677
678
679 /**
680 * Store the files
681 *
682 * @param array $inFiles
683 *
684 * @return array
685 */
686 private function maybeStoreFiles( array $inFiles ): array {
687 $this->get_logger()->debug( 'maybeStoreFiles called', [
688 'plugin' => 'double-opt-in',
689 'class' => __CLASS__,
690 'method' => __METHOD__,
691 'files_in' => $inFiles,
692 ] );
693
694 $outFiles = [];
695
696 if ( empty( $inFiles ) ) {
697 $this->get_logger()->debug( 'No files provided to maybeStoreFiles', [
698 'plugin' => 'double-opt-in',
699 ] );
700 return $outFiles;
701 }
702
703 foreach ( $inFiles as $key => $subfiles ) {
704 foreach ( $subfiles as $file ) {
705 $newFile = $this->copyAndRenameFile( $file );
706 if ( $newFile ) {
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 ] );
718 }
719 }
720 }
721
722 $this->get_logger()->debug( 'maybeStoreFiles completed', [
723 'plugin' => 'double-opt-in',
724 'files_out' => $outFiles,
725 ] );
726
727 return $outFiles;
728 }
729
730
731 /**
732 * Copy and rename a file
733 *
734 * @param string $file The path to the file to copy and rename
735 *
736 * @return string|null The path to the copied and renamed file, or null if the copy operation failed
737 */
738 private function copyAndRenameFile( string $file ): ?string {
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;
749 $newFile[ count( $newFile ) - 1 ] = $name;
750 $newFile = implode( "/", $newFile );
751
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 ] );
758 return $newFile;
759 }
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
767 return null;
768 }
769
770
771 /**
772 * Create the OptIn
773 *
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.
788 *
789 * @return OptIn|null
790 */
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
801 /**
802 * Mögliche Dateien speichern, um sie während der Opt-In-Bestätigung vorzuhalten
803 */
804 $files = $this->maybeStoreFiles( $files );
805 $this->get_logger()->debug( 'Files checked and possibly stored', [
806 'plugin' => 'double-opt-in',
807 'files' => $files,
808 ] );
809
810 /**
811 * Filter, um die Parameter vor dem Speichern in der Datenbank zu manipulieren
812 */
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 ] );
818
819 /**
820 * Globale Einstellungen für das Formular abrufen
821 */
822 $formParameter = CF7DoubleOptIn::getInstance()->getParameter( $formId );
823
824 /**
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.
847 *
848 * Until 5.4.0 this path had no gate at all. Everything that does
849 * not extend AbstractFormIntegration comes through here —
850 * Elementor, plus the CF7 and Avada legacy shims — so on those
851 * forms the acceptance checkbox was stored as consent proof
852 * without ever having been enforced. The banner in the admin UI
853 * said as much since 5.3.2; this closes it.
854 *
855 * ConsentGate::FIELD_UNKNOWN never rejects. See the class for why
856 * that matters: a stale `consent_field` must not take a site's
857 * registrations offline.
858 */
859 $consentSnapshot = $this->loadConsentSnapshot( $formId, $formParameter );
860 $consentField = $consentSnapshot['field'];
861 $consentGate = ConsentGate::evaluate(
862 $consentField,
863 $parameter,
864 $this->resolveKnownFieldNames( $formId, $knownFields )
865 );
866
867 if ( $consentGate === ConsentGate::NOT_GIVEN && ! ConsentGate::isEnforced( $formId, $this->type ) ) {
868 $this->get_logger()->warning( 'Consent gate disabled by filter — accepting an unconfirmed submission', [
869 'plugin' => 'double-opt-in',
870 'form_id' => $formId,
871 'integration' => $this->type,
872 'consent_field' => $consentField,
873 ] );
874 $consentGate = ConsentGate::PASSED;
875 }
876
877 if ( $consentGate === ConsentGate::NOT_GIVEN ) {
878 $this->get_logger()->info( 'Consent acceptance not given, rejecting OptIn', [
879 'plugin' => 'double-opt-in',
880 'form_id' => $formId,
881 'integration' => $this->type,
882 'consent_field' => $consentField,
883 ] );
884 do_action( 'f12_cf7_doubleoptin_consent_not_given', $formId, $consentField );
885 ErrorNotification::store(
886 OptInError::fromCode(
887 OptInError::CONSENT_NOT_GIVEN,
888 [ 'form_id' => $formId, 'consent_field' => $consentField ]
889 ),
890 $formId
891 );
892 return null;
893 }
894
895 if ( $consentGate === ConsentGate::FIELD_UNKNOWN ) {
896 $this->get_logger()->warning( 'Consent field is not on this form — opt-in accepted without provable consent', [
897 'plugin' => 'double-opt-in',
898 'form_id' => $formId,
899 'integration' => $this->type,
900 'consent_field' => $consentField,
901 ] );
902 do_action( 'f12_doi_consent_field_unknown', $formId, $consentField, $this->type );
903 }
904
905 /**
906 * Rate-Limiting: Check IP and email limits before creating OptIn.
907 */
908 $rateLimiter = new RateLimiter();
909 $ratSettings = CF7DoubleOptIn::getInstance()->getSettings();
910 $rateLimitIp = (int) ( $ratSettings['rate_limit_ip'] ?? 5 );
911 $rateLimitEmail = (int) ( $ratSettings['rate_limit_email'] ?? 3 );
912 $rateLimitWindow = (int) ( $ratSettings['rate_limit_window'] ?? 60 );
913
914 $ip = IPHelper::getIPAdress();
915 if ( ! $rateLimiter->isAllowed( 'ip', $ip, $rateLimitIp, $rateLimitWindow ) ) {
916 $this->get_logger()->warning( 'Rate limit exceeded for IP', [
917 'plugin' => 'double-opt-in',
918 'ip' => $ip,
919 'formId' => $formId,
920 ] );
921 do_action( 'f12_cf7_doubleoptin_rate_limited', 'ip', $ip, $formId );
922 ErrorNotification::store(
923 OptInError::fromCode( OptInError::RATE_LIMIT_IP, [ 'ip' => $ip, 'form_id' => $formId ] ),
924 $formId
925 );
926 return null;
927 }
928
929 if ( ! $rateLimiter->isAllowed( 'email', $recipient, $rateLimitEmail, $rateLimitWindow ) ) {
930 $this->get_logger()->warning( 'Rate limit exceeded for email', [
931 'plugin' => 'double-opt-in',
932 'email' => $recipient,
933 'formId' => $formId,
934 ] );
935 do_action( 'f12_cf7_doubleoptin_rate_limited', 'email', $recipient, $formId );
936 ErrorNotification::store(
937 OptInError::fromCode( OptInError::RATE_LIMIT_EMAIL, [ 'email' => $recipient, 'form_id' => $formId ] ),
938 $formId
939 );
940 return null;
941 }
942
943 /**
944 * Validate recipient (extensible via Pro plugin: MX check, unique
945 * email, etc.). Uses a lightweight proxy so filters that expect
946 * FormDataInterface::getFormId() keep working.
947 *
948 * IMPORTANT: `getFormType()` must be set to `$this->type` — that's
949 * the integration identifier ("elementor", "cf7" via legacy path,
950 * "avada" via legacy path). The Unique Email validator's
951 * resolver matches conditions on the (integration, form_id) pair;
952 * a proxy without getFormType returns '' and breaks the
953 * `selected` and `all_except` modes for every form that flows
954 * through this legacy path. User-reported 2026-05-13. Pinned by
955 * `LegacyFormDataProxyTest` in core tests.
956 */
957 $formDataProxy = new class( $formId, $this->type ) {
958 private int $formId;
959 private string $formType;
960 public function __construct( int $formId, string $formType ) {
961 $this->formId = $formId;
962 $this->formType = $formType;
963 }
964 public function getFormId(): int { return $this->formId; }
965 public function getFormType(): string { return $this->formType; }
966 };
967
968 $recipientValid = \apply_filters(
969 'f12_cf7_doubleoptin_validate_recipient',
970 true,
971 $recipient,
972 $formDataProxy
973 );
974
975 if ( $recipientValid !== true ) {
976 $errorMsg = is_string( $recipientValid ) ? $recipientValid : '';
977 $errorCode = OptInError::RECIPIENT_INVALID;
978
979 if ( $errorMsg === 'unique_email_rejected' ) {
980 $errorCode = OptInError::UNIQUE_EMAIL_DUPLICATE;
981 $errorMsg = OptInError::fromCode( OptInError::UNIQUE_EMAIL_DUPLICATE )->getMessage();
982 }
983
984 $this->get_logger()->warning( 'Recipient validation failed', [
985 'plugin' => 'double-opt-in',
986 'email' => $recipient,
987 'form_id' => $formId,
988 'reason' => $errorMsg,
989 ] );
990
991 $this->lastCreationError = new OptInError(
992 $errorCode,
993 ! empty( $errorMsg ) ? $errorMsg : OptInError::fromCode( OptInError::RECIPIENT_INVALID )->getMessage(),
994 [ 'email' => $recipient, 'form_id' => $formId ]
995 );
996
997 ErrorNotification::store( $this->lastCreationError, $formId );
998 return null;
999 }
1000
1001 /**
1002 * Consent-Snapshot aus den Form-Settings laden. Both the wording
1003 * shown to the user (`consent_text`) AND the form-field key the
1004 * user had to tick (`consent_field`) need to be persisted, or
1005 * the Consent Evidence panel reports "No acknowledgment field
1006 * configured — consent text is stored but not legally provable"
1007 * even when the admin wired up the checkbox. The modern
1008 * AbstractFormIntegration::buildOptInProperties() path includes
1009 * both; this legacy path (used by Elementor + the CF7/Avada
1010 * legacy compat shims) used to only carry consent_text.
1011 */
1012 $consentText = $consentSnapshot['text'];
1013 $consentField = $consentSnapshot['field'];
1014
1015 /**
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 */
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
1055 return $OptIn;
1056 }
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
1069 return null;
1070 }
1071
1072
1073 /**
1074 * Validate if the optin is enabled.
1075 */
1076 protected function isOptinEnabled( int $formId ): bool {
1077 // Disable optin sending if the optin flag is set.
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 ] );
1084 return false;
1085 }
1086
1087 $parameter = CF7DoubleOptIn::getInstance()->getParameter( $formId );
1088
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 ] );
1096 return false;
1097 }
1098
1099 // Check the custom condition
1100 if ( isset( $parameter['conditions'] ) ) {
1101 $condition = sanitize_text_field( $parameter['conditions'] );
1102
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 ] );
1111 return false;
1112 }
1113 }
1114
1115 $this->get_logger()->debug( 'Optin enabled', [
1116 'plugin' => 'double-opt-in',
1117 'class' => __CLASS__,
1118 'method' => __METHOD__,
1119 ] );
1120
1121 return true;
1122 }
1123
1124
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 /**
1150 * Removes files associated with the optin parameter.
1151 *
1152 * This method checks if the optin parameter is set and
1153 * loads the OptIn object based on the hash value. If
1154 * the OptIn does not exist or no files are found,
1155 * the method will return. Otherwise, it will iterate
1156 * through the files and delete each one.
1157 *
1158 * @return void
1159 */
1160 public function removeFiles(): void {
1161 /**
1162 * Skip if the optin parameter is not set.
1163 */
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 ] );
1170 return;
1171 }
1172
1173 $hash = sanitize_text_field( wp_unslash( $_GET['optin'] ) );
1174
1175 /**
1176 * Load the OptIn
1177 */
1178 $OptIn = OptIn::get_by_hash( $hash );
1179
1180 /**
1181 * Skip if the OptIn does not exist
1182 */
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 ] );
1190 return;
1191 }
1192
1193 /**
1194 * Load all files
1195 */
1196 $files = maybe_unserialize( $OptIn->get_files() );
1197
1198 /**
1199 * Skip if no files found
1200 */
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 ] );
1208 return;
1209 }
1210
1211 foreach ( $files as $file ) {
1212 /**
1213 * Skip if empty
1214 */
1215 if ( empty( $file ) ) {
1216 continue;
1217 }
1218
1219 /**
1220 * Skip if no file found
1221 */
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 ] );
1229 continue;
1230 }
1231
1232 /**
1233 * Delete the file
1234 */
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 ] );
1249 }
1250 }
1251 }
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 }
1499 }