All posts

How to Verify Email Addresses on WordPress Forms (CF7, Gravity, WPForms)

··7 min read

WordPress doesn't verify email addresses. is_email() only checks that the input is shaped like one, and it accepts some malformed addresses. To catch dead domains, add a small plugin that looks up the domain's MX records with dns_get_record(), caches the answer in a transient, and hooks into each form's validation filter. Don't use checkdnsrr() alone: it reports success for domains that publish a null MX.

We tested the PHP below on PHP 8.5 on 6 October 2026, using is_email() and sanitize_email() from the current WordPress source. The hook names were checked against the WordPress, WooCommerce, Contact Form 7, Gravity Forms and WPForms documentation and source.

What is_email() actually checks

is_email() checks the shape of an address, loosely. It accepts some invalid addresses and rejects some valid ones.

The is_email reference lists what it tests: a minimum of 6 characters, an @ after the first character, allowed local-part characters, and a domain with at least two labels, no doubled periods and no misplaced hyphens. Its own docblock says it "does not grok i18n domains. Not RFC compliant."

We ran it against the cases from our regex test:

Input is_email() Correct answer
jane+news@example.com, o'brien@example.ie Valid Valid
john..doe@example.com Valid Invalid (doubled dot)
.john@example.com Valid Invalid (leading dot)
65-character local part Valid Invalid (64-octet limit)
user@münchen.de Invalid Valid (IDN domain)
jane@example.com (leading space) Invalid Valid once trimmed
jane@gmial.con Valid Valid shape, but the domain doesn't exist

PHP's built-in filter_var($email, FILTER_VALIDATE_EMAIL) rejected the doubled dot, the leading dot and the long local part correctly, but it also rejected münchen.de. Neither function looks at DNS.

The sanitize_email() trap

sanitize_email() deletes characters instead of rejecting the address, so sanitizing before validating can quietly store a different address.

Input sanitize_email() output
user@münchen.de user@mnchen.de
"john doe"@example.com johndoe@example.com
john@-example.com john@example.com
jane@[192.168.0.1] jane@192.168.0.1
用户@例子.广告 empty string

Each of those outputs is a valid-looking address, and some of them could belong to someone else. Validate the trimmed input as typed. If sanitize_email() changes anything other than surrounding whitespace, show an error rather than saving the changed version. If you need internationalized domains, see internationalized email addresses.

The checkdnsrr() trap

checkdnsrr($domain, 'MX') returns true for a null MX record, which is a domain saying it never accepts mail.

RFC 7505 defines the null MX: a single MX record with preference 0 and a target of .. It's how a domain says "no mail here". Our results:

Domain checkdnsrr(..., 'MX') dns_get_record(..., DNS_MX)
gmail.com true five records
example.com true one record, priority 0, target ""
a domain that doesn't exist false empty array

So dns_get_record() it is. PHP returns the null MX target as an empty string, which is what the plugin checks for.

The plugin

One helper that returns an error message or null, with DNS results cached for a day.

Save this as wp-content/plugins/email-domain-check/email-domain-check.php and activate it:

<?php
/**
 * Plugin Name: Email Domain Check
 * Description: Rejects form emails whose domain does not exist or does not accept mail.
 */

defined( 'ABSPATH' ) || exit;

/**
 * Returns an error message, or null if the address is acceptable.
 * DNS failures return null: a slow resolver is not evidence against the user.
 */
function edc_email_problem( string $email ): ?string {
	$email = trim( $email );
	if ( ! is_email( $email ) ) {
		return __( 'Please enter a valid email address.', 'edc' );
	}

	$domain = strtolower( substr( strrchr( $email, '@' ), 1 ) );
	$key    = 'edc_mx_' . md5( $domain );
	$cached = get_transient( $key );
	if ( false !== $cached ) {
		return 'ok' === $cached ? null : $cached;
	}

	$records = dns_get_record( $domain, DNS_MX );
	if ( false === $records ) {
		return null;
	}

	$problem = null;
	if ( 1 === count( $records ) && in_array( $records[0]['target'], array( '', '.' ), true ) ) {
		/* translators: %s: domain name */
		$problem = sprintf( __( '%s does not accept email.', 'edc' ), $domain );
	} elseif ( ! $records && ! checkdnsrr( $domain, 'A' ) && ! checkdnsrr( $domain, 'AAAA' ) ) {
		/* translators: %s: domain name */
		$problem = sprintf( __( '%s can\'t receive email. Check for a typo.', 'edc' ), $domain );
	}

	set_transient( $key, $problem ?? 'ok', DAY_IN_SECONDS );
	return $problem;
}

With WordPress's functions stubbed, the helper returned null for jane@gmail.com, "example.com does not accept email." for jane@example.com, and "gmial.con can't receive email. Check for a typo." for jane@gmial.con. A domain with no MX record but an A or AAAA record passes, because RFC 5321 section 5.1 treats the address record as an implicit MX.

The transient keeps a busy form from repeating the same lookup. It caches the message, not just a flag, so a cached rejection shows the same text. If dns_get_record() fails outright it returns false, usually with a PHP warning in your log, and the helper lets the submission through.

Hooking it into each form

Every form plugin has its own validation filter. Add the ones you use to the same plugin file.

// Core registration (wp-login.php?action=register) and WooCommerce My Account registration.
$edc_registration = function ( $errors, $username, $email ) {
	$problem = edc_email_problem( (string) $email );
	if ( $problem ) {
		$errors->add( 'edc_email', $problem );
	}
	return $errors;
};
add_filter( 'registration_errors', $edc_registration, 10, 3 );
add_filter( 'woocommerce_registration_errors', $edc_registration, 10, 3 );

// Contact Form 7: optional and required email fields.
foreach ( array( 'wpcf7_validate_email', 'wpcf7_validate_email*' ) as $edc_hook ) {
	add_filter( $edc_hook, function ( $result, $tag ) {
		$value = isset( $_POST[ $tag->name ] ) ? trim( wp_unslash( (string) $_POST[ $tag->name ] ) ) : '';
		if ( '' !== $value ) {
			$problem = edc_email_problem( $value );
			if ( $problem ) {
				$result->invalidate( $tag, $problem );
			}
		}
		return $result;
	}, 20, 2 );
}

// Gravity Forms: every email field on every form.
add_filter( 'gform_field_validation', function ( $result, $value, $form, $field ) {
	if ( 'email' !== $field->type || ! $result['is_valid'] ) {
		return $result;
	}
	$email = is_array( $value ) ? rgar( $value, 0 ) : (string) $value;
	if ( '' !== $email ) {
		$problem = edc_email_problem( $email );
		if ( $problem ) {
			$result['is_valid'] = false;
			$result['message']  = $problem;
		}
	}
	return $result;
}, 10, 4 );

// WPForms: every email field on every form.
add_action( 'wpforms_process', function ( $fields, $entry, $form_data ) {
	foreach ( $fields as $id => $field ) {
		if ( 'email' !== $field['type'] || '' === $field['value'] ) {
			continue;
		}
		$problem = edc_email_problem( $field['value'] );
		if ( $problem ) {
			wpforms()->process->errors[ $form_data['id'] ][ $id ] = $problem;
		}
	}
}, 10, 3 );

Notes from each plugin's documentation:

  • registration_errors passes a WP_Error, the sanitized login and the email. The callback must return $errors even when there's no error. WooCommerce applies woocommerce_registration_errors with the same three arguments in wc_create_new_customer().
  • Contact Form 7 applies wpcf7_validate_{type}, so email and email* (required) are separate filters. Priority 20 runs after CF7's own checks. CF7 itself calls is_email() through its wpcf7_is_email() wrapper.
  • Gravity Forms: when an email field has confirmation enabled, $value is an array, which is why the code reads rgar( $value, 0 ). Use gform_field_validation_{form}_{field} to target one field.
  • WPForms: writing to wpforms()->process->errors[ $form_id ][ $field_id ] stops the submission and shows the message under that field.

Test on a staging site first. Form plugins change their internals between major versions, and these hooks run on every submission.

Going further than DNS

An MX check catches dead domains and typos. It can't tell whether the mailbox exists, and it doesn't stop bots.

  • Mailbox checks need SMTP, which shared WordPress hosts rarely allow on port 25. If you call a verification API from edc_email_problem(), use wp_remote_get() with 'timeout' => 3, and return null when it times out. The fail-open reasoning is in real-time email verification on signup forms.
  • Bots should be stopped before the DNS lookup. Honeypot fields and the layers in how to stop fake signups cost real users nothing.
  • Throwaway domains have working MX records. They need a list-plus-MX approach, covered in blocking disposable emails.

The takeaway

  • is_email() checks the shape, loosely. It isn't verification.
  • Never sanitize-then-validate. sanitize_email() can turn a valid address into someone else's.
  • Use dns_get_record(), not checkdnsrr(), and treat an empty MX target as "no mail".
  • Cache lookups in a transient and fail open on DNS errors.
  • To see the MX records for a domain your form rejected, use the MX lookup tool.

Common questions

Does WordPress verify email addresses?

Only the format. is_email() checks length, the @, allowed characters and the domain's shape. It doesn't look up DNS or contact the mail server, and its own documentation says it is not RFC compliant and does not handle internationalized domains. Real verification has to be added by a plugin or custom code.

Can I use checkdnsrr() to check an email domain in PHP?

Only with care. checkdnsrr($domain, 'MX') returned true for example.com in our test, even though example.com publishes a null MX record that says it never accepts mail. Use dns_get_record($domain, DNS_MX) and treat a single record whose target is empty as no mail.

Which hook do I use to validate email in Contact Form 7?

The wpcf7_validate_email and wpcf7_validate_email* filters. The asterisk version is for required email fields. The callback receives the validation result and the form tag, and calls $result->invalidate($tag, $message) to show an error under the field.

Should I run sanitize_email() before validating?

Validate the trimmed input first. sanitize_email() removes characters it doesn't like instead of rejecting the address. In our test it turned user@münchen.de into user@mnchen.de, a different domain, and "john doe"@example.com into johndoe@example.com. If sanitizing changes anything beyond whitespace, ask the user to re-enter the address.

Check your list before you send

Real mailbox checks over SMTP, and nothing is sent to the address. Sign in with Google and your first 100 email checks are free, no card. Plans for bigger lists are on the pricing page.