File: //srv/wordpress/plugins/wp-cloud-client/beta/src/Mail/FormPluginReplyTo.php
<?php
declare(strict_types=1);
namespace VPlugins\WPCloudClient\Mail;
/**
* Ensures outgoing form notification emails carry a "Name <email>" Reply-To
* header so wp-cloud-manager can create CRM contacts with the correct name.
*
* Supported plugins (each hook registered only when the plugin is active):
* - Contact Form 7 (WPCF7)
* - Gravity Forms (GFForms)
* - WPForms (WPForms)
* - Ninja Forms (Ninja_Forms)
* - Formidable Forms (FrmAppController)
*/
final class FormPluginReplyTo {
/**
* Ninja Forms submitted fields cached between the submit and email hooks.
*
* @var array
*/
private static array $nfFields = [];
/**
* Check whether any of the five supported form plugins is active:
* Contact Form 7, Gravity Forms, WPForms, Ninja Forms, or Formidable Forms.
*/
public static function isActive(): bool {
return class_exists( 'GFForms' )
|| class_exists( 'WPCF7' )
|| class_exists( 'WPForms' )
|| class_exists( 'Ninja_Forms' )
|| class_exists( 'FrmAppController' );
}
/**
* Register hooks for each active form plugin.
*/
public function register(): void {
if ( class_exists( 'WPCF7' ) ) {
// 3 args so we can inspect the WPCF7_Mail template and skip the
// autoresponder (mail_2), which is addressed to the visitor.
add_filter( 'wpcf7_mail_components', [ $this, 'handleCF7' ], 10, 3 );
}
if ( class_exists( 'GFForms' ) ) {
add_filter( 'gform_pre_send_email', [ $this, 'handleGF' ], 10, 4 );
}
if ( class_exists( 'WPForms' ) ) {
// $email (array), $fields (array) — drop unused $entry/$form_data/$notification_id.
add_filter( 'wpforms_entry_email_atts', [ $this, 'handleWPForms' ], 10, 2 );
}
if ( class_exists( 'Ninja_Forms' ) ) {
add_filter( 'ninja_forms_submit_data', [ $this, 'cacheNFFields' ] );
// Only $settings needed — drop unused $action.
add_filter( 'ninja_forms_run_action_settings', [ $this, 'handleNinjaForms' ], 10, 1 );
}
if ( class_exists( 'FrmAppController' ) ) {
add_filter( 'frm_email_header', [ $this, 'handleFormidable' ], 10, 2 );
}
}
// -------------------------------------------------------------------------
// Contact Form 7
// -------------------------------------------------------------------------
/**
* Inject Reply-To from the CF7 submission, mirroring the Gravity Forms handler.
*
* If the mail already carries any Reply-To header, it is left untouched. When
* there is none, inject "Reply-To: Name <email>" — locating the visitor's email
* by CF7 field type (email/email*) so it works regardless of the field's tag
* name — so replies reach the visitor rather than the site's From address.
*
* Skips CF7's autoresponder ("mail_2"), which is addressed to the visitor —
* injecting the visitor's own address as Reply-To there would loop a reply
* back to the sender rather than reaching the site.
*
* @param array $components Mail components (additional_headers, body, …).
* @param object $contactForm WPCF7_ContactForm (unused; part of the filter signature).
* @param object $mail WPCF7_Mail template being composed ("mail" or "mail_2").
* @return array
*/
public function handleCF7( array $components, $contactForm = null, $mail = null ): array {
try {
// Only enrich the primary notification. The autoresponder (mail_2) goes
// to the visitor, so a visitor Reply-To there is wrong.
if ( is_object( $mail ) && method_exists( $mail, 'name' ) && 'mail_2' === $mail->name() ) {
return $components;
}
$submission = class_exists( 'WPCF7_Submission' ) ? \WPCF7_Submission::get_instance() : null;
if ( null === $submission ) {
return $components;
}
$headers = (string) ( $components['additional_headers'] ?? '' );
// Mirror the Gravity Forms handler: if the mail already carries any
// Reply-To header, leave it untouched. Otherwise inject one built from
// the visitor's submitted email + name.
if ( preg_match( '/^Reply-To:/im', $headers ) ) {
return $components;
}
$posted = (array) $submission->get_posted_data();
$visitorEmail = $this->findEmailFromCF7Submission( $submission, $posted );
if ( '' === $visitorEmail ) {
return $components;
}
$name = $this->sanitizeHeaderValue( $this->findNameFromKeyedData( $posted, 'your-name' ) );
$visitorEmail = $this->sanitizeHeaderValue( $visitorEmail );
$replyTo = '' !== $name ? "{$name} <{$visitorEmail}>" : $visitorEmail;
$prefix = '' !== trim( $headers ) ? rtrim( $headers, "\r\n" ) . "\r\n" : '';
$components['additional_headers'] = $prefix . "Reply-To: {$replyTo}";
return $components;
} catch ( \Throwable $e ) {
return $components;
}
}
/**
* Find the visitor's email from a CF7 submission, preferring the form's actual
* email-type fields (like the Gravity Forms / WPForms handlers key off field
* type). This is name-agnostic: it finds the email even when the email field
* uses a non-standard tag name (e.g. `contact-address` instead of `your-email`).
* Falls back to the field-name heuristic if the form's tags can't be scanned.
*
* @param object $submission WPCF7_Submission instance.
* @param array $posted Posted data (field name => value).
* @return string
*/
private function findEmailFromCF7Submission( object $submission, array $posted ): string {
if ( method_exists( $submission, 'get_contact_form' ) ) {
$form = $submission->get_contact_form();
if ( is_object( $form ) && method_exists( $form, 'scan_form_tags' ) ) {
$tags = $form->scan_form_tags( array( 'type' => array( 'email', 'email*' ) ) );
foreach ( (array) $tags as $tag ) {
$fieldName = is_object( $tag ) ? ( $tag->name ?? '' ) : ( is_array( $tag ) ? ( $tag['name'] ?? '' ) : '' );
if ( '' === $fieldName || ! isset( $posted[ $fieldName ] ) ) {
continue;
}
$value = is_array( $posted[ $fieldName ] ) ? (string) reset( $posted[ $fieldName ] ) : (string) $posted[ $fieldName ];
$value = trim( $value );
if ( false !== filter_var( $value, FILTER_VALIDATE_EMAIL ) ) {
return $value;
}
}
}
}
// Fallback: match by field name (your-email, then keys containing "email").
return $this->findEmailFromKeyedData( $posted, 'your-email' );
}
// -------------------------------------------------------------------------
// Gravity Forms
// -------------------------------------------------------------------------
/**
* Inject "Reply-To: Name <email>" from the GF entry when Reply-To is absent.
*
* @param array $emailData GF email array (to, from, subject, message, headers, …).
* @param string $messageFormat 'html' or 'text'.
* @param array $notification GF notification settings.
* @param array $entry GF entry data.
* @return array
*/
public function handleGF( array $emailData, string $messageFormat, array $notification, array $entry ): array {
try {
$headers = (array) ( $emailData['headers'] ?? [] );
foreach ( $headers as $header ) {
if ( stripos( (string) $header, 'reply-to:' ) === 0 ) {
return $emailData;
}
}
$form = class_exists( 'GFAPI' ) ? ( \GFAPI::get_form( (int) ( $entry['form_id'] ?? 0 ) ) ?: [] ) : [];
$visitorEmail = $this->findEmailFromGFEntry( $form, $entry );
if ( '' === $visitorEmail ) {
return $emailData;
}
$name = $this->sanitizeHeaderValue( $this->findNameFromGFEntry( $form, $entry ) );
$visitorEmail = $this->sanitizeHeaderValue( $visitorEmail );
$replyTo = '' !== $name ? "{$name} <{$visitorEmail}>" : $visitorEmail;
$emailData['headers'][] = "Reply-To: {$replyTo}";
return $emailData;
} catch ( \Throwable $e ) {
return $emailData;
}
}
// -------------------------------------------------------------------------
// WPForms
// -------------------------------------------------------------------------
/**
* Inject or upgrade Reply-To from WPForms submitted fields.
*
* @param array $email WPForms email array (headers as string, subject, message, …).
* @param array $fields Submitted form fields keyed by field ID.
* @return array
*/
public function handleWPForms( array $email, array $fields ): array {
try {
$headers = (string) ( $email['headers'] ?? '' );
$visitorEmail = $this->findEmailFromWPFormsFields( $fields );
if ( '' === $visitorEmail ) {
return $email;
}
$name = $this->sanitizeHeaderValue( $this->findNameFromWPFormsFields( $fields ) );
$visitorEmail = $this->sanitizeHeaderValue( $visitorEmail );
if ( preg_match( '/^Reply-To:[ \t]*([^\r\n]+)/im', $headers, $m ) ) {
// Upgrade only if display name is missing.
if ( preg_match( '/^[^<]+</', trim( $m[1] ) ) || '' === $name ) {
return $email;
}
$email['headers'] = preg_replace(
'/^Reply-To:[ \t]*[^\r\n]+/im',
"Reply-To: {$name} <{$visitorEmail}>",
$headers
);
return $email;
}
$replyTo = '' !== $name ? "{$name} <{$visitorEmail}>" : $visitorEmail;
$email['headers'] .= "\r\nReply-To: {$replyTo}";
return $email;
} catch ( \Throwable $e ) {
return $email;
}
}
// -------------------------------------------------------------------------
// Ninja Forms
// -------------------------------------------------------------------------
/**
* Cache submitted Ninja Forms fields for use in the email action hook.
* Keyed by form_id to avoid cross-form contamination.
*
* @param array $data Submission data including 'fields' and 'form_id'.
* @return array
*/
public function cacheNFFields( array $data ): array {
$formId = (string) ( $data['form_id'] ?? '0' );
self::$nfFields[ $formId ] = $data['fields'] ?? [];
return $data;
}
/**
* Inject Reply-To into Ninja Forms email action settings.
* Uses the most recently cached field set and clears the cache after use
* to prevent stale data from bleeding into subsequent submissions.
*
* @param array $settings Email action settings.
* @return array
*/
public function handleNinjaForms( array $settings ): array {
try {
if ( 'email' !== ( $settings['type'] ?? '' ) ) {
return $settings;
}
if ( ! empty( $settings['reply_to'] ) ) {
return $settings;
}
// Use the most recently cached entry (last inserted).
$fields = ! empty( self::$nfFields ) ? end( self::$nfFields ) : [];
self::$nfFields = [];
$visitorEmail = $this->findEmailFromNFFields( (array) $fields );
if ( '' === $visitorEmail ) {
return $settings;
}
$settings['reply_to'] = $this->sanitizeHeaderValue( $visitorEmail );
$settings['reply_to_name'] = $this->sanitizeHeaderValue( $this->findNameFromNFFields( (array) $fields ) );
return $settings;
} catch ( \Throwable $e ) {
return $settings;
}
}
// -------------------------------------------------------------------------
// Formidable Forms
// -------------------------------------------------------------------------
/**
* Inject or upgrade Reply-To from Formidable Forms entry data.
*
* @param array $header Email headers array.
* @param array $atts Email attributes including 'entry' and 'form'.
* @return array
*/
public function handleFormidable( array $header, array $atts ): array {
try {
foreach ( $header as $h ) {
if ( stripos( (string) $h, 'reply-to:' ) === 0 ) {
return $header;
}
}
$entry = $atts['entry'] ?? null;
if ( ! is_object( $entry ) ) {
return $header;
}
$metas = (array) ( $entry->metas ?? [] );
$visitorEmail = $this->findEmailFromMetas( $metas );
if ( '' === $visitorEmail ) {
return $header;
}
$name = $this->sanitizeHeaderValue( $this->findNameFromFormidable( $atts, $metas ) );
$visitorEmail = $this->sanitizeHeaderValue( $visitorEmail );
$replyTo = '' !== $name ? "{$name} <{$visitorEmail}>" : $visitorEmail;
$header[] = "Reply-To: {$replyTo}";
return $header;
} catch ( \Throwable $e ) {
return $header;
}
}
// -------------------------------------------------------------------------
// Shared helpers
// -------------------------------------------------------------------------
/**
* Strip carriage-return and newline characters from a header value to
* prevent header injection attacks, then trim surrounding whitespace.
*
* Must be called on every name and email value before building a Reply-To
* header string.
*
* @param string $value Raw header value.
* @return string Sanitized value.
*/
private function sanitizeHeaderValue( string $value ): string {
return trim( preg_replace( '/[\r\n]+/', ' ', $value ) );
}
/**
* Find a name from a flat key=>value data array (CF7 posted data, WPForms text fields).
* Checks $defaultKey first, then any key containing "name".
*
* @param array $data Key => value pairs.
* @param string $defaultKey Preferred key to check first.
* @return string
*/
private function findNameFromKeyedData( array $data, string $defaultKey ): string {
if ( isset( $data[ $defaultKey ] ) && '' !== trim( (string) $data[ $defaultKey ] ) ) {
return trim( (string) $data[ $defaultKey ] );
}
foreach ( $data as $key => $value ) {
if ( is_string( $value ) && '' !== trim( $value ) && stripos( (string) $key, 'name' ) !== false ) {
return trim( $value );
}
}
return '';
}
/**
* Find the visitor's email from a flat key=>value data array (CF7 posted data).
*
* Checks $defaultKey ("your-email") first, then only fields whose key name looks
* like an email field (contains "email"/"e-mail"). It deliberately does NOT fall
* back to "any value that validates as an email", so a honeypot or a
* refer-a-friend field earlier in POST order cannot be picked over the visitor's
* own address. Array-valued fields take their first entry.
*
* @param array $data Key => value pairs.
* @param string $defaultKey Preferred key to check first.
* @return string
*/
private function findEmailFromKeyedData( array $data, string $defaultKey ): string {
$firstValue = static function ( $value ): string {
if ( is_array( $value ) ) {
$value = reset( $value );
}
return is_string( $value ) ? trim( $value ) : '';
};
if ( isset( $data[ $defaultKey ] ) ) {
$preferred = $firstValue( $data[ $defaultKey ] );
if ( false !== filter_var( $preferred, FILTER_VALIDATE_EMAIL ) ) {
return $preferred;
}
}
foreach ( $data as $key => $value ) {
if ( stripos( (string) $key, 'email' ) === false ) {
continue;
}
$candidate = $firstValue( $value );
if ( false !== filter_var( $candidate, FILTER_VALIDATE_EMAIL ) ) {
return $candidate;
}
}
return '';
}
/**
* Find an email address from a flat array of values by FILTER_VALIDATE_EMAIL.
*
* @param array $metas Flat array of values.
* @return string
*/
private function findEmailFromMetas( array $metas ): string {
foreach ( $metas as $value ) {
if ( is_string( $value ) && false !== filter_var( $value, FILTER_VALIDATE_EMAIL ) ) {
return $value;
}
}
return '';
}
// -------------------------------------------------------------------------
// GF helpers
// -------------------------------------------------------------------------
/**
* Find the first email-type field value in a GF entry.
*
* @param array $form GF form definition.
* @param array $entry GF entry data.
* @return string
*/
private function findEmailFromGFEntry( array $form, array $entry ): string {
foreach ( $form['fields'] ?? [] as $field ) {
if ( 'email' === $field->type ) {
$value = (string) ( $entry[ (string) $field->id ] ?? '' );
if ( '' !== $value ) {
return $value;
}
}
}
return '';
}
/**
* Find the visitor's name from a GF entry.
*
* @param array $form GF form definition.
* @param array $entry GF entry data.
* @return string
*/
private function findNameFromGFEntry( array $form, array $entry ): string {
foreach ( $form['fields'] ?? [] as $field ) {
if ( 'name' === $field->type ) {
$first = trim( (string) ( $entry[ $field->id . '.3' ] ?? '' ) );
$last = trim( (string) ( $entry[ $field->id . '.6' ] ?? '' ) );
$name = trim( $first . ' ' . $last );
if ( '' !== $name ) {
return $name;
}
// Also check the base field id (simple name format with no sub-fields).
$base = trim( (string) ( $entry[ (string) $field->id ] ?? '' ) );
if ( '' !== $base ) {
return $base;
}
}
}
foreach ( $form['fields'] ?? [] as $field ) {
if ( 'text' === $field->type && stripos( (string) ( $field->label ?? '' ), 'name' ) !== false ) {
$value = trim( (string) ( $entry[ (string) $field->id ] ?? '' ) );
if ( '' !== $value ) {
return $value;
}
}
}
return '';
}
// -------------------------------------------------------------------------
// WPForms helpers
// -------------------------------------------------------------------------
/**
* Find the visitor's email from WPForms submitted fields.
*
* @param array $fields WPForms fields array keyed by field ID.
* @return string
*/
private function findEmailFromWPFormsFields( array $fields ): string {
foreach ( $fields as $field ) {
if ( 'email' === ( $field['type'] ?? '' ) && '' !== trim( (string) ( $field['value'] ?? '' ) ) ) {
return trim( (string) $field['value'] );
}
}
return '';
}
/**
* Find the visitor's name from WPForms submitted fields.
*
* @param array $fields WPForms fields array keyed by field ID.
* @return string
*/
private function findNameFromWPFormsFields( array $fields ): string {
foreach ( $fields as $field ) {
if ( 'name' === ( $field['type'] ?? '' ) ) {
$raw = $field['value_raw'] ?? [];
$first = trim( (string) ( $raw['first'] ?? '' ) );
$last = trim( (string) ( $raw['last'] ?? '' ) );
$name = trim( $first . ' ' . $last );
if ( '' !== $name ) {
return $name;
}
if ( '' !== trim( (string) ( $field['value'] ?? '' ) ) ) {
return trim( (string) $field['value'] );
}
}
}
// Fallback: look for a text field whose label contains "name".
foreach ( $fields as $field ) {
$label = (string) ( $field['label'] ?? '' );
if ( stripos( $label, 'name' ) !== false ) {
$value = trim( (string) ( $field['value'] ?? '' ) );
if ( '' !== $value && is_string( $field['value'] ?? null ) ) {
return $value;
}
}
}
return '';
}
// -------------------------------------------------------------------------
// Ninja Forms helpers
// -------------------------------------------------------------------------
/**
* Find the visitor's email from Ninja Forms cached fields.
*
* @param array $fields NF fields array.
* @return string
*/
private function findEmailFromNFFields( array $fields ): string {
foreach ( $fields as $field ) {
if ( 'email' === ( $field['type'] ?? '' ) && '' !== trim( (string) ( $field['value'] ?? '' ) ) ) {
return trim( (string) $field['value'] );
}
}
return '';
}
/**
* Find the visitor's name from Ninja Forms cached fields.
*
* @param array $fields NF fields array.
* @return string
*/
private function findNameFromNFFields( array $fields ): string {
$first = '';
$last = '';
foreach ( $fields as $field ) {
$type = (string) ( $field['type'] ?? '' );
$value = trim( (string) ( $field['value'] ?? '' ) );
if ( '' === $value ) {
continue;
}
if ( 'firstname' === $type ) {
$first = $value;
} elseif ( 'lastname' === $type ) {
$last = $value;
} elseif ( 'name' === $type ) {
return $value;
}
}
$name = trim( $first . ' ' . $last );
if ( '' !== $name ) {
return $name;
}
// Fallback: text field whose label contains "name".
foreach ( $fields as $field ) {
if ( stripos( (string) ( $field['label'] ?? '' ), 'name' ) !== false ) {
$value = trim( (string) ( $field['value'] ?? '' ) );
if ( '' !== $value ) {
return $value;
}
}
}
return '';
}
// -------------------------------------------------------------------------
// Formidable helpers
// -------------------------------------------------------------------------
/**
* Find the visitor's name from Formidable entry and form field definitions.
*
* @param array $atts Hook attributes (may include 'form' with field definitions).
* @param array $metas Entry meta values (field_id => value).
* @return string
*/
private function findNameFromFormidable( array $atts, array $metas ): string {
$form = $atts['form'] ?? null;
if ( is_object( $form ) && isset( $form->fields ) ) {
foreach ( (array) $form->fields as $field ) {
if ( stripos( (string) ( $field->name ?? '' ), 'name' ) !== false ) {
$value = trim( (string) ( $metas[ $field->id ] ?? '' ) );
if ( '' !== $value && false === filter_var( $value, FILTER_VALIDATE_EMAIL ) ) {
return $value;
}
}
}
}
// Fallback: non-email string values from metas.
foreach ( $metas as $value ) {
if ( is_string( $value ) && '' !== trim( $value )
&& false === filter_var( $value, FILTER_VALIDATE_EMAIL )
) {
return trim( $value );
}
}
return '';
}
}