# double-opt-in/5.5.0/src/Integration/AbstractFormIntegration.php

Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification, version 5.5.0. 1,414 lines.

- Page: https://pluginprobe.com/plugins/double-opt-in/5.5.0/code/src/Integration/AbstractFormIntegration.php
- Raw: https://pluginprobe.com/plugins/double-opt-in/5.5.0/raw/src/Integration/AbstractFormIntegration.php
- Modified: 2026-08-28T11:48:48+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/double-opt-in/5.5.0/code/src/Integration/AbstractFormIntegration.php#L10-L20`.

```php
<?php
/**
 * Abstract Form Integration
 *
 * @package Forge12\DoubleOptIn\Integration
 * @since   4.0.0
 */

namespace Forge12\DoubleOptIn\Integration;

use Forge12\DoubleOptIn\Consent\ConsentGate;
use Forge12\DoubleOptIn\Container\Container;
use Forge12\DoubleOptIn\EmailTemplates\PlaceholderMapper;
use Forge12\DoubleOptIn\EventSystem\EventDispatcherInterface;
use Forge12\DoubleOptIn\Events\Integration\FormSubmissionEvent;
use Forge12\DoubleOptIn\Events\Lifecycle\OptInConfirmedEvent;
use Forge12\DoubleOptIn\Events\Lifecycle\OptInCreatedEvent;
use Forge12\DoubleOptIn\Files\FileStorage;
use Forge12\DoubleOptIn\Frontend\ErrorNotification;
use Forge12\DoubleOptIn\Service\RateLimiter;
use forge12\contactform7\CF7DoubleOptIn\CF7DoubleOptIn;
use forge12\contactform7\CF7DoubleOptIn\IPHelper;
use forge12\contactform7\CF7DoubleOptIn\OptIn;
use forge12\contactform7\CF7DoubleOptIn\OptInFrontend;
use forge12\contactform7\CF7DoubleOptIn\Telemetry;
use Forge12\Shared\LoggerInterface;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Class AbstractFormIntegration
 *
 * @api
 *
 * Base class providing common functionality for all form integrations.
 * Extracted from the legacy OptInFrontend class to provide reusable logic.
 * Addons that integrate a form system extend this class to reduce
 * boilerplate — see docs/addon-api.md §4.3. Covered by the Addon API
 * semver policy as of Core API 4.3.0.
 */
abstract class AbstractFormIntegration implements FormIntegrationInterface {

	/**
	 * Logger instance.
	 *
	 * @var LoggerInterface
	 */
	protected LoggerInterface $logger;

	/**
	 * Validation status from the last validateOptIn() call.
	 *
	 * @var string
	 */
	private static string $validationStatus = '';

	/**
	 * Stored hCaptcha CF7 instance for restore after mail send.
	 *
	 * @var object|null
	 */
	private $hcaptchaCf7Instance = null;

	/**
	 * Stored hCaptcha CF7 filter priority for restore after mail send.
	 *
	 * @var int
	 */
	private int $hcaptchaCf7Priority = 20;

	/**
	 * Last error from the most recent createOptIn() call.
	 *
	 * @var OptInError|null
	 */
	private static ?OptInError $lastError = null;

	/**
	 * Get the validation status from the last validateOptIn() call.
	 *
	 * @return string One of: '', 'confirmed', 'already_confirmed', 'expired', 'not_found'.
	 */
	public static function getValidationStatus(): string {
		return self::$validationStatus;
	}

	/**
	 * Set the validation status.
	 *
	 * @param string $status The validation status.
	 */
	private static function setValidationStatus( string $status ): void {
		self::$validationStatus = $status;
	}

	/**
	 * Get the last error from the most recent createOptIn() call.
	 *
	 * @return OptInError|null The error, or null if no error occurred.
	 */
	public static function getLastError(): ?OptInError {
		return self::$lastError;
	}

	/**
	 * Clear the last error.
	 */
	private static function clearLastError(): void {
		self::$lastError = null;
	}

	/**
	 * Set the last error and store it for the frontend notification system.
	 *
	 * @param OptInError $error  The error that occurred.
	 * @param int        $formId The form ID.
	 */
	private static function setLastError( OptInError $error, int $formId ): void {
		self::$lastError = $error;
		ErrorNotification::store( $error, $formId );
	}

	/**
	 * Get the recipient validation error from the last createOptIn() call.
	 *
	 * @deprecated Use getLastError() instead.
	 *
	 * @return string The error message, or empty string if no error.
	 */
	public static function getLastRecipientValidationError(): string {
		if ( self::$lastError && self::$lastError->getCode() === OptInError::RECIPIENT_INVALID ) {
			return self::$lastError->getMessage();
		}
		return '';
	}

	/**
	 * Constructor.
	 *
	 * @param LoggerInterface $logger The logger instance.
	 */
	public function __construct( LoggerInterface $logger ) {
		$this->logger = $logger;
	}

	/**
	 * Get the logger instance.
	 *
	 * @return LoggerInterface
	 */
	protected function getLogger(): LoggerInterface {
		return $this->logger;
	}

	/**
	 * {@inheritdoc}
	 */
	public function getHookPriority(): int {
		return 10;
	}

	/**
	 * {@inheritdoc}
	 */
	public function getFormParameter( int $formId ): array {
		return CF7DoubleOptIn::getInstance()->getParameter( $formId );
	}

	/**
	 * {@inheritdoc}
	 */
	public function isOptInEnabled( int $formId ): bool {
		// Disable if opt-in confirmation is in progress
		if ( isset( $_GET['optin'] ) ) {
			$this->getLogger()->debug(
				'Opt-in disabled due to optin flag in GET request',
				array(
					'plugin' => 'double-opt-in',
					'class'  => static::class,
				)
			);
			return false;
		}

		$parameter = $this->getFormParameter( $formId );

		if ( (int) ( $parameter['enable'] ?? 0 ) !== 1 ) {
			$this->getLogger()->debug(
				'Opt-in not enabled in form parameter',
				array(
					'plugin'  => 'double-opt-in',
					'form_id' => $formId,
				)
			);
			return false;
		}

		// Check the custom condition
		if ( isset( $parameter['conditions'] ) ) {
			$condition = sanitize_text_field( $parameter['conditions'] );

			if ( ( $condition !== 'disable' && $condition !== 'disabled' )
				&& ( ! isset( $_POST[ $condition ] ) || empty( $_POST[ $condition ] ) ) ) {
				$this->getLogger()->debug(
					'Opt-in disabled due to unmet custom condition',
					array(
						'plugin'    => 'double-opt-in',
						'condition' => $condition,
					)
				);
				return false;
			}
		}

		return true;
	}

	/**
	 * Create an OptIn record from form data.
	 *
	 * @param FormDataInterface $formData      The normalized form data.
	 * @param array             $formParameter The form configuration.
	 *
	 * @return OptIn|null The created OptIn or null on failure.
	 */
	protected function createOptIn( FormDataInterface $formData, array $formParameter ): ?OptIn {
		$this->getLogger()->debug(
			'Creating OptIn record',
			array(
				'plugin'    => 'double-opt-in',
				'class'     => static::class,
				'form_id'   => $formData->getFormId(),
				'form_type' => $formData->getFormType(),
			)
		);

		// Clear previous error
		self::clearLastError();

		// Dispatch FormSubmissionEvent to allow modifications/cancellation

		$event = $this->dispatchFormSubmissionEvent( $formData );
		if ( $event && $event->shouldSkipOptIn() ) {
			$this->getLogger()->info(
				'OptIn skipped by FormSubmissionEvent',
				array(
					'plugin'  => 'double-opt-in',
					'form_id' => $formData->getFormId(),
				)
			);
			self::setLastError(
				OptInError::fromCode( OptInError::SUBMISSION_CANCELLED, array( 'form_id' => $formData->getFormId() ) ),
				$formData->getFormId()
			);
			return null;
		}

		// Store uploaded files
		$files = $this->storeFiles( $formData->getFiles() );

		// Filter parameters before saving
		$fields = apply_filters( 'f12_cf7_doubleoptin_add_request_parameter', $formData->getFields() );

		// Resolve recipient email
		$recipient = $this->resolveRecipient( $formData, $formParameter );

		if ( empty( $recipient ) ) {
			$this->getLogger()->warning(
				'No recipient found, skipping OptIn creation',
				array(
					'plugin'  => 'double-opt-in',
					'form_id' => $formData->getFormId(),
				)
			);
			self::setLastError(
				OptInError::fromCode( OptInError::NO_RECIPIENT, array( 'form_id' => $formData->getFormId() ) ),
				$formData->getFormId()
			);
			return null;
		}

		$consentError = $this->validateConsentAcceptance( $formData, $formParameter );
		if ( $consentError !== null ) {
			$this->getLogger()->info(
				'Consent acceptance not given, rejecting OptIn',
				array(
					'plugin'        => 'double-opt-in',
					'form_id'       => $formData->getFormId(),
					'consent_field' => $consentError->getContext()['consent_field'] ?? '',
				)
			);
			do_action(
				'f12_cf7_doubleoptin_consent_not_given',
				$formData->getFormId(),
				$consentError->getContext()['consent_field'] ?? ''
			);
			self::setLastError( $consentError, $formData->getFormId() );
			return null;
		}

		// Rate-Limiting: Check IP and email limits before creating OptIn
		$rateLimiter     = new RateLimiter();
		$settings        = CF7DoubleOptIn::getInstance()->getSettings();
		$rateLimitIp     = (int) ( $settings['rate_limit_ip'] ?? 5 );
		$rateLimitEmail  = (int) ( $settings['rate_limit_email'] ?? 3 );
		$rateLimitWindow = (int) ( $settings['rate_limit_window'] ?? 60 );

		$ip = IPHelper::getIPAdress();
		if ( ! $rateLimiter->isAllowed( 'ip', $ip, $rateLimitIp, $rateLimitWindow ) ) {
			$this->getLogger()->warning(
				'Rate limit exceeded for IP',
				array(
					'plugin'  => 'double-opt-in',
					'ip'      => $ip,
					'form_id' => $formData->getFormId(),
				)
			);
			do_action( 'f12_cf7_doubleoptin_rate_limited', 'ip', $ip, $formData->getFormId() );
			self::setLastError(
				OptInError::fromCode(
					OptInError::RATE_LIMIT_IP,
					array(
						'ip'      => $ip,
						'form_id' => $formData->getFormId(),
					)
				),
				$formData->getFormId()
			);
			return null;
		}

		if ( ! $rateLimiter->isAllowed( 'email', $recipient, $rateLimitEmail, $rateLimitWindow ) ) {
			$this->getLogger()->warning(
				'Rate limit exceeded for email',
				array(
					'plugin'  => 'double-opt-in',
					'email'   => $recipient,
					'form_id' => $formData->getFormId(),
				)
			);
			do_action( 'f12_cf7_doubleoptin_rate_limited', 'email', $recipient, $formData->getFormId() );
			self::setLastError(
				OptInError::fromCode(
					OptInError::RATE_LIMIT_EMAIL,
					array(
						'email'   => $recipient,
						'form_id' => $formData->getFormId(),
					)
				),
				$formData->getFormId()
			);
			return null;
		}

		// Validate recipient (extensible via Pro MX check)
		$recipientValid = apply_filters(
			'f12_cf7_doubleoptin_validate_recipient',
			true,
			$recipient,
			$formData
		);

		if ( $recipientValid !== true ) {
			$errorMsg  = is_string( $recipientValid ) ? $recipientValid : '';
			$errorCode = OptInError::RECIPIENT_INVALID;

			// Unique Email rejection gets its own error code
			if ( $errorMsg === 'unique_email_rejected' ) {
				$errorCode = OptInError::UNIQUE_EMAIL_DUPLICATE;
				$errorMsg  = OptInError::fromCode( OptInError::UNIQUE_EMAIL_DUPLICATE )->getMessage();
			}

			$this->getLogger()->warning(
				'Recipient validation failed',
				array(
					'plugin'  => 'double-opt-in',
					'email'   => $recipient,
					'form_id' => $formData->getFormId(),
					'reason'  => $errorMsg,
				)
			);
			do_action( 'f12_cf7_doubleoptin_recipient_invalid', $recipient, $formData->getFormId(), $errorMsg );
			self::setLastError(
				new OptInError(
					$errorCode,
					! empty( $errorMsg ) ? $errorMsg : OptInError::fromCode( OptInError::RECIPIENT_INVALID )->getMessage(),
					array(
						'email'   => $recipient,
						'form_id' => $formData->getFormId(),
					)
				),
				$formData->getFormId()
			);
			return null;
		}

		$properties = $this->buildOptInProperties( $formData, $formParameter, $recipient, $fields, $files );

		$optIn = new OptIn( $this->getLogger(), $properties );

		if ( $optIn->save() ) {
			$this->getLogger()->info(
				'OptIn record created successfully',
				array(
					'plugin'   => 'double-opt-in',
					'optin_id' => $optIn->get_id(),
					'form_id'  => $formData->getFormId(),
				)
			);

			// Dispatch typed event
			$this->dispatchOptInCreatedEvent( $optIn, $formData );

			// Track telemetry
			$telemetry = new Telemetry( $this->getLogger() );
			$telemetry->increment( 'total_optins' );
			$telemetry->increment( $this->getIdentifier() . '_optins' );

			return $optIn;
		}

		$this->getLogger()->error(
			'Failed to save OptIn record',
			array(
				'plugin'  => 'double-opt-in',
				'form_id' => $formData->getFormId(),
			)
		);

		do_action( 'f12_cf7_doubleoptin_creation_failed', $formData->getFormId(), $recipient );

		self::setLastError(
			OptInError::fromCode( OptInError::SAVE_FAILED, array( 'form_id' => $formData->getFormId() ) ),
			$formData->getFormId()
		);

		return null;
	}

	/**
	 * Validate the consent-acceptance gate (GDPR Art. 7).
	 *
	 * The decision itself lives in {@see ConsentGate} — this method only
	 * turns it into the return value `createOptIn()` expects and writes
	 * the log lines. Both halves of the plugin share that class now: the
	 * integrations that extend this base, and the legacy path in
	 * `OptInFrontend::maybeCreateOptIn()` that serves Elementor and the
	 * CF7/Avada shims.
	 *
	 * `ConsentGate::FIELD_UNKNOWN` — the configured field is not on this
	 * form — deliberately does NOT reject. Until 5.4.0 it did, which took
	 * a site's registrations offline over a settings mistake the visitor
	 * could neither see nor fix (customer report 2026-08-27). The admin
	 * now hears about it through the log, the Site Health check and the
	 * banner on the form's settings tab instead.
	 *
	 * @param FormDataInterface    $formData      The submitted form data.
	 * @param array<string, mixed> $formParameter The form-settings snapshot.
	 *
	 * @return OptInError|null Error when the gate rejects; null when the
	 *                         submission may proceed.
	 */
	protected function validateConsentAcceptance( FormDataInterface $formData, array $formParameter ): ?OptInError {
		$consentField = (string) ( $formParameter['consent_field'] ?? '' );
		if ( $consentField === '' ) {
			return null;
		}

		$verdict = ConsentGate::evaluate(
			$consentField,
			$formData->getFields(),
			$this->getKnownFieldNames( $formData->getFormId() )
		);

		if ( $verdict === ConsentGate::PASSED ) {
			return null;
		}

		if ( $verdict === ConsentGate::FIELD_UNKNOWN ) {
			// Never a rejection — see the ConsentGate docblock. Logged as
			// a warning all the same: until someone fixes the setting, the
			// consent proof stored with every opt-in of this form is
			// worthless.
			$this->getLogger()->warning(
				'Consent field is not on this form — opt-in accepted without provable consent',
				array(
					'plugin'        => 'double-opt-in',
					'form_id'       => $formData->getFormId(),
					'integration'   => $this->getIdentifier(),
					'consent_field' => $consentField,
				)
			);

			/**
			 * Fires when a form's configured acceptance field cannot be
			 * found on the form itself. The submission is accepted.
			 *
			 * @since 5.4.0
			 *
			 * @param int    $formId       The form the submission came from.
			 * @param string $consentField The configured field name.
			 * @param string $integration  The integration identifier.
			 */
			do_action(
				'f12_doi_consent_field_unknown',
				$formData->getFormId(),
				$consentField,
				$this->getIdentifier()
			);

			return null;
		}

		if ( ! ConsentGate::isEnforced( $formData->getFormId(), $this->getIdentifier() ) ) {
			$this->getLogger()->warning(
				'Consent gate disabled by filter — accepting an unconfirmed submission',
				array(
					'plugin'        => 'double-opt-in',
					'form_id'       => $formData->getFormId(),
					'integration'   => $this->getIdentifier(),
					'consent_field' => $consentField,
				)
			);

			return null;
		}

		return OptInError::fromCode(
			OptInError::CONSENT_NOT_GIVEN,
			array(
				'form_id'       => $formData->getFormId(),
				'consent_field' => $consentField,
			)
		);
	}

	/**
	 * The names of the fields this form actually declares.
	 *
	 * Needed to tell an unticked checkbox — which the browser leaves out
	 * of the payload entirely — from a `consent_field` pointing at a
	 * field the admin has since renamed or deleted. An integration that
	 * cannot answer yields an empty list, and the gate then stays on the
	 * cautious side and rejects nothing it cannot prove.
	 *
	 * @param int $formId The form to inspect.
	 *
	 * @return array<int,string>
	 */
	protected function getKnownFieldNames( int $formId ): array {
		try {
			return ConsentGate::normalizeFieldNames( $this->getFormFields( $formId ) );
		} catch ( \Throwable $e ) {
			$this->getLogger()->warning(
				'Could not read the form field inventory for the consent gate',
				array(
					'plugin'  => 'double-opt-in',
					'form_id' => $formId,
					'error'   => $e->getMessage(),
				)
			);

			return array();
		}
	}

	/**
	 * Build the OptIn properties array that will be persisted on
	 * record creation. Extracted from {@see createOptIn()} so the
	 * field-coverage contract is testable in isolation — the full
	 * createOptIn() flow has too many side-effects (rate-limiting,
	 * file storage, container access) for clean unit testing.
	 *
	 * Snapshot semantics:
	 *  - `consent_text` is captured per GDPR Art. 7 — the consent
	 *    record reflects the wording the user actually agreed to,
	 *    even if the form's settings change later.
	 *  - Custom addon fields land via the `f12_doi_optin_properties`
	 *    filter; addons that contribute a per-form setting hook here
	 *    to persist a snapshot with each opt-in.
	 *
	 * @param FormDataInterface    $formData      The submitted form data.
	 * @param array<string, mixed> $formParameter The form-settings snapshot.
	 * @param string               $recipient     The resolved recipient email.
	 * @param array<string, mixed> $fields        Filtered form fields.
	 * @param array<string, mixed> $files         Stored file references.
	 *
	 * @return array<string, mixed>
	 */
	protected function buildOptInProperties(
		FormDataInterface $formData,
		array $formParameter,
		string $recipient,
		array $fields,
		array $files
	): array {
		$properties = array(
			'cf_form_id'      => $formData->getFormId(),
			'doubleoptin'     => 0,
			'createtime'      => time(),
			'content'         => maybe_serialize( $fields ),
			'files'           => maybe_serialize( $files ),
			'ipaddr_register' => IPHelper::getIPAdress(),
			'category'        => (int) ( $formParameter['category'] ?? 0 ),
			'form'            => $formData->getFormHtml(),
			'email'           => $recipient,
			'consent_text'    => (string) ( $formParameter['consent_text'] ?? '' ),
			'consent_field'   => (string) ( $formParameter['consent_field'] ?? '' ),
		);

		/**
		 * Filter the OptIn properties array before the record is
		 * created. Addons hook this to snapshot their own per-form
		 * settings into the opt-in record at submit time. Mirrors the
		 * symmetric DTO filter pattern (`f12_doi_settings_dto_from_array`
		 * / `f12_doi_settings_dto_sanitize`) — an addon that contributes
		 * a per-form setting AND wants it persisted with each opt-in
		 * snapshots it through this filter.
		 *
		 * @since 4.4.0
		 *
		 * @param array<string, mixed> $properties    The properties array
		 *                                            for the new OptIn.
		 * @param FormDataInterface    $formData      The submitted form data.
		 * @param array<string, mixed> $formParameter The form-settings snapshot.
		 */
		return apply_filters( 'f12_doi_optin_properties', $properties, $formData, $formParameter );
	}

	/**
	 * Prepare the opt-in mail body with placeholders replaced.
	 *
	 * @param string $body          The mail body template.
	 * @param OptIn  $optIn         The OptIn record.
	 * @param array  $formParameter The form configuration.
	 *
	 * @return string The processed mail body.
	 */
	protected function prepareMailBody( string $body, OptIn $optIn, array $formParameter ): string {
		// Replace system placeholders
		$body = $this->addSystemPlaceholders( $body, $optIn, $formParameter );

		// Replace form field placeholders
		$formData = maybe_unserialize( $optIn->get_content() );
		if ( is_array( $formData ) ) {
			// Handle nested content structure (e.g., Avada stores {data: {...}, field_labels: {...}, ...})
			// Extract the flat field data for placeholder replacement
			$fieldData = isset( $formData['data'] ) && is_array( $formData['data'] ) ? $formData['data'] : $formData;

			$body = PlaceholderMapper::replacePlaceholders(
				$body,
				$fieldData,
				$optIn->get_cf_form_id(),
				array(),
				$this->getIdentifier()
			);
		}

		return $body;
	}

	/**
	 * Add system placeholders to the mail body.
	 *
	 * @param string $body          The mail body.
	 * @param OptIn  $optIn         The OptIn record.
	 * @param array  $formParameter The form configuration.
	 *
	 * @return string The body with placeholders replaced.
	 */
	protected function addSystemPlaceholders( string $body, OptIn $optIn, array $formParameter ): string {
		$placeholders = array(
			// User-influenced (the submit page URL incl. query string) — sanitise
			// as a URL so a crafted `?x="><script>` can't reflect into the mail
			// HTML. esc_url_raw (not esc_url) keeps ampersands un-entity-encoded
			// so the plain-text mail variant stays intact too.
			'doubleoptin_form_url'     => esc_url_raw( (string) ( $formParameter['formUrl'] ?? '' ) ),
			'doubleoptin_form_subject' => $formParameter['subject'] ?? '',
			// wp_date() formats in the site's timezone without mutating PHP's
			// global timezone (the old date() + date_default_timezone_set() did).
			'doubleoptin_form_date'    => wp_date( get_option( 'date_format' ) ),
			'doubleoptin_form_time'    => wp_date( get_option( 'time_format' ) ),
			'doubleoptin_form_email'   => get_option( 'admin_email' ),
			'doubleoptinlink'          => $optIn->get_link_optin( $formParameter ),
			'doubleoptoutlink'         => $optIn->get_link_optout(),
			'doubleoptin_privacy_url'  => $this->getPrivacyPolicyUrl(),
		);

		foreach ( $placeholders as $key => $value ) {
			if ( is_array( $value ) || is_object( $value ) ) {
				$value = wp_json_encode( $value );
			}
			$body = str_replace( '[' . $key . ']', (string) $value, $body );
		}

		return $body;
	}

	/**
	 * Get the privacy policy URL.
	 *
	 * @return string The privacy policy URL or empty string.
	 */
	protected function getPrivacyPolicyUrl(): string {
		$settings = CF7DoubleOptIn::getInstance()->getSettings();
		$pageId   = (int) ( $settings['privacy_policy_page'] ?? 0 );

		if ( $pageId > 0 ) {
			$url = get_permalink( $pageId );
			if ( $url ) {
				return $url;
			}
		}

		// Fallback to WordPress privacy policy page
		$wpPrivacyPageId = (int) get_option( 'wp_page_for_privacy_policy', 0 );
		if ( $wpPrivacyPageId > 0 ) {
			$url = get_permalink( $wpPrivacyPageId );
			if ( $url ) {
				return $url;
			}
		}

		return '';
	}

	/**
	 * Store uploaded files for later use after opt-in confirmation.
	 *
	 * @param array $files The uploaded files.
	 *
	 * @return array The stored file paths.
	 */
	protected function storeFiles( array $files ): array {
		// File-lifecycle plan, Step 1 (2026-05-07): delegate to the
		// centralised FileStorage service. This moves files into
		// `wp-content/uploads/f12-doi/pending/` (deny-from-all) with
		// random hex names, instead of the pre-4.3 in-tmp-dir copy.
		// The legacy copyAndRenameFile path below stays for now as a
		// fallback if FileStorage construction fails — removed in
		// Schritt 2 once each integration's hand-off is wired up.
		try {
			$storage = new FileStorage( $this->logger );
			return $storage->store( $files );
		} catch ( \Throwable $e ) {
			$this->logger->error(
				'FileStorage unavailable, falling back to legacy in-tmp store',
				array(
					'plugin' => 'double-opt-in',
					'error'  => $e->getMessage(),
				)
			);
		}

		// ── Legacy fallback (pre-4.3) ─────────────────────────────────
		$storedFiles = array();

		if ( empty( $files ) ) {
			return $storedFiles;
		}

		foreach ( $files as $key => $fileList ) {
			if ( ! is_array( $fileList ) ) {
				$fileList = array( $fileList );
			}

			foreach ( $fileList as $file ) {
				if ( empty( $file ) || ! is_file( $file ) ) {
					continue;
				}

				$newFile = $this->copyAndRenameFile( $file );
				if ( $newFile ) {
					$storedFiles[] = $newFile;
				}
			}
		}

		return $storedFiles;
	}

	/**
	 * Hand off the opt-in's stored files to the integration's own
	 * form-system entry (Avada form_entries / GF entry / WPForms
	 * entry / CF7 mail attachment). Per-integration override.
	 *
	 * Default behaviour: return false (no hand-off). The files stay
	 * in `pending/` until the OptIn is deleted (cron / manual /
	 * REST), at which point cascade-delete removes them.
	 *
	 * Contract (file-lifecycle plan, 2026-05-07):
	 *
	 *   - true: hand-off succeeded. Caller (template-method below)
	 *           will delete the pending/ copies — single source of
	 *           truth = the integration's own DB. GDPR-compliant by
	 *           construction (consent-bound retention).
	 *
	 *   - false: hand-off failed or not implemented. Files stay in
	 *           pending/ and are removed eventually via OptIn-deletion
	 *           cascade (cron expiry or manual delete). No silent
	 *           data loss.
	 *
	 * @param OptIn $optIn The just-confirmed opt-in record.
	 *
	 * @return bool true on successful hand-off, false otherwise.
	 *
	 * @since 4.3.0
	 */
	public function handOffFilesToFormSystem( OptIn $optIn ): bool {
		// Default: not implemented for this integration. Per-
		// integration overrides in addon-avada / addon-cf7 / etc.
		return false;
	}

	/**
	 * Template-method: process file hand-off when an opt-in confirms.
	 *
	 * Hooked on `f12_cf7_doubleoptin_after_confirm` at priority 5
	 * (BEFORE addon-specific listeners that fire at default 10), so
	 * file hand-off completes before any side-effects that might
	 * rely on the integration's entry being fully populated.
	 *
	 * Each subclass registers this in its own `registerHooks()` —
	 * see Schritt 2 per-integration commits.
	 *
	 * @param string $hash  The confirmation hash (action arg).
	 * @param OptIn  $optIn The confirmed opt-in (action arg).
	 *
	 * @since 4.3.0
	 */
	final public function processFilesOnConfirm( string $hash, OptIn $optIn ): void {
		// Bail for opt-ins that aren't this integration's responsibility.
		// The after_confirm action fires for ALL types — every integration
		// hooks it but only acts on its own.
		if ( ! $optIn->isType( $this->getIdentifier() ) ) {
			return;
		}

		$rawFiles    = (string) $optIn->get_files();
		$decoded     = $rawFiles === '' ? array() : maybe_unserialize( $rawFiles );
		$decoded     = is_array( $decoded ) ? $decoded : array();
		$files       = array_values(
			array_filter(
				$decoded,
				static function ( $p ) { return is_string( $p ) && $p !== ''; }
			)
		);

		if ( empty( $files ) ) {
			return;
		}

		$handedOff = $this->handOffFilesToFormSystem( $optIn );

		if ( ! $handedOff ) {
			$this->logger->info(
				'File hand-off not performed (or failed) — pending files will be cleaned up on OptIn deletion',
				array(
					'plugin'      => 'double-opt-in',
					'integration' => $this->getIdentifier(),
					'optin_id'    => $optIn->get_id(),
					'file_count'  => count( $files ),
				)
			);
			return;
		}

		// Hand-off succeeded → integration now owns the files in its
		// own DB. Delete our pending copies to avoid duplicate storage.
		try {
			$storage = new FileStorage( $this->logger );
			$storage->deletePaths( $files );

			// Clear the OptIn::files column so the cascade-delete on
			// later OptIn removal doesn't try to re-unlink missing
			// paths. Idempotent if save fails — pending dir is empty
			// either way.
			if ( method_exists( $optIn, 'set_files' ) ) {
				$optIn->set_files( serialize( array() ) );
				if ( method_exists( $optIn, 'save' ) ) {
					$optIn->save();
				}
			}

			$this->logger->info(
				'Files handed off to form system + pending copies deleted',
				array(
					'plugin'      => 'double-opt-in',
					'integration' => $this->getIdentifier(),
					'optin_id'    => $optIn->get_id(),
					'file_count'  => count( $files ),
				)
			);
		} catch ( \Throwable $e ) {
			$this->logger->error(
				'Hand-off succeeded but pending-cleanup failed',
				array(
					'plugin' => 'double-opt-in',
					'error'  => $e->getMessage(),
				)
			);
		}
	}

	/**
	 * Allowed MIME types for file uploads stored with opt-in records.
	 */
	private const ALLOWED_MIME_TYPES = array(
		'jpg'  => 'image/jpeg',
		'jpeg' => 'image/jpeg',
		'png'  => 'image/png',
		'gif'  => 'image/gif',
		'webp' => 'image/webp',
		'pdf'  => 'application/pdf',
		'doc'  => 'application/msword',
		'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
		'txt'  => 'text/plain',
		'csv'  => 'text/csv',
	);

	/**
	 * Copy and rename a file with a unique, non-guessable name.
	 *
	 * Validates the file MIME type against an allowlist before copying.
	 *
	 * @param string $file The source file path.
	 *
	 * @return string|null The new file path or null on failure/rejection.
	 */
	private function copyAndRenameFile( string $file ): ?string {
		$allowedMimes = apply_filters( 'f12_cf7_doubleoptin_allowed_mime_types', self::ALLOWED_MIME_TYPES );

		$fileType = wp_check_filetype_and_ext( $file, wp_basename( $file ), $allowedMimes );

		if ( empty( $fileType['type'] ) || empty( $fileType['ext'] ) ) {
			$this->getLogger()->warning(
				'File rejected: MIME type not allowed',
				array(
					'plugin'   => 'double-opt-in',
					'original' => $file,
				)
			);
			return null;
		}

		$pathParts = explode( '/', $file );
		array_pop( $pathParts );
		$newName     = bin2hex( random_bytes( 16 ) ) . '.' . $fileType['ext'];
		$pathParts[] = $newName;
		$newFile     = implode( '/', $pathParts );

		if ( copy( $file, $newFile ) ) {
			$this->getLogger()->debug(
				'File copied successfully',
				array(
					'plugin'   => 'double-opt-in',
					'original' => $file,
					'new_file' => $newFile,
				)
			);
			return $newFile;
		}

		$this->getLogger()->error(
			'Failed to copy file',
			array(
				'plugin'   => 'double-opt-in',
				'original' => $file,
			)
		);

		return null;
	}

	/**
	 * Validate and confirm an opt-in by hash.
	 *
	 * @param string $hash The opt-in hash.
	 *
	 * @return bool True if the opt-in was confirmed successfully.
	 */
	public function validateOptIn( string $hash ): bool {
		$optIn = OptIn::get_by_hash( $hash );

		if ( ! $optIn ) {
			$this->getLogger()->warning(
				'OptIn not found for hash',
				array(
					'plugin' => 'double-opt-in',
					'hash'   => $hash,
				)
			);
			self::setValidationStatus( 'not_found' );
			return false;
		}

		// Check if this opt-in belongs to this integration
		if ( ! $optIn->isType( $this->getIdentifier() ) ) {
			return false;
		}

		// Check if the token has expired
		$settings    = CF7DoubleOptIn::getInstance()->getSettings();
		$expiryHours = (int) ( $settings['token_expiry_hours'] ?? 48 );
		if ( $expiryHours > 0 && ( time() - (int) $optIn->get_createtime() ) > ( $expiryHours * 3600 ) ) {
			$this->getLogger()->info(
				'OptIn token expired',
				array(
					'plugin'       => 'double-opt-in',
					'optin_id'     => $optIn->get_id(),
					'expiry_hours' => $expiryHours,
				)
			);
			do_action( 'f12_cf7_doubleoptin_token_expired', $hash, $optIn );
			self::setValidationStatus( 'expired' );
			return false;
		}

		// Skip if already confirmed
		if ( $optIn->is_confirmed() ) {
			$this->getLogger()->info(
				'OptIn already confirmed',
				array(
					'plugin'   => 'double-opt-in',
					'optin_id' => $optIn->get_id(),
				)
			);
			do_action( 'f12_cf7_doubleoptin_already_confirmed', $hash, $optIn );
			self::setValidationStatus( 'already_confirmed' );
			return false;
		}

		// Confirm the opt-in
		do_action( 'f12_cf7_doubleoptin_before_confirm', $hash, $optIn );

		$optIn->set_doubleoptin( 1 );
		$optIn->set_updatetime( time() );
		$optIn->set_ipaddr_confirmation( IPHelper::getIPAdress() );

		if ( ! $optIn->save() ) {
			$this->getLogger()->error(
				'Failed to confirm OptIn',
				array(
					'plugin'   => 'double-opt-in',
					'optin_id' => $optIn->get_id(),
				)
			);
			return false;
		}

		self::setValidationStatus( 'confirmed' );

		// Track telemetry
		$telemetry = new Telemetry( $this->getLogger() );
		$telemetry->increment( 'confirmed_optins' );

		// Dispatch event
		$this->dispatchOptInConfirmedEvent( $optIn, $hash );

		do_action( 'f12_cf7_doubleoptin_after_confirm', $hash, $optIn );

		// Send the original mail if enabled
		if ( apply_filters( 'f12_cf7_doubleoptin_send_default_mail', true, $optIn->get_cf_form_id() ) ) {
			do_action( 'f12_cf7_doubleoptin_before_send_default_mail', $optIn );
			$this->sendConfirmationMail( $optIn );
			do_action( 'f12_cf7_doubleoptin_after_send_default_mail', $optIn );
		}

		$this->getLogger()->info(
			'OptIn confirmed successfully',
			array(
				'plugin'   => 'double-opt-in',
				'optin_id' => $optIn->get_id(),
			)
		);

		return true;
	}

	/**
	 * Remove stored files after processing.
	 *
	 * @param OptIn $optIn The opt-in record.
	 *
	 * @return void
	 */
	public function removeStoredFiles( OptIn $optIn ): void {
		$files = maybe_unserialize( $optIn->get_files() );

		if ( empty( $files ) || ! is_array( $files ) ) {
			return;
		}

		foreach ( $files as $file ) {
			if ( empty( $file ) || ! is_file( $file ) ) {
				continue;
			}

			if ( unlink( $file ) ) {
				$this->getLogger()->debug(
					'File removed successfully',
					array(
						'plugin' => 'double-opt-in',
						'file'   => $file,
					)
				);
			} else {
				$this->getLogger()->warning(
					'Failed to remove file',
					array(
						'plugin' => 'double-opt-in',
						'file'   => $file,
					)
				);
			}
		}
	}

	/**
	 * Disable spam protection hooks before sending confirmation mail.
	 *
	 * @return void
	 */
	protected function beforeSendConfirmationMail(): void {
		// Disable CF7 validation for confirmation mail resend.
		// CF7 re-runs all form validations (required fields, quiz, acceptance checkboxes)
		// when creating a WPCF7_Submission instance. Since this is a confirmed opt-in
		// (not a real form submit), these validations must be bypassed.
		add_filter( 'wpcf7_validate', array( $this, 'clearValidationResult' ), 999 );
		add_filter( 'wpcf7_spam', '__return_false', 0 );
		add_filter( 'wpcf7_skip_spam_check', '__return_true', 0 );

		// Disable CF7 Captcha if present
		add_filter( 'f12_cf7_captcha_is_installed_cf7', '__return_false', 999 );

		// Remove reCAPTCHA filter
		remove_filter( 'wpcf7_spam', 'wpcf7_recaptcha_verify_response', 9 );

		// Remove hCaptcha validation filter
		$this->removeHCaptchaFilter();

		$this->getLogger()->debug(
			'Validation and spam protection disabled for confirmation mail',
			array(
				'plugin' => 'double-opt-in',
			)
		);
	}

	/**
	 * Clear CF7 validation result to bypass field validation during confirmation mail.
	 *
	 * @param \WPCF7_Validation $result The validation result.
	 *
	 * @return \WPCF7_Validation A clean validation result with no errors.
	 */
	public function clearValidationResult( $result ) {
		return new \WPCF7_Validation();
	}

	/**
	 * Re-enable spam protection hooks after sending confirmation mail.
	 *
	 * @return void
	 */
	protected function afterSendConfirmationMail(): void {
		// Re-enable CF7 validation
		remove_filter( 'wpcf7_validate', array( $this, 'clearValidationResult' ), 999 );
		remove_filter( 'wpcf7_spam', '__return_false', 0 );
		remove_filter( 'wpcf7_skip_spam_check', '__return_true', 0 );

		// Re-add reCAPTCHA filter
		if ( function_exists( 'wpcf7_recaptcha_verify_response' ) ) {
			add_filter( 'wpcf7_spam', 'wpcf7_recaptcha_verify_response', 9, 2 );
		}

		// Re-add CF7 Captcha hooks
		if ( class_exists( '\forge12\contactform7\CF7Captcha\TimerValidatorCF7' ) ) {
			add_filter( 'wpcf7_spam', '\forge12\contactform7\CF7Captcha\TimerValidatorCF7::isSpam', 100, 2 );
			add_filter( 'wpcf7_spam', '\forge12\contactform7\CF7Captcha\CF7IPLog::isSpam', 100, 2 );
			add_action( 'wpcf7_mail_sent', '\forge12\contactform7\CF7Captcha\CF7IPLog::doLogIP', 100, 1 );
		}

		// Re-add hCaptcha validation filter
		$this->restoreHCaptchaFilter();

		$this->getLogger()->debug(
			'Validation and spam protection re-enabled',
			array(
				'plugin' => 'double-opt-in',
			)
		);
	}

	/**
	 * Remove hCaptcha CF7 validation filter and store the instance for later restore.
	 *
	 * @return void
	 */
	private function removeHCaptchaFilter(): void {
		if ( ! class_exists( '\HCaptcha\CF7\CF7' ) ) {
			return;
		}

		global $wp_filter;

		if ( ! isset( $wp_filter['wpcf7_validate'] ) ) {
			return;
		}

		foreach ( $wp_filter['wpcf7_validate']->callbacks as $priority => $hooks ) {
			foreach ( $hooks as $key => $hook ) {
				if ( is_array( $hook['function'] ) && $hook['function'][0] instanceof \HCaptcha\CF7\CF7 ) {
					$this->hcaptchaCf7Instance = $hook['function'][0];
					$this->hcaptchaCf7Priority = $priority;
					remove_filter( 'wpcf7_validate', $hook['function'], $priority );
					$this->getLogger()->debug(
						'hCaptcha CF7 validation filter removed',
						array(
							'plugin' => 'double-opt-in',
						)
					);

					return;
				}
			}
		}
	}

	/**
	 * Re-add hCaptcha CF7 validation filter if it was previously removed.
	 *
	 * @return void
	 */
	private function restoreHCaptchaFilter(): void {
		if ( isset( $this->hcaptchaCf7Instance ) ) {
			add_filter( 'wpcf7_validate', array( $this->hcaptchaCf7Instance, 'verify_hcaptcha' ), $this->hcaptchaCf7Priority, 2 );
			$this->getLogger()->debug(
				'hCaptcha CF7 validation filter re-added',
				array(
					'plugin' => 'double-opt-in',
				)
			);
			unset( $this->hcaptchaCf7Instance, $this->hcaptchaCf7Priority );
		}
	}

	/**
	 * Dispatch FormSubmissionEvent.
	 *
	 * @param FormDataInterface $formData The form data.
	 *
	 * @return FormSubmissionEvent|null The event or null if dispatcher unavailable.
	 */
	protected function dispatchFormSubmissionEvent( FormDataInterface $formData ): ?FormSubmissionEvent {
		try {
			$container = Container::getInstance();
			if ( $container->has( EventDispatcherInterface::class ) ) {
				$dispatcher = $container->get( EventDispatcherInterface::class );
				$event      = new FormSubmissionEvent(
					$formData,
					$this->getIdentifier()
				);
				$dispatcher->dispatch( $event );
				return $event;
			}
		} catch ( \Exception $e ) {
			$this->getLogger()->warning(
				'Failed to dispatch FormSubmissionEvent',
				array(
					'plugin' => 'double-opt-in',
					'error'  => $e->getMessage(),
				)
			);
		}
		return null;
	}

	/**
	 * Dispatch OptInCreatedEvent.
	 *
	 * @param OptIn             $optIn    The created opt-in.
	 * @param FormDataInterface $formData The form data.
	 *
	 * @return void
	 */
	protected function dispatchOptInCreatedEvent( OptIn $optIn, FormDataInterface $formData ): void {
		try {
			$container = Container::getInstance();
			if ( $container->has( EventDispatcherInterface::class ) ) {
				$dispatcher = $container->get( EventDispatcherInterface::class );
				$event      = new OptInCreatedEvent(
					$optIn->get_id(),
					$formData->getFormId(),
					$this->getIdentifier(),
					$optIn->get_email(),
					$optIn->get_hash(),
					$formData->getFields()
				);
				$dispatcher->dispatch( $event );
			}
		} catch ( \Exception $e ) {
			$this->getLogger()->warning(
				'Failed to dispatch OptInCreatedEvent',
				array(
					'plugin' => 'double-opt-in',
					'error'  => $e->getMessage(),
				)
			);
		}
	}

	/**
	 * Dispatch OptInConfirmedEvent.
	 *
	 * @param OptIn  $optIn The confirmed opt-in.
	 * @param string $hash  The opt-in hash.
	 *
	 * @return void
	 */
	protected function dispatchOptInConfirmedEvent( OptIn $optIn, string $hash ): void {
		try {
			$container = Container::getInstance();
			if ( $container->has( EventDispatcherInterface::class ) ) {
				$dispatcher = $container->get( EventDispatcherInterface::class );

				$formData = maybe_unserialize( $optIn->get_content() );

				$event = new OptInConfirmedEvent(
					$optIn->get_id(),
					$hash,
					$optIn->get_email(),
					$optIn->get_ipaddr_confirmation(),
					(int) $optIn->get_cf_form_id(),
					is_array( $formData ) ? $formData : array()
				);
				$dispatcher->dispatch( $event );
			}
		} catch ( \Exception $e ) {
			$this->getLogger()->warning(
				'Failed to dispatch OptInConfirmedEvent',
				array(
					'plugin' => 'double-opt-in',
					'error'  => $e->getMessage(),
				)
			);
		}
	}

	/**
	 * Get the post type for this integration's forms.
	 *
	 * @since 4.1.0
	 *
	 * @return string The post type.
	 */
	abstract protected function getPostType(): string;

	/**
	 * {@inheritdoc}
	 */
	public function getForms(): array {
		$posts = get_posts(
			array(
				'post_type'      => $this->getPostType(),
				'posts_per_page' => -1,
				'post_status'    => 'publish',
				'orderby'        => 'title',
				'order'          => 'ASC',
			)
		);

		$forms = array();
		foreach ( $posts as $post ) {
			$parameter = $this->getFormParameter( $post->ID );
			$forms[]   = array(
				'id'          => $post->ID,
				'title'       => $post->post_title,
				'integration' => $this->getIdentifier(),
				'enabled'     => (int) ( $parameter['enable'] ?? 0 ) === 1,
				'edit_url'    => $this->getFormEditUrl( $post->ID ),
			);
		}

		$this->getLogger()->debug(
			'Retrieved forms for integration',
			array(
				'plugin'      => 'double-opt-in',
				'integration' => $this->getIdentifier(),
				'count'       => count( $forms ),
			)
		);

		return $forms;
	}

	/**
	 * {@inheritdoc}
	 */
	public function getFormTitle( $formId ): string {
		$post = get_post( (int) $formId );
		return $post ? $post->post_title : '';
	}

	/**
	 * {@inheritdoc}
	 */
	public function getFormEditUrl( $formId ): string {
		return get_edit_post_link( (int) $formId, 'raw' ) ?: '';
	}
}

```
