Skip to content

Building a simple email risk score from free signals

· 7 min read · Email validation

You can build a useful email risk score from signals that cost nothing or close to it: whether the domain is disposable, whether the local part looks machine-generated, whether the domain can receive mail, whether it has a website and how old it is, and how many other accounts share the same inbox or domain. Split them into two groups. A few deterministic facts justify a hard rule. Everything else adds points, and the total decides how much friction you add, not whether the person gets in. Log every input so you can tune the weights against real outcomes later.

This article is about assembling those signals into one decision. Each signal has its own deeper write-up, linked where it comes up.

Hard rules first, points second

The most common mistake in homegrown scoring is putting everything on one scale. A disposable domain and a role-based address end up as "+40" and "+15", and someone eventually tunes the threshold so that two soft signals equal one hard one. Now info@ at a small business with a new domain gets blocked like a Mailinator address.

Keep the scale for uncertain things. Facts get rules:

Signal Why it's a rule Action
Invalid syntax or nonexistent domain The address cannot work. Reject inline, ask to fix.
Domain can't receive mail (no MX, no A, or a null MX) Your confirmation will never arrive. Reject inline.
Disposable domain The address is designed to be abandoned. Reject or restrict, per your policy.

Whether disposable is a hard block or a heavy flag is a product decision; block or flag disposable emails lays out both sides. Either way, decide it once, explicitly, outside the score.

The scored signals

These are all free or near-free, and none is reliable alone:

Signal Source Rough cost
Local part looks machine-generated Character statistics on the local part Microseconds
Domain label looks random Same, on the domain name Microseconds
Role or system address (info@, noreply@) Fixed list Microseconds
Mail-only domain (MX, no website) DNS One cached DNS round
Young domain RDAP registration date One HTTP call, cache per domain
Same inbox already seen (dots, +tag) Your user table One indexed query
Many distinct signups on one domain today Your user table One indexed query

The first four are properties of the address. The last three need your own data, and they're often the strongest, because they catch behaviour rather than appearance. A perfectly normal-looking j.smith+7@gmail.com is only suspicious because you already have jsmith@, j.smith+1@ through +6. The canonical-key approach is in Gmail dots and plus addressing, and the per-domain count in catch-all email domains.

A scoring function you can start with

This uses pingutil for the address-level signals and DNS, because one call returns all of them. The field names are the real ones from the API reference. The ctx values come from your own database, and domain age from an RDAP lookup like the one in signup domains with no website.

type Band = 'allow' | 'confirm_first' | 'review' | 'reject';

interface Context {
  canonicalMatches: number;   // existing users with the same canonical email key
  domainSignups24h: number;   // distinct inboxes on this registrable domain, last 24h
  domainAgeDays: number | null;
}

interface RiskResult {
  band: Band;
  score: number;
  reasons: string[];
  features: Record<string, unknown>; // log this
}

export async function emailRisk(email: string, ctx: Context): Promise<RiskResult> {
  const res = await fetch('https://pingutil.com/v1/lookup', {
    method: 'POST',
    headers: {
      authorization: `Bearer ${process.env.PINGUTIL_KEY}`,
      'content-type': 'application/json',
    },
    body: JSON.stringify({ input: email, skip_enrichment: true, check_dns: true }),
    signal: AbortSignal.timeout(3000),
  }).catch(() => null);

  // 400 invalid_input: IP literal, reserved TLD, malformed address.
  if (res && res.status === 400) {
    return { band: 'reject', score: 100, reasons: ['invalid'], features: {} };
  }
  // Fail open: a lookup outage should not stop real people signing up.
  if (!res || !res.ok) {
    return { band: 'confirm_first', score: 0, reasons: ['lookup_unavailable'], features: {} };
  }
  const d = await res.json();
  const sig = d.email_signals;
  const dns = d.domain_signals;

  const features = {
    category: d.category,
    verdict_reason: d.verdict.reason,
    randomness_score: sig?.randomness_score ?? null,
    domain_looks_random: sig?.domain_looks_random ?? null,
    is_role_based: sig?.is_role_based ?? null,
    is_system_generated: sig?.is_system_generated ?? null,
    ...dns,
    ...ctx,
  };

  // Hard rules
  if (d.category === 'unknown') return { band: 'reject', score: 100, reasons: ['invalid'], features };
  if (d.category === 'disposable') return { band: 'reject', score: 100, reasons: ['disposable'], features };
  if (dns && (!dns.domain_exists || !dns.can_receive_email)) {
    return { band: 'reject', score: 100, reasons: ['undeliverable'], features };
  }

  // Points (starting weights, not calibrated)
  let score = 0;
  const reasons: string[] = [];
  const add = (pts: number, why: string) => { score += pts; reasons.push(why); };

  if (d.verdict.reason === 'randomized_local_part') add(35, 'randomized_local_part');
  else if ((sig?.randomness_score ?? 0) >= 30) add(10, 'somewhat_random_local_part');
  if (sig?.is_system_generated) add(20, 'system_address');   // noreply@ signing up is odd
  else if (sig?.is_role_based) add(5, 'role_address');
  if (sig?.domain_looks_random) add(15, 'random_domain_label');

  if (d.category === 'company' && dns && !dns.has_website) {
    add(10, 'mail_only_domain');
    if (ctx.domainAgeDays !== null && ctx.domainAgeDays < 30) add(25, 'young_mail_only_domain');
  }
  if (ctx.canonicalMatches > 0) add(Math.min(40, 20 * ctx.canonicalMatches), 'same_inbox_reused');
  if (d.category === 'company' && ctx.domainSignups24h >= 5) add(20, 'domain_velocity');

  score = Math.min(score, 99);
  const band: Band = score >= 60 ? 'review' : score >= 30 ? 'confirm_first' : 'allow';
  return { band, score, reasons, features };
}

Why the weights look like that

They're opinions, and you should treat them as a first draft:

  • Randomized local part is heavy but not a rule. pingutil sets verdict.is_spam for it, and for most products blocking on is_spam is fine. In a score, though, it's worth remembering why it's a heuristic. The detector spares real names with dates (priya99@, john1990@), but a non-name handle with random digits like coolcat123@ also gets flagged. That trade-off is described in detecting machine-generated email addresses. 35 points puts a randomized address into "confirm first" alone, and into review with any second signal.
  • System addresses score higher than role addresses. info@ signing up for a B2B tool is a small business owner using their main inbox. noreply@ signing up for anything is a misconfigured script or a test.
  • Free webmail adds zero. gmail.com is not a risk factor. It's where most consumers live. If you're B2B and want work emails, that belongs in a qualification rule, separate from fraud scoring.
  • Velocity only counts on company domains. A hundred signups from gmail.com today is normal. Five distinct inboxes from one unknown custom domain is not.
  • Reused inbox scales with count. One prior match might be a forgotten account. Three is a pattern.

Map bands to friction, not to a yes/no

The point of a score is to stop treating every user the same way, in both directions:

  • allow: normal signup.
  • confirm_first: account is created, but nothing valuable (free credits, sending email, inviting others, API keys) is enabled until the email is confirmed. Real users barely notice.
  • review: same as above, plus the account goes into a queue or gets a lower free-tier limit until a person or a later signal clears it.
  • reject: only from hard rules.

Note that nothing scored can reach reject. That's deliberate. Every scored signal has innocent explanations, and the cost of turning away a real customer at signup is usually higher than the cost of watching an abuser for a day.

Log features, then calibrate

The features object is the most valuable part of the function. Store it with the user record, unaggregated. After a few weeks you'll have outcomes to compare against: accounts you banned, chargebacks, trials that converted, users who never confirmed.

Then ask simple questions with SQL:

  1. What share of review-band accounts were later banned? If it's tiny, your review threshold is too low and you're wasting reviewer time.
  2. Which single reason appears most often in banned accounts that scored allow? That's the weight to raise.
  3. Which reason appears often in accounts that converted to paid? That's the weight to lower.

Don't trust any published weights more than your own data, including the ones above. Abuse is product-specific: a free-credits AI tool attracts different attackers from a B2B CRM.

Keep it fresh

Free signals decay in predictable ways. Disposable lists miss domains that appeared yesterday; the community disposable-email-domains list that pingutil syncs weekly is good, but it is always behind the newest services, which is one reason the velocity and young-domain signals carry weight here. RDAP answers change when domains are re-registered, so cache domain age for days, not forever. And the abusers adapt to whatever you block outright, which is another argument for scoring rather than blocking: friction is harder to reverse-engineer than a wall.

Checklist to ship this week:

  • Hard rules for invalid, undeliverable and disposable.
  • A canonical email column and a per-domain signup count.
  • The scoring function, with features logged.
  • confirm_first gating on credits and sending.
  • A calendar reminder to look at the logged features against outcomes in a month.

Questions people ask

What is an email risk score?
A number, computed at signup or login, that summarises how likely an email address is to belong to a throwaway, automated or abusive account. It is built from signals like disposable domains, random-looking local parts, domain age and how many accounts share the same inbox or domain.
Should a high email risk score block the signup?
Usually not on its own. Block only on deterministic facts such as a disposable or nonexistent domain, and use the score to decide how much friction or review to add. Email signals are cheap but noisy, and real users trip them.
How do I choose the weights for an email risk score?
Start with rough hand-picked weights, log every signal value alongside the score, and after a few weeks compare them with accounts you later banned or that charged back. Adjust weights so the high bands contain mostly confirmed abuse.