From 41f3c5342341851c1356db801c3a08e37d1add97 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Thu, 16 Jul 2026 14:35:24 -0700 Subject: [PATCH] Email: Default to non-Unicode addresses and ease toggling support. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trac ticket: Core-31992 See WordPress/wordpress-develop#5237 Replaces the legacy `is_email()` and `sanitize_email()` functions, augmenting them through instead by selecting email address support. The new `wp_email_address_support` filter provides an opportunity for plugins to upgrade or downgrade email support depending on an individual site’s needs, while paving a path towards future support for Unicode email addresses by default. This patch downgrades default support to the updated `ascii` mode, which rejects Unicode addresses but applies updated parsing rules for better ecosystem integration. Follow-up to: [[32482]](https://core.trac.wordpress.org/changeset/62482). --- src/wp-includes/default-filters.php | 12 +- .../{ => email}/class-wp-email-address.php | 46 ++++ src/wp-includes/email/wp-email-support.php | 117 +++++++++ src/wp-includes/formatting.php | 235 +++++++++++++----- src/wp-settings.php | 2 +- 5 files changed, 336 insertions(+), 76 deletions(-) rename src/wp-includes/{ => email}/class-wp-email-address.php (91%) create mode 100644 src/wp-includes/email/wp-email-support.php 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';