PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.5.0
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.5.0
5.5.0 5.4.0 5.3.2 5.3.1 5.1.6 5.1.5 trunk 2.1.5 2.11 2.12 2.13 2.15 3.0.0 3.0.1 3.0.2 3.0.3 3.0.5 3.0.51 3.0.60 3.0.61 3.0.62 3.0.70 3.0.71 3.0.72 3.1.0 All 34 releases
double-opt-in / src / Integration / AbstractFormIntegration.php

AbstractFormIntegration.php in Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification 5.5.0, at src/Integration/AbstractFormIntegration.php

1,414 lines 40.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Abstract Form Integration
4 *
5 * @package Forge12\DoubleOptIn\Integration
6 * @since 4.0.0
7 */
8
9 namespace Forge12\DoubleOptIn\Integration;
10
11 use Forge12\DoubleOptIn\Consent\ConsentGate;
12 use Forge12\DoubleOptIn\Container\Container;
13 use Forge12\DoubleOptIn\EmailTemplates\PlaceholderMapper;
14 use Forge12\DoubleOptIn\EventSystem\EventDispatcherInterface;
15 use Forge12\DoubleOptIn\Events\Integration\FormSubmissionEvent;
16 use Forge12\DoubleOptIn\Events\Lifecycle\OptInConfirmedEvent;
17 use Forge12\DoubleOptIn\Events\Lifecycle\OptInCreatedEvent;
18 use Forge12\DoubleOptIn\Files\FileStorage;
19 use Forge12\DoubleOptIn\Frontend\ErrorNotification;
20 use Forge12\DoubleOptIn\Service\RateLimiter;
21 use forge12\contactform7\CF7DoubleOptIn\CF7DoubleOptIn;
22 use forge12\contactform7\CF7DoubleOptIn\IPHelper;
23 use forge12\contactform7\CF7DoubleOptIn\OptIn;
24 use forge12\contactform7\CF7DoubleOptIn\OptInFrontend;
25 use forge12\contactform7\CF7DoubleOptIn\Telemetry;
26 use Forge12\Shared\LoggerInterface;
27
28 if ( ! defined( 'ABSPATH' ) ) {
29 exit;
30 }
31
32 /**
33 * Class AbstractFormIntegration
34 *
35 * @api
36 *
37 * Base class providing common functionality for all form integrations.
38 * Extracted from the legacy OptInFrontend class to provide reusable logic.
39 * Addons that integrate a form system extend this class to reduce
40 * boilerplate — see docs/addon-api.md §4.3. Covered by the Addon API
41 * semver policy as of Core API 4.3.0.
42 */
43 abstract class AbstractFormIntegration implements FormIntegrationInterface {
44
45 /**
46 * Logger instance.
47 *
48 * @var LoggerInterface
49 */
50 protected LoggerInterface $logger;
51
52 /**
53 * Validation status from the last validateOptIn() call.
54 *
55 * @var string
56 */
57 private static string $validationStatus = '';
58
59 /**
60 * Stored hCaptcha CF7 instance for restore after mail send.
61 *
62 * @var object|null
63 */
64 private $hcaptchaCf7Instance = null;
65
66 /**
67 * Stored hCaptcha CF7 filter priority for restore after mail send.
68 *
69 * @var int
70 */
71 private int $hcaptchaCf7Priority = 20;
72
73 /**
74 * Last error from the most recent createOptIn() call.
75 *
76 * @var OptInError|null
77 */
78 private static ?OptInError $lastError = null;
79
80 /**
81 * Get the validation status from the last validateOptIn() call.
82 *
83 * @return string One of: '', 'confirmed', 'already_confirmed', 'expired', 'not_found'.
84 */
85 public static function getValidationStatus(): string {
86 return self::$validationStatus;
87 }
88
89 /**
90 * Set the validation status.
91 *
92 * @param string $status The validation status.
93 */
94 private static function setValidationStatus( string $status ): void {
95 self::$validationStatus = $status;
96 }
97
98 /**
99 * Get the last error from the most recent createOptIn() call.
100 *
101 * @return OptInError|null The error, or null if no error occurred.
102 */
103 public static function getLastError(): ?OptInError {
104 return self::$lastError;
105 }
106
107 /**
108 * Clear the last error.
109 */
110 private static function clearLastError(): void {
111 self::$lastError = null;
112 }
113
114 /**
115 * Set the last error and store it for the frontend notification system.
116 *
117 * @param OptInError $error The error that occurred.
118 * @param int $formId The form ID.
119 */
120 private static function setLastError( OptInError $error, int $formId ): void {
121 self::$lastError = $error;
122 ErrorNotification::store( $error, $formId );
123 }
124
125 /**
126 * Get the recipient validation error from the last createOptIn() call.
127 *
128 * @deprecated Use getLastError() instead.
129 *
130 * @return string The error message, or empty string if no error.
131 */
132 public static function getLastRecipientValidationError(): string {
133 if ( self::$lastError && self::$lastError->getCode() === OptInError::RECIPIENT_INVALID ) {
134 return self::$lastError->getMessage();
135 }
136 return '';
137 }
138
139 /**
140 * Constructor.
141 *
142 * @param LoggerInterface $logger The logger instance.
143 */
144 public function __construct( LoggerInterface $logger ) {
145 $this->logger = $logger;
146 }
147
148 /**
149 * Get the logger instance.
150 *
151 * @return LoggerInterface
152 */
153 protected function getLogger(): LoggerInterface {
154 return $this->logger;
155 }
156
157 /**
158 * {@inheritdoc}
159 */
160 public function getHookPriority(): int {
161 return 10;
162 }
163
164 /**
165 * {@inheritdoc}
166 */
167 public function getFormParameter( int $formId ): array {
168 return CF7DoubleOptIn::getInstance()->getParameter( $formId );
169 }
170
171 /**
172 * {@inheritdoc}
173 */
174 public function isOptInEnabled( int $formId ): bool {
175 // Disable if opt-in confirmation is in progress
176 if ( isset( $_GET['optin'] ) ) {
177 $this->getLogger()->debug(
178 'Opt-in disabled due to optin flag in GET request',
179 array(
180 'plugin' => 'double-opt-in',
181 'class' => static::class,
182 )
183 );
184 return false;
185 }
186
187 $parameter = $this->getFormParameter( $formId );
188
189 if ( (int) ( $parameter['enable'] ?? 0 ) !== 1 ) {
190 $this->getLogger()->debug(
191 'Opt-in not enabled in form parameter',
192 array(
193 'plugin' => 'double-opt-in',
194 'form_id' => $formId,
195 )
196 );
197 return false;
198 }
199
200 // Check the custom condition
201 if ( isset( $parameter['conditions'] ) ) {
202 $condition = sanitize_text_field( $parameter['conditions'] );
203
204 if ( ( $condition !== 'disable' && $condition !== 'disabled' )
205 && ( ! isset( $_POST[ $condition ] ) || empty( $_POST[ $condition ] ) ) ) {
206 $this->getLogger()->debug(
207 'Opt-in disabled due to unmet custom condition',
208 array(
209 'plugin' => 'double-opt-in',
210 'condition' => $condition,
211 )
212 );
213 return false;
214 }
215 }
216
217 return true;
218 }
219
220 /**
221 * Create an OptIn record from form data.
222 *
223 * @param FormDataInterface $formData The normalized form data.
224 * @param array $formParameter The form configuration.
225 *
226 * @return OptIn|null The created OptIn or null on failure.
227 */
228 protected function createOptIn( FormDataInterface $formData, array $formParameter ): ?OptIn {
229 $this->getLogger()->debug(
230 'Creating OptIn record',
231 array(
232 'plugin' => 'double-opt-in',
233 'class' => static::class,
234 'form_id' => $formData->getFormId(),
235 'form_type' => $formData->getFormType(),
236 )
237 );
238
239 // Clear previous error
240 self::clearLastError();
241
242 // Dispatch FormSubmissionEvent to allow modifications/cancellation
243
244 $event = $this->dispatchFormSubmissionEvent( $formData );
245 if ( $event && $event->shouldSkipOptIn() ) {
246 $this->getLogger()->info(
247 'OptIn skipped by FormSubmissionEvent',
248 array(
249 'plugin' => 'double-opt-in',
250 'form_id' => $formData->getFormId(),
251 )
252 );
253 self::setLastError(
254 OptInError::fromCode( OptInError::SUBMISSION_CANCELLED, array( 'form_id' => $formData->getFormId() ) ),
255 $formData->getFormId()
256 );
257 return null;
258 }
259
260 // Store uploaded files
261 $files = $this->storeFiles( $formData->getFiles() );
262
263 // Filter parameters before saving
264 $fields = apply_filters( 'f12_cf7_doubleoptin_add_request_parameter', $formData->getFields() );
265
266 // Resolve recipient email
267 $recipient = $this->resolveRecipient( $formData, $formParameter );
268
269 if ( empty( $recipient ) ) {
270 $this->getLogger()->warning(
271 'No recipient found, skipping OptIn creation',
272 array(
273 'plugin' => 'double-opt-in',
274 'form_id' => $formData->getFormId(),
275 )
276 );
277 self::setLastError(
278 OptInError::fromCode( OptInError::NO_RECIPIENT, array( 'form_id' => $formData->getFormId() ) ),
279 $formData->getFormId()
280 );
281 return null;
282 }
283
284 $consentError = $this->validateConsentAcceptance( $formData, $formParameter );
285 if ( $consentError !== null ) {
286 $this->getLogger()->info(
287 'Consent acceptance not given, rejecting OptIn',
288 array(
289 'plugin' => 'double-opt-in',
290 'form_id' => $formData->getFormId(),
291 'consent_field' => $consentError->getContext()['consent_field'] ?? '',
292 )
293 );
294 do_action(
295 'f12_cf7_doubleoptin_consent_not_given',
296 $formData->getFormId(),
297 $consentError->getContext()['consent_field'] ?? ''
298 );
299 self::setLastError( $consentError, $formData->getFormId() );
300 return null;
301 }
302
303 // Rate-Limiting: Check IP and email limits before creating OptIn
304 $rateLimiter = new RateLimiter();
305 $settings = CF7DoubleOptIn::getInstance()->getSettings();
306 $rateLimitIp = (int) ( $settings['rate_limit_ip'] ?? 5 );
307 $rateLimitEmail = (int) ( $settings['rate_limit_email'] ?? 3 );
308 $rateLimitWindow = (int) ( $settings['rate_limit_window'] ?? 60 );
309
310 $ip = IPHelper::getIPAdress();
311 if ( ! $rateLimiter->isAllowed( 'ip', $ip, $rateLimitIp, $rateLimitWindow ) ) {
312 $this->getLogger()->warning(
313 'Rate limit exceeded for IP',
314 array(
315 'plugin' => 'double-opt-in',
316 'ip' => $ip,
317 'form_id' => $formData->getFormId(),
318 )
319 );
320 do_action( 'f12_cf7_doubleoptin_rate_limited', 'ip', $ip, $formData->getFormId() );
321 self::setLastError(
322 OptInError::fromCode(
323 OptInError::RATE_LIMIT_IP,
324 array(
325 'ip' => $ip,
326 'form_id' => $formData->getFormId(),
327 )
328 ),
329 $formData->getFormId()
330 );
331 return null;
332 }
333
334 if ( ! $rateLimiter->isAllowed( 'email', $recipient, $rateLimitEmail, $rateLimitWindow ) ) {
335 $this->getLogger()->warning(
336 'Rate limit exceeded for email',
337 array(
338 'plugin' => 'double-opt-in',
339 'email' => $recipient,
340 'form_id' => $formData->getFormId(),
341 )
342 );
343 do_action( 'f12_cf7_doubleoptin_rate_limited', 'email', $recipient, $formData->getFormId() );
344 self::setLastError(
345 OptInError::fromCode(
346 OptInError::RATE_LIMIT_EMAIL,
347 array(
348 'email' => $recipient,
349 'form_id' => $formData->getFormId(),
350 )
351 ),
352 $formData->getFormId()
353 );
354 return null;
355 }
356
357 // Validate recipient (extensible via Pro MX check)
358 $recipientValid = apply_filters(
359 'f12_cf7_doubleoptin_validate_recipient',
360 true,
361 $recipient,
362 $formData
363 );
364
365 if ( $recipientValid !== true ) {
366 $errorMsg = is_string( $recipientValid ) ? $recipientValid : '';
367 $errorCode = OptInError::RECIPIENT_INVALID;
368
369 // Unique Email rejection gets its own error code
370 if ( $errorMsg === 'unique_email_rejected' ) {
371 $errorCode = OptInError::UNIQUE_EMAIL_DUPLICATE;
372 $errorMsg = OptInError::fromCode( OptInError::UNIQUE_EMAIL_DUPLICATE )->getMessage();
373 }
374
375 $this->getLogger()->warning(
376 'Recipient validation failed',
377 array(
378 'plugin' => 'double-opt-in',
379 'email' => $recipient,
380 'form_id' => $formData->getFormId(),
381 'reason' => $errorMsg,
382 )
383 );
384 do_action( 'f12_cf7_doubleoptin_recipient_invalid', $recipient, $formData->getFormId(), $errorMsg );
385 self::setLastError(
386 new OptInError(
387 $errorCode,
388 ! empty( $errorMsg ) ? $errorMsg : OptInError::fromCode( OptInError::RECIPIENT_INVALID )->getMessage(),
389 array(
390 'email' => $recipient,
391 'form_id' => $formData->getFormId(),
392 )
393 ),
394 $formData->getFormId()
395 );
396 return null;
397 }
398
399 $properties = $this->buildOptInProperties( $formData, $formParameter, $recipient, $fields, $files );
400
401 $optIn = new OptIn( $this->getLogger(), $properties );
402
403 if ( $optIn->save() ) {
404 $this->getLogger()->info(
405 'OptIn record created successfully',
406 array(
407 'plugin' => 'double-opt-in',
408 'optin_id' => $optIn->get_id(),
409 'form_id' => $formData->getFormId(),
410 )
411 );
412
413 // Dispatch typed event
414 $this->dispatchOptInCreatedEvent( $optIn, $formData );
415
416 // Track telemetry
417 $telemetry = new Telemetry( $this->getLogger() );
418 $telemetry->increment( 'total_optins' );
419 $telemetry->increment( $this->getIdentifier() . '_optins' );
420
421 return $optIn;
422 }
423
424 $this->getLogger()->error(
425 'Failed to save OptIn record',
426 array(
427 'plugin' => 'double-opt-in',
428 'form_id' => $formData->getFormId(),
429 )
430 );
431
432 do_action( 'f12_cf7_doubleoptin_creation_failed', $formData->getFormId(), $recipient );
433
434 self::setLastError(
435 OptInError::fromCode( OptInError::SAVE_FAILED, array( 'form_id' => $formData->getFormId() ) ),
436 $formData->getFormId()
437 );
438
439 return null;
440 }
441
442 /**
443 * Validate the consent-acceptance gate (GDPR Art. 7).
444 *
445 * The decision itself lives in {@see ConsentGate} — this method only
446 * turns it into the return value `createOptIn()` expects and writes
447 * the log lines. Both halves of the plugin share that class now: the
448 * integrations that extend this base, and the legacy path in
449 * `OptInFrontend::maybeCreateOptIn()` that serves Elementor and the
450 * CF7/Avada shims.
451 *
452 * `ConsentGate::FIELD_UNKNOWN` — the configured field is not on this
453 * form — deliberately does NOT reject. Until 5.4.0 it did, which took
454 * a site's registrations offline over a settings mistake the visitor
455 * could neither see nor fix (customer report 2026-08-27). The admin
456 * now hears about it through the log, the Site Health check and the
457 * banner on the form's settings tab instead.
458 *
459 * @param FormDataInterface $formData The submitted form data.
460 * @param array<string, mixed> $formParameter The form-settings snapshot.
461 *
462 * @return OptInError|null Error when the gate rejects; null when the
463 * submission may proceed.
464 */
465 protected function validateConsentAcceptance( FormDataInterface $formData, array $formParameter ): ?OptInError {
466 $consentField = (string) ( $formParameter['consent_field'] ?? '' );
467 if ( $consentField === '' ) {
468 return null;
469 }
470
471 $verdict = ConsentGate::evaluate(
472 $consentField,
473 $formData->getFields(),
474 $this->getKnownFieldNames( $formData->getFormId() )
475 );
476
477 if ( $verdict === ConsentGate::PASSED ) {
478 return null;
479 }
480
481 if ( $verdict === ConsentGate::FIELD_UNKNOWN ) {
482 // Never a rejection — see the ConsentGate docblock. Logged as
483 // a warning all the same: until someone fixes the setting, the
484 // consent proof stored with every opt-in of this form is
485 // worthless.
486 $this->getLogger()->warning(
487 'Consent field is not on this form — opt-in accepted without provable consent',
488 array(
489 'plugin' => 'double-opt-in',
490 'form_id' => $formData->getFormId(),
491 'integration' => $this->getIdentifier(),
492 'consent_field' => $consentField,
493 )
494 );
495
496 /**
497 * Fires when a form's configured acceptance field cannot be
498 * found on the form itself. The submission is accepted.
499 *
500 * @since 5.4.0
501 *
502 * @param int $formId The form the submission came from.
503 * @param string $consentField The configured field name.
504 * @param string $integration The integration identifier.
505 */
506 do_action(
507 'f12_doi_consent_field_unknown',
508 $formData->getFormId(),
509 $consentField,
510 $this->getIdentifier()
511 );
512
513 return null;
514 }
515
516 if ( ! ConsentGate::isEnforced( $formData->getFormId(), $this->getIdentifier() ) ) {
517 $this->getLogger()->warning(
518 'Consent gate disabled by filter — accepting an unconfirmed submission',
519 array(
520 'plugin' => 'double-opt-in',
521 'form_id' => $formData->getFormId(),
522 'integration' => $this->getIdentifier(),
523 'consent_field' => $consentField,
524 )
525 );
526
527 return null;
528 }
529
530 return OptInError::fromCode(
531 OptInError::CONSENT_NOT_GIVEN,
532 array(
533 'form_id' => $formData->getFormId(),
534 'consent_field' => $consentField,
535 )
536 );
537 }
538
539 /**
540 * The names of the fields this form actually declares.
541 *
542 * Needed to tell an unticked checkbox — which the browser leaves out
543 * of the payload entirely — from a `consent_field` pointing at a
544 * field the admin has since renamed or deleted. An integration that
545 * cannot answer yields an empty list, and the gate then stays on the
546 * cautious side and rejects nothing it cannot prove.
547 *
548 * @param int $formId The form to inspect.
549 *
550 * @return array<int,string>
551 */
552 protected function getKnownFieldNames( int $formId ): array {
553 try {
554 return ConsentGate::normalizeFieldNames( $this->getFormFields( $formId ) );
555 } catch ( \Throwable $e ) {
556 $this->getLogger()->warning(
557 'Could not read the form field inventory for the consent gate',
558 array(
559 'plugin' => 'double-opt-in',
560 'form_id' => $formId,
561 'error' => $e->getMessage(),
562 )
563 );
564
565 return array();
566 }
567 }
568
569 /**
570 * Build the OptIn properties array that will be persisted on
571 * record creation. Extracted from {@see createOptIn()} so the
572 * field-coverage contract is testable in isolation — the full
573 * createOptIn() flow has too many side-effects (rate-limiting,
574 * file storage, container access) for clean unit testing.
575 *
576 * Snapshot semantics:
577 * - `consent_text` is captured per GDPR Art. 7 — the consent
578 * record reflects the wording the user actually agreed to,
579 * even if the form's settings change later.
580 * - Custom addon fields land via the `f12_doi_optin_properties`
581 * filter; addons that contribute a per-form setting hook here
582 * to persist a snapshot with each opt-in.
583 *
584 * @param FormDataInterface $formData The submitted form data.
585 * @param array<string, mixed> $formParameter The form-settings snapshot.
586 * @param string $recipient The resolved recipient email.
587 * @param array<string, mixed> $fields Filtered form fields.
588 * @param array<string, mixed> $files Stored file references.
589 *
590 * @return array<string, mixed>
591 */
592 protected function buildOptInProperties(
593 FormDataInterface $formData,
594 array $formParameter,
595 string $recipient,
596 array $fields,
597 array $files
598 ): array {
599 $properties = array(
600 'cf_form_id' => $formData->getFormId(),
601 'doubleoptin' => 0,
602 'createtime' => time(),
603 'content' => maybe_serialize( $fields ),
604 'files' => maybe_serialize( $files ),
605 'ipaddr_register' => IPHelper::getIPAdress(),
606 'category' => (int) ( $formParameter['category'] ?? 0 ),
607 'form' => $formData->getFormHtml(),
608 'email' => $recipient,
609 'consent_text' => (string) ( $formParameter['consent_text'] ?? '' ),
610 'consent_field' => (string) ( $formParameter['consent_field'] ?? '' ),
611 );
612
613 /**
614 * Filter the OptIn properties array before the record is
615 * created. Addons hook this to snapshot their own per-form
616 * settings into the opt-in record at submit time. Mirrors the
617 * symmetric DTO filter pattern (`f12_doi_settings_dto_from_array`
618 * / `f12_doi_settings_dto_sanitize`) — an addon that contributes
619 * a per-form setting AND wants it persisted with each opt-in
620 * snapshots it through this filter.
621 *
622 * @since 4.4.0
623 *
624 * @param array<string, mixed> $properties The properties array
625 * for the new OptIn.
626 * @param FormDataInterface $formData The submitted form data.
627 * @param array<string, mixed> $formParameter The form-settings snapshot.
628 */
629 return apply_filters( 'f12_doi_optin_properties', $properties, $formData, $formParameter );
630 }
631
632 /**
633 * Prepare the opt-in mail body with placeholders replaced.
634 *
635 * @param string $body The mail body template.
636 * @param OptIn $optIn The OptIn record.
637 * @param array $formParameter The form configuration.
638 *
639 * @return string The processed mail body.
640 */
641 protected function prepareMailBody( string $body, OptIn $optIn, array $formParameter ): string {
642 // Replace system placeholders
643 $body = $this->addSystemPlaceholders( $body, $optIn, $formParameter );
644
645 // Replace form field placeholders
646 $formData = maybe_unserialize( $optIn->get_content() );
647 if ( is_array( $formData ) ) {
648 // Handle nested content structure (e.g., Avada stores {data: {...}, field_labels: {...}, ...})
649 // Extract the flat field data for placeholder replacement
650 $fieldData = isset( $formData['data'] ) && is_array( $formData['data'] ) ? $formData['data'] : $formData;
651
652 $body = PlaceholderMapper::replacePlaceholders(
653 $body,
654 $fieldData,
655 $optIn->get_cf_form_id(),
656 array(),
657 $this->getIdentifier()
658 );
659 }
660
661 return $body;
662 }
663
664 /**
665 * Add system placeholders to the mail body.
666 *
667 * @param string $body The mail body.
668 * @param OptIn $optIn The OptIn record.
669 * @param array $formParameter The form configuration.
670 *
671 * @return string The body with placeholders replaced.
672 */
673 protected function addSystemPlaceholders( string $body, OptIn $optIn, array $formParameter ): string {
674 $placeholders = array(
675 // User-influenced (the submit page URL incl. query string) — sanitise
676 // as a URL so a crafted `?x="><script>` can't reflect into the mail
677 // HTML. esc_url_raw (not esc_url) keeps ampersands un-entity-encoded
678 // so the plain-text mail variant stays intact too.
679 'doubleoptin_form_url' => esc_url_raw( (string) ( $formParameter['formUrl'] ?? '' ) ),
680 'doubleoptin_form_subject' => $formParameter['subject'] ?? '',
681 // wp_date() formats in the site's timezone without mutating PHP's
682 // global timezone (the old date() + date_default_timezone_set() did).
683 'doubleoptin_form_date' => wp_date( get_option( 'date_format' ) ),
684 'doubleoptin_form_time' => wp_date( get_option( 'time_format' ) ),
685 'doubleoptin_form_email' => get_option( 'admin_email' ),
686 'doubleoptinlink' => $optIn->get_link_optin( $formParameter ),
687 'doubleoptoutlink' => $optIn->get_link_optout(),
688 'doubleoptin_privacy_url' => $this->getPrivacyPolicyUrl(),
689 );
690
691 foreach ( $placeholders as $key => $value ) {
692 if ( is_array( $value ) || is_object( $value ) ) {
693 $value = wp_json_encode( $value );
694 }
695 $body = str_replace( '[' . $key . ']', (string) $value, $body );
696 }
697
698 return $body;
699 }
700
701 /**
702 * Get the privacy policy URL.
703 *
704 * @return string The privacy policy URL or empty string.
705 */
706 protected function getPrivacyPolicyUrl(): string {
707 $settings = CF7DoubleOptIn::getInstance()->getSettings();
708 $pageId = (int) ( $settings['privacy_policy_page'] ?? 0 );
709
710 if ( $pageId > 0 ) {
711 $url = get_permalink( $pageId );
712 if ( $url ) {
713 return $url;
714 }
715 }
716
717 // Fallback to WordPress privacy policy page
718 $wpPrivacyPageId = (int) get_option( 'wp_page_for_privacy_policy', 0 );
719 if ( $wpPrivacyPageId > 0 ) {
720 $url = get_permalink( $wpPrivacyPageId );
721 if ( $url ) {
722 return $url;
723 }
724 }
725
726 return '';
727 }
728
729 /**
730 * Store uploaded files for later use after opt-in confirmation.
731 *
732 * @param array $files The uploaded files.
733 *
734 * @return array The stored file paths.
735 */
736 protected function storeFiles( array $files ): array {
737 // File-lifecycle plan, Step 1 (2026-05-07): delegate to the
738 // centralised FileStorage service. This moves files into
739 // `wp-content/uploads/f12-doi/pending/` (deny-from-all) with
740 // random hex names, instead of the pre-4.3 in-tmp-dir copy.
741 // The legacy copyAndRenameFile path below stays for now as a
742 // fallback if FileStorage construction fails — removed in
743 // Schritt 2 once each integration's hand-off is wired up.
744 try {
745 $storage = new FileStorage( $this->logger );
746 return $storage->store( $files );
747 } catch ( \Throwable $e ) {
748 $this->logger->error(
749 'FileStorage unavailable, falling back to legacy in-tmp store',
750 array(
751 'plugin' => 'double-opt-in',
752 'error' => $e->getMessage(),
753 )
754 );
755 }
756
757 // ── Legacy fallback (pre-4.3) ─────────────────────────────────
758 $storedFiles = array();
759
760 if ( empty( $files ) ) {
761 return $storedFiles;
762 }
763
764 foreach ( $files as $key => $fileList ) {
765 if ( ! is_array( $fileList ) ) {
766 $fileList = array( $fileList );
767 }
768
769 foreach ( $fileList as $file ) {
770 if ( empty( $file ) || ! is_file( $file ) ) {
771 continue;
772 }
773
774 $newFile = $this->copyAndRenameFile( $file );
775 if ( $newFile ) {
776 $storedFiles[] = $newFile;
777 }
778 }
779 }
780
781 return $storedFiles;
782 }
783
784 /**
785 * Hand off the opt-in's stored files to the integration's own
786 * form-system entry (Avada form_entries / GF entry / WPForms
787 * entry / CF7 mail attachment). Per-integration override.
788 *
789 * Default behaviour: return false (no hand-off). The files stay
790 * in `pending/` until the OptIn is deleted (cron / manual /
791 * REST), at which point cascade-delete removes them.
792 *
793 * Contract (file-lifecycle plan, 2026-05-07):
794 *
795 * - true: hand-off succeeded. Caller (template-method below)
796 * will delete the pending/ copies — single source of
797 * truth = the integration's own DB. GDPR-compliant by
798 * construction (consent-bound retention).
799 *
800 * - false: hand-off failed or not implemented. Files stay in
801 * pending/ and are removed eventually via OptIn-deletion
802 * cascade (cron expiry or manual delete). No silent
803 * data loss.
804 *
805 * @param OptIn $optIn The just-confirmed opt-in record.
806 *
807 * @return bool true on successful hand-off, false otherwise.
808 *
809 * @since 4.3.0
810 */
811 public function handOffFilesToFormSystem( OptIn $optIn ): bool {
812 // Default: not implemented for this integration. Per-
813 // integration overrides in addon-avada / addon-cf7 / etc.
814 return false;
815 }
816
817 /**
818 * Template-method: process file hand-off when an opt-in confirms.
819 *
820 * Hooked on `f12_cf7_doubleoptin_after_confirm` at priority 5
821 * (BEFORE addon-specific listeners that fire at default 10), so
822 * file hand-off completes before any side-effects that might
823 * rely on the integration's entry being fully populated.
824 *
825 * Each subclass registers this in its own `registerHooks()` —
826 * see Schritt 2 per-integration commits.
827 *
828 * @param string $hash The confirmation hash (action arg).
829 * @param OptIn $optIn The confirmed opt-in (action arg).
830 *
831 * @since 4.3.0
832 */
833 final public function processFilesOnConfirm( string $hash, OptIn $optIn ): void {
834 // Bail for opt-ins that aren't this integration's responsibility.
835 // The after_confirm action fires for ALL types — every integration
836 // hooks it but only acts on its own.
837 if ( ! $optIn->isType( $this->getIdentifier() ) ) {
838 return;
839 }
840
841 $rawFiles = (string) $optIn->get_files();
842 $decoded = $rawFiles === '' ? array() : maybe_unserialize( $rawFiles );
843 $decoded = is_array( $decoded ) ? $decoded : array();
844 $files = array_values(
845 array_filter(
846 $decoded,
847 static function ( $p ) { return is_string( $p ) && $p !== ''; }
848 )
849 );
850
851 if ( empty( $files ) ) {
852 return;
853 }
854
855 $handedOff = $this->handOffFilesToFormSystem( $optIn );
856
857 if ( ! $handedOff ) {
858 $this->logger->info(
859 'File hand-off not performed (or failed) — pending files will be cleaned up on OptIn deletion',
860 array(
861 'plugin' => 'double-opt-in',
862 'integration' => $this->getIdentifier(),
863 'optin_id' => $optIn->get_id(),
864 'file_count' => count( $files ),
865 )
866 );
867 return;
868 }
869
870 // Hand-off succeeded → integration now owns the files in its
871 // own DB. Delete our pending copies to avoid duplicate storage.
872 try {
873 $storage = new FileStorage( $this->logger );
874 $storage->deletePaths( $files );
875
876 // Clear the OptIn::files column so the cascade-delete on
877 // later OptIn removal doesn't try to re-unlink missing
878 // paths. Idempotent if save fails — pending dir is empty
879 // either way.
880 if ( method_exists( $optIn, 'set_files' ) ) {
881 $optIn->set_files( serialize( array() ) );
882 if ( method_exists( $optIn, 'save' ) ) {
883 $optIn->save();
884 }
885 }
886
887 $this->logger->info(
888 'Files handed off to form system + pending copies deleted',
889 array(
890 'plugin' => 'double-opt-in',
891 'integration' => $this->getIdentifier(),
892 'optin_id' => $optIn->get_id(),
893 'file_count' => count( $files ),
894 )
895 );
896 } catch ( \Throwable $e ) {
897 $this->logger->error(
898 'Hand-off succeeded but pending-cleanup failed',
899 array(
900 'plugin' => 'double-opt-in',
901 'error' => $e->getMessage(),
902 )
903 );
904 }
905 }
906
907 /**
908 * Allowed MIME types for file uploads stored with opt-in records.
909 */
910 private const ALLOWED_MIME_TYPES = array(
911 'jpg' => 'image/jpeg',
912 'jpeg' => 'image/jpeg',
913 'png' => 'image/png',
914 'gif' => 'image/gif',
915 'webp' => 'image/webp',
916 'pdf' => 'application/pdf',
917 'doc' => 'application/msword',
918 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
919 'txt' => 'text/plain',
920 'csv' => 'text/csv',
921 );
922
923 /**
924 * Copy and rename a file with a unique, non-guessable name.
925 *
926 * Validates the file MIME type against an allowlist before copying.
927 *
928 * @param string $file The source file path.
929 *
930 * @return string|null The new file path or null on failure/rejection.
931 */
932 private function copyAndRenameFile( string $file ): ?string {
933 $allowedMimes = apply_filters( 'f12_cf7_doubleoptin_allowed_mime_types', self::ALLOWED_MIME_TYPES );
934
935 $fileType = wp_check_filetype_and_ext( $file, wp_basename( $file ), $allowedMimes );
936
937 if ( empty( $fileType['type'] ) || empty( $fileType['ext'] ) ) {
938 $this->getLogger()->warning(
939 'File rejected: MIME type not allowed',
940 array(
941 'plugin' => 'double-opt-in',
942 'original' => $file,
943 )
944 );
945 return null;
946 }
947
948 $pathParts = explode( '/', $file );
949 array_pop( $pathParts );
950 $newName = bin2hex( random_bytes( 16 ) ) . '.' . $fileType['ext'];
951 $pathParts[] = $newName;
952 $newFile = implode( '/', $pathParts );
953
954 if ( copy( $file, $newFile ) ) {
955 $this->getLogger()->debug(
956 'File copied successfully',
957 array(
958 'plugin' => 'double-opt-in',
959 'original' => $file,
960 'new_file' => $newFile,
961 )
962 );
963 return $newFile;
964 }
965
966 $this->getLogger()->error(
967 'Failed to copy file',
968 array(
969 'plugin' => 'double-opt-in',
970 'original' => $file,
971 )
972 );
973
974 return null;
975 }
976
977 /**
978 * Validate and confirm an opt-in by hash.
979 *
980 * @param string $hash The opt-in hash.
981 *
982 * @return bool True if the opt-in was confirmed successfully.
983 */
984 public function validateOptIn( string $hash ): bool {
985 $optIn = OptIn::get_by_hash( $hash );
986
987 if ( ! $optIn ) {
988 $this->getLogger()->warning(
989 'OptIn not found for hash',
990 array(
991 'plugin' => 'double-opt-in',
992 'hash' => $hash,
993 )
994 );
995 self::setValidationStatus( 'not_found' );
996 return false;
997 }
998
999 // Check if this opt-in belongs to this integration
1000 if ( ! $optIn->isType( $this->getIdentifier() ) ) {
1001 return false;
1002 }
1003
1004 // Check if the token has expired
1005 $settings = CF7DoubleOptIn::getInstance()->getSettings();
1006 $expiryHours = (int) ( $settings['token_expiry_hours'] ?? 48 );
1007 if ( $expiryHours > 0 && ( time() - (int) $optIn->get_createtime() ) > ( $expiryHours * 3600 ) ) {
1008 $this->getLogger()->info(
1009 'OptIn token expired',
1010 array(
1011 'plugin' => 'double-opt-in',
1012 'optin_id' => $optIn->get_id(),
1013 'expiry_hours' => $expiryHours,
1014 )
1015 );
1016 do_action( 'f12_cf7_doubleoptin_token_expired', $hash, $optIn );
1017 self::setValidationStatus( 'expired' );
1018 return false;
1019 }
1020
1021 // Skip if already confirmed
1022 if ( $optIn->is_confirmed() ) {
1023 $this->getLogger()->info(
1024 'OptIn already confirmed',
1025 array(
1026 'plugin' => 'double-opt-in',
1027 'optin_id' => $optIn->get_id(),
1028 )
1029 );
1030 do_action( 'f12_cf7_doubleoptin_already_confirmed', $hash, $optIn );
1031 self::setValidationStatus( 'already_confirmed' );
1032 return false;
1033 }
1034
1035 // Confirm the opt-in
1036 do_action( 'f12_cf7_doubleoptin_before_confirm', $hash, $optIn );
1037
1038 $optIn->set_doubleoptin( 1 );
1039 $optIn->set_updatetime( time() );
1040 $optIn->set_ipaddr_confirmation( IPHelper::getIPAdress() );
1041
1042 if ( ! $optIn->save() ) {
1043 $this->getLogger()->error(
1044 'Failed to confirm OptIn',
1045 array(
1046 'plugin' => 'double-opt-in',
1047 'optin_id' => $optIn->get_id(),
1048 )
1049 );
1050 return false;
1051 }
1052
1053 self::setValidationStatus( 'confirmed' );
1054
1055 // Track telemetry
1056 $telemetry = new Telemetry( $this->getLogger() );
1057 $telemetry->increment( 'confirmed_optins' );
1058
1059 // Dispatch event
1060 $this->dispatchOptInConfirmedEvent( $optIn, $hash );
1061
1062 do_action( 'f12_cf7_doubleoptin_after_confirm', $hash, $optIn );
1063
1064 // Send the original mail if enabled
1065 if ( apply_filters( 'f12_cf7_doubleoptin_send_default_mail', true, $optIn->get_cf_form_id() ) ) {
1066 do_action( 'f12_cf7_doubleoptin_before_send_default_mail', $optIn );
1067 $this->sendConfirmationMail( $optIn );
1068 do_action( 'f12_cf7_doubleoptin_after_send_default_mail', $optIn );
1069 }
1070
1071 $this->getLogger()->info(
1072 'OptIn confirmed successfully',
1073 array(
1074 'plugin' => 'double-opt-in',
1075 'optin_id' => $optIn->get_id(),
1076 )
1077 );
1078
1079 return true;
1080 }
1081
1082 /**
1083 * Remove stored files after processing.
1084 *
1085 * @param OptIn $optIn The opt-in record.
1086 *
1087 * @return void
1088 */
1089 public function removeStoredFiles( OptIn $optIn ): void {
1090 $files = maybe_unserialize( $optIn->get_files() );
1091
1092 if ( empty( $files ) || ! is_array( $files ) ) {
1093 return;
1094 }
1095
1096 foreach ( $files as $file ) {
1097 if ( empty( $file ) || ! is_file( $file ) ) {
1098 continue;
1099 }
1100
1101 if ( unlink( $file ) ) {
1102 $this->getLogger()->debug(
1103 'File removed successfully',
1104 array(
1105 'plugin' => 'double-opt-in',
1106 'file' => $file,
1107 )
1108 );
1109 } else {
1110 $this->getLogger()->warning(
1111 'Failed to remove file',
1112 array(
1113 'plugin' => 'double-opt-in',
1114 'file' => $file,
1115 )
1116 );
1117 }
1118 }
1119 }
1120
1121 /**
1122 * Disable spam protection hooks before sending confirmation mail.
1123 *
1124 * @return void
1125 */
1126 protected function beforeSendConfirmationMail(): void {
1127 // Disable CF7 validation for confirmation mail resend.
1128 // CF7 re-runs all form validations (required fields, quiz, acceptance checkboxes)
1129 // when creating a WPCF7_Submission instance. Since this is a confirmed opt-in
1130 // (not a real form submit), these validations must be bypassed.
1131 add_filter( 'wpcf7_validate', array( $this, 'clearValidationResult' ), 999 );
1132 add_filter( 'wpcf7_spam', '__return_false', 0 );
1133 add_filter( 'wpcf7_skip_spam_check', '__return_true', 0 );
1134
1135 // Disable CF7 Captcha if present
1136 add_filter( 'f12_cf7_captcha_is_installed_cf7', '__return_false', 999 );
1137
1138 // Remove reCAPTCHA filter
1139 remove_filter( 'wpcf7_spam', 'wpcf7_recaptcha_verify_response', 9 );
1140
1141 // Remove hCaptcha validation filter
1142 $this->removeHCaptchaFilter();
1143
1144 $this->getLogger()->debug(
1145 'Validation and spam protection disabled for confirmation mail',
1146 array(
1147 'plugin' => 'double-opt-in',
1148 )
1149 );
1150 }
1151
1152 /**
1153 * Clear CF7 validation result to bypass field validation during confirmation mail.
1154 *
1155 * @param \WPCF7_Validation $result The validation result.
1156 *
1157 * @return \WPCF7_Validation A clean validation result with no errors.
1158 */
1159 public function clearValidationResult( $result ) {
1160 return new \WPCF7_Validation();
1161 }
1162
1163 /**
1164 * Re-enable spam protection hooks after sending confirmation mail.
1165 *
1166 * @return void
1167 */
1168 protected function afterSendConfirmationMail(): void {
1169 // Re-enable CF7 validation
1170 remove_filter( 'wpcf7_validate', array( $this, 'clearValidationResult' ), 999 );
1171 remove_filter( 'wpcf7_spam', '__return_false', 0 );
1172 remove_filter( 'wpcf7_skip_spam_check', '__return_true', 0 );
1173
1174 // Re-add reCAPTCHA filter
1175 if ( function_exists( 'wpcf7_recaptcha_verify_response' ) ) {
1176 add_filter( 'wpcf7_spam', 'wpcf7_recaptcha_verify_response', 9, 2 );
1177 }
1178
1179 // Re-add CF7 Captcha hooks
1180 if ( class_exists( '\forge12\contactform7\CF7Captcha\TimerValidatorCF7' ) ) {
1181 add_filter( 'wpcf7_spam', '\forge12\contactform7\CF7Captcha\TimerValidatorCF7::isSpam', 100, 2 );
1182 add_filter( 'wpcf7_spam', '\forge12\contactform7\CF7Captcha\CF7IPLog::isSpam', 100, 2 );
1183 add_action( 'wpcf7_mail_sent', '\forge12\contactform7\CF7Captcha\CF7IPLog::doLogIP', 100, 1 );
1184 }
1185
1186 // Re-add hCaptcha validation filter
1187 $this->restoreHCaptchaFilter();
1188
1189 $this->getLogger()->debug(
1190 'Validation and spam protection re-enabled',
1191 array(
1192 'plugin' => 'double-opt-in',
1193 )
1194 );
1195 }
1196
1197 /**
1198 * Remove hCaptcha CF7 validation filter and store the instance for later restore.
1199 *
1200 * @return void
1201 */
1202 private function removeHCaptchaFilter(): void {
1203 if ( ! class_exists( '\HCaptcha\CF7\CF7' ) ) {
1204 return;
1205 }
1206
1207 global $wp_filter;
1208
1209 if ( ! isset( $wp_filter['wpcf7_validate'] ) ) {
1210 return;
1211 }
1212
1213 foreach ( $wp_filter['wpcf7_validate']->callbacks as $priority => $hooks ) {
1214 foreach ( $hooks as $key => $hook ) {
1215 if ( is_array( $hook['function'] ) && $hook['function'][0] instanceof \HCaptcha\CF7\CF7 ) {
1216 $this->hcaptchaCf7Instance = $hook['function'][0];
1217 $this->hcaptchaCf7Priority = $priority;
1218 remove_filter( 'wpcf7_validate', $hook['function'], $priority );
1219 $this->getLogger()->debug(
1220 'hCaptcha CF7 validation filter removed',
1221 array(
1222 'plugin' => 'double-opt-in',
1223 )
1224 );
1225
1226 return;
1227 }
1228 }
1229 }
1230 }
1231
1232 /**
1233 * Re-add hCaptcha CF7 validation filter if it was previously removed.
1234 *
1235 * @return void
1236 */
1237 private function restoreHCaptchaFilter(): void {
1238 if ( isset( $this->hcaptchaCf7Instance ) ) {
1239 add_filter( 'wpcf7_validate', array( $this->hcaptchaCf7Instance, 'verify_hcaptcha' ), $this->hcaptchaCf7Priority, 2 );
1240 $this->getLogger()->debug(
1241 'hCaptcha CF7 validation filter re-added',
1242 array(
1243 'plugin' => 'double-opt-in',
1244 )
1245 );
1246 unset( $this->hcaptchaCf7Instance, $this->hcaptchaCf7Priority );
1247 }
1248 }
1249
1250 /**
1251 * Dispatch FormSubmissionEvent.
1252 *
1253 * @param FormDataInterface $formData The form data.
1254 *
1255 * @return FormSubmissionEvent|null The event or null if dispatcher unavailable.
1256 */
1257 protected function dispatchFormSubmissionEvent( FormDataInterface $formData ): ?FormSubmissionEvent {
1258 try {
1259 $container = Container::getInstance();
1260 if ( $container->has( EventDispatcherInterface::class ) ) {
1261 $dispatcher = $container->get( EventDispatcherInterface::class );
1262 $event = new FormSubmissionEvent(
1263 $formData,
1264 $this->getIdentifier()
1265 );
1266 $dispatcher->dispatch( $event );
1267 return $event;
1268 }
1269 } catch ( \Exception $e ) {
1270 $this->getLogger()->warning(
1271 'Failed to dispatch FormSubmissionEvent',
1272 array(
1273 'plugin' => 'double-opt-in',
1274 'error' => $e->getMessage(),
1275 )
1276 );
1277 }
1278 return null;
1279 }
1280
1281 /**
1282 * Dispatch OptInCreatedEvent.
1283 *
1284 * @param OptIn $optIn The created opt-in.
1285 * @param FormDataInterface $formData The form data.
1286 *
1287 * @return void
1288 */
1289 protected function dispatchOptInCreatedEvent( OptIn $optIn, FormDataInterface $formData ): void {
1290 try {
1291 $container = Container::getInstance();
1292 if ( $container->has( EventDispatcherInterface::class ) ) {
1293 $dispatcher = $container->get( EventDispatcherInterface::class );
1294 $event = new OptInCreatedEvent(
1295 $optIn->get_id(),
1296 $formData->getFormId(),
1297 $this->getIdentifier(),
1298 $optIn->get_email(),
1299 $optIn->get_hash(),
1300 $formData->getFields()
1301 );
1302 $dispatcher->dispatch( $event );
1303 }
1304 } catch ( \Exception $e ) {
1305 $this->getLogger()->warning(
1306 'Failed to dispatch OptInCreatedEvent',
1307 array(
1308 'plugin' => 'double-opt-in',
1309 'error' => $e->getMessage(),
1310 )
1311 );
1312 }
1313 }
1314
1315 /**
1316 * Dispatch OptInConfirmedEvent.
1317 *
1318 * @param OptIn $optIn The confirmed opt-in.
1319 * @param string $hash The opt-in hash.
1320 *
1321 * @return void
1322 */
1323 protected function dispatchOptInConfirmedEvent( OptIn $optIn, string $hash ): void {
1324 try {
1325 $container = Container::getInstance();
1326 if ( $container->has( EventDispatcherInterface::class ) ) {
1327 $dispatcher = $container->get( EventDispatcherInterface::class );
1328
1329 $formData = maybe_unserialize( $optIn->get_content() );
1330
1331 $event = new OptInConfirmedEvent(
1332 $optIn->get_id(),
1333 $hash,
1334 $optIn->get_email(),
1335 $optIn->get_ipaddr_confirmation(),
1336 (int) $optIn->get_cf_form_id(),
1337 is_array( $formData ) ? $formData : array()
1338 );
1339 $dispatcher->dispatch( $event );
1340 }
1341 } catch ( \Exception $e ) {
1342 $this->getLogger()->warning(
1343 'Failed to dispatch OptInConfirmedEvent',
1344 array(
1345 'plugin' => 'double-opt-in',
1346 'error' => $e->getMessage(),
1347 )
1348 );
1349 }
1350 }
1351
1352 /**
1353 * Get the post type for this integration's forms.
1354 *
1355 * @since 4.1.0
1356 *
1357 * @return string The post type.
1358 */
1359 abstract protected function getPostType(): string;
1360
1361 /**
1362 * {@inheritdoc}
1363 */
1364 public function getForms(): array {
1365 $posts = get_posts(
1366 array(
1367 'post_type' => $this->getPostType(),
1368 'posts_per_page' => -1,
1369 'post_status' => 'publish',
1370 'orderby' => 'title',
1371 'order' => 'ASC',
1372 )
1373 );
1374
1375 $forms = array();
1376 foreach ( $posts as $post ) {
1377 $parameter = $this->getFormParameter( $post->ID );
1378 $forms[] = array(
1379 'id' => $post->ID,
1380 'title' => $post->post_title,
1381 'integration' => $this->getIdentifier(),
1382 'enabled' => (int) ( $parameter['enable'] ?? 0 ) === 1,
1383 'edit_url' => $this->getFormEditUrl( $post->ID ),
1384 );
1385 }
1386
1387 $this->getLogger()->debug(
1388 'Retrieved forms for integration',
1389 array(
1390 'plugin' => 'double-opt-in',
1391 'integration' => $this->getIdentifier(),
1392 'count' => count( $forms ),
1393 )
1394 );
1395
1396 return $forms;
1397 }
1398
1399 /**
1400 * {@inheritdoc}
1401 */
1402 public function getFormTitle( $formId ): string {
1403 $post = get_post( (int) $formId );
1404 return $post ? $post->post_title : '';
1405 }
1406
1407 /**
1408 * {@inheritdoc}
1409 */
1410 public function getFormEditUrl( $formId ): string {
1411 return get_edit_post_link( (int) $formId, 'raw' ) ?: '';
1412 }
1413 }
1414