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 $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 */ 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 $formParameter The form-settings snapshot. * @param string $recipient The resolved recipient email. * @param array $fields Filtered form fields. * @param array $files Stored file references. * * @return array */ 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 $properties The properties array * for the new OptIn. * @param FormDataInterface $formData The submitted form data. * @param array $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=">