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,
];
}
}