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
← All changes | compatibility/OptInFrontend.class.php +1567 -1353 5.3.1 → 5.6.2 View file →
@@ -1,1354 +1,1568 @@
1 -<?php
2 -
3 -namespace forge12\contactform7\CF7DoubleOptIn;
4 -
5 -
6 -use Forge12\DoubleOptIn\Container\Container;
7 -use Forge12\DoubleOptIn\EmailTemplates\PlaceholderMapper;
8 -use Forge12\DoubleOptIn\EventSystem\EventDispatcherInterface;
9 -use Forge12\DoubleOptIn\Events\Lifecycle\OptInConfirmedEvent;
10 -use Forge12\DoubleOptIn\Events\Lifecycle\OptInCreatedEvent;
11 -use Forge12\DoubleOptIn\Frontend\ErrorNotification;
12 -use Forge12\DoubleOptIn\Integration\OptInError;
13 -use Forge12\DoubleOptIn\Service\RateLimiter;
14 -use Forge12\Shared\Logger;
15 -use Forge12\Shared\LoggerInterface;
16 -
17 -if ( ! defined( 'ABSPATH' ) ) {
18 - exit;
19 -}
20 -
21 -/**
22 - * @deprecated 4.0.0 Use \Forge12\DoubleOptIn\Integration\AbstractFormIntegration instead.
23 - *
24 - * This class is maintained for backward compatibility only.
25 - * New integrations should extend AbstractFormIntegration and implement FormIntegrationInterface.
26 - *
27 - * @see \Forge12\DoubleOptIn\Integration\AbstractFormIntegration
28 - * @see \Forge12\DoubleOptIn\Integration\FormIntegrationInterface
29 - */
30 -abstract class OptInFrontend {
31 - /**
32 - * The Type of the OptIn Form System
33 - *
34 - * @var string
35 - */
36 - protected string $type = '';
37 -
38 - private LoggerInterface $logger;
39 -
40 - /**
41 - * Stored hCaptcha CF7 instance for restore after mail send.
42 - *
43 - * @var object|null
44 - */
45 - private $hcaptchaCf7Instance = null;
46 -
47 - /**
48 - * Stored hCaptcha CF7 filter priority for restore after mail send.
49 - *
50 - * @var int
51 - */
52 - private int $hcaptchaCf7Priority = 20;
53 -
54 - /**
55 - * Validation status from the last validateOptIn() call.
56 - *
57 - * @var string
58 - */
59 - private static string $validationStatus = '';
60 -
61 - /**
62 - * Last error from maybeCreateOptIn(), if any.
63 - *
64 - * @var OptInError|null
65 - */
66 - protected ?OptInError $lastCreationError = null;
67 -
68 - /**
69 - * Get the validation status from the last validateOptIn() call.
70 - *
71 - * @return string One of: '', 'confirmed', 'already_confirmed', 'expired', 'not_found'.
72 - */
73 - public static function getValidationStatus(): string {
74 - return self::$validationStatus;
75 - }
76 -
77 - /**
78 - * Set the validation status.
79 - *
80 - * @param string $status The validation status.
81 - */
82 - private function setValidationStatus( string $status ): void {
83 - self::$validationStatus = $status;
84 - }
85 -
86 - /**
87 - * Constructor for the class.
88 - *
89 - * This constructor registers the necessary actions to be performed,
90 - * by hooking methods of the current class, for the following events:
91 - * - 'f12_cf7_doubleoptin_before_send_default_mail'
92 - * - 'f12_cf7_doubleoptin_after_send_default_mail'
93 - * - 'f12_cf7_doubleoptin_trigger_default_mail'
94 - * - 'shutdown'
95 - *
96 - * @return void
97 - */
98 - public function __construct( LoggerInterface $logger, string $type ) {
99 - $this->logger = $logger;
100 - $this->type = $type;
101 -
102 - $this->get_logger()->debug( 'Base integration constructor called', [
103 - 'plugin' => 'double-opt-in',
104 - 'class' => __CLASS__,
105 - 'method' => __METHOD__,
106 - 'type' => $type,
107 - ] );
108 -
109 - add_action( 'f12_cf7_doubleoptin_before_send_default_mail', [ $this, 'beforeSendDefaultMail' ], 10, 1 );
110 - $this->get_logger()->debug( 'Hook f12_cf7_doubleoptin_before_send_default_mail registered', [
111 - 'plugin' => 'double-opt-in',
112 - ] );
113 -
114 - add_action( 'f12_cf7_doubleoptin_after_send_default_mail', [ $this, 'afterSendDefaultMail' ], 10, 1 );
115 - $this->get_logger()->debug( 'Hook f12_cf7_doubleoptin_after_send_default_mail registered', [
116 - 'plugin' => 'double-opt-in',
117 - ] );
118 -
119 - add_action( 'f12_cf7_doubleoptin_trigger_default_mail', [ $this, 'sendDefaultMail' ], 10, 1 );
120 - $this->get_logger()->debug( 'Hook f12_cf7_doubleoptin_trigger_default_mail registered', [
121 - 'plugin' => 'double-opt-in',
122 - ] );
123 -
124 - add_action( 'shutdown', [ $this, 'removeFiles' ] );
125 - $this->get_logger()->debug( 'Hook shutdown registered for removeFiles', [
126 - 'plugin' => 'double-opt-in',
127 - ] );
128 -
129 - add_action( 'init', [ $this, 'validateOptIn' ] );
130 - $this->get_logger()->debug( 'Hook init registered for validateOptIn', [
131 - 'plugin' => 'double-opt-in',
132 - ] );
133 -
134 - add_action( 'wp_footer', [ $this, 'renderValidationFeedback' ] );
135 -
136 - // Default feedback handler for validation statuses
137 - add_action( 'f12_cf7_doubleoptin_validation_feedback', function ( $status ) {
138 - if ( $status === 'confirmed' ) {
139 - return;
140 - }
141 -
142 - $messages = [
143 - 'already_confirmed' => __( 'Your opt-in has already been confirmed.', 'double-opt-in' ),
144 - 'expired' => __( 'This confirmation link has expired. Please submit the form again.', 'double-opt-in' ),
145 - 'not_found' => __( 'This confirmation link is invalid.', 'double-opt-in' ),
146 - ];
147 -
148 - $message = $messages[ $status ] ?? '';
149 - if ( ! empty( $message ) ) {
150 - echo '<div class="doi-validation-notice doi-notice-' . esc_attr( $status ) . '">';
151 - echo '<p>' . esc_html( $message ) . '</p>';
152 - echo '</div>';
153 - }
154 - }, 10, 1 );
155 -
156 - add_filter( 'f12_cf7_doubleoptin_get_recipient_' . $this->type, [ $this, 'getRecipient' ], 10, 3 );
157 - $this->get_logger()->debug( 'Filter f12_cf7_doubleoptin_get_recipient_' . $this->type . ' registered', [
158 - 'plugin' => 'double-opt-in',
159 - ] );
160 - }
161 -
162 -
163 - public function get_logger() {
164 - return $this->logger;
165 - }
166 -
167 - /**
168 - * Retrieves the recipient for a given recipient name, form parameters, and post parameters.
169 - *
170 - * @param string $recipient The name of the recipient to retrieve.
171 - * @param array $formParameter The array of form parameters.
172 - * @param array $postParameter The array of post parameters.
173 - *
174 - * @return string The recipient for the given parameters.
175 - */
176 - abstract public function getRecipient( string $recipient, array $formParameter, array $postParameter ): string;
177 -
178 - /**
179 - * Sends a default mail for the given OptIn object.
180 - *
181 - * This method is declared as abstract, meaning that it must be implemented
182 - * by any child class that extends the current class. The method takes
183 - * a single parameter, $OptIn, of type OptIn. This parameter represents
184 - * the OptIn object for which the default mail needs to be sent.
185 - *
186 - * This method should be overridden by child classes to define the specific
187 - * logic for sending the default mail for the given OptIn object.
188 - *
189 - * Note that the implementation details of this method vary depending on the
190 - * specific subclass. Therefore, the implementation code is not provided here.
191 - *
192 - * @param OptIn $OptIn The OptIn object for which the default mail needs to be sent.
193 - *
194 - * @return void
195 - */
196 - abstract public function sendDefaultMail( OptIn $OptIn ): void;
197 -
198 - public function disable_contact_form_7_captcha($is_active){
199 - if($is_active){
200 - $this->get_logger()->debug( 'Forge12 CF7Captcha filters and actions removed', ['plugin' => 'double-opt-in']);
201 - return false;
202 - }
203 - $this->get_logger()->debug( 'Forge12 CF7Captcha filters and actions removed', ['plugin' => 'double-opt-in']);
204 - return $is_active;
205 - }
206 -
207 - /**
208 - * This method is used to perform necessary actions before sending the default mail.
209 - *
210 - * This method removes the filter for the forge12 spam captcha if the class
211 - * '\forge12\contactform7\CF7Captcha\TimerValidatorCF7' exists. It removes the filters 'wpcf7_spam' for the methods
212 - * '\forge12\contactform7\CF7Captcha::isSpam' and
213 - * '\forge12\contactform7\CF7Captcha\CF7IPLog::isSpam', and also removes the action 'wpcf7_mail_sent' for the
214 - * method
215 - * '\forge12\contactform7\CF7Captcha\CF7IPLog::doLogIP', if these filters and action exist.
216 - *
217 - * Additionally, this method removes the filter 'wpcf7_spam' for the method 'wpcf7_recaptcha_verify_response' with
218 - * a priority of 9.
219 - *
220 - * @return void
221 - */
222 - public function beforeSendDefaultMail() {
223 - $this->get_logger()->debug( 'beforeSendDefaultMail called', [
224 - 'plugin' => 'double-opt-in',
225 - 'class' => __CLASS__,
226 - 'method' => __METHOD__,
227 - ] );
228 -
229 - // Remove the filter for the Forge12 spam captcha
230 - add_filter('f12_cf7_captcha_is_installed_cf7', [$this, 'disable_contact_form_7_captcha'], 999, 1);
231 - $this->get_logger()->debug( 'Forge12 CF7Captcha filters and actions removed', [
232 - 'plugin' => 'double-opt-in',
233 - ] );
234 -
235 - // Remove the filter for the Google reCAPTCHA validation
236 - remove_filter( 'wpcf7_spam', 'wpcf7_recaptcha_verify_response', 9 );
237 -
238 - $this->get_logger()->debug( 'Google reCAPTCHA filter removed', [
239 - 'plugin' => 'double-opt-in',
240 - ] );
241 -
242 - // Remove hCaptcha validation filter
243 - $this->removeHCaptchaFilter();
244 - }
245 -
246 -
247 - /**
248 - * Performs actions after sending the default mail.
249 - *
250 - * In this method, two filters and an action are added to the WordPress hooks system.
251 - *
252 - * The first filter is added with the hook name 'wpcf7_spam' and the callback
253 - * function is set to 'wpcf7_recaptcha_verify_response'. The priority is set to 9
254 - * and the number of accepted arguments for the callback function is 2.
255 - * This filter is added only if the function 'wpcf7_recaptcha_verifiy_response'
256 - * exists.
257 - *
258 - * The second filter is added with the hook name 'wpcf7_spam' and the callback
259 - * function is set to '\forge12\contactform7\CF7Captcha\TimerValidatorCF7::isSpam'.
260 - * The priority is set to 100 and the number of accepted arguments for the callback
261 - * function is 2. This filter is added only if the class '\forge12\contactform7\CF7Captcha\TimerValidatorCF7'
262 - * exists.
263 - *
264 - * The third filter is added with the hook name 'wpcf7_spam' and the callback
265 - * function is set to '\forge12\contactform7\CF7Captcha\CF7IPLog::isSpam'.
266 - * The priority is set to 100 and the number of accepted arguments for the callback
267 - * function is 2. This filter is added only if the class '\forge12\contactform7\CF7Captcha\CF7IPLog'
268 - * exists.
269 - *
270 - * An action is added with the hook name 'wpcf7_mail_sent' and the callback function
271 - * is set to '\forge12\contactform7\CF7Captcha\CF7IPLog::doLogIP'. The priority is set
272 - * to 100 and the number of accepted arguments for the callback function is 1.
273 - * This action is added only if the class '\forge12\contactform7\CF7Captcha\CF7IPLog' exists.
274 - *
275 - * @return void
276 - */
277 - public function afterSendDefaultMail() {
278 - $this->get_logger()->debug( 'afterSendDefaultMail called', [
279 - 'plugin' => 'double-opt-in',
280 - 'class' => __CLASS__,
281 - 'method' => __METHOD__,
282 - ] );
283 -
284 - // re-add the filter to ensure for all other forms the reCAPTCHA is used
285 - if ( function_exists( 'wpcf7_recaptcha_verify_response' ) ) {
286 - add_filter( 'wpcf7_spam', 'wpcf7_recaptcha_verify_response', 9, 2 );
287 - $this->get_logger()->debug( 'Google reCAPTCHA filter re-added', [
288 - 'plugin' => 'double-opt-in',
289 - ] );
290 - }
291 -
292 - // re-add the filter for the Forge12 spam captcha
293 - if ( class_exists( '\forge12\contactform7\CF7Captcha\TimerValidatorCF7' ) ) {
294 - add_filter( 'wpcf7_spam', '\forge12\contactform7\CF7Captcha\TimerValidatorCF7::isSpam', 100, 2 );
295 - add_filter( 'wpcf7_spam', '\forge12\contactform7\CF7Captcha\CF7IPLog::isSpam', 100, 2 );
296 - add_action( 'wpcf7_mail_sent', '\forge12\contactform7\CF7Captcha\CF7IPLog::doLogIP', 100, 1 );
297 -
298 - $this->get_logger()->debug( 'Forge12 CF7Captcha filters and actions re-added', [
299 - 'plugin' => 'double-opt-in',
300 - ] );
301 - }
302 -
303 - // re-add hCaptcha validation filter
304 - $this->restoreHCaptchaFilter();
305 - }
306 -
307 - /**
308 - * Remove hCaptcha CF7 validation filter and store the instance for later restore.
309 - *
310 - * @return void
311 - */
312 - private function removeHCaptchaFilter(): void {
313 - if ( ! class_exists( '\HCaptcha\CF7\CF7' ) ) {
314 - return;
315 - }
316 -
317 - global $wp_filter;
318 -
319 - if ( ! isset( $wp_filter['wpcf7_validate'] ) ) {
320 - return;
321 - }
322 -
323 - foreach ( $wp_filter['wpcf7_validate']->callbacks as $priority => $hooks ) {
324 - foreach ( $hooks as $key => $hook ) {
325 - if ( is_array( $hook['function'] ) && $hook['function'][0] instanceof \HCaptcha\CF7\CF7 ) {
326 - $this->hcaptchaCf7Instance = $hook['function'][0];
327 - $this->hcaptchaCf7Priority = $priority;
328 - remove_filter( 'wpcf7_validate', $hook['function'], $priority );
329 - $this->get_logger()->debug( 'hCaptcha CF7 validation filter removed', [
330 - 'plugin' => 'double-opt-in',
331 - ] );
332 -
333 - return;
334 - }
335 - }
336 - }
337 - }
338 -
339 - /**
340 - * Re-add hCaptcha CF7 validation filter if it was previously removed.
341 - *
342 - * @return void
343 - */
344 - private function restoreHCaptchaFilter(): void {
345 - if ( isset( $this->hcaptchaCf7Instance ) ) {
346 - add_filter( 'wpcf7_validate', [ $this->hcaptchaCf7Instance, 'verify_hcaptcha' ], $this->hcaptchaCf7Priority, 2 );
347 - $this->get_logger()->debug( 'hCaptcha CF7 validation filter re-added', [
348 - 'plugin' => 'double-opt-in',
349 - ] );
350 - $this->hcaptchaCf7Instance = null;
351 - }
352 - }
353 -
354 - /**
355 - * Updates the opt-in status by hash.
356 - *
357 - * @param string $hash The opt-in hash.
358 - * @param int $value The opt-in value to set.
359 - * @param OptIn|null $OptIn The opt-in object. Optional.
360 - *
361 - * @return int Returns 0 if the opt-in is already confirmed, 1 if the opt-in is successfully updated.
362 - */
363 - protected function updateOptInByHash( string $hash, int $value, ?OptIn $OptIn = null ): int {
364 - $this->get_logger()->debug( 'updateOptInByHash called', [
365 - 'plugin' => 'double-opt-in',
366 - 'class' => __CLASS__,
367 - 'method' => __METHOD__,
368 - 'hash' => $hash,
369 - 'value' => $value,
370 - ] );
371 -
372 - $OptIn = $OptIn ?? OptIn::get_by_hash( $hash );
373 -
374 - if ( ! $OptIn ) {
375 - $this->get_logger()->warning( 'No OptIn found for hash', [
376 - 'plugin' => 'double-opt-in',
377 - 'hash' => $hash,
378 - ] );
379 - return 0;
380 - }
381 -
382 - if ( $OptIn->is_confirmed() ) {
383 - $this->get_logger()->info( 'OptIn already confirmed, skipping update', [
384 - 'plugin' => 'double-opt-in',
385 - 'hash' => $hash,
386 - 'optin_id' => $OptIn->get_id(),
387 - ] );
388 -
389 - do_action( 'f12_cf7_doubleoptin_already_confirmed', $hash, $OptIn );
390 - return 0;
391 - }
392 -
393 - do_action( 'f12_cf7_doubleoptin_before_confirm', $hash, $OptIn );
394 -
395 - $OptIn->set_doubleoptin( $value );
396 - $OptIn->set_updatetime( time() );
397 - $OptIn->set_ipaddr_confirmation( IPHelper::getIPAdress() );
398 -
399 - $telemetry = new Telemetry( $this->get_logger() );
400 -
401 - $result = $OptIn->save();
402 -
403 - if ( $result ) {
404 - if ( (int)$value === 1 ) {
405 - $telemetry->increment( 'confirmed_optins' );
406 -
407 - // Dispatch typed event for new event-driven architecture
408 - $this->dispatchOptInConfirmedEvent( $OptIn, $hash );
409 - }
410 -
411 - $this->get_logger()->info( 'OptIn updated successfully', [
412 - 'plugin' => 'double-opt-in',
413 - 'hash' => $hash,
414 - 'optin_id' => $OptIn->get_id(),
415 - ] );
416 - do_action( 'f12_cf7_doubleoptin_after_confirm', $hash, $OptIn );
417 - } else {
418 - $this->get_logger()->error( 'Failed to update OptIn', [
419 - 'plugin' => 'double-opt-in',
420 - 'hash' => $hash,
421 - 'optin_id' => $OptIn->get_id(),
422 - ] );
423 - }
424 -
425 - return (int) $result;
426 - }
427 -
428 -
429 - /**
430 - * Add additional placeholder like time, date, subject
431 - *
432 - * @formatter:off
433 - *
434 - * @param string $body The content containing the placeholder that will be replaced.
435 - * @param OptIn $OptIn The OptIn Object.
436 - *
437 - * @param array $parameter {
438 - * @type string $formUrl The URL where the form is displayed.
439 - * @type string $subject The Subject of the Form
440 - * }
441 - * #
442 - * @formatter:on
443 - */
444 - protected function addPlaceholders( string $body, OptIn $OptIn, array $parameter ): string {
445 - $this->get_logger()->debug( 'addPlaceholders called', [
446 - 'plugin' => 'double-opt-in',
447 - 'class' => __CLASS__,
448 - 'method' => __METHOD__,
449 - 'optin_id' => $OptIn->get_id(),
450 - 'parameter' => $parameter,
451 - ] );
452 -
453 - $placeholder = [
454 - // User-influenced submit URL — sanitise so a crafted query string
455 - // can't reflect into the mail HTML (esc_url_raw keeps text-mail intact).
456 - 'doubleoptin_form_url' => esc_url_raw( (string) ( $parameter['formUrl'] ?? '' ) ),
457 - 'doubleoptin_form_subject' => $parameter['subject'] ?? '',
458 - // wp_date() formats in the site's timezone without mutating PHP's
459 - // global timezone (the old date() + date_default_timezone_set() did).
460 - 'doubleoptin_form_date' => wp_date( get_option( 'date_format' ) ),
461 - 'doubleoptin_form_time' => wp_date( get_option( 'time_format' ) ),
462 - 'doubleoptin_form_email' => get_option( 'admin_email' ),
463 - 'doubleoptinlink' => $OptIn->get_link_optin( $parameter ),
464 - 'doubleoptoutlink' => $OptIn->get_link_optout(),
465 - 'doubleoptin_privacy_url' => $this->getPrivacyPolicyUrl(),
466 - ];
467 -
468 -
469 - foreach ( $placeholder as $key => $value ) {
470 - if ( is_array( $value ) || is_object( $value ) ) {
471 - // Array/Objekte serialisieren oder in JSON umwandeln
472 - $value = wp_json_encode( $value );
473 - }
474 -
475 - $replacement = (string) ( $value ?? '' );
476 -
477 - $body = str_replace( '[' . $key . ']', $replacement, $body );
478 -
479 - // Logging je Platzhalter
480 - $this->get_logger()->debug( 'Placeholder replaced', [
481 - 'plugin' => 'double-opt-in',
482 - 'placeholder' => '[' . $key . ']',
483 - 'value' => $replacement,
484 - ] );
485 - }
486 -
487 - // Logging nach allen Ersetzungen
488 - $this->get_logger()->debug( 'System placeholders replaced in body', [
489 - 'plugin' => 'double-opt-in',
490 - 'placeholders' => array_keys( $placeholder ),
491 - 'body_length' => strlen( $body ),
492 - ] );
493 -
494 - // Replace standard placeholders (doi_email, doi_name, etc.)
495 - $formData = maybe_unserialize( $OptIn->get_content() );
496 - if ( is_array( $formData ) ) {
497 - // Per-integration nesting unwrap. The serialized OptIn content
498 - // is whatever each frontend stored, which differs by integration:
499 - // - Avada wraps submitted values under `data` (plus
500 - // `field_labels`, `field_types`, ... siblings).
501 - // - Elementor stores the original $_POST parameter dict; the
502 - // actual form-field values live under `form_fields`
503 - // (or `fields` on older Elementor Pro versions — see
504 - // ElementorFrontend Z. 315 for the same key fallback).
505 - // - CF7 / GF / WPForms write the field values flat at the
506 - // top level, so no unwrap needed.
507 - // Without this, PlaceholderMapper::replacePlaceholders looks
508 - // for `$formData[$mappedField]` at the top level and finds
509 - // nothing for Elementor — every `[doi_*]` placeholder renders
510 - // as an empty string in the confirmation mail.
511 - if ( isset( $formData['form_fields'] ) && is_array( $formData['form_fields'] ) ) {
512 - $fieldData = $formData['form_fields'];
513 - } elseif ( isset( $formData['fields'] ) && is_array( $formData['fields'] ) ) {
514 - $fieldData = $formData['fields'];
515 - } elseif ( isset( $formData['data'] ) && is_array( $formData['data'] ) ) {
516 - $fieldData = $formData['data'];
517 - } else {
518 - $fieldData = $formData;
519 - }
520 -
521 - $body = PlaceholderMapper::replacePlaceholders(
522 - $body,
523 - $fieldData,
524 - $OptIn->get_cf_form_id(),
525 - [],
526 - $this->type
527 - );
528 -
529 - $this->get_logger()->debug( 'Standard placeholders replaced', [
530 - 'plugin' => 'double-opt-in',
531 - 'form_id' => $OptIn->get_cf_form_id(),
532 - 'field_data_keys' => array_keys( $fieldData ),
533 - 'unwrapped_from' => isset( $formData['form_fields'] ) ? 'form_fields'
534 - : ( isset( $formData['fields'] ) ? 'fields'
535 - : ( isset( $formData['data'] ) ? 'data' : 'top-level' ) ),
536 - ] );
537 - }
538 -
539 - return $body;
540 - }
541 -
542 -
543 - /**
544 - * Add Stylesheets
545 - */
546 - public function validateOptIn(): bool {
547 - $this->get_logger()->debug( 'validateOptIn started', [
548 - 'plugin' => 'double-opt-in',
549 - ] );
550 -
551 - /**
552 - * Skip if the hash has not been submitted.
553 - */
554 - if ( ! isset( $_GET['optin'] ) ) {
555 - $this->get_logger()->debug( 'No optin hash found in request, skipping', [
556 - 'plugin' => 'double-opt-in',
557 - ] );
558 - return false;
559 - }
560 -
561 - /**
562 - * Get the Hash
563 - */
564 - $hash = sanitize_text_field( $_GET['optin'] );
565 -
566 - /**
567 - * Load the OptIn
568 - */
569 - $OptIn = OptIn::get_by_hash( $hash );
570 -
571 - /**
572 - * Skip if the OptIn does not exist
573 - */
574 - if ( null == $OptIn ) {
575 - $this->get_logger()->warning( 'OptIn not found for hash', [
576 - 'plugin' => 'double-opt-in',
577 - 'hash' => $hash,
578 - ] );
579 - $this->setValidationStatus( 'not_found' );
580 - return false;
581 - }
582 -
583 - /**
584 - * Skip if the OptIn is not from Type cf7.
585 - */
586 - if ( ! $OptIn->isType( $this->type ) ) {
587 - $this->get_logger()->warning( 'OptIn type mismatch', [
588 - 'plugin' => 'double-opt-in',
589 - 'hash' => $hash,
590 - 'type' => $this->type,
591 - 'optin_id' => $OptIn->get_id(),
592 - ] );
593 - return false;
594 - }
595 -
596 - $this->get_logger()->debug( 'OptIn type found', [
597 - 'plugin' => 'double-opt-in',
598 - 'hash' => $hash,
599 - 'type' => $this->type,
600 - 'optin_id' => $OptIn->get_id(),
601 - ] );
602 -
603 - /**
604 - * Check if the token has expired.
605 - */
606 - $settings = CF7DoubleOptIn::getInstance()->getSettings();
607 - $expiryHours = (int) ( $settings['token_expiry_hours'] ?? 48 );
608 - if ( $expiryHours > 0 && ( time() - (int) $OptIn->get_createtime() ) > ( $expiryHours * 3600 ) ) {
609 - $this->get_logger()->info( 'OptIn token expired', [
610 - 'plugin' => 'double-opt-in',
611 - 'hash' => $hash,
612 - 'optin_id' => $OptIn->get_id(),
613 - 'expiry_hours' => $expiryHours,
614 - ] );
615 - do_action( 'f12_cf7_doubleoptin_token_expired', $hash, $OptIn );
616 - $this->setValidationStatus( 'expired' );
617 - return false;
618 - }
619 -
620 - /**
621 - * Check if already confirmed (before calling updateOptInByHash).
622 - */
623 - if ( $OptIn->is_confirmed() ) {
624 - $this->get_logger()->info( 'OptIn already confirmed', [
625 - 'plugin' => 'double-opt-in',
626 - 'hash' => $hash,
627 - 'optin_id' => $OptIn->get_id(),
628 - ] );
629 - do_action( 'f12_cf7_doubleoptin_already_confirmed', $hash, $OptIn );
630 - $this->setValidationStatus( 'already_confirmed' );
631 - return false;
632 - }
633 -
634 - /**
635 - * Confirm the OptIn.
636 - */
637 - if ( $this->updateOptInByHash( $hash, 1, $OptIn ) <= 0 ) {
638 - $this->get_logger()->info( 'OptIn update failed', [
639 - 'plugin' => 'double-opt-in',
640 - 'hash' => $hash,
641 - 'optin_id' => $OptIn->get_id(),
642 - ] );
643 - return false;
644 - }
645 -
646 - $this->setValidationStatus( 'confirmed' );
647 -
648 - /**
649 - * Enable / Disable default mail.
650 - *
651 - * @param bool $status Enable (true) or disable (false) the default mail.
652 - * @param int $postId The ID of the Post / Form.
653 - *
654 - * @since 2.3.3
655 - */
656 - if ( ! apply_filters( 'f12_cf7_doubleoptin_send_default_mail', true, $OptIn->get_cf_form_id() ) ) {
657 - $this->get_logger()->info( 'Default mail disabled for OptIn', [
658 - 'plugin' => 'double-opt-in',
659 - 'form_id' => $OptIn->get_cf_form_id(),
660 - 'optin_id' => $OptIn->get_id(),
661 - ] );
662 - return false;
663 - }
664 -
665 - $this->get_logger()->debug( 'Triggering before_send_default_mail hook', [
666 - 'plugin' => 'double-opt-in',
667 - 'optin_id' => $OptIn->get_id(),
668 - ] );
669 - do_action( 'f12_cf7_doubleoptin_before_send_default_mail', $OptIn );
670 -
671 - $this->get_logger()->info( 'Triggering send_default_mail hook', [
672 - 'plugin' => 'double-opt-in',
673 - 'optin_id' => $OptIn->get_id(),
674 - ] );
675 - do_action( 'f12_cf7_doubleoptin_trigger_default_mail', $OptIn );
676 -
677 - $this->get_logger()->debug( 'Triggering after_send_default_mail hook', [
678 - 'plugin' => 'double-opt-in',
679 - 'optin_id' => $OptIn->get_id(),
680 - ] );
681 - do_action( 'f12_cf7_doubleoptin_after_send_default_mail', $OptIn );
682 -
683 - return true;
684 - }
685 -
686 -
687 -
688 - /**
689 - * Store the files
690 - *
691 - * @param array $inFiles
692 - *
693 - * @return array
694 - */
695 - private function maybeStoreFiles( array $inFiles ): array {
696 - $this->get_logger()->debug( 'maybeStoreFiles called', [
697 - 'plugin' => 'double-opt-in',
698 - 'class' => __CLASS__,
699 - 'method' => __METHOD__,
700 - 'files_in' => $inFiles,
701 - ] );
702 -
703 - $outFiles = [];
704 -
705 - if ( empty( $inFiles ) ) {
706 - $this->get_logger()->debug( 'No files provided to maybeStoreFiles', [
707 - 'plugin' => 'double-opt-in',
708 - ] );
709 - return $outFiles;
710 - }
711 -
712 - foreach ( $inFiles as $key => $subfiles ) {
713 - foreach ( $subfiles as $file ) {
714 - $newFile = $this->copyAndRenameFile( $file );
715 - if ( $newFile ) {
716 - $outFiles[] = $newFile;
717 - $this->get_logger()->debug( 'File stored successfully', [
718 - 'plugin' => 'double-opt-in',
719 - 'original' => $file,
720 - 'new' => $newFile,
721 - ] );
722 - } else {
723 - $this->get_logger()->warning( 'File could not be stored', [
724 - 'plugin' => 'double-opt-in',
725 - 'original' => $file,
726 - ] );
727 - }
728 - }
729 - }
730 -
731 - $this->get_logger()->debug( 'maybeStoreFiles completed', [
732 - 'plugin' => 'double-opt-in',
733 - 'files_out' => $outFiles,
734 - ] );
735 -
736 - return $outFiles;
737 - }
738 -
739 -
740 - /**
741 - * Copy and rename a file
742 - *
743 - * @param string $file The path to the file to copy and rename
744 - *
745 - * @return string|null The path to the copied and renamed file, or null if the copy operation failed
746 - */
747 - private function copyAndRenameFile( string $file ): ?string {
748 - $this->get_logger()->debug( 'copyAndRenameFile called', [
749 - 'plugin' => 'double-opt-in',
750 - 'class' => __CLASS__,
751 - 'method' => __METHOD__,
752 - 'file' => $file,
753 - ] );
754 -
755 - $newFile = explode( '/', $file );
756 - $name = $newFile[ count( $newFile ) - 1 ];
757 - $name = time() . '_' . $name;
758 - $newFile[ count( $newFile ) - 1 ] = $name;
759 - $newFile = implode( "/", $newFile );
760 -
761 - if ( copy( $file, $newFile ) ) {
762 - $this->get_logger()->info( 'File copied and renamed successfully', [
763 - 'plugin' => 'double-opt-in',
764 - 'original' => $file,
765 - 'new_file' => $newFile,
766 - ] );
767 - return $newFile;
768 - }
769 -
770 - $this->get_logger()->error( 'Failed to copy and rename file', [
771 - 'plugin' => 'double-opt-in',
772 - 'original' => $file,
773 - 'target' => $newFile,
774 - ] );
775 -
776 - return null;
777 - }
778 -
779 -
780 - /**
781 - * Create the OptIn
782 - *
783 - * @param int $formId The identifier of the form
784 - * @param string $formHtml The HTML code of the form
785 - * @param array $parameter The Post Parameter of the form.
786 - * @param array $files The Files attached to the form.
787 - *
788 - * @return OptIn|null
789 - */
790 - protected function maybeCreateOptIn( int $formId, string $formHtml, array $parameter, array $files = array() ): ?OptIn {
791 - $this->get_logger()->debug( 'maybeCreateOptIn called', [
792 - 'plugin' => 'double-opt-in',
793 - 'class' => __CLASS__,
794 - 'method' => __METHOD__,
795 - 'formId' => $formId,
796 - 'formHtml' => substr( $formHtml, 0, 200 ),
797 - 'files' => $files,
798 - ] );
799 -
800 - /**
801 - * Mögliche Dateien speichern, um sie während der Opt-In-Bestätigung vorzuhalten
802 - */
803 - $files = $this->maybeStoreFiles( $files );
804 - $this->get_logger()->debug( 'Files checked and possibly stored', [
805 - 'plugin' => 'double-opt-in',
806 - 'files' => $files,
807 - ] );
808 -
809 - /**
810 - * Filter, um die Parameter vor dem Speichern in der Datenbank zu manipulieren
811 - */
812 - $parameter = \apply_filters( 'f12_cf7_doubleoptin_add_request_parameter', $parameter );
813 - $this->get_logger()->debug( 'Request parameters filtered', [
814 - 'plugin' => 'double-opt-in',
815 - 'parameter' => $parameter,
816 - ] );
817 -
818 - /**
819 - * Globale Einstellungen für das Formular abrufen
820 - */
821 - $formParameter = CF7DoubleOptIn::getInstance()->getParameter( $formId );
822 -
823 - /**
824 - * Filter, um den Empfänger zu ermitteln, bevor das OptIn-Objekt erstellt wird
825 - */
826 - $recipient = \apply_filters( 'f12_cf7_doubleoptin_get_recipient_' . $this->type, '', $formParameter, $parameter );
827 -
828 - /**
829 - * Wenn keine E-Mail-Adresse gefunden wurde, Abbruch
830 - */
831 - if ( empty( $recipient ) ) {
832 - $this->get_logger()->warning( 'No recipient found, skipping OptIn creation', [
833 - 'plugin' => 'double-opt-in',
834 - ] );
835 - ErrorNotification::store(
836 - OptInError::fromCode( OptInError::NO_RECIPIENT, [ 'form_id' => $formId ] ),
837 - $formId
838 - );
839 - return null;
840 - }
841 -
842 - /**
843 - * Rate-Limiting: Check IP and email limits before creating OptIn.
844 - */
845 - $rateLimiter = new RateLimiter();
846 - $ratSettings = CF7DoubleOptIn::getInstance()->getSettings();
847 - $rateLimitIp = (int) ( $ratSettings['rate_limit_ip'] ?? 5 );
848 - $rateLimitEmail = (int) ( $ratSettings['rate_limit_email'] ?? 3 );
849 - $rateLimitWindow = (int) ( $ratSettings['rate_limit_window'] ?? 60 );
850 -
851 - $ip = IPHelper::getIPAdress();
852 - if ( ! $rateLimiter->isAllowed( 'ip', $ip, $rateLimitIp, $rateLimitWindow ) ) {
853 - $this->get_logger()->warning( 'Rate limit exceeded for IP', [
854 - 'plugin' => 'double-opt-in',
855 - 'ip' => $ip,
856 - 'formId' => $formId,
857 - ] );
858 - do_action( 'f12_cf7_doubleoptin_rate_limited', 'ip', $ip, $formId );
859 - ErrorNotification::store(
860 - OptInError::fromCode( OptInError::RATE_LIMIT_IP, [ 'ip' => $ip, 'form_id' => $formId ] ),
861 - $formId
862 - );
863 - return null;
864 - }
865 -
866 - if ( ! $rateLimiter->isAllowed( 'email', $recipient, $rateLimitEmail, $rateLimitWindow ) ) {
867 - $this->get_logger()->warning( 'Rate limit exceeded for email', [
868 - 'plugin' => 'double-opt-in',
869 - 'email' => $recipient,
870 - 'formId' => $formId,
871 - ] );
872 - do_action( 'f12_cf7_doubleoptin_rate_limited', 'email', $recipient, $formId );
873 - ErrorNotification::store(
874 - OptInError::fromCode( OptInError::RATE_LIMIT_EMAIL, [ 'email' => $recipient, 'form_id' => $formId ] ),
875 - $formId
876 - );
877 - return null;
878 - }
879 -
880 - /**
881 - * Validate recipient (extensible via Pro plugin: MX check, unique
882 - * email, etc.). Uses a lightweight proxy so filters that expect
883 - * FormDataInterface::getFormId() keep working.
884 - *
885 - * IMPORTANT: `getFormType()` must be set to `$this->type` — that's
886 - * the integration identifier ("elementor", "cf7" via legacy path,
887 - * "avada" via legacy path). The Unique Email validator's
888 - * resolver matches conditions on the (integration, form_id) pair;
889 - * a proxy without getFormType returns '' and breaks the
890 - * `selected` and `all_except` modes for every form that flows
891 - * through this legacy path. User-reported 2026-05-13. Pinned by
892 - * `LegacyFormDataProxyTest` in core tests.
893 - */
894 - $formDataProxy = new class( $formId, $this->type ) {
895 - private int $formId;
896 - private string $formType;
897 - public function __construct( int $formId, string $formType ) {
898 - $this->formId = $formId;
899 - $this->formType = $formType;
900 - }
901 - public function getFormId(): int { return $this->formId; }
902 - public function getFormType(): string { return $this->formType; }
903 - };
904 -
905 - $recipientValid = \apply_filters(
906 - 'f12_cf7_doubleoptin_validate_recipient',
907 - true,
908 - $recipient,
909 - $formDataProxy
910 - );
911 -
912 - if ( $recipientValid !== true ) {
913 - $errorMsg = is_string( $recipientValid ) ? $recipientValid : '';
914 - $errorCode = OptInError::RECIPIENT_INVALID;
915 -
916 - if ( $errorMsg === 'unique_email_rejected' ) {
917 - $errorCode = OptInError::UNIQUE_EMAIL_DUPLICATE;
918 - $errorMsg = OptInError::fromCode( OptInError::UNIQUE_EMAIL_DUPLICATE )->getMessage();
919 - }
920 -
921 - $this->get_logger()->warning( 'Recipient validation failed', [
922 - 'plugin' => 'double-opt-in',
923 - 'email' => $recipient,
924 - 'form_id' => $formId,
925 - 'reason' => $errorMsg,
926 - ] );
927 -
928 - $this->lastCreationError = new OptInError(
929 - $errorCode,
930 - ! empty( $errorMsg ) ? $errorMsg : OptInError::fromCode( OptInError::RECIPIENT_INVALID )->getMessage(),
931 - [ 'email' => $recipient, 'form_id' => $formId ]
932 - );
933 -
934 - ErrorNotification::store( $this->lastCreationError, $formId );
935 - return null;
936 - }
937 -
938 - /**
939 - * Consent-Snapshot aus den Form-Settings laden. Both the wording
940 - * shown to the user (`consent_text`) AND the form-field key the
941 - * user had to tick (`consent_field`) need to be persisted, or
942 - * the Consent Evidence panel reports "No acknowledgment field
943 - * configured — consent text is stored but not legally provable"
944 - * even when the admin wired up the checkbox. The modern
945 - * AbstractFormIntegration::buildOptInProperties() path includes
946 - * both; this legacy path (used by Elementor + the CF7/Avada
947 - * legacy compat shims) used to only carry consent_text.
948 - */
949 - $consentText = '';
950 - $consentField = '';
951 - try {
952 - $container = \Forge12\DoubleOptIn\Container\Container::getInstance();
953 - $settingsService = $container->get( \Forge12\DoubleOptIn\FormSettings\FormSettingsService::class );
954 - $formSettings = $settingsService->getSettings( $formId );
955 - $consentText = $formSettings->consentText ?? '';
956 - $consentField = $formSettings->consentField ?? '';
957 - } catch ( \Exception $e ) {
958 - $this->get_logger()->debug( 'Could not load consent snapshot from FormSettings', [
959 - 'plugin' => 'double-opt-in',
960 - 'error' => $e->getMessage(),
961 - ] );
962 - }
963 -
964 - /**
965 - * Eigenschaften des OptIn-Objekts festlegen
966 - */
967 - $properties = $this->buildLegacyOptInProperties(
968 - $formId,
969 - $formHtml,
970 - $parameter,
971 - $files,
972 - $formParameter,
973 - $recipient,
974 - $consentText,
975 - $consentField
976 - );
977 -
978 - $this->get_logger()->debug( 'OptIn properties created', [
979 - 'plugin' => 'double-opt-in',
980 - 'properties' => $properties,
981 - ] );
982 -
983 - /**
984 - * OptIn-Objekt erstellen
985 - */
986 - $OptIn = new OptIn($this->get_logger(), $properties );
987 - $this->get_logger()->debug( 'OptIn object instantiated', [
988 - 'plugin' => 'double-opt-in',
989 - 'OptIn' => $OptIn,
990 - ] );
991 -
992 - /**
993 - * OptIn speichern
994 - */
995 - if ( $OptIn->save() ) {
996 - $this->get_logger()->info( 'OptIn object saved successfully', [
997 - 'plugin' => 'double-opt-in',
998 - 'OptIn' => $OptIn,
999 - ] );
1000 -
1001 - // Dispatch typed event for new event-driven architecture
1002 - $this->dispatchOptInCreatedEvent( $OptIn, $formId );
1003 -
1004 - return $OptIn;
1005 - }
1006 -
1007 - $this->get_logger()->error( 'Failed to save OptIn object', [
1008 - 'plugin' => 'double-opt-in',
1009 - ] );
1010 -
1011 - do_action( 'f12_cf7_doubleoptin_creation_failed', $formId, $recipient );
1012 -
1013 - ErrorNotification::store(
1014 - OptInError::fromCode( OptInError::SAVE_FAILED, [ 'form_id' => $formId ] ),
1015 - $formId
1016 - );
1017 -
1018 - return null;
1019 - }
1020 -
1021 -
1022 - /**
1023 - * Validate if the optin is enabled.
1024 - */
1025 - protected function isOptinEnabled( int $formId ): bool {
1026 - // Disable optin sending if the optin flag is set.
1027 - if ( isset( $_GET['optin'] ) ) {
1028 - $this->get_logger()->debug( 'Optin disabled due to optin flag in GET request', [
1029 - 'plugin' => 'double-opt-in',
1030 - 'class' => __CLASS__,
1031 - 'method' => __METHOD__,
1032 - ] );
1033 - return false;
1034 - }
1035 -
1036 - $parameter = CF7DoubleOptIn::getInstance()->getParameter( $formId );
1037 -
1038 - if ( (int) $parameter['enable'] != 1 ) {
1039 - $this->get_logger()->debug( 'Optin not enabled in form parameter', [
1040 - 'plugin' => 'double-opt-in',
1041 - 'class' => __CLASS__,
1042 - 'method' => __METHOD__,
1043 - 'parameter' => $parameter,
1044 - ] );
1045 - return false;
1046 - }
1047 -
1048 - // Check the custom condition
1049 - if ( isset( $parameter['conditions'] ) ) {
1050 - $condition = sanitize_text_field( $parameter['conditions'] );
1051 -
1052 - if ( ( $condition != 'disable' && $condition !== 'disabled' ) && ( ! isset( $_POST[ $condition ] ) || empty( $_POST[ $condition ] ) ) ) {
1053 - $this->get_logger()->debug( 'Optin disabled due to unmet custom condition', [
1054 - 'plugin' => 'double-opt-in',
1055 - 'class' => __CLASS__,
1056 - 'method' => __METHOD__,
1057 - 'condition' => $condition,
1058 - 'post_keys' => array_keys( $_POST ),
1059 - ] );
1060 - return false;
1061 - }
1062 - }
1063 -
1064 - $this->get_logger()->debug( 'Optin enabled', [
1065 - 'plugin' => 'double-opt-in',
1066 - 'class' => __CLASS__,
1067 - 'method' => __METHOD__,
1068 - ] );
1069 -
1070 - return true;
1071 - }
1072 -
1073 -
1074 - /**
1075 - * Render validation feedback in the frontend via wp_footer.
1076 - *
1077 - * Fires the 'f12_cf7_doubleoptin_validation_feedback' action for customization,
1078 - * and provides a default inline notice for non-confirmed statuses.
1079 - *
1080 - * @return void
1081 - */
1082 - public function renderValidationFeedback(): void {
1083 - $status = self::getValidationStatus();
1084 - if ( empty( $status ) ) {
1085 - return;
1086 - }
1087 -
1088 - /**
1089 - * Allow themes/plugins to handle the validation feedback display.
1090 - *
1091 - * @param string $status The validation status.
1092 - *
1093 - * @since 3.3.0
1094 - */
1095 - do_action( 'f12_cf7_doubleoptin_validation_feedback', $status );
1096 - }
1097 -
1098 - /**
1099 - * Removes files associated with the optin parameter.
1100 - *
1101 - * This method checks if the optin parameter is set and
1102 - * loads the OptIn object based on the hash value. If
1103 - * the OptIn does not exist or no files are found,
1104 - * the method will return. Otherwise, it will iterate
1105 - * through the files and delete each one.
1106 - *
1107 - * @return void
1108 - */
1109 - public function removeFiles(): void {
1110 - /**
1111 - * Skip if the optin parameter is not set.
1112 - */
1113 - if ( ! isset( $_GET['optin'] ) ) {
1114 - $this->get_logger()->debug( 'No optin parameter found, skipping file removal', [
1115 - 'plugin' => 'double-opt-in',
1116 - 'class' => __CLASS__,
1117 - 'method' => __METHOD__,
1118 - ] );
1119 - return;
1120 - }
1121 -
1122 - $hash = sanitize_text_field( wp_unslash( $_GET['optin'] ) );
1123 -
1124 - /**
1125 - * Load the OptIn
1126 - */
1127 - $OptIn = OptIn::get_by_hash( $hash );
1128 -
1129 - /**
1130 - * Skip if the OptIn does not exist
1131 - */
1132 - if ( null == $OptIn ) {
1133 - $this->get_logger()->warning( 'OptIn not found, skipping file removal', [
1134 - 'plugin' => 'double-opt-in',
1135 - 'class' => __CLASS__,
1136 - 'method' => __METHOD__,
1137 - 'hash' => $hash,
1138 - ] );
1139 - return;
1140 - }
1141 -
1142 - /**
1143 - * Load all files
1144 - */
1145 - $files = maybe_unserialize( $OptIn->get_files() );
1146 -
1147 - /**
1148 - * Skip if no files found
1149 - */
1150 - if ( empty( $files ) ) {
1151 - $this->get_logger()->debug( 'No files found in OptIn, skipping removal', [
1152 - 'plugin' => 'double-opt-in',
1153 - 'class' => __CLASS__,
1154 - 'method' => __METHOD__,
1155 - 'hash' => $hash,
1156 - ] );
1157 - return;
1158 - }
1159 -
1160 - foreach ( $files as $file ) {
1161 - /**
1162 - * Skip if empty
1163 - */
1164 - if ( empty( $file ) ) {
1165 - continue;
1166 - }
1167 -
1168 - /**
1169 - * Skip if no file found
1170 - */
1171 - if ( ! is_file( $file ) ) {
1172 - $this->get_logger()->warning( 'File not found, skipping', [
1173 - 'plugin' => 'double-opt-in',
1174 - 'class' => __CLASS__,
1175 - 'method' => __METHOD__,
1176 - 'file' => $file,
1177 - ] );
1178 - continue;
1179 - }
1180 -
1181 - /**
1182 - * Delete the file
1183 - */
1184 - if ( unlink( $file ) ) {
1185 - $this->get_logger()->info( 'File deleted successfully', [
1186 - 'plugin' => 'double-opt-in',
1187 - 'class' => __CLASS__,
1188 - 'method' => __METHOD__,
1189 - 'file' => $file,
1190 - ] );
1191 - } else {
1192 - $this->get_logger()->error( 'Could not delete file', [
1193 - 'plugin' => 'double-opt-in',
1194 - 'class' => __CLASS__,
1195 - 'method' => __METHOD__,
1196 - 'file' => $file,
1197 - ] );
1198 - }
1199 - }
1200 - }
1201 -
1202 - /**
1203 - * Get the privacy policy URL.
1204 - *
1205 - * Fallback chain: Plugin setting → WordPress Privacy Policy page → empty string.
1206 - *
1207 - * @return string
1208 - */
1209 - private function getPrivacyPolicyUrl(): string {
1210 - $settings = CF7DoubleOptIn::getInstance()->getSettings();
1211 - $pageId = (int) ( $settings['privacy_policy_page'] ?? 0 );
1212 -
1213 - if ( $pageId > 0 ) {
1214 - $url = get_permalink( $pageId );
1215 - if ( $url ) {
1216 - return $url;
1217 - }
1218 - }
1219 -
1220 - // Fallback to WordPress privacy policy page
1221 - $wpPrivacyPageId = (int) get_option( 'wp_page_for_privacy_policy', 0 );
1222 - if ( $wpPrivacyPageId > 0 ) {
1223 - $url = get_permalink( $wpPrivacyPageId );
1224 - if ( $url ) {
1225 - return $url;
1226 - }
1227 - }
1228 -
1229 - return '';
1230 - }
1231 -
1232 - /**
1233 - * Dispatch OptInCreatedEvent via the new event system.
1234 - *
1235 - * @param OptIn $optIn The created OptIn object.
1236 - * @param int $formId The form ID.
1237 - *
1238 - * @since 4.0.0
1239 - */
1240 - protected function dispatchOptInCreatedEvent( OptIn $optIn, int $formId ): void {
1241 - try {
1242 - $container = Container::getInstance();
1243 - if ( $container->has( EventDispatcherInterface::class ) ) {
1244 - $dispatcher = $container->get( EventDispatcherInterface::class );
1245 - $event = new OptInCreatedEvent(
1246 - $optIn->get_id(),
1247 - $formId,
1248 - $this->type,
1249 - $optIn->get_email(),
1250 - $optIn->get_hash()
1251 - );
1252 - $dispatcher->dispatch( $event );
1253 -
1254 - $this->get_logger()->debug( 'OptInCreatedEvent dispatched', [
1255 - 'plugin' => 'double-opt-in',
1256 - 'optin_id' => $optIn->get_id(),
1257 - 'form_id' => $formId,
1258 - ] );
1259 - }
1260 - } catch ( \Exception $e ) {
1261 - $this->get_logger()->warning( 'Failed to dispatch OptInCreatedEvent', [
1262 - 'plugin' => 'double-opt-in',
1263 - 'error' => $e->getMessage(),
1264 - ] );
1265 - }
1266 - }
1267 -
1268 - /**
1269 - * Dispatch OptInConfirmedEvent via the new event system.
1270 - *
1271 - * @param OptIn $optIn The confirmed OptIn object.
1272 - * @param string $hash The opt-in hash.
1273 - *
1274 - * @since 4.0.0
1275 - */
1276 - protected function dispatchOptInConfirmedEvent( OptIn $optIn, string $hash ): void {
1277 - try {
1278 - $container = Container::getInstance();
1279 - if ( $container->has( EventDispatcherInterface::class ) ) {
1280 - $dispatcher = $container->get( EventDispatcherInterface::class );
1281 -
1282 - $formData = maybe_unserialize( $optIn->get_content() );
1283 -
1284 - $event = new OptInConfirmedEvent(
1285 - $optIn->get_id(),
1286 - $hash,
1287 - $optIn->get_email(),
1288 - $optIn->get_ipaddr_confirmation(),
1289 - (int) $optIn->get_cf_form_id(),
1290 - is_array( $formData ) ? $formData : []
1291 - );
1292 - $dispatcher->dispatch( $event );
1293 -
1294 - $this->get_logger()->debug( 'OptInConfirmedEvent dispatched', [
1295 - 'plugin' => 'double-opt-in',
1296 - 'optin_id' => $optIn->get_id(),
1297 - 'hash' => $hash,
1298 - ] );
1299 - }
1300 - } catch ( \Exception $e ) {
1301 - $this->get_logger()->warning( 'Failed to dispatch OptInConfirmedEvent', [
1302 - 'plugin' => 'double-opt-in',
1303 - 'error' => $e->getMessage(),
1304 - ] );
1305 - }
1306 - }
1307 -
1308 - /**
1309 - * Assemble the legacy OptIn properties array.
1310 - *
1311 - * Extracted from {@see maybeCreateOptIn()} so the column inventory
1312 - * is unit-testable without standing up the whole submit pipeline
1313 - * (rate limiter, FormSettingsService, $wpdb save, etc.). The
1314 - * modern {@see \Forge12\DoubleOptIn\Integration\AbstractFormIntegration::buildOptInProperties()}
1315 - * has a sibling helper; the two MUST stay in sync — every column
1316 - * the modern path snapshots, this legacy path also has to snapshot,
1317 - * otherwise integrations on the legacy compat path (Elementor +
1318 - * the CF7/Avada legacy shims) lose the column silently.
1319 - *
1320 - * The 2026-05-13 incident this guards against: `consent_field`
1321 - * was missing here, so Elementor opt-ins shipped with an empty
1322 - * acknowledgment field even when the admin configured one. The
1323 - * Consent Evidence panel then rendered "No acknowledgment field
1324 - * configured — consent text is stored but not legally provable"
1325 - * on a record that was, in fact, ticked through a configured
1326 - * checkbox.
1327 - *
1328 - * @return array<string, mixed>
1329 - */
1330 - protected function buildLegacyOptInProperties(
1331 - int $formId,
1332 - string $formHtml,
1333 - array $parameter,
1334 - array $files,
1335 - array $formParameter,
1336 - string $recipient,
1337 - string $consentText,
1338 - string $consentField
1339 - ): array {
1340 - return [
1341 - 'cf_form_id' => $formId,
1342 - 'doubleoptin' => 0,
1343 - 'createtime' => time(),
1344 - 'content' => maybe_serialize( $parameter ),
1345 - 'files' => maybe_serialize( $files ),
1346 - 'ipaddr_register' => IPHelper::getIPAdress(),
1347 - 'category' => (int) ( $formParameter['category'] ?? 0 ),
1348 - 'form' => $formHtml,
1349 - 'email' => $recipient,
1350 - 'consent_text' => $consentText,
1351 - 'consent_field' => $consentField,
1352 - ];
1353 - }
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 + }
1354 1568 }