A client site I maintain, a booking platform with self-serve signup, was slowly collecting strange accounts. During a routine QA pass I found one that made me stop: the address was testing@g.com. That g.com domain is obviously bogus, not gmail.com, with no mail server behind it. Yet the account was not half-baked. It was a full WordPress user, complete with a role and meta, sitting in the database from who knows when.
What made it stranger: the flow was supposed to send a verification email before an account counted as active. That email never arrived, because the domain is dead. But the account got created first and stuck around forever, even though verification would never complete. The database was quietly bloating with ghost accounts nobody was cleaning up.
The symptom
I reproduced it by hand. I opened the signup form, typed a made-up domain, qwerty@asdf-does-not-exist.co, and submitted. The page returned success and told me to check my inbox. I opened the user list in wp-admin, and sure enough, a fresh user was there with an active status. The verification email was never coming, but the account existed. A few more invented domains gave the same result: any string shaped like an email became a permanent account.
Tracing the root cause
I opened the signup handler and found the one and only email validation gate: is_email(). That is where it broke. is_email() is a pure syntax check. It matches the string against the pattern of a valid address, and that is it. It never touches DNS, never confirms the domain exists, never checks whether any mail server could accept a message. So anything shaped correctly, x@y.z, testing@g.com, a domain that died years ago, all sail through.
The string that passed went straight into wp_create_user(), with no layer in between. So there were two holes gaping at once. First, the gate never asked whether this address could actually receive mail. Second, nothing ever cleaned up accounts created but never verified.
The fix
I patched both: a gate in front to reject the clearly undeliverable, plus a sweeper in back for what already slipped in.
Layer one: a deliverability check right after is_email() passes. The method PC_REST_Auth::validate_email_deliverability() grabs the domain and queries DNS. The key detail: do not check MX only. Small or personal domains sometimes have no explicit MX record, and per RFC 5321 §5.1, when MX is absent the A record acts as an implicit MX. So I check MX first, fall back to A, and only reject when both fail.
public static function validate_email_deliverability( $email ) {
// is_email() already passed: the syntax is fine. Now check the DNS.
$domain = substr( strrchr( $email, '@' ), 1 );
// Some hosts disable checkdnsrr(): do not block everyone. Fail open.
if ( ! function_exists( 'checkdnsrr' ) ) {
return true;
}
// MX first. No MX? RFC 5321 §5.1: use the A record as an implicit MX.
if ( ! checkdnsrr( $domain, 'MX' ) && ! checkdnsrr( $domain, 'A' ) ) {
return new WP_Error(
'rest_undeliverable_email',
'The email domain has no reachable mail server.',
array( 'status' => 400 )
);
}
return true;
}Note the function_exists branch. Some managed hosts disable checkdnsrr(), and hard-rejecting whenever the function is missing would kill signup for everyone on that kind of host. So I fail open: if the check is unavailable, let it through rather than ship a regression worse than the bug itself.
Wiring it into the handler is a single block, and it runs before wp_create_user() is ever called:
$deliverable = self::validate_email_deliverability( $email );
if ( is_wp_error( $deliverable ) ) {
return $deliverable; // stop before the account can be created
}Layer two: a daily cron, pc_purge_unverified_users, to sweep accounts that already exist and will never be verified. The rules are deliberately conservative so nothing gets deleted by accident. Delete only accounts unverified for more than 7 days that are not admins, are not demo seed, and own zero pc_property posts.
add_action( 'pc_purge_unverified_users', function () {
$stale = get_users( array(
'meta_key' => 'pc_email_verified',
'meta_value' => '0',
'date_query' => array(
array( 'before' => '7 days ago', 'column' => 'user_registered' ),
),
) );
foreach ( $stale as $user ) {
if ( user_can( $user, 'manage_options' ) ) continue; // admin, never touch
if ( get_user_meta( $user->ID, 'pc_demo_seed', true ) ) continue; // demo seed, keep
if ( count_user_posts( $user->ID, 'pc_property' ) > 0 ) continue; // owns content, keep
require_once ABSPATH . 'wp-admin/includes/user.php';
wp_delete_user( $user->ID );
}
} );Those three conditions are the safety brakes. Owning a pc_property means the account is genuinely in use, whatever its email status. Demo seed is protected so it is never swept, and an admin must never be removed by an automated process.
Checklist
is_email()only checks syntax. Passing it does not mean the address can receive anything.- After syntax passes, check domain deliverability with
checkdnsrr($domain, 'MX'). - Fall back to the
Arecord per RFC 5321 §5.1 so domains without an explicit MX still validate. - Fail open when
checkdnsrr()is disabled by the host: do not break signup for everyone to block the bogus few. - Run the deliverability gate before
wp_create_user(), not after the account already exists. - Add a cleanup cron for stale unverified accounts, with conservative rules: not admin, not seed, no content of their own.
