logger = $logger; $this->type = $type; $this->get_logger()->debug( 'Base integration constructor called', [ 'plugin' => 'double-opt-in', 'class' => __CLASS__, 'method' => __METHOD__, 'type' => $type, ] ); add_action( 'f12_cf7_doubleoptin_before_send_default_mail', [ $this, 'beforeSendDefaultMail' ], 10, 1 ); $this->get_logger()->debug( 'Hook f12_cf7_doubleoptin_before_send_default_mail registered', [ 'plugin' => 'double-opt-in', ] ); add_action( 'f12_cf7_doubleoptin_after_send_default_mail', [ $this, 'afterSendDefaultMail' ], 10, 1 ); $this->get_logger()->debug( 'Hook f12_cf7_doubleoptin_after_send_default_mail registered', [ 'plugin' => 'double-opt-in', ] ); add_action( 'f12_cf7_doubleoptin_trigger_default_mail', [ $this, 'sendDefaultMail' ], 10, 1 ); $this->get_logger()->debug( 'Hook f12_cf7_doubleoptin_trigger_default_mail registered', [ 'plugin' => 'double-opt-in', ] ); add_action( 'shutdown', [ $this, 'removeFiles' ] ); $this->get_logger()->debug( 'Hook shutdown registered for removeFiles', [ 'plugin' => 'double-opt-in', ] ); add_action( 'init', [ $this, 'validateOptIn' ] ); $this->get_logger()->debug( 'Hook init registered for validateOptIn', [ 'plugin' => 'double-opt-in', ] ); add_action( 'wp_footer', [ $this, 'renderValidationFeedback' ] ); // Default feedback handler for validation statuses add_action( 'f12_cf7_doubleoptin_validation_feedback', function ( $status ) { if ( $status === 'confirmed' ) { return; } $messages = [ 'already_confirmed' => __( 'Your opt-in has already been confirmed.', 'double-opt-in' ), 'expired' => __( 'This confirmation link has expired. Please submit the form again.', 'double-opt-in' ), 'not_found' => __( 'This confirmation link is invalid.', 'double-opt-in' ), ]; $message = $messages[ $status ] ?? ''; if ( ! empty( $message ) ) { echo '
'; echo '

' . esc_html( $message ) . '

'; echo '
'; } }, 10, 1 ); add_filter( 'f12_cf7_doubleoptin_get_recipient_' . $this->type, [ $this, 'getRecipient' ], 10, 3 ); $this->get_logger()->debug( 'Filter f12_cf7_doubleoptin_get_recipient_' . $this->type . ' registered', [ 'plugin' => 'double-opt-in', ] ); } public function get_logger() { return $this->logger; } /** * Retrieves the recipient for a given recipient name, form parameters, and post parameters. * * @param string $recipient The name of the recipient to retrieve. * @param array $formParameter The array of form parameters. * @param array $postParameter The array of post parameters. * * @return string The recipient for the given parameters. */ abstract public function getRecipient( string $recipient, array $formParameter, array $postParameter ): string; /** * Sends a default mail for the given OptIn object. * * This method is declared as abstract, meaning that it must be implemented * by any child class that extends the current class. The method takes * a single parameter, $OptIn, of type OptIn. This parameter represents * the OptIn object for which the default mail needs to be sent. * * This method should be overridden by child classes to define the specific * logic for sending the default mail for the given OptIn object. * * Note that the implementation details of this method vary depending on the * specific subclass. Therefore, the implementation code is not provided here. * * @param OptIn $OptIn The OptIn object for which the default mail needs to be sent. * * @return void */ abstract public function sendDefaultMail( OptIn $OptIn ): void; public function disable_contact_form_7_captcha($is_active){ if($is_active){ $this->get_logger()->debug( 'Forge12 CF7Captcha filters and actions removed', ['plugin' => 'double-opt-in']); return false; } $this->get_logger()->debug( 'Forge12 CF7Captcha filters and actions removed', ['plugin' => 'double-opt-in']); return $is_active; } /** * This method is used to perform necessary actions before sending the default mail. * * This method removes the filter for the forge12 spam captcha if the class * '\forge12\contactform7\CF7Captcha\TimerValidatorCF7' exists. It removes the filters 'wpcf7_spam' for the methods * '\forge12\contactform7\CF7Captcha::isSpam' and * '\forge12\contactform7\CF7Captcha\CF7IPLog::isSpam', and also removes the action 'wpcf7_mail_sent' for the * method * '\forge12\contactform7\CF7Captcha\CF7IPLog::doLogIP', if these filters and action exist. * * Additionally, this method removes the filter 'wpcf7_spam' for the method 'wpcf7_recaptcha_verify_response' with * a priority of 9. * * @return void */ public function beforeSendDefaultMail() { $this->get_logger()->debug( 'beforeSendDefaultMail called', [ 'plugin' => 'double-opt-in', 'class' => __CLASS__, 'method' => __METHOD__, ] ); // Remove the filter for the Forge12 spam captcha add_filter('f12_cf7_captcha_is_installed_cf7', [$this, 'disable_contact_form_7_captcha'], 999, 1); $this->get_logger()->debug( 'Forge12 CF7Captcha filters and actions removed', [ 'plugin' => 'double-opt-in', ] ); // Remove the filter for the Google reCAPTCHA validation remove_filter( 'wpcf7_spam', 'wpcf7_recaptcha_verify_response', 9 ); $this->get_logger()->debug( 'Google reCAPTCHA filter removed', [ 'plugin' => 'double-opt-in', ] ); // Remove hCaptcha validation filter $this->removeHCaptchaFilter(); } /** * Performs actions after sending the default mail. * * In this method, two filters and an action are added to the WordPress hooks system. * * The first filter is added with the hook name 'wpcf7_spam' and the callback * function is set to 'wpcf7_recaptcha_verify_response'. The priority is set to 9 * and the number of accepted arguments for the callback function is 2. * This filter is added only if the function 'wpcf7_recaptcha_verifiy_response' * exists. * * The second filter is added with the hook name 'wpcf7_spam' and the callback * function is set to '\forge12\contactform7\CF7Captcha\TimerValidatorCF7::isSpam'. * The priority is set to 100 and the number of accepted arguments for the callback * function is 2. This filter is added only if the class '\forge12\contactform7\CF7Captcha\TimerValidatorCF7' * exists. * * The third filter is added with the hook name 'wpcf7_spam' and the callback * function is set to '\forge12\contactform7\CF7Captcha\CF7IPLog::isSpam'. * The priority is set to 100 and the number of accepted arguments for the callback * function is 2. This filter is added only if the class '\forge12\contactform7\CF7Captcha\CF7IPLog' * exists. * * An action is added with the hook name 'wpcf7_mail_sent' and the callback function * is set to '\forge12\contactform7\CF7Captcha\CF7IPLog::doLogIP'. The priority is set * to 100 and the number of accepted arguments for the callback function is 1. * This action is added only if the class '\forge12\contactform7\CF7Captcha\CF7IPLog' exists. * * @return void */ public function afterSendDefaultMail() { $this->get_logger()->debug( 'afterSendDefaultMail called', [ 'plugin' => 'double-opt-in', 'class' => __CLASS__, 'method' => __METHOD__, ] ); // re-add the filter to ensure for all other forms the reCAPTCHA is used if ( function_exists( 'wpcf7_recaptcha_verify_response' ) ) { add_filter( 'wpcf7_spam', 'wpcf7_recaptcha_verify_response', 9, 2 ); $this->get_logger()->debug( 'Google reCAPTCHA filter re-added', [ 'plugin' => 'double-opt-in', ] ); } // re-add the filter for the Forge12 spam captcha 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 ); $this->get_logger()->debug( 'Forge12 CF7Captcha filters and actions re-added', [ 'plugin' => 'double-opt-in', ] ); } // re-add hCaptcha validation filter $this->restoreHCaptchaFilter(); } /** * 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->get_logger()->debug( 'hCaptcha CF7 validation filter removed', [ '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', [ $this->hcaptchaCf7Instance, 'verify_hcaptcha' ], $this->hcaptchaCf7Priority, 2 ); $this->get_logger()->debug( 'hCaptcha CF7 validation filter re-added', [ 'plugin' => 'double-opt-in', ] ); $this->hcaptchaCf7Instance = null; } } /** * Updates the opt-in status by hash. * * @param string $hash The opt-in hash. * @param int $value The opt-in value to set. * @param OptIn|null $OptIn The opt-in object. Optional. * * @return int Returns 0 if the opt-in is already confirmed, 1 if the opt-in is successfully updated. */ protected function updateOptInByHash( string $hash, int $value, ?OptIn $OptIn = null ): int { $this->get_logger()->debug( 'updateOptInByHash called', [ 'plugin' => 'double-opt-in', 'class' => __CLASS__, 'method' => __METHOD__, 'hash' => $hash, 'value' => $value, ] ); $OptIn = $OptIn ?? OptIn::get_by_hash( $hash ); if ( ! $OptIn ) { $this->get_logger()->warning( 'No OptIn found for hash', [ 'plugin' => 'double-opt-in', 'hash' => $hash, ] ); return 0; } if ( $OptIn->is_confirmed() ) { $this->get_logger()->info( 'OptIn already confirmed, skipping update', [ 'plugin' => 'double-opt-in', 'hash' => $hash, 'optin_id' => $OptIn->get_id(), ] ); do_action( 'f12_cf7_doubleoptin_already_confirmed', $hash, $OptIn ); return 0; } do_action( 'f12_cf7_doubleoptin_before_confirm', $hash, $OptIn ); $OptIn->set_doubleoptin( $value ); $OptIn->set_updatetime( time() ); $OptIn->set_ipaddr_confirmation( IPHelper::getIPAdress() ); $telemetry = new Telemetry( $this->get_logger() ); $result = $OptIn->save(); if ( $result ) { if ( (int)$value === 1 ) { $telemetry->increment( 'confirmed_optins' ); // Dispatch typed event for new event-driven architecture $this->dispatchOptInConfirmedEvent( $OptIn, $hash ); } $this->get_logger()->info( 'OptIn updated successfully', [ 'plugin' => 'double-opt-in', 'hash' => $hash, 'optin_id' => $OptIn->get_id(), ] ); do_action( 'f12_cf7_doubleoptin_after_confirm', $hash, $OptIn ); } else { $this->get_logger()->error( 'Failed to update OptIn', [ 'plugin' => 'double-opt-in', 'hash' => $hash, 'optin_id' => $OptIn->get_id(), ] ); } return (int) $result; } /** * Add additional placeholder like time, date, subject * * @formatter:off * * @param string $body The content containing the placeholder that will be replaced. * @param OptIn $OptIn The OptIn Object. * * @param array $parameter { * @type string $formUrl The URL where the form is displayed. * @type string $subject The Subject of the Form * } * # * @formatter:on */ protected function addPlaceholders( string $body, OptIn $OptIn, array $parameter ): string { $this->get_logger()->debug( 'addPlaceholders called', [ 'plugin' => 'double-opt-in', 'class' => __CLASS__, 'method' => __METHOD__, 'optin_id' => $OptIn->get_id(), 'parameter' => $parameter, ] ); $placeholder = [ // User-influenced submit URL — sanitise so a crafted query string // can't reflect into the mail HTML (esc_url_raw keeps text-mail intact). 'doubleoptin_form_url' => esc_url_raw( (string) ( $parameter['formUrl'] ?? '' ) ), 'doubleoptin_form_subject' => $parameter['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( $parameter ), 'doubleoptoutlink' => $OptIn->get_link_optout(), 'doubleoptin_privacy_url' => $this->getPrivacyPolicyUrl(), ]; foreach ( $placeholder as $key => $value ) { if ( is_array( $value ) || is_object( $value ) ) { // Array/Objekte serialisieren oder in JSON umwandeln $value = wp_json_encode( $value ); } $replacement = (string) ( $value ?? '' ); $body = str_replace( '[' . $key . ']', $replacement, $body ); // Logging je Platzhalter $this->get_logger()->debug( 'Placeholder replaced', [ 'plugin' => 'double-opt-in', 'placeholder' => '[' . $key . ']', 'value' => $replacement, ] ); } // Logging nach allen Ersetzungen $this->get_logger()->debug( 'System placeholders replaced in body', [ 'plugin' => 'double-opt-in', 'placeholders' => array_keys( $placeholder ), 'body_length' => strlen( $body ), ] ); // Replace standard placeholders (doi_email, doi_name, etc.) $formData = maybe_unserialize( $OptIn->get_content() ); if ( is_array( $formData ) ) { // Per-integration nesting unwrap. The serialized OptIn content // is whatever each frontend stored, and that differs by // integration — see SubmittedContent for the table. // Without this, PlaceholderMapper::replacePlaceholders looks // for `$formData[$mappedField]` at the top level and finds // nothing for Elementor — every `[doi_*]` placeholder renders // as an empty string in the confirmation mail. // // The chain used to be spelled out here. It now lives in // SubmittedContent because the audit reader needs the same // knowledge, and the copy it had was one shape short — every // Elementor opt-in reported its consent checkbox as unticked // (customer report 2026-08-27). $fieldData = \Forge12\DoubleOptIn\Integration\SubmittedContent::unwrapFields( $formData ); $body = PlaceholderMapper::replacePlaceholders( $body, $fieldData, $OptIn->get_cf_form_id(), [], $this->type ); $this->get_logger()->debug( 'Standard placeholders replaced', [ 'plugin' => 'double-opt-in', 'form_id' => $OptIn->get_cf_form_id(), 'field_data_keys' => array_keys( $fieldData ), 'unwrapped_from' => \Forge12\DoubleOptIn\Integration\SubmittedContent::describeShape( $formData ), ] ); } return $body; } /** * Add Stylesheets */ public function validateOptIn(): bool { $this->get_logger()->debug( 'validateOptIn started', [ 'plugin' => 'double-opt-in', ] ); /** * Skip if the hash has not been submitted. */ if ( ! isset( $_GET['optin'] ) ) { $this->get_logger()->debug( 'No optin hash found in request, skipping', [ 'plugin' => 'double-opt-in', ] ); return false; } /** * Get the Hash */ $hash = sanitize_text_field( $_GET['optin'] ); /** * Load the OptIn */ $OptIn = OptIn::get_by_hash( $hash ); /** * Skip if the OptIn does not exist */ if ( null == $OptIn ) { $this->get_logger()->warning( 'OptIn not found for hash', [ 'plugin' => 'double-opt-in', 'hash' => $hash, ] ); $this->setValidationStatus( 'not_found' ); return false; } /** * Skip if the OptIn is not from Type cf7. */ if ( ! $OptIn->isType( $this->type ) ) { $this->get_logger()->warning( 'OptIn type mismatch', [ 'plugin' => 'double-opt-in', 'hash' => $hash, 'type' => $this->type, 'optin_id' => $OptIn->get_id(), ] ); return false; } $this->get_logger()->debug( 'OptIn type found', [ 'plugin' => 'double-opt-in', 'hash' => $hash, 'type' => $this->type, 'optin_id' => $OptIn->get_id(), ] ); /** * 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->get_logger()->info( 'OptIn token expired', [ 'plugin' => 'double-opt-in', 'hash' => $hash, 'optin_id' => $OptIn->get_id(), 'expiry_hours' => $expiryHours, ] ); do_action( 'f12_cf7_doubleoptin_token_expired', $hash, $OptIn ); $this->setValidationStatus( 'expired' ); return false; } /** * Check if already confirmed (before calling updateOptInByHash). */ if ( $OptIn->is_confirmed() ) { $this->get_logger()->info( 'OptIn already confirmed', [ 'plugin' => 'double-opt-in', 'hash' => $hash, 'optin_id' => $OptIn->get_id(), ] ); do_action( 'f12_cf7_doubleoptin_already_confirmed', $hash, $OptIn ); $this->setValidationStatus( 'already_confirmed' ); return false; } /** * Confirm the OptIn. */ if ( $this->updateOptInByHash( $hash, 1, $OptIn ) <= 0 ) { $this->get_logger()->info( 'OptIn update failed', [ 'plugin' => 'double-opt-in', 'hash' => $hash, 'optin_id' => $OptIn->get_id(), ] ); return false; } $this->setValidationStatus( 'confirmed' ); /** * Enable / Disable default mail. * * @param bool $status Enable (true) or disable (false) the default mail. * @param int $postId The ID of the Post / Form. * * @since 2.3.3 */ if ( ! apply_filters( 'f12_cf7_doubleoptin_send_default_mail', true, $OptIn->get_cf_form_id() ) ) { $this->get_logger()->info( 'Default mail disabled for OptIn', [ 'plugin' => 'double-opt-in', 'form_id' => $OptIn->get_cf_form_id(), 'optin_id' => $OptIn->get_id(), ] ); return false; } $this->get_logger()->debug( 'Triggering before_send_default_mail hook', [ 'plugin' => 'double-opt-in', 'optin_id' => $OptIn->get_id(), ] ); do_action( 'f12_cf7_doubleoptin_before_send_default_mail', $OptIn ); $this->get_logger()->info( 'Triggering send_default_mail hook', [ 'plugin' => 'double-opt-in', 'optin_id' => $OptIn->get_id(), ] ); do_action( 'f12_cf7_doubleoptin_trigger_default_mail', $OptIn ); $this->get_logger()->debug( 'Triggering after_send_default_mail hook', [ 'plugin' => 'double-opt-in', 'optin_id' => $OptIn->get_id(), ] ); do_action( 'f12_cf7_doubleoptin_after_send_default_mail', $OptIn ); return true; } /** * Store the files * * @param array $inFiles * * @return array */ private function maybeStoreFiles( array $inFiles ): array { $this->get_logger()->debug( 'maybeStoreFiles called', [ 'plugin' => 'double-opt-in', 'class' => __CLASS__, 'method' => __METHOD__, 'files_in' => $inFiles, ] ); $outFiles = []; if ( empty( $inFiles ) ) { $this->get_logger()->debug( 'No files provided to maybeStoreFiles', [ 'plugin' => 'double-opt-in', ] ); return $outFiles; } foreach ( $inFiles as $key => $subfiles ) { foreach ( $subfiles as $file ) { $newFile = $this->copyAndRenameFile( $file ); if ( $newFile ) { $outFiles[] = $newFile; $this->get_logger()->debug( 'File stored successfully', [ 'plugin' => 'double-opt-in', 'original' => $file, 'new' => $newFile, ] ); } else { $this->get_logger()->warning( 'File could not be stored', [ 'plugin' => 'double-opt-in', 'original' => $file, ] ); } } } $this->get_logger()->debug( 'maybeStoreFiles completed', [ 'plugin' => 'double-opt-in', 'files_out' => $outFiles, ] ); return $outFiles; } /** * Copy and rename a file * * @param string $file The path to the file to copy and rename * * @return string|null The path to the copied and renamed file, or null if the copy operation failed */ private function copyAndRenameFile( string $file ): ?string { $this->get_logger()->debug( 'copyAndRenameFile called', [ 'plugin' => 'double-opt-in', 'class' => __CLASS__, 'method' => __METHOD__, 'file' => $file, ] ); $newFile = explode( '/', $file ); $name = $newFile[ count( $newFile ) - 1 ]; $name = time() . '_' . $name; $newFile[ count( $newFile ) - 1 ] = $name; $newFile = implode( "/", $newFile ); if ( copy( $file, $newFile ) ) { $this->get_logger()->info( 'File copied and renamed successfully', [ 'plugin' => 'double-opt-in', 'original' => $file, 'new_file' => $newFile, ] ); return $newFile; } $this->get_logger()->error( 'Failed to copy and rename file', [ 'plugin' => 'double-opt-in', 'original' => $file, 'target' => $newFile, ] ); return null; } /** * Create the OptIn * * @param int $formId The identifier of the form * @param string $formHtml The HTML code of the form * @param array $parameter The Post Parameter of the form. * @param array $files The Files attached to the form. * @param array $knownFields The fields this form declares, `name => label` * or a plain list. Only the consent gate uses * them, to tell an unticked checkbox from a * setting that points at a field which no * longer exists. A shim that knows its form * better than the registry does (Elementor * has the Form_Record in hand) passes them in; * everyone else leaves it empty and * {@see self::resolveKnownFieldNames()} asks * the integration. * * @return OptIn|null */ protected function maybeCreateOptIn( int $formId, string $formHtml, array $parameter, array $files = array(), array $knownFields = array() ): ?OptIn { $this->get_logger()->debug( 'maybeCreateOptIn called', [ 'plugin' => 'double-opt-in', 'class' => __CLASS__, 'method' => __METHOD__, 'formId' => $formId, 'formHtml' => substr( $formHtml, 0, 200 ), 'files' => $files, ] ); /** * Mögliche Dateien speichern, um sie während der Opt-In-Bestätigung vorzuhalten */ $files = $this->maybeStoreFiles( $files ); $this->get_logger()->debug( 'Files checked and possibly stored', [ 'plugin' => 'double-opt-in', 'files' => $files, ] ); /** * Filter, um die Parameter vor dem Speichern in der Datenbank zu manipulieren */ $parameter = \apply_filters( 'f12_cf7_doubleoptin_add_request_parameter', $parameter ); $this->get_logger()->debug( 'Request parameters filtered', [ 'plugin' => 'double-opt-in', 'parameter' => $parameter, ] ); /** * Globale Einstellungen für das Formular abrufen */ $formParameter = CF7DoubleOptIn::getInstance()->getParameter( $formId ); /** * Filter, um den Empfänger zu ermitteln, bevor das OptIn-Objekt erstellt wird */ $recipient = \apply_filters( 'f12_cf7_doubleoptin_get_recipient_' . $this->type, '', $formParameter, $parameter ); /** * Wenn keine E-Mail-Adresse gefunden wurde, Abbruch */ if ( empty( $recipient ) ) { $this->get_logger()->warning( 'No recipient found, skipping OptIn creation', [ 'plugin' => 'double-opt-in', ] ); ErrorNotification::store( OptInError::fromCode( OptInError::NO_RECIPIENT, [ 'form_id' => $formId ] ), $formId ); return null; } /** * Consent gate (GDPR Art. 7) — same position in the flow as in * AbstractFormIntegration::createOptIn(): after the recipient is * known, before rate limiting. * * Until 5.4.0 this path had no gate at all. Everything that does * not extend AbstractFormIntegration comes through here — * Elementor, plus the CF7 and Avada legacy shims — so on those * forms the acceptance checkbox was stored as consent proof * without ever having been enforced. The banner in the admin UI * said as much since 5.3.2; this closes it. * * ConsentGate::FIELD_UNKNOWN never rejects. See the class for why * that matters: a stale `consent_field` must not take a site's * registrations offline. */ $consentSnapshot = $this->loadConsentSnapshot( $formId, $formParameter ); $consentField = $consentSnapshot['field']; $consentGate = ConsentGate::evaluate( $consentField, $parameter, $this->resolveKnownFieldNames( $formId, $knownFields ) ); if ( $consentGate === ConsentGate::NOT_GIVEN && ! ConsentGate::isEnforced( $formId, $this->type ) ) { $this->get_logger()->warning( 'Consent gate disabled by filter — accepting an unconfirmed submission', [ 'plugin' => 'double-opt-in', 'form_id' => $formId, 'integration' => $this->type, 'consent_field' => $consentField, ] ); $consentGate = ConsentGate::PASSED; } if ( $consentGate === ConsentGate::NOT_GIVEN ) { $this->get_logger()->info( 'Consent acceptance not given, rejecting OptIn', [ 'plugin' => 'double-opt-in', 'form_id' => $formId, 'integration' => $this->type, 'consent_field' => $consentField, ] ); do_action( 'f12_cf7_doubleoptin_consent_not_given', $formId, $consentField ); ErrorNotification::store( OptInError::fromCode( OptInError::CONSENT_NOT_GIVEN, [ 'form_id' => $formId, 'consent_field' => $consentField ] ), $formId ); return null; } if ( $consentGate === ConsentGate::FIELD_UNKNOWN ) { $this->get_logger()->warning( 'Consent field is not on this form — opt-in accepted without provable consent', [ 'plugin' => 'double-opt-in', 'form_id' => $formId, 'integration' => $this->type, 'consent_field' => $consentField, ] ); do_action( 'f12_doi_consent_field_unknown', $formId, $consentField, $this->type ); } /** * Rate-Limiting: Check IP and email limits before creating OptIn. */ $rateLimiter = new RateLimiter(); $ratSettings = CF7DoubleOptIn::getInstance()->getSettings(); $rateLimitIp = (int) ( $ratSettings['rate_limit_ip'] ?? 5 ); $rateLimitEmail = (int) ( $ratSettings['rate_limit_email'] ?? 3 ); $rateLimitWindow = (int) ( $ratSettings['rate_limit_window'] ?? 60 ); $ip = IPHelper::getIPAdress(); if ( ! $rateLimiter->isAllowed( 'ip', $ip, $rateLimitIp, $rateLimitWindow ) ) { $this->get_logger()->warning( 'Rate limit exceeded for IP', [ 'plugin' => 'double-opt-in', 'ip' => $ip, 'formId' => $formId, ] ); do_action( 'f12_cf7_doubleoptin_rate_limited', 'ip', $ip, $formId ); ErrorNotification::store( OptInError::fromCode( OptInError::RATE_LIMIT_IP, [ 'ip' => $ip, 'form_id' => $formId ] ), $formId ); return null; } if ( ! $rateLimiter->isAllowed( 'email', $recipient, $rateLimitEmail, $rateLimitWindow ) ) { $this->get_logger()->warning( 'Rate limit exceeded for email', [ 'plugin' => 'double-opt-in', 'email' => $recipient, 'formId' => $formId, ] ); do_action( 'f12_cf7_doubleoptin_rate_limited', 'email', $recipient, $formId ); ErrorNotification::store( OptInError::fromCode( OptInError::RATE_LIMIT_EMAIL, [ 'email' => $recipient, 'form_id' => $formId ] ), $formId ); return null; } /** * Validate recipient (extensible via Pro plugin: MX check, unique * email, etc.). Uses a lightweight proxy so filters that expect * FormDataInterface::getFormId() keep working. * * IMPORTANT: `getFormType()` must be set to `$this->type` — that's * the integration identifier ("elementor", "cf7" via legacy path, * "avada" via legacy path). The Unique Email validator's * resolver matches conditions on the (integration, form_id) pair; * a proxy without getFormType returns '' and breaks the * `selected` and `all_except` modes for every form that flows * through this legacy path. User-reported 2026-05-13. Pinned by * `LegacyFormDataProxyTest` in core tests. */ $formDataProxy = new class( $formId, $this->type ) { private int $formId; private string $formType; public function __construct( int $formId, string $formType ) { $this->formId = $formId; $this->formType = $formType; } public function getFormId(): int { return $this->formId; } public function getFormType(): string { return $this->formType; } }; $recipientValid = \apply_filters( 'f12_cf7_doubleoptin_validate_recipient', true, $recipient, $formDataProxy ); if ( $recipientValid !== true ) { $errorMsg = is_string( $recipientValid ) ? $recipientValid : ''; $errorCode = OptInError::RECIPIENT_INVALID; if ( $errorMsg === 'unique_email_rejected' ) { $errorCode = OptInError::UNIQUE_EMAIL_DUPLICATE; $errorMsg = OptInError::fromCode( OptInError::UNIQUE_EMAIL_DUPLICATE )->getMessage(); } $this->get_logger()->warning( 'Recipient validation failed', [ 'plugin' => 'double-opt-in', 'email' => $recipient, 'form_id' => $formId, 'reason' => $errorMsg, ] ); $this->lastCreationError = new OptInError( $errorCode, ! empty( $errorMsg ) ? $errorMsg : OptInError::fromCode( OptInError::RECIPIENT_INVALID )->getMessage(), [ 'email' => $recipient, 'form_id' => $formId ] ); ErrorNotification::store( $this->lastCreationError, $formId ); return null; } /** * Consent-Snapshot aus den Form-Settings laden. Both the wording * shown to the user (`consent_text`) AND the form-field key the * user had to tick (`consent_field`) need to be persisted, or * the Consent Evidence panel reports "No acknowledgment field * configured — consent text is stored but not legally provable" * even when the admin wired up the checkbox. The modern * AbstractFormIntegration::buildOptInProperties() path includes * both; this legacy path (used by Elementor + the CF7/Avada * legacy compat shims) used to only carry consent_text. */ $consentText = $consentSnapshot['text']; $consentField = $consentSnapshot['field']; /** * Eigenschaften des OptIn-Objekts festlegen */ $properties = $this->buildLegacyOptInProperties( $formId, $formHtml, $parameter, $files, $formParameter, $recipient, $consentText, $consentField ); $this->get_logger()->debug( 'OptIn properties created', [ 'plugin' => 'double-opt-in', 'properties' => $properties, ] ); /** * OptIn-Objekt erstellen */ $OptIn = new OptIn($this->get_logger(), $properties ); $this->get_logger()->debug( 'OptIn object instantiated', [ 'plugin' => 'double-opt-in', 'OptIn' => $OptIn, ] ); /** * OptIn speichern */ if ( $OptIn->save() ) { $this->get_logger()->info( 'OptIn object saved successfully', [ 'plugin' => 'double-opt-in', 'OptIn' => $OptIn, ] ); // Dispatch typed event for new event-driven architecture $this->dispatchOptInCreatedEvent( $OptIn, $formId ); return $OptIn; } $this->get_logger()->error( 'Failed to save OptIn object', [ 'plugin' => 'double-opt-in', ] ); do_action( 'f12_cf7_doubleoptin_creation_failed', $formId, $recipient ); ErrorNotification::store( OptInError::fromCode( OptInError::SAVE_FAILED, [ 'form_id' => $formId ] ), $formId ); return null; } /** * Validate if the optin is enabled. */ protected function isOptinEnabled( int $formId ): bool { // Disable optin sending if the optin flag is set. if ( isset( $_GET['optin'] ) ) { $this->get_logger()->debug( 'Optin disabled due to optin flag in GET request', [ 'plugin' => 'double-opt-in', 'class' => __CLASS__, 'method' => __METHOD__, ] ); return false; } $parameter = CF7DoubleOptIn::getInstance()->getParameter( $formId ); if ( (int) $parameter['enable'] != 1 ) { $this->get_logger()->debug( 'Optin not enabled in form parameter', [ 'plugin' => 'double-opt-in', 'class' => __CLASS__, 'method' => __METHOD__, 'parameter' => $parameter, ] ); 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->get_logger()->debug( 'Optin disabled due to unmet custom condition', [ 'plugin' => 'double-opt-in', 'class' => __CLASS__, 'method' => __METHOD__, 'condition' => $condition, 'post_keys' => array_keys( $_POST ), ] ); return false; } } $this->get_logger()->debug( 'Optin enabled', [ 'plugin' => 'double-opt-in', 'class' => __CLASS__, 'method' => __METHOD__, ] ); return true; } /** * Render validation feedback in the frontend via wp_footer. * * Fires the 'f12_cf7_doubleoptin_validation_feedback' action for customization, * and provides a default inline notice for non-confirmed statuses. * * @return void */ public function renderValidationFeedback(): void { $status = self::getValidationStatus(); if ( empty( $status ) ) { return; } /** * Allow themes/plugins to handle the validation feedback display. * * @param string $status The validation status. * * @since 3.3.0 */ do_action( 'f12_cf7_doubleoptin_validation_feedback', $status ); } /** * Removes files associated with the optin parameter. * * This method checks if the optin parameter is set and * loads the OptIn object based on the hash value. If * the OptIn does not exist or no files are found, * the method will return. Otherwise, it will iterate * through the files and delete each one. * * @return void */ public function removeFiles(): void { /** * Skip if the optin parameter is not set. */ if ( ! isset( $_GET['optin'] ) ) { $this->get_logger()->debug( 'No optin parameter found, skipping file removal', [ 'plugin' => 'double-opt-in', 'class' => __CLASS__, 'method' => __METHOD__, ] ); return; } $hash = sanitize_text_field( wp_unslash( $_GET['optin'] ) ); /** * Load the OptIn */ $OptIn = OptIn::get_by_hash( $hash ); /** * Skip if the OptIn does not exist */ if ( null == $OptIn ) { $this->get_logger()->warning( 'OptIn not found, skipping file removal', [ 'plugin' => 'double-opt-in', 'class' => __CLASS__, 'method' => __METHOD__, 'hash' => $hash, ] ); return; } /** * Load all files */ $files = maybe_unserialize( $OptIn->get_files() ); /** * Skip if no files found */ if ( empty( $files ) ) { $this->get_logger()->debug( 'No files found in OptIn, skipping removal', [ 'plugin' => 'double-opt-in', 'class' => __CLASS__, 'method' => __METHOD__, 'hash' => $hash, ] ); return; } foreach ( $files as $file ) { /** * Skip if empty */ if ( empty( $file ) ) { continue; } /** * Skip if no file found */ if ( ! is_file( $file ) ) { $this->get_logger()->warning( 'File not found, skipping', [ 'plugin' => 'double-opt-in', 'class' => __CLASS__, 'method' => __METHOD__, 'file' => $file, ] ); continue; } /** * Delete the file */ if ( unlink( $file ) ) { $this->get_logger()->info( 'File deleted successfully', [ 'plugin' => 'double-opt-in', 'class' => __CLASS__, 'method' => __METHOD__, 'file' => $file, ] ); } else { $this->get_logger()->error( 'Could not delete file', [ 'plugin' => 'double-opt-in', 'class' => __CLASS__, 'method' => __METHOD__, 'file' => $file, ] ); } } } /** * Get the privacy policy URL. * * Fallback chain: Plugin setting → WordPress Privacy Policy page → empty string. * * @return string */ private 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 ''; } /** * Dispatch OptInCreatedEvent via the new event system. * * @param OptIn $optIn The created OptIn object. * @param int $formId The form ID. * * @since 4.0.0 */ protected function dispatchOptInCreatedEvent( OptIn $optIn, int $formId ): void { try { $container = Container::getInstance(); if ( $container->has( EventDispatcherInterface::class ) ) { $dispatcher = $container->get( EventDispatcherInterface::class ); $event = new OptInCreatedEvent( $optIn->get_id(), $formId, $this->type, $optIn->get_email(), $optIn->get_hash() ); $dispatcher->dispatch( $event ); $this->get_logger()->debug( 'OptInCreatedEvent dispatched', [ 'plugin' => 'double-opt-in', 'optin_id' => $optIn->get_id(), 'form_id' => $formId, ] ); } } catch ( \Exception $e ) { $this->get_logger()->warning( 'Failed to dispatch OptInCreatedEvent', [ 'plugin' => 'double-opt-in', 'error' => $e->getMessage(), ] ); } } /** * Dispatch OptInConfirmedEvent via the new event system. * * @param OptIn $optIn The confirmed OptIn object. * @param string $hash The opt-in hash. * * @since 4.0.0 */ 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 : [] ); $dispatcher->dispatch( $event ); $this->get_logger()->debug( 'OptInConfirmedEvent dispatched', [ 'plugin' => 'double-opt-in', 'optin_id' => $optIn->get_id(), 'hash' => $hash, ] ); } } catch ( \Exception $e ) { $this->get_logger()->warning( 'Failed to dispatch OptInConfirmedEvent', [ 'plugin' => 'double-opt-in', 'error' => $e->getMessage(), ] ); } } /** * Load the consent snapshot (wording + acceptance field) for a form. * * The authoritative source is `FormSettingsService`, not the * `$formParameter` array this legacy path carries — the settings the * admin edits in the React UI land in post_meta and only some of them * make it into `getParameter()`. `$formParameter` is used purely as a * fallback for the case where the container is not available. * * Read once per submission and used twice: by the consent gate before * the opt-in is created, and by the snapshot that is persisted with * it. They must agree — a gate that reads a different field than the * record stores would produce a proof of the wrong checkbox. * * @param int $formId The form being submitted. * @param array $formParameter The legacy form-parameter array. * * @return array{text:string,field:string} */ protected function loadConsentSnapshot( int $formId, array $formParameter = array() ): array { $snapshot = [ 'text' => (string) ( $formParameter['consent_text'] ?? '' ), 'field' => (string) ( $formParameter['consent_field'] ?? '' ), ]; try { $container = Container::getInstance(); $settingsService = $container->get( \Forge12\DoubleOptIn\FormSettings\FormSettingsService::class ); $formSettings = $settingsService->getSettings( $formId ); $snapshot['text'] = (string) ( $formSettings->consentText ?? '' ); $snapshot['field'] = (string) ( $formSettings->consentField ?? '' ); } catch ( \Throwable $e ) { $this->get_logger()->debug( 'Could not load consent snapshot from FormSettings', [ 'plugin' => 'double-opt-in', 'form_id' => $formId, 'error' => $e->getMessage(), ] ); } return $snapshot; } /** * The field names this form declares, for the consent gate. * * An unticked checkbox never reaches the server, so the payload alone * cannot distinguish "the visitor left the box alone" from "the * configured field does not exist any more". The form's own * definition can, and every integration exposes it through * `FormIntegrationInterface::getFormFields()`. * * A shim may pass the inventory in directly — Elementor does, because * its `Form_Record` lists every declared field including the empty * ones, while the composite form ID its `getFormFields()` wants * (`{postId}_{widgetId}`) is not what this legacy path carries. * Otherwise we ask the registry for the integration behind * `$this->type`. * * Returning an empty array is a valid answer and means "unknown" — * the gate then rejects nothing it cannot prove. * * @param int $formId The form being submitted. * @param array $explicit Inventory supplied by the caller, if any. * * @return array */ protected function resolveKnownFieldNames( int $formId, array $explicit = array() ): array { if ( $explicit !== array() ) { return ConsentGate::normalizeFieldNames( $explicit ); } if ( $this->type === '' || ! class_exists( FormIntegrationRegistry::class ) ) { return array(); } try { $integration = FormIntegrationRegistry::getInstance()->get( $this->type ); if ( $integration === null ) { return array(); } return ConsentGate::normalizeFieldNames( $integration->getFormFields( $formId ) ); } catch ( \Throwable $e ) { $this->get_logger()->warning( 'Could not read the form field inventory for the consent gate', [ 'plugin' => 'double-opt-in', 'form_id' => $formId, 'error' => $e->getMessage(), ] ); return array(); } } /** * Assemble the legacy OptIn properties array. * * Extracted from {@see maybeCreateOptIn()} so the column inventory * is unit-testable without standing up the whole submit pipeline * (rate limiter, FormSettingsService, $wpdb save, etc.). The * modern {@see \Forge12\DoubleOptIn\Integration\AbstractFormIntegration::buildOptInProperties()} * has a sibling helper; the two MUST stay in sync — every column * the modern path snapshots, this legacy path also has to snapshot, * otherwise integrations on the legacy compat path (Elementor + * the CF7/Avada legacy shims) lose the column silently. * * The 2026-05-13 incident this guards against: `consent_field` * was missing here, so Elementor opt-ins shipped with an empty * acknowledgment field even when the admin configured one. The * Consent Evidence panel then rendered "No acknowledgment field * configured — consent text is stored but not legally provable" * on a record that was, in fact, ticked through a configured * checkbox. * * @return array */ protected function buildLegacyOptInProperties( int $formId, string $formHtml, array $parameter, array $files, array $formParameter, string $recipient, string $consentText, string $consentField ): array { return [ 'cf_form_id' => $formId, 'doubleoptin' => 0, 'createtime' => time(), 'content' => maybe_serialize( $parameter ), 'files' => maybe_serialize( $files ), 'ipaddr_register' => IPHelper::getIPAdress(), 'category' => (int) ( $formParameter['category'] ?? 0 ), 'form' => $formHtml, 'email' => $recipient, 'consent_text' => $consentText, 'consent_field' => $consentField, ]; } }