PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.1.6
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.1.6
5.6.2 5.6.3 5.6.1 5.6.0 5.5.0 5.4.0 5.3.2 5.3.1 5.1.6 5.1.5 trunk 2.1.5 2.11 2.12 2.13 2.15 3.0.0 3.0.1 3.0.2 3.0.3 3.0.5 3.0.51 3.0.60 3.0.61 3.0.62 All 38 releases
double-opt-in / compatibility / OptInFrontend.class.php

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

1,354 lines 42.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace forge12\contactform7\CF7DoubleOptIn;
4
5
6 use Forge12\DoubleOptIn\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 }
1354 }