PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.6.2
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.6.2
5.6.2 5.6.3 5.6.1 5.6.0 5.5.0 5.4.0 5.3.2 5.3.1 5.1.6 5.1.5 trunk 2.1.5 2.11 2.12 2.13 2.15 3.0.0 3.0.1 3.0.2 3.0.3 3.0.5 3.0.51 3.0.60 3.0.61 3.0.62 All 38 releases
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.2, at compatibility/OptInFrontend.class.php

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