“Hang on,” I hear you say. “Didn’t you already do this?”
Well, kind of. I did with Ninja Forms (and actually that resulted in me removing Ninja Forms and going back to Jetpack, and isn’t that a pill?). And I’ve mentioned Gravity Forms before with disallowed keys.
This is really an extension of the disallowed keys and some very specific complex things I’ve changed since those posts.
Yeah, tech changes.
What’s a “Disallowed Key”?
Disallowed keys are basically all those terms you put in your Disallowed Comment list (formerly the Blacklist):

Now if you look at my example above, I have a bunch of terms in there. Each line is a ‘key,’ meaning @example.net
is a key.
WordPress built this in for handling comments, specifically, but I’ve long advocated for things to be global, and since this is saved in my site, I can easily re-use it by telling Jetpack or anything else “If this email is on the list of disallowed keys, I don’t want to see it.”
The reality of doing that is, of course, harder than it looks.
For example, if you use Gmail, then foobar@gmail.com
and foo.bar@gmail.com
and foobar+ihatemika@gmail.com
are all the same bloody thing! And if I block generaljerkyperson
do I then want to block generaljerkyperson@nonjerky.com
?
You have to make some decisions. Now solving the email one with periods and plus signs was easy. By comparison.
Jerky People Aren’t Spammers
This is the other thing to get into our heads. Jerky people ain’t spammers. Spammers are, weirdly, easier to spot and handle. Someone posts about viagra? Spam. Someone posts about SEO? Probably spam (though not always on a tech site). But those are all pretty self evident once you look at them!
What is not self-evident is something like the world I have.
Over on a site I run, we have a group instance (Slack, Discord kind of thing) and we let people sign up via Google Sheets for a while. But we ended up with one person in the group who was an antagonist. Let’s call her Bertie.
Bertie isn’t a terrible human, but she has “Opinions” and they are the right ones. She likes a very specific thing, and does not like variations or changes. And if you don’t like what she does, you’re wrong, she’s right, and damn you. She picks fights, constantly, she disrespects anything anyone else likes… To put it how my friend Tracy might say “She yucks on other peoples’ yums.”
We attempted to negotiate with her. Point out “Hey you’re in a GROUP and if you cannot respect people enough to tolerate their opinions when they differ, then this ain’t the group for you.”
Bertie didn’t. And she didn’t change. So we banned her. Fine.
She tried to sign up via Google Sheets again. We said no. And again. No. Then she tried new emails, new bios, new fake names. One actually got in. But as soon as we learned it was her, we kicked her.
And at this point, we were fucking tired of playing whack-a-mole. So I decided to make something better.
Catch and Release
My overall thought process boils down to this: Every single jerky person has tells.
They just do. And a human can spot them and go ‘wait a second…’ because we’re actually really good at recognizing patterns that are similar. A computer has to be taught that “If the submission is from this region or has this kind of email, it’s probably them.”
So I started to build out some logic that would check all my flagged emails and IPs. Then I took advantage of Gravity Forms’ API to make a note in the entry so I could have a record of why someone was flagged.
Here’s what it looks like for someone who’s email and IP was on the naughty list:

Seeing that come in overnight, by the way, delighted me. It shunted the annoying Bertie to spam for two reasons, and none of us had to deal with her.
How it Works
This is the code part. The down and dirty here is I have two classes, one for finding spammers, and then the Gravity Forms that calls it. I did this because at the start, I had both Jetpack and Gravity Forms AND Google Forms. Obviously I can’t block someone hitting the Google Form directly, but I used to have this prevent that from loading the form. It was easy to get around. I know I know.
Even though I now exclusively use Gravity Forms on the site, I left these separate to be future friendly. Also it means you can steal that to put it into whatever you’re doing. It’s GPLv2.
By the way, everything goes to spam for a simple reason: It lets me clear up false positives.
Find Spammers
This class is what hooks into disallowed keys and checks if the email is banned, the domain is banned, or the IP used it banned. It also has an option to check for people who are moderated! That means if you wanted to flag people who might be jerks, you can do it.
class Find_Spammers {
/**
* List of disallowed Keys
*
* We check for emails, domains, and IPs.
*
* @return array the list
*/
public static function list( $keys = 'disallowed_keys' ) {
// Preflight check:
$valid_keys = array( 'disallowed_keys', 'moderation_keys' );
$keys = ( in_array( $keys, $valid_keys, true ) ) ? $keys : 'disallowed_keys';
// Time for the show!
$disallowed_keys = array();
$disallowed_array = explode( "\n", get_option( $keys ) );
// Make a list of spammer emails and domains.
foreach ( $disallowed_array as $spammer ) {
if ( is_email( $spammer ) ) {
// This is an email address, so it's valid.
$disallowed_keys[] = $spammer;
} elseif ( strpos( $spammer, '@' ) !== false ) {
// This contains an @ so it's probably a whole domain.
$disallowed_keys[] = $spammer;
} elseif ( rest_is_ip_address( $spammer ) ) {
// IP adresses are also spammery people.
$disallowed_keys[] = $spammer;
}
}
return $disallowed_keys;
}
/**
* Is someone a spammer...
* @param string $email_address The email address
* @param string $plugin The plugin we're checking (default FALSE)
* @return boolean True/False spammer
*/
public static function is_spammer( $to_check, $type = 'email', $keys = 'disallowed_keys' ) {
// Default assume good people.
$return = false;
// Get disallowed keys & convert to array
$disallowed = self::list( $keys );
if ( 'email' === $type ) {
$email_address = $to_check;
// Break apart email into parts
$emailparts = explode( '@', $email_address );
$username = $emailparts[0]; // i.e. foobar
$domain = '@' . $emailparts[1]; // i.e. @example.com
// Remove all periods (i.e. foo.bar > foobar )
$clean_username = str_replace( '.', '', $username );
// Remove everything AFTER a + sign (i.e. foobar+spamavoid > foobar )
$clean_username = strstr( $clean_username, '+', true ) ? strstr( $clean_username, '+', true ) : $clean_username;
// rebuild email now that it's clean.
$email = $clean_username . '@' . $emailparts[1];
// If the email OR the domain is an exact match in the array, then it's a spammer
if ( in_array( $email, $disallowed, true ) || in_array( $domain, $disallowed, true ) ) {
$return = true;
}
}
if ( 'ip' === $type ) {
$ip = $to_check;
$bad_ips = false;
foreach ( $disallowed as $nope ) {
if ( rest_is_ip_address( $nope ) ) {
if ( ( strpos( $ip, $nope ) !== false ) || $ip === $nope ) {
$bad_ips = true;
}
}
}
// If they're a bad IP, then they're a bad IP and we flag.
if ( false !== $bad_ips ) {
$return = true;
}
}
return $return;
}
}
new Find_Spammers();
Gravity Forms Check
You may have noticed that the spammer checker is really just that, a checker. You have to call it. How I call it is via a Gravity Forms function. This does a couple kind of redundant things, and I know it can be optimized.
The IP checker has some extra stuff to help me record where an IP is from when people submit, in order to try and catch other ‘common traits.’ It’s using ip-info, and amusingly I’ve found it’s mostly right. For some reason, it got the IP location of the same IP as being from 3 separate locations. I suspect it’s Bertie trying to be smarter and use a VPN. The fact that the Location is not the only measuring stick I use though means she can change her IP and email a bunch of times, but I have other checks.
One improvement on my list is that if someone has a certain number of red-flags, it treats it like a jerk and sends to spam.
class My_Gravity_Forms {
public function __construct() {
// Check all Gravity Forms ... forms for spammers.
add_action( 'gform_entry_is_spam', array( $this, 'gform_entry_is_spam' ), 10, 3 );
}
/**
* Mark as spam
*
* If someone on our block-list emails, auto-mark as spam becuase we do
* not want to hear from them, but we don't want them to know they were rejected
* and thus encourage them to try other methods. Aren't assholes fun?
*
* @param boolean $is_spam -- Is this already spam or not?
* @param array $form -- All the form info
* @param array $entry -- All info from the entry
* @return boolean true/false if it's "spam"
*/
public function gform_entry_is_spam( $is_spam, $form, $entry ) {
// If this is already spam, we're gonna return and be done.
if ( $is_spam ) {
return $is_spam;
}
$spam_message = 'Failed internal spam checks';
$warn_message = '';
$is_spammer = false;
$is_moderated = false;
$is_bot = false;
$is_vpn = false;
// Loop and find the email:
foreach ( $entry as $value => $key ) {
if ( is_email( $key ) && ! $is_spammer ) {
$email = $key;
$is_spammer = Find_Spammers::is_spammer( $email, 'email', 'disallowed_keys' );
$is_moderated = Find_Spammers::is_spammer( $email, 'email', 'moderated_keys' );
}
if ( rest_is_ip_address( $key ) && ! $is_spammer ) {
$ip = $key;
$is_spammer = Find_Spammers::is_spammer( $ip, 'ip', 'disallowed_keys' );
$is_moderated = Find_Spammers::is_spammer( $ip, 'ip', 'moderated_keys' );
$is_bot = self::check_ip_location( $ip, 'hosting' );
$is_vpn = self::check_ip_location( $ip, 'proxy' );
}
}
// If this was a bot...
if ( true === $is_bot ) {
$warn_message .= 'Likely submitted by a bot or someone scripting. ';
}
// If a VPN...
if ( true === $is_vpn ) {
$warn_message .= 'Using a VPN. This may be harmless, but it\'s also how people evade bans. ';
}
// And if it's a spammer...
if ( $is_spammer ) {
$message = $spam_message;
if ( isset( $email ) ) {
$message .= ' - Email ( ' . $email . ' )';
}
if ( isset( $ip ) ) {
$message .= ' - IP Address ( ' . $ip . ' )';
}
$result = GFAPI::add_note( $entry['id'], 0, 'My Robot', $message, 'error', 'spam' );
return true;
} else {
if ( ! empty( $warn_message ) ) {
$add_note = GFAPI::add_note( $entry['id'], 0, 'My Robot', $warn_message, 'warning', 'spam' );
}
}
// If we got all the way down here, we're not spam!
return false;
}
/**
* IP Checker
*/
public function check_ip_location( $ip, $format = 'full' ) {
$return = $ip;
$localhost = array( '127.0.0.1', '::1', 'localhost' );
if ( in_array( $ip, $localhost, true ) ) {
$return = 'localhost';
} else {
$api = 'http://ip-api.com/json/' . $ip;
$request = wp_remote_get( $api );
if ( is_wp_error( $request ) ) {
return $ip; // Bail early
}
$body = wp_remote_retrieve_body( $request );
$data = json_decode( $body );
switch ( $format ) {
case 'full':
// Return: US - Chicago
$return .= ( isset( $data->countryCode ) ) ? ' ' . $data->countryCode : ''; // phpcs:ignore
$return .= ( isset( $data->countryCode ) ) ? ' - ' . $data->city : ''; // phpcs:ignore
$return .= ( isset( $data->proxy ) && true === $data->proxy ) ? ' (VPN)' : '';
break;
case 'hosting':
$return = ( isset( $data->hosting ) && true === $data->hosting ) ? true : false;
break;
case 'proxy':
$return .= ( isset( $data->proxy ) && true === $data->proxy ) ? true : false;
break;
}
}
return $return;
}
}
new My_Gravity_Forms();
Any Issues?
Two.
- I use an ‘approval’ feature (forked from “Gravity Forms Approvals” to allow for multiple approvers optional but only one has to approve to be a go – the original requires all approvers to approve) – for some reason this is not properly moving anything in spam or trash to a ‘rejected’ status
- IP-Info got a ‘different’ IP location than I see from the IP in two cases. I believe that’s due to the individual trying to juke the system and being caught anyway, but it needs some debugging.
Oh and clearly I have some optimization I could stand to work on, but that’s for another day.
This code is live, in production, and has been merrily blocking Bertie for some time.