DEV Community

Ozor
Ozor

Posted on

How to Verify Email Addresses in JavaScript (Without Sending Emails)

You know the drill: a user signs up with asdfgh@notreal.xyz, and your onboarding flow sends a welcome email into the void. Bounce rates climb, your sender reputation tanks, and deliverability drops for everyone.

Most email verification guides tell you to send a confirmation email. But you can catch 80%+ of bad addresses before sending anything — using DNS lookups.

In this tutorial, you'll build an email verification function that checks:

  • Syntax — is it a valid email format?
  • MX records — can the domain actually receive email?
  • SPF records — does the domain have proper email authentication?
  • DMARC policy — is the domain protecting against spoofing?
  • Disposable domains — is it a throwaway address?

All without sending a single email.


The API

We'll use a free DNS lookup API that resolves any record type — MX, TXT, A, AAAA, NS — with a single HTTP call:

curl "https://api.frostbyte.world/v1/agent-dns/api/resolve/gmail.com/MX"
Enter fullscreen mode Exit fullscreen mode
{
  "domain": "gmail.com",
  "type": "MX",
  "records": [
    { "exchange": "gmail-smtp-in.l.google.com", "priority": 5 },
    { "exchange": "alt1.gmail-smtp-in.l.google.com", "priority": 10 },
    { "exchange": "alt2.gmail-smtp-in.l.google.com", "priority": 20 }
  ],
  "queryTime": 4
}
Enter fullscreen mode Exit fullscreen mode

No API key needed for basic usage, or create a free key for 200 requests.


Step 1: Syntax Validation

Start with the obvious — reject malformed addresses before making any network calls:

function validateSyntax(email) {
  // RFC 5322 simplified — covers 99% of real addresses
  const re = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/;
  if (!re.test(email)) return { valid: false, reason: 'Invalid email format' };

  const [local, domain] = email.split('@');
  if (local.length > 64) return { valid: false, reason: 'Local part too long (max 64 chars)' };
  if (domain.length > 253) return { valid: false, reason: 'Domain too long (max 253 chars)' };

  const parts = domain.split('.');
  if (parts.length < 2) return { valid: false, reason: 'Domain must have at least two parts' };
  if (parts[parts.length - 1].length < 2) return { valid: false, reason: 'Invalid TLD' };

  return { valid: true, local, domain };
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Check MX Records

This is the most important check. If a domain has no MX records, it cannot receive email — period.

const API_BASE = 'https://api.frostbyte.world/v1/agent-dns/api';

async function checkMX(domain) {
  const res = await fetch(`${API_BASE}/resolve/${domain}/MX`);
  const data = await res.json();

  if (!data.records || data.records.length === 0) {
    return { hasMX: false, reason: 'No MX records — domain cannot receive email' };
  }

  // Sort by priority (lower = preferred)
  const sorted = data.records.sort((a, b) => a.priority - b.priority);

  return {
    hasMX: true,
    primaryServer: sorted[0].exchange,
    totalServers: sorted.length,
    records: sorted,
  };
}
Enter fullscreen mode Exit fullscreen mode

Example output for gmail.com:

{
  "hasMX": true,
  "primaryServer": "gmail-smtp-in.l.google.com",
  "totalServers": 5
}
Enter fullscreen mode Exit fullscreen mode

Example output for notarealdomain12345.xyz:

{
  "hasMX": false,
  "reason": "No MX records — domain cannot receive email"
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Check SPF Records

SPF (Sender Policy Framework) tells receiving servers which IPs are authorized to send email for a domain. A domain without SPF is suspicious — legitimate organizations almost always set it up.

async function checkSPF(domain) {
  const res = await fetch(`${API_BASE}/resolve/${domain}/TXT`);
  const data = await res.json();

  if (!data.records) return { hasSPF: false };

  const spfRecord = data.records.find(r =>
    (typeof r === 'string' ? r : '').startsWith('v=spf1')
  );

  if (!spfRecord) return { hasSPF: false, reason: 'No SPF record found' };

  return {
    hasSPF: true,
    record: spfRecord,
    strict: spfRecord.includes('-all'),    // hard fail = strict
    softFail: spfRecord.includes('~all'),  // soft fail = moderate
    neutral: spfRecord.includes('?all'),   // neutral = permissive
  };
}
Enter fullscreen mode Exit fullscreen mode

For gmail.com, SPF resolves to:

v=spf1 redirect=_spf.google.com
Enter fullscreen mode Exit fullscreen mode

Step 4: Check DMARC Policy

DMARC builds on SPF and DKIM to tell receivers what to do with unauthenticated email. A proper DMARC policy is a strong signal that the domain is professionally managed.

async function checkDMARC(domain) {
  const res = await fetch(`${API_BASE}/resolve/_dmarc.${domain}/TXT`);
  const data = await res.json();

  if (!data.records || data.records.length === 0) {
    return { hasDMARC: false, reason: 'No DMARC policy — domain may not be protecting against spoofing' };
  }

  const dmarcRecord = data.records.find(r =>
    (typeof r === 'string' ? r : '').startsWith('v=DMARC1')
  );

  if (!dmarcRecord) return { hasDMARC: false };

  // Parse the policy
  const policyMatch = dmarcRecord.match(/p=(\w+)/);
  const policy = policyMatch ? policyMatch[1] : 'unknown';

  return {
    hasDMARC: true,
    policy,       // none, quarantine, or reject
    record: dmarcRecord,
    strict: policy === 'reject',
  };
}
Enter fullscreen mode Exit fullscreen mode

For gmail.com:

{
  "hasDMARC": true,
  "policy": "none",
  "record": "v=DMARC1; p=none; sp=quarantine; rua=mailto:mailauth-reports@google.com"
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Disposable Email Detection

Disposable email services (Mailinator, Guerrilla Mail, temp-mail, etc.) are used by bots and users trying to bypass verification. Keep a blocklist:

const DISPOSABLE_DOMAINS = new Set([
  'mailinator.com', 'guerrillamail.com', 'tempmail.com',
  'throwaway.email', 'yopmail.com', 'sharklasers.com',
  'guerrillamailblock.com', 'grr.la', 'dispostable.com',
  'mailnesia.com', 'temp-mail.org', '10minutemail.com',
  'trashmail.com', 'fakeinbox.com', 'mailcatch.com',
  // Add more as needed — there are 3000+ disposable domains
]);

function isDisposable(domain) {
  return DISPOSABLE_DOMAINS.has(domain.toLowerCase());
}
Enter fullscreen mode Exit fullscreen mode

Tip: Maintain your own list or use a community-maintained list with 3000+ entries.


Putting It All Together

Now combine all checks into a single verification function with a confidence score:

async function verifyEmail(email) {
  // Step 1: Syntax
  const syntax = validateSyntax(email);
  if (!syntax.valid) {
    return { email, valid: false, score: 0, reason: syntax.reason };
  }

  const { domain } = syntax;
  let score = 20; // Base score for valid syntax
  const checks = {};

  // Step 2: Disposable check
  if (isDisposable(domain)) {
    return { email, valid: false, score: 5, reason: 'Disposable email domain' };
  }

  // Step 3: MX records
  const mx = await checkMX(domain);
  checks.mx = mx;
  if (!mx.hasMX) {
    return { email, valid: false, score: 10, reason: mx.reason, checks };
  }
  score += 30; // Has MX records

  // Step 4: SPF
  const spf = await checkSPF(domain);
  checks.spf = spf;
  if (spf.hasSPF) {
    score += 25;
    if (spf.strict) score += 5; // Bonus for strict SPF
  }

  // Step 5: DMARC
  const dmarc = await checkDMARC(domain);
  checks.dmarc = dmarc;
  if (dmarc.hasDMARC) {
    score += 20;
    if (dmarc.strict) score += 5; // Bonus for reject policy
  }

  return {
    email,
    valid: true,
    score: Math.min(score, 100),
    confidence: score >= 75 ? 'high' : score >= 50 ? 'medium' : 'low',
    domain,
    checks,
  };
}
Enter fullscreen mode Exit fullscreen mode

Test It

// Test with common providers
const emails = [
  'user@gmail.com',
  'dev@outlook.com',
  'fake@notarealdomain12345.xyz',
  'spam@mailinator.com',
  'bad-format@@.com',
];

for (const email of emails) {
  const result = await verifyEmail(email);
  console.log(`${email}: score=${result.score} (${result.confidence || 'invalid'})`);
}
Enter fullscreen mode Exit fullscreen mode

Output:

user@gmail.com: score=95 (high)
dev@outlook.com: score=100 (high)
fake@notarealdomain12345.xyz: score=10 (invalid)
spam@mailinator.com: score=5 (invalid)
bad-format@@.com: score=0 (invalid)
Enter fullscreen mode Exit fullscreen mode

Express Middleware

Drop this into your signup flow to reject bad emails before they hit your database:

async function emailVerificationMiddleware(req, res, next) {
  const { email } = req.body;
  if (!email) return res.status(400).json({ error: 'Email is required' });

  const result = await verifyEmail(email);

  if (!result.valid) {
    return res.status(422).json({
      error: 'Invalid email address',
      reason: result.reason,
      score: result.score,
    });
  }

  if (result.score < 50) {
    return res.status(422).json({
      error: 'Email domain appears suspicious',
      score: result.score,
      confidence: result.confidence,
    });
  }

  req.emailVerification = result;
  next();
}

// Usage
app.post('/api/signup', emailVerificationMiddleware, (req, res) => {
  // Email is verified — proceed with registration
  res.json({ message: 'Account created', emailScore: req.emailVerification.score });
});
Enter fullscreen mode Exit fullscreen mode

What This Catches vs. What It Doesn't

This approach catches:

  • Typos in domain names (gmal.com, gmial.com)
  • Completely fake domains
  • Disposable/throwaway emails
  • Domains that can't receive email
  • Domains without proper email authentication

This approach doesn't catch:

  • Valid domains with non-existent mailboxes (e.g., nonexistent@gmail.com)
  • Catch-all servers that accept everything
  • Recently deactivated accounts

For those edge cases, you'd need SMTP verification (connecting to the mail server and checking if the mailbox exists) — which is more aggressive and can get your IP blacklisted. The DNS approach above covers 80-90% of bad addresses safely.


Cost

Each verification makes 3 DNS API calls (MX, TXT for SPF, TXT for DMARC). With the free tier, you get:

  • 50 requests/day without a key (enough for ~16 verifications/day)
  • 200 credits with a free API key (enough for ~66 verifications)
  • $1 USDC = 500 credits if you need more (enough for ~166 verifications)

That's roughly $0.006 per email verified — far cheaper than dedicated email verification services that charge $0.01-0.05 per address.


Full Source Code

The complete script (~80 lines) is ready to copy-paste. All dependencies are just fetch — no npm packages needed.

Get a free API key: api-catalog-three.vercel.app


What's your email verification strategy? Do you use DNS checks in production? Let me know in the comments.

Top comments (0)