diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php
index 3702c17b418e8..82e57b5441ead 100644
--- a/src/wp-includes/default-filters.php
+++ b/src/wp-includes/default-filters.php
@@ -87,17 +87,6 @@
add_filter( $filter, 'wp_filter_kses' );
}
-// Email addresses: Allow unicode if and only if as the database can
-// store them. This affects all addresses, including those entered
-// into contact forms.
-if ( 'utf8mb4' === $wpdb->charset ) {
- add_filter( 'is_email', 'wp_is_unicode_email', 10, 3 );
- add_filter( 'sanitize_email', 'wp_sanitize_unicode_email', 10, 3 );
-} else {
- add_filter( 'is_email', 'wp_is_ascii_email', 10, 3 );
- add_filter( 'sanitize_email', 'wp_sanitize_ascii_email', 10, 3 );
-}
-
// Display URL.
foreach ( array( 'user_url', 'link_url', 'link_image', 'link_rss', 'comment_url', 'post_guid' ) as $filter ) {
if ( is_admin() ) {
@@ -282,6 +271,7 @@
add_filter( 'the_guid', 'esc_url' );
// Email filters.
+add_action( 'plugins_loaded', 'wp_select_email_address_support' );
add_filter( 'wp_mail', 'wp_staticize_emoji_for_email' );
// Robots filters.
diff --git a/src/wp-includes/class-wp-email-address.php b/src/wp-includes/email/class-wp-email-address.php
similarity index 91%
rename from src/wp-includes/class-wp-email-address.php
rename to src/wp-includes/email/class-wp-email-address.php
index fd4f0ef8937ba..09de9c74d7d0a 100644
--- a/src/wp-includes/class-wp-email-address.php
+++ b/src/wp-includes/email/class-wp-email-address.php
@@ -101,6 +101,14 @@ final class WP_Email_Address {
*/
const DOMAIN_UNICODE_REGEX = '/^' . self::DOMAIN_LABEL_UNICODE . '(?:\.' . self::DOMAIN_LABEL_UNICODE . ')*$/u';
+ /**
+ * The last email address parsing error, if any.
+ *
+ * @since 7.1.0
+ * @var ?string
+ */
+ private static $last_error = null;
+
/**
* The local part of the email address (the portion before the '@').
*
@@ -198,6 +206,8 @@ private function __construct( string $localpart, string $ascii_domain, ?string $
* Note! If an address contains punycode encodings but the required {@see idn_to_utf8()}
* function is missing (from the `intl` extension), this will reject that email address.
*
+ * @see self::get_last_error() for why a provided address was rejected.
+ *
* @since 7.1.0
*
* @param string $input The email address string to parse.
@@ -205,9 +215,12 @@ private function __construct( string $localpart, string $ascii_domain, ?string $
* @return WP_Email_Address|null A WP_Email_Address instance, or null if the input fails to validate.
*/
public static function from_string( string $input, string $character_set = 'unicode' ): ?WP_Email_Address {
+ self::$last_error = null;
+
// There must be exactly one '@' sign.
$at_pos = strpos( $input, '@' );
if ( false === $at_pos || strrpos( $input, '@' ) !== $at_pos ) {
+ self::$last_error = 'email_no_at';
return null;
}
@@ -221,6 +234,7 @@ public static function from_string( string $input, string $character_set = 'unic
foreach ( $domain_labels as $label ) {
// DNS limits each label to 63 octets.
if ( strlen( $label ) > 63 ) {
+ self::$last_error = 'domain_label_too_long';
return null;
}
}
@@ -233,6 +247,7 @@ public static function from_string( string $input, string $character_set = 'unic
*/
$needs_decoding = 1 === preg_match( '/(?:^|\.)..--/', $ascii_domain );
if ( $needs_decoding && ! function_exists( 'idn_to_utf8' ) ) {
+ self::$last_error = 'wp_unsupported';
return null;
}
@@ -247,10 +262,12 @@ public static function from_string( string $input, string $character_set = 'unic
if ( str_starts_with( $label, 'xn--' ) ) {
$label = idn_to_utf8( $label, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46 );
if ( false === $label ) {
+ self::$last_error = 'domain_invalid_idn';
return null;
}
} elseif ( 1 === preg_match( '/^..--/', $label ) ) {
// Reject labels with a reserved ACE-like prefix (two chars followed by '--').
+ self::$last_error = 'domain_invalid_chars';
return null;
}
$decoded_labels[] = $label;
@@ -298,9 +315,24 @@ public static function from_string( string $input, string $character_set = 'unic
// Validate the domain against the allowed structure.
if ( 1 !== preg_match( $domain_pattern, $decoded_domain ) ) {
+ self::$last_error = 'domaind_invalid_sequence';
return null;
}
+ // Renormalize the ASCII domain when non-ASCII characters exist
+ if ( 1 === preg_match( '~[^\x00-\x7F]~', $ascii_domain ) ) {
+ if ( ! function_exists( 'idn_to_ascii' ) ) {
+ self::$last_error = 'wp_unsupported';
+ return null;
+ }
+
+ $labels = explode( '.', $ascii_domain );
+ foreach ( $labels as &$label ) {
+ $label = idn_to_ascii( $label, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46 );
+ }
+ $ascii_domain = implode( '.', $labels );
+ }
+
return new self( $localpart, $ascii_domain, $decoded_domain );
}
@@ -402,4 +434,18 @@ public function get_ascii_address(): string {
public function get_unicode_address(): string {
return $this->localpart . '@' . $this->decoded_domain;
}
+
+ /**
+ * Returns the error code for the last attempt to parse an email address,
+ * if any error prevented recognizing the address.
+ *
+ * @see self::from_string()
+ *
+ * @since 7.1.0
+ *
+ * @return string|null
+ */
+ public static function get_last_error(): ?string {
+ return self::$last_error;
+ }
}
diff --git a/src/wp-includes/email/wp-email-support.php b/src/wp-includes/email/wp-email-support.php
new file mode 100644
index 0000000000000..7e97f59a48d3b
--- /dev/null
+++ b/src/wp-includes/email/wp-email-support.php
@@ -0,0 +1,117 @@
+charset;
+
+ /**
+ * These functions convert into and out of punycode. Should WordPress add a polyfill
+ * for these then this check could be removed. The {@see \WpOrg\Requests\IdnaEncoder}
+ * class exists, but provides encoding only, and incompletely as noted in the class.
+ */
+ $has_idn_codec = function_exists( 'idn_to_utf8' ) && function_exists( 'idn_to_ascii' );
+
+ return $is_utf8_db && $has_idn_codec;
+}
diff --git a/src/wp-includes/formatting.php b/src/wp-includes/formatting.php
index acacb131a627b..0cc694248ab20 100644
--- a/src/wp-includes/formatting.php
+++ b/src/wp-includes/formatting.php
@@ -2176,7 +2176,6 @@ function sanitize_user( $username, $strict = false ) {
return apply_filters( 'sanitize_user', $username, $raw_username, $strict );
}
-
/**
* Sanitizes a string key.
*
@@ -3600,16 +3599,12 @@ function convert_smilies( $text ) {
}
/**
- * Verifies that an email is valid.
+ * Verifies that an email is valid according to WordPress legacy rules.
*
- * This accepts the addresses that matches the WHATWG specifications,
- * i.e. what browsers use for ``. It also accepts some
- * additional addresses.
+ * Does not grok i18n domains. Not RFC compliant.
*
- * By default this accepts addresses like info@grå.org (also accepted
- * by Firefox) ``. You can disable Unicode support by
- * using the wp_is_ascii_email filter instead of wp_is_unicode_email,
- * which is the default.
+ * @see wp_is_ascii_email()
+ * @see wp_is_unicode_email()
*
* @since 0.71
*
@@ -3622,21 +3617,84 @@ function is_email( $email, $deprecated = false ) {
_deprecated_argument( __FUNCTION__, '3.0.0' );
}
- /**
- * Filters whether an email address is valid.
- *
- * This filter is evaluated under several different contexts, such as
- * 'local_invalid_chars', 'domain_no_periods', or no specific context.
- * Filters registered on this hook perform the actual validation; the
- * default filter is registered in default-filters.php.
- *
- * @since 2.8.0
- *
- * @param string|false $is_email The email address if successfully passed the is_email() checks, false otherwise.
- * @param string $email The email address being checked.
- * @param string|null $context Context under which the email was tested, or null for the initial call.
+ // Test for the minimum length the email can be.
+ if ( strlen( $email ) < 6 ) {
+ /**
+ * Filters whether an email address is valid.
+ *
+ * This filter is evaluated under several different contexts, such as 'email_too_short',
+ * 'email_no_at', 'local_invalid_chars', 'domain_period_sequence', 'domain_period_limits',
+ * 'domain_no_periods', 'sub_hyphen_limits', 'sub_invalid_chars', or no specific context.
+ *
+ * @since 2.8.0
+ *
+ * @param string|false $is_email The email address if successfully passed the is_email() checks, false otherwise.
+ * @param string $email The email address being checked.
+ * @param string $context Context under which the email was tested.
+ */
+ return apply_filters( 'is_email', false, $email, 'email_too_short' );
+ }
+
+ // Test for an @ character after the first position.
+ if ( false === strpos( $email, '@', 1 ) ) {
+ /** This filter is documented in wp-includes/formatting.php */
+ return apply_filters( 'is_email', false, $email, 'email_no_at' );
+ }
+
+ // Split out the local and domain parts.
+ list( $local, $domain ) = explode( '@', $email, 2 );
+
+ /*
+ * LOCAL PART
+ * Test for invalid characters.
+ */
+ if ( ! preg_match( '/^[a-zA-Z0-9!#$%&\'*+\/=?^_`{|}~\.-]+$/', $local ) ) {
+ /** This filter is documented in wp-includes/formatting.php */
+ return apply_filters( 'is_email', false, $email, 'local_invalid_chars' );
+ }
+
+ /*
+ * DOMAIN PART
+ * Test for sequences of periods.
*/
- return apply_filters( 'is_email', false, $email, null );
+ if ( preg_match( '/\.{2,}/', $domain ) ) {
+ /** This filter is documented in wp-includes/formatting.php */
+ return apply_filters( 'is_email', false, $email, 'domain_period_sequence' );
+ }
+
+ // Test for leading and trailing periods and whitespace.
+ if ( trim( $domain, " \t\n\r\0\x0B." ) !== $domain ) {
+ /** This filter is documented in wp-includes/formatting.php */
+ return apply_filters( 'is_email', false, $email, 'domain_period_limits' );
+ }
+
+ // Split the domain into subs.
+ $subs = explode( '.', $domain );
+
+ // Assume the domain will have at least two subs.
+ if ( 2 > count( $subs ) ) {
+ /** This filter is documented in wp-includes/formatting.php */
+ return apply_filters( 'is_email', false, $email, 'domain_no_periods' );
+ }
+
+ // Loop through each sub.
+ foreach ( $subs as $sub ) {
+ // Test for leading and trailing hyphens and whitespace.
+ if ( trim( $sub, " \t\n\r\0\x0B-" ) !== $sub ) {
+ /** This filter is documented in wp-includes/formatting.php */
+ return apply_filters( 'is_email', false, $email, 'sub_hyphen_limits' );
+ }
+
+ // Test for invalid characters.
+ if ( ! preg_match( '/^[a-z0-9-]+$/i', $sub ) ) {
+ /** This filter is documented in wp-includes/formatting.php */
+ return apply_filters( 'is_email', false, $email, 'sub_invalid_chars' );
+ }
+ }
+
+ // Congratulations, your email made it!
+ /** This filter is documented in wp-includes/formatting.php */
+ return apply_filters( 'is_email', $email, $email, null );
}
/**
@@ -3809,60 +3867,109 @@ function iso8601_to_datetime( $date_string, $timezone = 'user' ) {
}
/**
- * Sanitizes an email address.
- *
- * Strips stray whitespace from the input, then strips trailing dots from the domain.
- * This is designed to recover from cut/paste mistakes without any risk of transforming
- * the input into a different address than the user intended.
- *
- * Validation and final form are determined by the 'sanitize_email' filter; the default
- * filter is registered in default-filters.php and delegates to {@see WP_Email_Address::from_string()}.
+ * Strips out all characters that are not allowable in an email.
*
* @since 1.5.0
- * @since 7.1.0 Accepts Unicode email addresses on supporting platforms.
*
- * @param string $email Email address to sanitize.
- * @return string The sanitized email address, or an empty string if invalid.
+ * @param string $email Email address to filter.
+ * @return string Filtered email address.
*/
function sanitize_email( $email ) {
- // Strip surrounding whitespace.
- $email = trim( $email );
+ // Test for the minimum length the email can be.
+ if ( strlen( $email ) < 6 ) {
+ /**
+ * Filters a sanitized email address.
+ *
+ * This filter is evaluated under several contexts, including 'email_too_short',
+ * 'email_no_at', 'local_invalid_chars', 'domain_period_sequence', 'domain_period_limits',
+ * 'domain_no_periods', 'domain_no_valid_subs', or no context.
+ *
+ * @since 2.8.0
+ *
+ * @param string $sanitized_email The sanitized email address.
+ * @param string $email The email address, as provided to sanitize_email().
+ * @param string|null $message A message to pass to the user. null if email is sanitized.
+ */
+ return apply_filters( 'sanitize_email', '', $email, 'email_too_short' );
+ }
- // Extract the address from "Display Name " format.
- if ( 1 === preg_match( '/<([^>]+)>$/', $email, $matches ) ) {
- $email = $matches[1];
+ // Test for an @ character after the first position.
+ if ( false === strpos( $email, '@', 1 ) ) {
+ /** This filter is documented in wp-includes/formatting.php */
+ return apply_filters( 'sanitize_email', '', $email, 'email_no_at' );
}
+ // Split out the local and domain parts.
+ list( $local, $domain ) = explode( '@', $email, 2 );
+
/*
- * Strip soft hyphens and whitespace adjacent to structural separators (dots and @),
- * e.g. copy-paste artifacts like "info@example\u{00AD}.com" or "info@example .com".
- *
- * In some cases, e.g. autocorrect, some older software has been seen to add the
- * space for unrecognized TLDs. This re-joins the parts for proper examination.
+ * LOCAL PART
+ * Test for invalid characters.
*/
- $email = preg_replace( '/[\x{00AD}\s]*([.@])[\x{00AD}\s]*/u', '$1', $email ) ?? $email;
-
- // Strip a trailing dot from the domain (e.g. if pasted from the end of a sentence).
- if ( str_contains( $email, '@' ) ) {
- list( $local, $domain ) = explode( '@', $email, 2 );
- $domain = rtrim( $domain, '.' );
- $email = $local . '@' . $domain;
+ $local = preg_replace( '/[^a-zA-Z0-9!#$%&\'*+\/=?^_`{|}~\.-]/', '', $local );
+ if ( '' === $local ) {
+ /** This filter is documented in wp-includes/formatting.php */
+ return apply_filters( 'sanitize_email', '', $email, 'local_invalid_chars' );
}
- /**
- * Filters a sanitized email address.
- *
- * Filters registered on this hook perform the actual validation and return
- * the canonical email string on success or an empty string on failure.
- * The default filter is registered in default-filters.php.
- *
- * @since 2.8.0
- *
- * @param string $sanitized_email The sanitized email address, or empty string.
- * @param string $email The email address as provided to sanitize_email().
- * @param string|null $context Validation context, or null for the initial call.
+ /*
+ * DOMAIN PART
+ * Test for sequences of periods.
*/
- return apply_filters( 'sanitize_email', '', $email, null );
+ $domain = preg_replace( '/\.{2,}/', '', $domain );
+ if ( '' === $domain ) {
+ /** This filter is documented in wp-includes/formatting.php */
+ return apply_filters( 'sanitize_email', '', $email, 'domain_period_sequence' );
+ }
+
+ // Test for leading and trailing periods and whitespace.
+ $domain = trim( $domain, " \t\n\r\0\x0B." );
+ if ( '' === $domain ) {
+ /** This filter is documented in wp-includes/formatting.php */
+ return apply_filters( 'sanitize_email', '', $email, 'domain_period_limits' );
+ }
+
+ // Split the domain into subs.
+ $subs = explode( '.', $domain );
+
+ // Assume the domain will have at least two subs.
+ if ( 2 > count( $subs ) ) {
+ /** This filter is documented in wp-includes/formatting.php */
+ return apply_filters( 'sanitize_email', '', $email, 'domain_no_periods' );
+ }
+
+ // Create an array that will contain valid subs.
+ $new_subs = array();
+
+ // Loop through each sub.
+ foreach ( $subs as $sub ) {
+ // Test for leading and trailing hyphens.
+ $sub = trim( $sub, " \t\n\r\0\x0B-" );
+
+ // Test for invalid characters.
+ $sub = preg_replace( '/[^a-z0-9-]+/i', '', $sub );
+
+ // If there's anything left, add it to the valid subs.
+ if ( '' !== $sub ) {
+ $new_subs[] = $sub;
+ }
+ }
+
+ // If there aren't 2 or more valid subs.
+ if ( 2 > count( $new_subs ) ) {
+ /** This filter is documented in wp-includes/formatting.php */
+ return apply_filters( 'sanitize_email', '', $email, 'domain_no_valid_subs' );
+ }
+
+ // Join valid subs into the new domain.
+ $domain = implode( '.', $new_subs );
+
+ // Put the email back together.
+ $sanitized_email = $local . '@' . $domain;
+
+ // Congratulations, your email made it!
+ /** This filter is documented in wp-includes/formatting.php */
+ return apply_filters( 'sanitize_email', $sanitized_email, $email, null );
}
/**
diff --git a/src/wp-settings.php b/src/wp-settings.php
index dc01c516ca810..48af51a4ec5dc 100644
--- a/src/wp-settings.php
+++ b/src/wp-settings.php
@@ -112,7 +112,7 @@
require ABSPATH . WPINC . '/class-wp-list-util.php';
require ABSPATH . WPINC . '/class-wp-token-map.php';
require ABSPATH . WPINC . '/utf8.php';
-require ABSPATH . WPINC . '/class-wp-email-address.php';
+require ABSPATH . WPINC . '/email/class-wp-email-address.php';
require ABSPATH . WPINC . '/formatting.php';
require ABSPATH . WPINC . '/meta.php';
require ABSPATH . WPINC . '/functions.php';