Email Address Validation in HTML5, PHP, and JavaScript — The Complete Production Guide

3410 views

Introduction

Validating email addresses is an essential step in building secure and user-friendly web applications. Without proper validation, users might enter invalid emails, which can cause issues in registrations, logins, or notifications.

In this guide, we’ll explore how to validate email addresses using HTML5, PHP, and JavaScript, along with best practices.

Why Email Validation is Important?

✅ Prevents invalid or fake signups
✅ Improves data quality in your database
✅ Enhances user experience with real-time feedback
✅ Reduces bounce rate in email campaigns
✅ Helps avoid spam and abuse

Why “Just Use filter_var()” Is Never Enough

Email validation sounds like a solved problem. Every tutorial gives you three lines of code and calls it done. But if you’ve ever sent a campaign to a list with 15% bounce rate, had users mistype their email at signup, or dealt with fake account registrations flooding your database — you know the real cost of treating email validation as trivial.

The original article’s approach — type=”email” in HTML, one regex in JavaScript, and filter_var() in PHP — validates email format only. It tells you whether a string looks like an email address. It does not tell you whether that email address:

  • Actually exists (a mailbox that accepts messages)
  • Has a valid mail server (MX records that resolve)
  • Is from a real domain (not a typo domain like gmai.com)
  • Is a disposable address (10minutemail, Guerrilla Mail)
  • Is one the user actually owns (not someone else’s address)
  • Will deliver (not on a blacklist, not a catch-all with full inbox)

This guide covers the complete email validation stack — every layer from the browser’s HTML attribute to server-side MX verification, with every production scenario developers actually encounter in 2025.

 

Email Validation Workflow

Part 1 — Understanding the Email Address Format (RFC 5321 and RFC 5322)

Before writing any validation code, understand what a technically valid email address actually looks like. The rules are far more permissive than most developers assume — and knowing them prevents you from blocking valid users.

The Anatomy of an Email Address

local-part @ domain
────────────────────────────────────────────────────────
user.name+tag+sorting@example.com
│          │   │        │
│          │   tag      domain
│          dot notation
local part (64 chars max)

Domain part: 255 chars max
Total: 320 chars max (per RFC 5321)

What IS Valid (That Most Validators Reject)

✅  user@example.com              ← Standard
✅  user.name@example.com         ← Dots in local part
✅  user+tag@example.com          ← Plus addressing (Gmail, etc.)
✅  user-name@example.com         ← Hyphens in local part
✅  user_name@example.com         ← Underscores in local part
✅  user123@example.com           ← Numbers in local part
✅  "user name"@example.com       ← Quoted local part (rare but valid)
✅  user@sub.domain.example.com   ← Multiple subdomains
✅  user@example.co.uk            ← Country code TLD
✅  user@xn--nxasmq6b.com        ← Punycode (internationalized domain)
✅  user@[192.168.1.1]           ← IP address domain (rare)
✅  UPPER@CASE.COM                ← Uppercase (case-insensitive)
✅  test@example.museum           ← Long TLDs (.museum, .photography)
✅  a@b.cc                        ← Very short but valid
✅  very.long.email.address.with.many.dots@a.very.long.domain.name.example.com

What is INVALID (Common Tricky Cases)

❌  @example.com                  ← Missing local part
❌  user@                         ← Missing domain
❌  user@.example.com             ← Leading dot in domain
❌  user.@example.com             ← Trailing dot in local part
❌  .user@example.com             ← Leading dot in local part
❌  user..name@example.com        ← Consecutive dots in local part
❌  user@example..com             ← Consecutive dots in domain
❌  user @example.com             ← Space in local part (unquoted)
❌  user@example                  ← No TLD (technically allowed by RFC but not used)
❌  user@-example.com             ← Domain starts with hyphen
❌  user@example-.com             ← Domain ends with hyphen
❌  user@exam_ple.com             ← Underscore in domain (invalid)
❌  user@.com                     ← Domain starts with dot

The “Valid But Undeliverable” Problem

Here’s the critical insight most developers miss:

✅ Format valid:    user@nonexistentdomain.xyz     ← filter_var passes this
✅ Format valid:    fake@gmail.com                 ← filter_var passes this
✅ Format valid:    test@throwaway.email           ← filter_var passes this

❌ Will bounce      ← Domain doesn't exist
❌ Real user?       ← Could be anyone's Gmail
❌ Disposable       ← Will be abandoned

Format validation (what HTML5/regex/filter_var does) only catches typos and format errors. For real confidence in an email address, you need deliverability validation — which requires DNS lookups and/or email verification APIs.

 

Part 2 — Layer 1: HTML5 Email Validation

HTML5’s type=”email” is the first line of defence — instant, zero-JavaScript, native browser validation.

Basic Implementation

<form method="post" action="/signup" novalidate>
  <!-- novalidate lets us control the validation UX ourselves -->

  <div class="field">
    <label for="email">Email Address <span aria-hidden="true">*</span></label>
    <input
      type="email"
      id="email"
      name="email"
      required
      autocomplete="email"
      autocapitalize="none"
      autocorrect="off"
      spellcheck="false"
      placeholder="you@example.com"
      aria-required="true"
      aria-describedby="email-hint email-error"
    >
    <p id="email-hint" class="field-hint">
      We'll send your confirmation here.
    </p>
    <p id="email-error" class="field-error" role="alert" aria-live="polite" hidden></p>
  </div>

  <button type="submit">Create Account</button>
</form>

 

Important HTML5 Email Attributes Explained

Attribute Value Purpose
type=”email” Native format validation + mobile keyboard
required Prevents empty submission
autocomplete=”email” Browser/password-manager autofill
autocapitalize=”none” Prevents iOS capitalising first letter
autocorrect=”off” Prevents iOS autocorrecting email
spellcheck=”false” Prevents spell-check underlining
inputmode=”email” Mobile keyboard hint (some browsers)
novalidate on <form> Disable browser UI to use custom validation

HTML5 Constraint Validation API (Custom Styling)

When you use novalidate on the form, you can use the browser’s built-in validation engine but apply your own messages and styles:

// Leverage the built-in validity API without default browser UI
const emailInput = document.getElementById('email');
const errorEl    = document.getElementById('email-error');

emailInput.addEventListener('blur', function() {
    validateEmailField(this);
});

function validateEmailField(input) {
    // Clear previous error
    errorEl.hidden = true;
    input.removeAttribute('aria-invalid');

    // Use browser's built-in validity object
    if (input.validity.valueMissing) {
        showError(input, 'Email address is required.');
        return false;
    }

    if (input.validity.typeMismatch) {
        // browser says it doesn't match email format
        showError(input, 'Please enter a valid email address (e.g., name@example.com).');
        return false;
    }

    // Custom checks the browser doesn't do
    const value = input.value.toLowerCase().trim();

    // Check for common typos in domain
    const typoFix = detectCommonTypo(value);
    if (typoFix) {
        showSuggestion(input, `Did you mean ${typoFix}?`, typoFix);
        return false;
    }

    // Mark as valid
    input.style.borderColor = '#16a34a';
    return true;
}

function showError(input, message) {
    input.setAttribute('aria-invalid', 'true');
    errorEl.textContent = message;
    errorEl.hidden = false;
    input.style.borderColor = '#ef4444';
}

function showSuggestion(input, message, correctedEmail) {
    input.setAttribute('aria-invalid', 'true');
    errorEl.innerHTML = `${message} <button type="button" class="fix-typo" 
        onclick="document.getElementById('email').value='${correctedEmail}';
                 document.getElementById('email-error').hidden=true;
                 document.getElementById('email').style.borderColor='';">
        Yes, fix it
    </button>`;
    errorEl.hidden = false;
}

 

What HTML5 Validation Catches vs Misses

HTML5 type="email" CATCHES:
✅ Missing @ symbol
✅ No domain after @
✅ Empty string (with required)
✅ Multiple @ symbols
✅ Leading/trailing spaces (in most browsers)

HTML5 type="email" MISSES:
❌ Fake domain (user@nonexistent.xyz)
❌ Disposable emails (user@mailinator.com)
❌ Valid format but nonexistent mailbox
❌ Domain typos (user@gmai.com, user@hotmial.com)
❌ Consecutive dots (user..name@example.com — varies by browser)
❌ IP domain validation nuances

 

Part 3 — Layer 2: JavaScript Email Validation (Client-Side)

JavaScript validation gives you real-time feedback as the user types, plus the ability to add features HTML5 can’t: typo detection, disposable email warnings, domain suggestions, and AJAX real-time verification.

The Right JavaScript Regex for 2025

The original article’s regex /^[^\s@]+@[^\s@]+\.[^\s@]+$/ is fine for basic use but has known gaps. Here are three options from basic to comprehensive:

// ── OPTION 1: Simple and Practical (covers 99% of real cases) ─────────────
// Good balance of correctness and readability
const EMAIL_REGEX_SIMPLE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

// ── OPTION 2: Balanced (recommended for production) ────────────────────────
// Covers proper format including + tags, dots, subdomains
// Still readable and maintainable
const EMAIL_REGEX_BALANCED = /^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/;

// ── OPTION 3: RFC 5322 Compliant (comprehensive, complex) ─────────────────
// Based on the official email specification
// Allows quoted local parts, IP addresses, special chars
const EMAIL_REGEX_RFC5322 = new RegExp(
    '^(?:[a-z0-9!#$%&\'*+/=?^_`{|}~-]+(?:\\.[a-z0-9!#$%&\'*+/=?^_`{|}~-]+)*' +
    '|"(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21\\x23-\\x5b\\x5d-\\x7f]' +
    '|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])*")' +
    '@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?' +
    '|\\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}' +
    '(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:' +
    '(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21-\\x5a\\x53-\\x7f]' +
    '|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])+)\\])$',
    'i'
);

// ── Which Should You Use? ─────────────────────────────────────────────────
// Most projects: BALANCED — easy to understand, catches most real errors
// Enterprise/compliance: RFC5322 — correct per spec
// Simple contact forms: SIMPLE — readable, works for common cases

Complete Real-Time Email Validation System

'use strict';

// ─────────────────────────────────────────────────────────────────────────────
//  DOMAIN TYPO DETECTOR
//  Catches the most common user mistakes in email domains
// ─────────────────────────────────────────────────────────────────────────────

const COMMON_DOMAIN_TYPOS = {
    // Gmail variants
    'gmai.com':       'gmail.com',
    'gmial.com':      'gmail.com',
    'gmali.com':      'gmail.com',
    'gmaill.com':     'gmail.com',
    'gmail.co':       'gmail.com',
    'gmail.con':      'gmail.com',
    'gmail.cpm':      'gmail.com',
    'gmai.l.com':     'gmail.com',
    'gmail.net':      'gmail.com',
    'gamil.com':      'gmail.com',
    'g,ail.com':      'gmail.com',
    'gmail.cm':       'gmail.com',

    // Yahoo variants
    'yaho.com':       'yahoo.com',
    'yahooo.com':     'yahoo.com',
    'yahoo.co':       'yahoo.com',
    'yahoo.con':      'yahoo.com',
    'yhoo.com':       'yahoo.com',
    'yhaoo.com':      'yahoo.com',
    'yaoo.com':       'yahoo.com',

    // Hotmail/Outlook variants
    'hotmial.com':    'hotmail.com',
    'homail.com':     'hotmail.com',
    'hotmai.com':     'hotmail.com',
    'hotmaill.com':   'hotmail.com',
    'hotmail.co':     'hotmail.com',
    'hotmail.con':    'hotmail.com',
    'hotamil.com':    'hotmail.com',
    'hotmali.com':    'hotmail.com',
    'outloo.com':     'outlook.com',
    'outlok.com':     'outlook.com',
    'outlook.co':     'outlook.com',

    // iCloud
    'iclould.com':    'icloud.com',
    'icoud.com':      'icloud.com',
    'icloud.co':      'icloud.com',

    // Proton
    'protonmai.com':  'protonmail.com',
    'proton.mai':     'protonmail.com',

    // General TLD typos
    '.con':           '.com',
    '.cmo':           '.com',
    '.ocm':           '.com',
    '.couk':          '.co.uk',
};

/**
 * Check email domain against known typo map.
 * Returns the suggested correction or null.
 */
function detectDomainTypo(email) {
    const atIndex = email.lastIndexOf('@');
    if (atIndex === -1) return null;

    const domain = email.slice(atIndex + 1).toLowerCase();

    // Direct typo match
    if (COMMON_DOMAIN_TYPOS[domain]) {
        const localPart = email.slice(0, atIndex);
        return `${localPart}@${COMMON_DOMAIN_TYPOS[domain]}`;
    }

    // Check for TLD typos
    for (const [typo, fix] of Object.entries(COMMON_DOMAIN_TYPOS)) {
        if (typo.startsWith('.') && domain.endsWith(typo)) {
            return email.replace(new RegExp(typo.replace('.', '\\.') + '$'), fix);
        }
    }

    return null;
}

// ─────────────────────────────────────────────────────────────────────────────
//  DISPOSABLE EMAIL DOMAIN DETECTOR
//  Catches throwaway email addresses used for fake signups
// ─────────────────────────────────────────────────────────────────────────────

// Core list — expand with a full list from disposable-email-domains on GitHub
const DISPOSABLE_DOMAINS = new Set([
    'mailinator.com', 'guerrillamail.com', 'guerrillamail.net',
    'trashmail.com', 'trashmail.net', 'trashmail.org',
    'throwaway.email', 'throwam.com',
    '10minutemail.com', '10minutemail.net', '10minutemail.org',
    'tempmail.com', 'temp-mail.org', 'tempail.com',
    'fakeinbox.com', 'fakeemail.com',
    'mailnull.com', 'spamgourmet.com', 'spamgourmet.net',
    'yopmail.com', 'yopmail.fr', 'cool.fr.nf',
    'maildrop.cc', 'dispostable.com',
    'sharklasers.com', 'guerrillamailblock.com',
    'grr.la', 'guerrillamail.info', 'spam4.me',
    'discard.email', 'cuvox.de', 'dayrep.com',
    'einrot.com', 'fleckens.hu', 'gustr.com',
    'jourrapide.com', 'laoeq.com', 'nospamfor.us',
    'rhyta.com', 'sogetthis.com', 'spamgourmet.org',
    'superrito.com', 'teleworm.us', 'tinyurl24.com',
    'trbvn.com', 'trommolone.com', 'turual.com',
    'gettempmail.com', 'tempinbox.com', 'burnermail.io',
    'mailnesia.com', 'nwytg.net', 'armyspy.com',
    'chothueweb.com', 'einrot.com', 'givmail.com',
]);

function isDisposableEmail(email) {
    const domain = email.split('@')[1]?.toLowerCase() || '';
    return DISPOSABLE_DOMAINS.has(domain);
}

// ─────────────────────────────────────────────────────────────────────────────
//  ROLE-BASED EMAIL DETECTOR
//  Catches addresses unlikely to belong to a real person
// ─────────────────────────────────────────────────────────────────────────────

const ROLE_BASED_PREFIXES = new Set([
    'admin', 'administrator', 'abuse', 'bounce', 'contact', 'email',
    'help', 'helpdesk', 'hostmaster', 'info', 'mailer-daemon', 'marketing',
    'noc', 'no-reply', 'noreply', 'postmaster', 'privacy', 'root',
    'sales', 'security', 'spam', 'support', 'sysadmin', 'tech',
    'test', 'undisclosed', 'unknown', 'unsubscribe', 'webmaster',
    'www', 'mail', 'newsletter', 'news', 'billing', 'accounts',
    'enquiries', 'enquiry', 'invoice', 'invoices', 'orders',
]);

function isRoleBasedEmail(email) {
    const localPart = email.split('@')[0]?.toLowerCase() || '';
    return ROLE_BASED_PREFIXES.has(localPart);
}

// ─────────────────────────────────────────────────────────────────────────────
//  MAIN VALIDATION FUNCTION
//  Returns a structured result with all checks
// ─────────────────────────────────────────────────────────────────────────────

const EMAIL_REGEX = /^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/;

/**
 * Comprehensive email validation.
 *
 * @param   {string} email   Raw email input
 * @param   {object} options Validation options
 * @returns {object}         Validation result
 */
function validateEmail(email, options = {}) {
    const {
        allowDisposable = false,
        allowRoleBased  = true,
        requireTLD      = true,
        minTLDLength    = 2,
        maxLength       = 254,
    } = options;

    const result = {
        valid:       false,
        email:       '',
        normalized:  '',
        errors:      [],
        warnings:    [],
        suggestion:  null,
        checks: {
            notEmpty:       false,
            validFormat:    false,
            validLength:    false,
            noConsecDots:   false,
            validLocalPart: false,
            validDomain:    false,
            notDisposable:  false,
            notRoleBased:   true,
        },
    };

    // ── Step 1: Empty check ───────────────────────────────────────────────
    if (!email || typeof email !== 'string') {
        result.errors.push('Email address is required.');
        return result;
    }

    result.checks.notEmpty = true;

    // ── Step 2: Trim and normalise ─────────────────────────────────────────
    const trimmed    = email.trim();
    const normalized = trimmed.toLowerCase();
    result.email     = trimmed;
    result.normalized= normalized;

    // ── Step 3: Length check ───────────────────────────────────────────────
    if (normalized.length > maxLength) {
        result.errors.push(`Email address must not exceed ${maxLength} characters.`);
        return result;
    }
    result.checks.validLength = true;

    // ── Step 4: Format check ───────────────────────────────────────────────
    if (!EMAIL_REGEX.test(normalized)) {
        // Try to give a helpful error message
        if (!normalized.includes('@')) {
            result.errors.push('Email address must contain @.');
        } else if (normalized.startsWith('@')) {
            result.errors.push('Email address must have a local part before @.');
        } else if (normalized.endsWith('@')) {
            result.errors.push('Email address must have a domain after @.');
        } else {
            result.errors.push('Please enter a valid email address (e.g., name@example.com).');
        }
        return result;
    }
    result.checks.validFormat = true;

    // ── Step 5: Split and analyse parts ───────────────────────────────────
    const atIndex   = normalized.lastIndexOf('@');
    const localPart = normalized.slice(0, atIndex);
    const domain    = normalized.slice(atIndex + 1);

    // Local part checks
    if (localPart.length > 64) {
        result.errors.push('The part before @ must not exceed 64 characters.');
        return result;
    }

    if (localPart.startsWith('.') || localPart.endsWith('.')) {
        result.errors.push('Email address cannot start or end with a dot before @.');
        return result;
    }

    if (localPart.includes('..')) {
        result.errors.push('Email address cannot have consecutive dots before @.');
        return result;
    }
    result.checks.noConsecDots   = true;
    result.checks.validLocalPart = true;

    // Domain checks
    if (domain.startsWith('-') || domain.endsWith('-')) {
        result.errors.push('Domain cannot start or end with a hyphen.');
        return result;
    }

    if (domain.includes('..')) {
        result.errors.push('Domain cannot have consecutive dots.');
        return result;
    }

    const domainParts = domain.split('.');
    const tld         = domainParts[domainParts.length - 1];

    if (requireTLD && tld.length < minTLDLength) {
        result.errors.push(`Domain must have a valid TLD (at least ${minTLDLength} characters).`);
        return result;
    }
    result.checks.validDomain = true;

    // ── Step 6: Domain typo detection ─────────────────────────────────────
    const typoFix = detectDomainTypo(normalized);
    if (typoFix) {
        result.suggestion = typoFix;
        result.warnings.push(`Did you mean ${typoFix}?`);
    }

    // ── Step 7: Disposable email check ────────────────────────────────────
    if (!allowDisposable && isDisposableEmail(normalized)) {
        result.checks.notDisposable = false;
        result.errors.push(
            'Please use a permanent email address. Temporary email services are not allowed.'
        );
        return result;
    }
    result.checks.notDisposable = true;

    // ── Step 8: Role-based email warning ──────────────────────────────────
    if (isRoleBasedEmail(normalized)) {
        result.checks.notRoleBased = false;
        if (!allowRoleBased) {
            result.errors.push(
                'Please use a personal email address rather than a shared or role-based address.'
            );
            return result;
        }
        result.warnings.push(
            'This appears to be a shared mailbox. You may not receive personal notifications.'
        );
    }

    // ── All checks passed ─────────────────────────────────────────────────
    result.valid = true;
    return result;
}

// ─────────────────────────────────────────────────────────────────────────────
//  COMPLETE FORM INTEGRATION
//  Real-time validation with debounce + AJAX verification
// ─────────────────────────────────────────────────────────────────────────────

class EmailValidator {

    constructor(inputSelector, options = {}) {
        this.input   = document.querySelector(inputSelector);
        this.options = {
            debounceMs:         600,    // Wait before running AJAX check
            showCheckmark:      true,
            ajaxVerifyEndpoint: '/api/verify-email',
            ajaxEnabled:        false,  // Set true to enable server-side AJAX check
            allowDisposable:    false,
            allowRoleBased:     true,
            onValid:            null,
            onInvalid:          null,
            ...options,
        };

        this.errorEl      = document.getElementById(
            this.input?.getAttribute('aria-describedby')?.split(' ')[1]
        );
        this.debounceTimer = null;
        this.lastValue     = '';
        this.isChecking    = false;

        if (this.input) this.#attachEvents();
    }

    #attachEvents() {
        // Validate on input (debounced)
        this.input.addEventListener('input', () => {
            clearTimeout(this.debounceTimer);
            const value = this.input.value;

            // Immediate format check while typing
            if (value.length > 5 && value.includes('@')) {
                this.#validateFormat(value);
            }

            // Delayed full check (including AJAX)
            this.debounceTimer = setTimeout(() => {
                this.#validateFull(value);
            }, this.options.debounceMs);
        });

        // Full validate on blur
        this.input.addEventListener('blur', () => {
            clearTimeout(this.debounceTimer);
            if (this.input.value) {
                this.#validateFull(this.input.value);
            }
        });

        // Clear errors when user starts typing again
        this.input.addEventListener('focus', () => {
            if (this.input.getAttribute('aria-invalid') === 'true') {
                this.#clearState();
            }
        });
    }

    #validateFormat(email) {
        const result = validateEmail(email, {
            allowDisposable: this.options.allowDisposable,
            allowRoleBased:  this.options.allowRoleBased,
        });

        if (!result.valid && result.errors.length > 0) {
            this.#showError(result.errors[0]);
        } else if (result.suggestion) {
            this.#showSuggestion(result.suggestion);
        } else {
            this.#clearState();
        }
    }

    async #validateFull(email) {
        if (email === this.lastValue) return;
        this.lastValue = email;

        const result = validateEmail(email, {
            allowDisposable: this.options.allowDisposable,
            allowRoleBased:  this.options.allowRoleBased,
        });

        if (!result.valid) {
            this.#showError(result.errors[0]);
            if (this.options.onInvalid) this.options.onInvalid(result);
            return;
        }

        if (result.suggestion) {
            this.#showSuggestion(result.suggestion);
            return;
        }

        // Show warnings (non-blocking)
        if (result.warnings.length > 0) {
            this.#showWarning(result.warnings[0]);
        }

        // AJAX verification (optional)
        if (this.options.ajaxEnabled) {
            await this.#ajaxVerify(email);
        } else {
            this.#showValid();
            if (this.options.onValid) this.options.onValid(result);
        }
    }

    async #ajaxVerify(email) {
        if (this.isChecking) return;
        this.isChecking = true;

        this.#showChecking();

        try {
            const response = await fetch(this.options.ajaxVerifyEndpoint, {
                method:  'POST',
                headers: { 'Content-Type': 'application/json' },
                body:    JSON.stringify({ email }),
            });

            if (!response.ok) throw new Error(`HTTP ${response.status}`);

            const data = await response.json();

            if (data.valid) {
                this.#showValid();
                if (this.options.onValid) this.options.onValid(data);
            } else {
                this.#showError(data.message || 'This email address could not be verified.');
                if (this.options.onInvalid) this.options.onInvalid(data);
            }

        } catch (err) {
            // On AJAX failure, don't block the user — just clear the state
            this.#clearState();
            console.warn('Email verification check failed:', err.message);
        } finally {
            this.isChecking = false;
        }
    }

    #showError(message) {
        this.input.setAttribute('aria-invalid', 'true');
        this.input.style.borderColor = '#ef4444';
        if (this.errorEl) {
            this.errorEl.textContent = message;
            this.errorEl.style.color = '#ef4444';
            this.errorEl.hidden = false;
        }
    }

    #showWarning(message) {
        this.input.removeAttribute('aria-invalid');
        this.input.style.borderColor = '#f59e0b';
        if (this.errorEl) {
            this.errorEl.textContent = '⚠️ ' + message;
            this.errorEl.style.color = '#92400e';
            this.errorEl.hidden = false;
        }
    }

    #showSuggestion(correctedEmail) {
        this.input.setAttribute('aria-invalid', 'true');
        this.input.style.borderColor = '#f59e0b';
        if (this.errorEl) {
            this.errorEl.innerHTML = `
                Did you mean <strong>${correctedEmail}</strong>?
                <button type="button" class="fix-typo-btn" style="
                    background: none; border: none; color: #2563eb;
                    cursor: pointer; text-decoration: underline; font-size: inherit;
                    padding: 0 4px;">
                    Yes, use this
                </button>
            `;
            this.errorEl.hidden = false;

            this.errorEl.querySelector('.fix-typo-btn')?.addEventListener('click', () => {
                this.input.value = correctedEmail;
                this.input.focus();
                this.#validateFull(correctedEmail);
            });
        }
    }

    #showChecking() {
        this.input.style.borderColor = '#6366f1';
        if (this.errorEl) {
            this.errorEl.textContent = '⌛ Verifying email…';
            this.errorEl.style.color = '#6366f1';
            this.errorEl.hidden = false;
        }
    }

    #showValid() {
        this.input.removeAttribute('aria-invalid');
        this.input.style.borderColor = '#16a34a';
        if (this.errorEl) {
            this.errorEl.textContent = '✓ Looks good!';
            this.errorEl.style.color = '#16a34a';
            this.errorEl.hidden = false;
        }
    }

    #clearState() {
        this.input.removeAttribute('aria-invalid');
        this.input.style.borderColor = '';
        if (this.errorEl) {
            this.errorEl.hidden = true;
        }
    }

    /** Public method: returns validation result for the current value */
    validate() {
        return validateEmail(this.input?.value || '', {
            allowDisposable: this.options.allowDisposable,
            allowRoleBased:  this.options.allowRoleBased,
        });
    }
}

// ── Usage ──────────────────────────────────────────────────────────────────
const emailValidator = new EmailValidator('#email', {
    allowDisposable:    false,
    allowRoleBased:     true,
    ajaxEnabled:        false,   // Set true + provide endpoint for real-time check
    ajaxVerifyEndpoint: '/api/check-email',
    onValid: (result) => {
        console.log('Valid email:', result.normalized);
        document.getElementById('submit-btn').disabled = false;
    },
    onInvalid: (result) => {
        document.getElementById('submit-btn').disabled = true;
    },
});

// On form submit — final synchronous check
document.getElementById('signup-form')?.addEventListener('submit', function(e) {
    const result = emailValidator.validate();
    if (!result.valid) {
        e.preventDefault();
        console.error('Blocked submission:', result.errors);
    }
});

 

Part 4 — Layer 3: PHP Server-Side Email Validation (Complete)

Client-side validation is a UX feature. PHP server-side validation is the security layer. Never, ever rely only on the client.

Basic: filter_var() (The Starting Point)

<?php
// The original article stops here — we're just starting
$email = $_POST['email'] ?? '';

if (filter_var($email, FILTER_VALIDATE_EMAIL)) {
    echo "Format looks valid";
} else {
    echo "Invalid format";
}

// BUT filter_var() is not enough for production.
// It accepts: user@fake-domain-that-doesnt-exist.xyz
// It accepts: test@mailinator.com (disposable)
// It accepts: admin@example.com (role-based)
// It doesn't check if the domain has a mail server

Production PHP Email Validator Class

<?php
declare(strict_types=1);

/**
 * EmailValidator — Production-grade PHP email validation.
 *
 * Validation layers:
 * 1. Format validation (RFC 5321 via filter_var)
 * 2. Length checks (per RFC 5321)
 * 3. Local part analysis
 * 4. Domain structure validation
 * 5. Disposable email detection
 * 6. Role-based email detection
 * 7. DNS / MX record verification
 * 8. SMTP connection check (optional, expensive)
 */
class EmailValidator {

    // ── Constants ─────────────────────────────────────────────────────────
    private const MAX_EMAIL_LENGTH      = 254;
    private const MAX_LOCAL_PART_LENGTH = 64;
    private const MAX_DOMAIN_LENGTH     = 255;
    private const DNS_TIMEOUT          = 5;    // seconds for DNS lookup
    private const DNS_ATTEMPTS         = 2;    // retry attempts

    // ── Disposable domain list (subset — use a file for full list) ────────
    private const DISPOSABLE_DOMAINS = [
        'mailinator.com', 'guerrillamail.com', 'guerrillamail.net',
        'trashmail.com',  'trashmail.net',     'trashmail.org',
        'throwaway.email','throwam.com',        '10minutemail.com',
        '10minutemail.net','tempmail.com',       'temp-mail.org',
        'fakeinbox.com',  'yopmail.com',        'yopmail.fr',
        'maildrop.cc',    'dispostable.com',    'sharklasers.com',
        'spam4.me',       'discard.email',      'burnermail.io',
        'mailnesia.com',  'guerrillamail.info', 'grr.la',
        'tempr.email',    'trashmail.at',       'trashmail.io',
        'fakemail.net',   'getairmail.com',     'getnada.com',
        'mailnull.com',   'spamgourmet.com',    'throwam.com',
        'mintemail.com',  'sofimail.com',       'meltmail.com',
    ];

    // ── Role-based prefixes ────────────────────────────────────────────────
    private const ROLE_PREFIXES = [
        'admin', 'administrator', 'abuse', 'bounce', 'contact',
        'email', 'help', 'helpdesk', 'hostmaster', 'info',
        'mailer-daemon', 'marketing', 'noc', 'no-reply', 'noreply',
        'postmaster', 'privacy', 'root', 'sales', 'security',
        'spam', 'support', 'sysadmin', 'tech', 'test',
        'webmaster', 'www', 'mail', 'newsletter', 'news',
        'billing', 'accounts', 'enquiries', 'enquiry',
    ];

    // ── Common domain typos ───────────────────────────────────────────────
    private const DOMAIN_TYPOS = [
        'gmai.com'     => 'gmail.com',
        'gmial.com'    => 'gmail.com',
        'gmali.com'    => 'gmail.com',
        'gmaill.com'   => 'gmail.com',
        'gmail.con'    => 'gmail.com',
        'gmail.co'     => 'gmail.com',
        'gamil.com'    => 'gmail.com',
        'yaho.com'     => 'yahoo.com',
        'yahooo.com'   => 'yahoo.com',
        'yhoo.com'     => 'yahoo.com',
        'hotmial.com'  => 'hotmail.com',
        'homail.com'   => 'hotmail.com',
        'hotamil.com'  => 'hotmail.com',
        'hotmail.con'  => 'hotmail.com',
        'outlok.com'   => 'outlook.com',
        'outloo.com'   => 'outlook.com',
    ];

    private bool $checkDNS;
    private bool $checkMX;

    public function __construct(
        bool $checkDNS = true,
        bool $checkMX  = true
    ) {
        $this->checkDNS = $checkDNS;
        $this->checkMX  = $checkMX;
    }

    /**
     * Validate an email address.
     *
     * @param  string $email           Raw email input
     * @param  array  $options         Validation options
     * @return array                   Validation result
     */
    public function validate(string $email, array $options = []): array {

        $opts = array_merge([
            'allow_disposable' => false,
            'allow_role_based' => true,
            'require_mx'       => false,  // Set true for critical signups
        ], $options);

        $result = [
            'valid'      => false,
            'email'      => $email,
            'normalized' => '',
            'errors'     => [],
            'warnings'   => [],
            'suggestion' => null,
            'checks'     => [
                'not_empty'      => false,
                'valid_format'   => false,
                'valid_length'   => false,
                'valid_local'    => false,
                'valid_domain'   => false,
                'not_disposable' => false,
                'not_role_based' => true,
                'mx_exists'      => null,
            ],
        ];

        // ── Step 1: Empty check ───────────────────────────────────────────
        $email = trim($email);

        if (empty($email)) {
            $result['errors'][] = 'Email address is required.';
            return $result;
        }
        $result['checks']['not_empty'] = true;

        // ── Step 2: Length check ──────────────────────────────────────────
        if (strlen($email) > self::MAX_EMAIL_LENGTH) {
            $result['errors'][] = 'Email address must not exceed ' . self::MAX_EMAIL_LENGTH . ' characters.';
            return $result;
        }
        $result['checks']['valid_length'] = true;

        // ── Step 3: Format validation (PHP's built-in) ────────────────────
        $filtered = filter_var($email, FILTER_VALIDATE_EMAIL);
        if ($filtered === false) {
            $result['errors'][] = $this->getFormatError($email);
            return $result;
        }
        $result['checks']['valid_format'] = true;

        // ── Step 4: Normalise ─────────────────────────────────────────────
        // Split into local and domain parts
        $atPos     = strrpos($email, '@');
        $localPart = substr($email, 0, $atPos);
        $domain    = strtolower(substr($email, $atPos + 1));
        $normalized= $localPart . '@' . $domain;

        $result['email']      = $email;
        $result['normalized'] = $normalized;

        // ── Step 5: Local part checks ─────────────────────────────────────
        if (strlen($localPart) > self::MAX_LOCAL_PART_LENGTH) {
            $result['errors'][] = 'The part before @ cannot exceed 64 characters.';
            return $result;
        }

        if (str_starts_with($localPart, '.') || str_ends_with($localPart, '.')) {
            $result['errors'][] = 'Email address cannot start or end with a dot.';
            return $result;
        }

        if (str_contains($localPart, '..')) {
            $result['errors'][] = 'Email address cannot contain consecutive dots.';
            return $result;
        }
        $result['checks']['valid_local'] = true;

        // ── Step 6: Domain structure checks ──────────────────────────────
        if (strlen($domain) > self::MAX_DOMAIN_LENGTH) {
            $result['errors'][] = 'Domain name is too long.';
            return $result;
        }

        if (str_starts_with($domain, '-') || str_ends_with($domain, '-')) {
            $result['errors'][] = 'Domain name cannot start or end with a hyphen.';
            return $result;
        }

        if (str_contains($domain, '..')) {
            $result['errors'][] = 'Domain cannot contain consecutive dots.';
            return $result;
        }

        $domainParts = explode('.', $domain);
        $tld         = end($domainParts);

        if (strlen($tld) < 2) {
            $result['errors'][] = 'Email domain must have a valid top-level domain.';
            return $result;
        }
        $result['checks']['valid_domain'] = true;

        // ── Step 7: Typo detection ────────────────────────────────────────
        $suggestion = $this->detectTypo($domain, $localPart);
        if ($suggestion) {
            $result['suggestion'] = $suggestion;
            $result['warnings'][] = "Did you mean {$suggestion}?";
        }

        // ── Step 8: Disposable email check ───────────────────────────────
        if (in_array($domain, self::DISPOSABLE_DOMAINS, true)) {
            $result['checks']['not_disposable'] = false;

            if (!$opts['allow_disposable']) {
                $result['errors'][] = 'Temporary email addresses are not allowed. '
                                   . 'Please use a permanent email.';
                return $result;
            }
            $result['warnings'][] = 'This appears to be a temporary email address.';
        } else {
            $result['checks']['not_disposable'] = true;
        }

        // ── Step 9: Role-based email check ───────────────────────────────
        $lowerLocal = strtolower($localPart);
        if (in_array($lowerLocal, self::ROLE_PREFIXES, true)) {
            $result['checks']['not_role_based'] = false;

            if (!$opts['allow_role_based']) {
                $result['errors'][] = 'Please use a personal email address, '
                                   . 'not a shared mailbox.';
                return $result;
            }
            $result['warnings'][] = 'This appears to be a shared or role-based email address.';
        }

        // ── Step 10: DNS / MX record check ───────────────────────────────
        if ($this->checkMX || $opts['require_mx']) {
            $mxResult = $this->checkMXRecords($domain);
            $result['checks']['mx_exists'] = $mxResult['exists'];

            if (!$mxResult['exists']) {
                if ($opts['require_mx'] || $this->checkDNS) {
                    $result['errors'][] = "The domain '{$domain}' does not appear to have "
                                       . 'an active mail server. Please check your email address.';
                    return $result;
                }
                $result['warnings'][] = "Could not verify mail server for domain '{$domain}'.";
            }
        }

        // ── All passed ────────────────────────────────────────────────────
        $result['valid'] = empty($result['errors']);
        return $result;
    }

    /**
     * Check if a domain has MX records (indicating it can receive email).
     * Also falls back to A record check (some valid mail servers only have A records).
     */
    private function checkMXRecords(string $domain): array {
        // Try MX records first
        if (checkdnsrr($domain, 'MX')) {
            return ['exists' => true, 'type' => 'MX'];
        }

        // Fall back to A record (some small providers use A records for mail)
        if (checkdnsrr($domain, 'A')) {
            return ['exists' => true, 'type' => 'A'];
        }

        // Fall back to AAAA (IPv6)
        if (checkdnsrr($domain, 'AAAA')) {
            return ['exists' => true, 'type' => 'AAAA'];
        }

        return ['exists' => false, 'type' => null];
    }

    /**
     * Check MX records with full details (priority, server).
     * Returns sorted MX records for debugging/SMTP verification.
     */
    public function getMXRecords(string $domain): array {
        $mxHosts   = [];
        $mxWeights = [];

        if (getmxrr($domain, $mxHosts, $mxWeights)) {
            $records = array_combine($mxHosts, $mxWeights);
            asort($records); // Sort by priority (lower = higher priority)
            return [
                'found'   => true,
                'records' => $records,
                'primary' => array_key_first($records),
            ];
        }

        return ['found' => false, 'records' => [], 'primary' => null];
    }

    /**
     * Generate a helpful format error message based on what's wrong.
     */
    private function getFormatError(string $email): string {
        if (!str_contains($email, '@')) {
            return 'Email address must contain @.';
        }

        $parts = explode('@', $email);

        if (count($parts) !== 2) {
            return 'Email address must contain exactly one @ symbol.';
        }

        [$local, $domain] = $parts;

        if (empty($local)) {
            return 'Please enter something before the @ symbol.';
        }

        if (empty($domain)) {
            return 'Please enter a domain after the @ symbol (e.g., gmail.com).';
        }

        if (!str_contains($domain, '.')) {
            return "'{$domain}' is not a valid domain. Did you forget .com or .net?";
        }

        return 'Please enter a valid email address (e.g., name@example.com).';
    }

    /**
     * Detect common domain typos and return the corrected email.
     */
    private function detectTypo(string $domain, string $localPart): ?string {
        if (isset(self::DOMAIN_TYPOS[$domain])) {
            return $localPart . '@' . self::DOMAIN_TYPOS[$domain];
        }
        return null;
    }

    /**
     * Normalise an email address.
     * - Lowercase the domain
     * - Optionally lowercase the local part
     * - Remove Gmail dots (a.b@gmail.com = ab@gmail.com)
     * - Remove Gmail plus addressing (user+tag@gmail.com = user@gmail.com)
     *
     * @param  string $email     Input email
     * @param  bool   $for_gmail Apply Gmail-specific normalisation
     * @return string            Normalised email
     */
    public function normalize(string $email, bool $for_gmail = false): string {
        $email  = trim($email);
        $atPos  = strrpos($email, '@');

        if ($atPos === false) return $email;

        $local  = substr($email, 0, $atPos);
        $domain = strtolower(substr($email, $atPos + 1));

        // Gmail normalisation
        if ($for_gmail && in_array($domain, ['gmail.com', 'googlemail.com'])) {
            // Remove dots from local part (googlemail ignores them)
            $local = str_replace('.', '', $local);

            // Remove plus-addressing
            if (($plusPos = strpos($local, '+')) !== false) {
                $local = substr($local, 0, $plusPos);
            }

            // Normalise to gmail.com
            $domain = 'gmail.com';
        }

        return strtolower($local) . '@' . $domain;
    }
}

// ── Usage ──────────────────────────────────────────────────────────────────
$validator = new EmailValidator(checkDNS: true, checkMX: true);

// Basic validation
$result = $validator->validate('user@example.com');
if ($result['valid']) {
    echo "Email is valid: " . $result['normalized'];
} else {
    echo "Invalid: " . implode(', ', $result['errors']);
}

// With options
$result = $validator->validate('admin@gmail.com', [
    'allow_disposable' => false,
    'allow_role_based' => false,  // Block role addresses
    'require_mx'       => true,   // Require MX records
]);

// Normalise email (remove Gmail plus tags and dots)
$normalized = $validator->normalize('User.Name+tag@Gmail.com', for_gmail: true);
// → username@gmail.com

// Get MX records for debugging
$mx = $validator->getMXRecords('gmail.com');
// → ['found' => true, 'records' => ['gmail-smtp-in.l.google.com' => 5, ...], 'primary' => 'gmail-smtp-in.l.google.com']

 

Part 5 — Layer 4: SMTP Verification (The Most Accurate Check)

SMTP verification actually connects to the recipient’s mail server and checks if the mailbox exists — without sending an email. This is the most accurate check possible short of sending a verification email.

<?php
/**
 * SMTP Email Verifier
 *
 * Connects to the mail server and performs an RCPT TO check.
 * WARNING: Some servers block this (anti-harvesting). Use carefully.
 * Best used with catch-all detection to filter false positives.
 */
class SMTPVerifier {

    private int    $timeout = 10;
    private string $heloHost;

    public function __construct(string $heloHost = '') {
        $this->heloHost = $heloHost ?: ($_SERVER['HTTP_HOST'] ?? 'localhost');
    }

    /**
     * Verify an email via SMTP conversation.
     *
     * @param  string $email    Email to verify
     * @return array            Verification result
     */
    public function verify(string $email): array {

        $result = [
            'valid'       => false,
            'exists'      => null,
            'is_catch_all'=> false,
            'smtp_code'   => null,
            'smtp_message'=> '',
            'error'       => null,
            'mx_host'     => null,
        ];

        // ── Step 1: Get MX records ────────────────────────────────────────
        [$localPart, $domain] = explode('@', $email, 2);

        $mxHosts   = [];
        $mxWeights = [];

        if (!getmxrr($domain, $mxHosts, $mxWeights)) {
            $result['error'] = "No MX records found for domain '{$domain}'.";
            return $result;
        }

        // Sort by priority
        $mxPairs = array_combine($mxHosts, $mxWeights);
        asort($mxPairs);
        $mxHost = array_key_first($mxPairs);
        $result['mx_host'] = $mxHost;

        // ── Step 2: Connect to mail server ────────────────────────────────
        $connection = @fsockopen($mxHost, 25, $errno, $errstr, $this->timeout);

        if (!$connection) {
            // Try port 587 as fallback
            $connection = @fsockopen($mxHost, 587, $errno, $errstr, $this->timeout);
        }

        if (!$connection) {
            $result['error'] = "Cannot connect to mail server '{$mxHost}': {$errstr}";
            return $result;
        }

        stream_set_timeout($connection, $this->timeout);

        // ── Step 3: SMTP conversation ─────────────────────────────────────
        try {
            // Read greeting
            $greeting = $this->readResponse($connection);

            if (!str_starts_with($greeting, '2')) {
                $result['error'] = 'Server rejected connection.';
                fclose($connection);
                return $result;
            }

            // EHLO
            $this->sendCommand($connection, "EHLO {$this->heloHost}");
            $ehlo = $this->readResponse($connection);

            // MAIL FROM
            $this->sendCommand($connection, "MAIL FROM:<verify@{$this->heloHost}>");
            $mailFrom = $this->readResponse($connection);

            if (!str_starts_with($mailFrom, '2')) {
                $result['error'] = 'MAIL FROM rejected.';
                $this->sendCommand($connection, 'QUIT');
                fclose($connection);
                return $result;
            }

            // RCPT TO — the actual check
            $this->sendCommand($connection, "RCPT TO:<{$email}>");
            $rcptResponse = $this->readResponse($connection);
            $code         = (int) substr($rcptResponse, 0, 3);

            $result['smtp_code']    = $code;
            $result['smtp_message'] = trim($rcptResponse);

            if ($code === 250 || $code === 251) {
                $result['valid']  = true;
                $result['exists'] = true;
            } elseif ($code === 550 || $code === 551 || $code === 553) {
                $result['valid']  = false;
                $result['exists'] = false;
            } elseif ($code === 450 || $code === 451 || $code === 452) {
                // Temporary rejection — possibly valid
                $result['valid']  = null;
                $result['exists'] = null;
                $result['error']  = 'Temporary rejection — cannot determine validity.';
            } else {
                $result['valid']  = null;
                $result['exists'] = null;
            }

            // ── Step 4: Catch-all detection ───────────────────────────────
            if ($result['exists']) {
                $randomEmail = 'nonexistent_' . bin2hex(random_bytes(8)) . '@' . $domain;
                $this->sendCommand($connection, "RCPT TO:<{$randomEmail}>");
                $catchAllResponse = $this->readResponse($connection);
                $catchAllCode = (int) substr($catchAllResponse, 0, 3);

                if ($catchAllCode === 250) {
                    $result['is_catch_all'] = true;
                    $result['error']        = 'Domain accepts all emails — cannot verify specific mailbox.';
                }
            }

            // QUIT
            $this->sendCommand($connection, 'QUIT');

        } catch (\Throwable $e) {
            $result['error'] = 'SMTP verification error: ' . $e->getMessage();
        } finally {
            if (is_resource($connection)) fclose($connection);
        }

        return $result;
    }

    private function sendCommand($conn, string $command): void {
        fwrite($conn, $command . "\r\n");
    }

    private function readResponse($conn): string {
        $response = '';
        $timeout  = time() + $this->timeout;

        while (!feof($conn) && time() < $timeout) {
            $line      = fgets($conn, 4096);
            $response .= $line;

            // Multi-line responses end with "XXX " (space after code)
            if (isset($line[3]) && $line[3] === ' ') break;
        }

        return $response;
    }
}

// ── Usage ───────────────────────────────────────────────────────────────────
$smtpVerifier = new SMTPVerifier('yoursite.com');
$result       = $smtpVerifier->verify('user@gmail.com');

echo $result['exists'] ? 'Mailbox exists!' : 'Mailbox not found.';
echo $result['is_catch_all'] ? ' (catch-all domain)' : '';

// Note: Many providers (Gmail, Yahoo, Outlook) block RCPT TO checks.
// For high-volume verification, use a dedicated API service instead.

 

Part 6 — Layer 5: Email Verification APIs (Production Alternative to SMTP)

For production applications at scale, dedicated email verification APIs are more reliable than DIY SMTP checks:

<?php
/**
 * Email verification using ZeroBounce API
 * (Alternative: Hunter.io, AbstractAPI, NeverBounce, Mailgun validation)
 */
class ZeroBounceVerifier {

    private const API_BASE = 'https://api.zerobounce.net/v2';
    private string $apiKey;

    public function __construct(string $apiKey) {
        $this->apiKey = $apiKey;
    }

    /**
     * Validate a single email address.
     * Cost: 1 credit per check.
     */
    public function validate(string $email, string $ipAddress = ''): array {
        $params = http_build_query([
            'api_key'    => $this->apiKey,
            'email'      => $email,
            'ip_address' => $ipAddress,
        ]);

        $ch = curl_init(self::API_BASE . '/validate?' . $params);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT        => 30,
            CURLOPT_SSL_VERIFYPEER => true,
        ]);

        $response = curl_exec($ch);
        $errno    = curl_errno($ch);
        curl_close($ch);

        if ($errno) {
            return ['error' => 'API connection failed'];
        }

        $data = json_decode($response, true);

        /**
         * ZeroBounce statuses:
         * valid        → Deliverable inbox
         * invalid      → Non-existent or non-accepting inbox
         * catch-all    → Domain accepts all mail — specific mailbox unknown
         * unknown      → Could not connect to mail server
         * spamtrap     → Address used to catch spammers
         * abuse        → Known abusive email
         * do_not_mail  → Do not contact
         */
        return [
            'valid'      => $data['status'] === 'valid',
            'status'     => $data['status']      ?? 'unknown',
            'sub_status' => $data['sub_status']  ?? '',
            'is_free'    => $data['free_email']  ?? false,
            'disposable' => $data['disposable']  ?? false,
            'did_you_mean' => $data['did_you_mean'] ?? null,
            'domain_age'   => $data['domain_age_days'] ?? null,
        ];
    }

    /**
     * Bulk validate multiple emails (for list cleaning).
     */
    public function validateBatch(array $emails): array {
        $payload = json_encode([
            'email_batch' => array_map(fn($e) => ['email_address' => $e], $emails),
        ]);

        $ch = curl_init(self::API_BASE . '/validatebatch');
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_POST           => true,
            CURLOPT_POSTFIELDS     => $payload,
            CURLOPT_HTTPHEADER     => [
                'Content-Type: application/json',
                'x-api-key: ' . $this->apiKey,
            ],
            CURLOPT_TIMEOUT        => 60,
        ]);

        $response = curl_exec($ch);
        curl_close($ch);

        return json_decode($response, true) ?? [];
    }

    /**
     * Get remaining API credits.
     */
    public function getCredits(): int {
        $ch = curl_init(self::API_BASE . '/getcredits?api_key=' . $this->apiKey);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        $data = json_decode(curl_exec($ch), true);
        curl_close($ch);
        return (int)($data['Credits'] ?? 0);
    }
}

// ── Comparison of Email Verification APIs ─────────────────────────────────
/*
Provider         | Free Tier     | Per Check | Catch-All | SMTP Check | Accuracy
─────────────────────────────────────────────────────────────────────────────────
ZeroBounce       | 100/month     | $0.008    | ✅ Yes    | ✅ Yes     | 98%+
NeverBounce      | 250/month     | $0.008    | ✅ Yes    | ✅ Yes     | 99.9%
Hunter.io        | 25/month      | $0.015    | ✅ Yes    | ✅ Yes     | 95%+
AbstractAPI      | 100/month     | $0.001    | ✅ Yes    | ✅ Yes     | 98%
Mailgun          | With plan     | ~$0.012   | ✅ Yes    | ✅ Yes     | 97%+
Kickbox          | 100/signup    | $0.010    | ✅ Yes    | ✅ Yes     | 99%+
*/

 

Part 7 — AJAX Real-Time Email Verification (Backend API Endpoint)

Connect the JavaScript frontend to the PHP backend for real-time email verification:

<?php
// api/verify-email.php — The AJAX endpoint called by the frontend EmailValidator class

declare(strict_types=1);

header('Content-Type: application/json');
header('X-Content-Type-Options: nosniff');

// CORS — only allow your own domain
header('Access-Control-Allow-Origin: ' . ($_SERVER['HTTP_ORIGIN'] ?? ''));
header('Access-Control-Allow-Methods: POST');
header('Access-Control-Allow-Headers: Content-Type');

if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
    http_response_code(204);
    exit;
}

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    echo json_encode(['error' => 'Method not allowed']);
    exit;
}

// ── Rate limiting ────────────────────────────────────────────────────────────
// Prevent abuse of the verification endpoint
$ipKey   = 'email_verify_' . md5($_SERVER['REMOTE_ADDR'] ?? '');
$cacheFile = sys_get_temp_dir() . '/' . $ipKey . '.json';
$requests  = [];

if (file_exists($cacheFile)) {
    $data = json_decode(file_get_contents($cacheFile), true) ?? [];
    // Keep only requests from last 60 seconds
    $requests = array_filter($data, fn($ts) => (time() - $ts) < 60);
}

if (count($requests) >= 10) { // Max 10 checks per minute per IP
    http_response_code(429);
    echo json_encode([
        'valid'   => null,
        'message' => 'Too many verification requests. Please slow down.',
    ]);
    exit;
}

$requests[] = time();
file_put_contents($cacheFile, json_encode(array_values($requests)), LOCK_EX);

// ── Get and validate input ────────────────────────────────────────────────────
$input = json_decode(file_get_contents('php://input'), true);
$email = trim($input['email'] ?? '');

if (empty($email)) {
    http_response_code(400);
    echo json_encode(['valid' => false, 'message' => 'Email is required.']);
    exit;
}

// ── Run validation ────────────────────────────────────────────────────────────
require_once 'EmailValidator.php'; // Include the class from Part 4

$validator = new EmailValidator(checkDNS: true, checkMX: true);
$result    = $validator->validate($email, [
    'allow_disposable' => false,
    'allow_role_based' => true,
    'require_mx'       => true,
]);

// ── Build response ────────────────────────────────────────────────────────────
$response = [
    'valid'      => $result['valid'],
    'normalized' => $result['normalized'] ?? '',
    'message'    => $result['valid']
        ? ($result['warnings'][0] ?? 'Email looks good!')
        : ($result['errors'][0]   ?? 'Invalid email address.'),
    'suggestion' => $result['suggestion'] ?? null,
    'checks'     => $result['checks'],
];

if (!$result['valid'] && !empty($result['suggestion'])) {
    $response['message'] = 'Did you mean ' . $result['suggestion'] . '?';
}

echo json_encode($response);

 

Part 8 — Framework Integration Examples

Laravel Validation

<?php
// ── Built-in Laravel email validation rules ────────────────────────────────

use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;

// Basic
$validator = Validator::make(['email' => $request->email], [
    'email' => ['required', 'email', 'max:254'],
]);

// More thorough — checks RFC compliance
$validator = Validator::make(['email' => $request->email], [
    'email' => ['required', 'email:rfc,dns', 'max:254'],
]);

// email:rfc       — Validates against RFC 5322
// email:dns       — Validates that domain has DNS records
// email:spoof     — Blocks spoofed unicode
// email:filter    — Uses PHP's filter_var (like the original article)
// email:strict    — RFC + no unicode
// email:rfc,dns   — Recommended combination

// ── Custom rule for disposable email blocking ─────────────────────────────
use Illuminate\Contracts\Validation\Rule as ValidationRule;

class NotDisposableEmail implements ValidationRule {

    private const DISPOSABLE = [
        'mailinator.com', 'guerrillamail.com', 'trashmail.com',
        '10minutemail.com', 'tempmail.com', 'yopmail.com',
        // ... full list
    ];

    public function validate(string $attribute, mixed $value, \Closure $fail): void {
        $domain = strtolower(substr($value, strrpos($value, '@') + 1));
        if (in_array($domain, self::DISPOSABLE, true)) {
            $fail('Temporary email addresses are not allowed.');
        }
    }
}

// In FormRequest:
class SignupRequest extends FormRequest {
    public function rules(): array {
        return [
            'email' => [
                'required',
                'string',
                'email:rfc,dns',
                'max:254',
                'unique:users,email',
                new NotDisposableEmail(),
            ],
        ];
    }

    public function messages(): array {
        return [
            'email.required'   => 'Please enter your email address.',
            'email.email'      => 'Please enter a valid email address.',
            'email.unique'     => 'This email address is already registered.',
            'email.max'        => 'Email address is too long.',
        ];
    }
}

WordPress Email Validation

<?php
/**
 * WordPress-specific email validation with hooks and filters.
 */

// ── Using WordPress built-in function ────────────────────────────────────────
$email = 'user@example.com';

if (is_email($email)) {
    echo 'Valid per WordPress'; // Uses filter_var internally
}

// ── Extending WordPress email validation ─────────────────────────────────────

// Block disposable emails in WordPress registration
add_filter('is_email', function(bool|string $is_email, string $email): bool|string {
    if (!$is_email) return $is_email;

    $disposable_domains = ['mailinator.com', 'guerrillamail.com', 'trashmail.com'];
    $domain = strtolower(substr($email, strrpos($email, '@') + 1));

    if (in_array($domain, $disposable_domains, true)) {
        return false; // WordPress treats false return as invalid
    }

    return $is_email;
}, 10, 2);

// Add custom validation to WordPress registration
add_filter('registration_errors', function(\WP_Error $errors, string $sanitized_user_login, string $user_email): \WP_Error {

    if (empty($user_email)) {
        $errors->add('empty_email', 'Please enter your email address.');
        return $errors;
    }

    $domain = strtolower(substr($user_email, strrpos($user_email, '@') + 1));

    // Block disposable emails
    $disposable = ['mailinator.com', 'guerrillamail.com', '10minutemail.com', 'trashmail.com'];
    if (in_array($domain, $disposable, true)) {
        $errors->add(
            'disposable_email',
            '<strong>Error:</strong> Temporary email addresses are not allowed. Please use a permanent email.'
        );
    }

    // Check MX records
    if (!checkdnsrr($domain, 'MX') && !checkdnsrr($domain, 'A')) {
        $errors->add(
            'invalid_email_domain',
            '<strong>Error:</strong> The email domain does not appear to have an active mail server.'
        );
    }

    return $errors;
}, 10, 3);

// Validate email in Contact Form 7
add_filter('wpcf7_validate_email*', function(\WPCF7_Validation $result, \WPCF7_FormTag $tag): \WPCF7_Validation {
    $email = sanitize_email($_POST[$tag->name] ?? '');

    if (empty($email) && $tag->is_required()) {
        $result->invalidate($tag, 'Please enter your email address.');
        return $result;
    }

    if ($email && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
        $result->invalidate($tag, 'Please enter a valid email address.');
        return $result;
    }

    // MX check
    if ($email) {
        $domain = substr($email, strrpos($email, '@') + 1);
        if (!checkdnsrr($domain, 'MX') && !checkdnsrr($domain, 'A')) {
            $result->invalidate($tag, 'The email domain does not appear to be valid.');
        }
    }

    return $result;
}, 10, 2);

 

Part 9 — Complete Test Suite

Test your validation implementation against every edge case:

<?php
/**
 * Comprehensive email validation test suite.
 * Run: php test-email-validation.php
 */

$validator = new EmailValidator(checkDNS: false, checkMX: false); // Disable DNS for unit tests

$tests = [

    // ── VALID EMAILS ────────────────────────────────────────────────────────
    // Should all return valid: true

    ['email' => 'user@example.com',                       'expected' => true,  'desc' => 'Standard email'],
    ['email' => 'USER@EXAMPLE.COM',                       'expected' => true,  'desc' => 'Uppercase (case insensitive)'],
    ['email' => 'user.name@example.com',                  'expected' => true,  'desc' => 'Dot in local part'],
    ['email' => 'user+tag@example.com',                   'expected' => true,  'desc' => 'Plus addressing'],
    ['email' => 'user-name@example.com',                  'expected' => true,  'desc' => 'Hyphen in local part'],
    ['email' => 'user_name@example.com',                  'expected' => true,  'desc' => 'Underscore in local part'],
    ['email' => 'user123@example.com',                    'expected' => true,  'desc' => 'Numbers in local part'],
    ['email' => 'user@sub.domain.example.com',            'expected' => true,  'desc' => 'Multiple subdomains'],
    ['email' => 'user@example.co.uk',                     'expected' => true,  'desc' => 'Country code TLD'],
    ['email' => 'user@example.museum',                    'expected' => true,  'desc' => 'Long TLD'],
    ['email' => 'a@b.cc',                                 'expected' => true,  'desc' => 'Minimal valid email'],
    ['email' => '  user@example.com  ',                   'expected' => true,  'desc' => 'With surrounding spaces (trimmed)'],
    ['email' => 'user@example.photography',               'expected' => true,  'desc' => 'Very long TLD'],

    // ── INVALID EMAILS ───────────────────────────────────────────────────────
    // Should all return valid: false

    ['email' => '',                                       'expected' => false, 'desc' => 'Empty string'],
    ['email' => 'notanemail',                             'expected' => false, 'desc' => 'No @ symbol'],
    ['email' => '@example.com',                           'expected' => false, 'desc' => 'Missing local part'],
    ['email' => 'user@',                                  'expected' => false, 'desc' => 'Missing domain'],
    ['email' => 'user @example.com',                      'expected' => false, 'desc' => 'Space in local part'],
    ['email' => 'user@example',                           'expected' => false, 'desc' => 'No TLD'],
    ['email' => '.user@example.com',                      'expected' => false, 'desc' => 'Leading dot in local part'],
    ['email' => 'user.@example.com',                      'expected' => false, 'desc' => 'Trailing dot in local part'],
    ['email' => 'user..name@example.com',                 'expected' => false, 'desc' => 'Consecutive dots in local'],
    ['email' => 'user@example..com',                      'expected' => false, 'desc' => 'Consecutive dots in domain'],
    ['email' => 'user@-example.com',                      'expected' => false, 'desc' => 'Domain starts with hyphen'],
    ['email' => 'user@example-.com',                      'expected' => false, 'desc' => 'Domain ends with hyphen'],
    ['email' => str_repeat('a', 65) . '@example.com',    'expected' => false, 'desc' => 'Local part > 64 chars'],
    ['email' => 'user@' . str_repeat('a', 250) . '.com', 'expected' => false, 'desc' => 'Email > 254 chars'],
    ['email' => 'plainaddress',                           'expected' => false, 'desc' => 'Plain text no @'],
    ['email' => '#@%^%#$@#$@#.com',                      'expected' => false, 'desc' => 'Special chars in local'],
    ['email' => 'email.example.com',                      'expected' => false, 'desc' => 'Missing @'],
    ['email' => 'email@example@example.com',              'expected' => false, 'desc' => 'Multiple @ symbols'],
    ['email' => '"email"@example.com',                    'expected' => true,  'desc' => 'Quoted local part (valid per RFC)'],

    // ── TYPO DETECTION ───────────────────────────────────────────────────────
    // Should return valid: true but include suggestion

    ['email' => 'user@gmai.com',   'expected' => true, 'suggestion' => 'user@gmail.com',   'desc' => 'Gmail typo: gmai.com'],
    ['email' => 'user@gmial.com',  'expected' => true, 'suggestion' => 'user@gmail.com',   'desc' => 'Gmail typo: gmial.com'],
    ['email' => 'user@yaho.com',   'expected' => true, 'suggestion' => 'user@yahoo.com',   'desc' => 'Yahoo typo: yaho.com'],
    ['email' => 'user@hotmial.com','expected' => true, 'suggestion' => 'user@hotmail.com', 'desc' => 'Hotmail typo: hotmial.com'],
];

$passed = 0;
$failed = 0;
$total  = count($tests);

foreach ($tests as $test) {
    $result     = $validator->validate($test['email'], ['allow_disposable' => true, 'allow_role_based' => true]);
    $actual     = $result['valid'];
    $expected   = $test['expected'];
    $ok         = $actual === $expected;

    // Check suggestion if specified
    if ($ok && isset($test['suggestion'])) {
        $ok = ($result['suggestion'] === $test['suggestion']);
    }

    if ($ok) {
        $passed++;
        echo "✅ PASS: {$test['desc']}\n";
    } else {
        $failed++;
        echo "❌ FAIL: {$test['desc']}\n";
        echo "   Email:    " . var_export($test['email'], true) . "\n";
        echo "   Expected: " . ($expected ? 'valid' : 'invalid') . "\n";
        echo "   Got:      " . ($actual ? 'valid' : 'invalid') . "\n";
        if (!empty($result['errors']))   echo "   Errors: "  . implode(', ', $result['errors'])  . "\n";
        if (!empty($result['warnings'])) echo "   Warnings: ". implode(', ', $result['warnings']). "\n";
        if (isset($test['suggestion'])) {
            echo "   Expected suggestion: " . $test['suggestion'] . "\n";
            echo "   Got suggestion:      " . ($result['suggestion'] ?? 'none') . "\n";
        }
    }
}

echo "\n" . str_repeat('═', 50) . "\n";
echo "Results: {$passed}/{$total} passed" . ($failed > 0 ? ", {$failed} FAILED" : " 🎉") . "\n";

 

Part 10 — Email Validation Best Practices for 2025

The 5-Layer Validation Stack

Layer 1: HTML5 type="email" required
         → Catches: missing @, empty, basic format
         → Cost: Free, instant
         → Block rate: ~15% of typos

Layer 2: JavaScript real-time validation
         → Catches: format errors, domain typos, disposables
         → Cost: Free, instant
         → Block rate: ~85% of invalid emails

Layer 3: PHP server-side validation
         → Catches: everything above + bypassed JS
         → Cost: Free, <1ms
         → Block rate: ~95% of invalid emails

Layer 4: DNS/MX verification
         → Catches: non-existent domains
         → Cost: Free, 50-200ms latency
         → Block rate: ~99% of fake domains

Layer 5: SMTP or API verification
         → Catches: non-existent mailboxes
         → Cost: API credits, 1-5s latency
         → Block rate: ~99.9% of invalid emails

When to Use Each Layer

Use Case Layers Needed
Simple contact form 1 + 3
Newsletter signup 1 + 2 + 3 + 4
SaaS registration 1 + 2 + 3 + 4
High-value B2B signup 1 + 2 + 3 + 4 + 5
Email list cleaning (bulk) 5 (API service)
eCommerce checkout 1 + 2 + 3 (speed priority)

UX Best Practices

TIMING OF VALIDATION
─────────────────────────────────────────────────────────
✅ Show errors after blur (not while typing)
✅ Show success indicator when valid
✅ Show typo suggestions immediately
✅ Debounce AJAX checks (500-800ms after last keystroke)
✅ Keep AJAX check non-blocking (don't freeze the form)

ERROR MESSAGE WRITING
─────────────────────────────────────────────────────────
✅ "Please enter a valid email address (e.g., name@example.com)"
✅ "Did you mean user@gmail.com?"
✅ "The email domain doesn't appear to have a mail server"
❌ "Invalid input"
❌ "Email validation failed"
❌ "Error 422: FILTER_VALIDATE_EMAIL returned false"

WHAT NOT TO DO
─────────────────────────────────────────────────────────
❌ Block email addresses with + signs (breaks Gmail users)
❌ Require confirmation email field for short forms
❌ Validate on every keystroke (annoying)
❌ Show "This email is already taken" before blur (premature)
❌ Strip the domain to lowercase but not the local part
❌ Log or store partial email addresses for "recovery"
❌ Block .co.uk or other country TLDs (valid addresses)
❌ Limit email to 50 chars (RFC allows 254)

Security Considerations

// ── Always sanitize before storing ────────────────────────────────────────
$email = filter_var(trim($_POST['email']), FILTER_SANITIZE_EMAIL);

// ── Always store normalised (lowercase domain) ────────────────────────────
$email = strtolower($email); // But be careful — local part IS case-sensitive per RFC

// Better approach:
$atPos = strrpos($email, '@');
$email = substr($email, 0, $atPos) . '@' . strtolower(substr($email, $atPos + 1));

// ── Email header injection prevention ────────────────────────────────────
// If using PHP mail(), never trust user-supplied email without this check:
if (preg_match('/[\r\n]/', $email)) {
    die('Email injection attempt detected');
}

// Better: use a proper mail library like PHPMailer or Symfony Mailer
// which handles this automatically

// ── Rate limit email verification endpoints ───────────────────────────────
// Attackers can use verification endpoints to harvest valid emails
// Always rate limit: max 10 checks per IP per minute

// ── Store only lowercase domain ───────────────────────────────────────────
// For deduplication and preventing "user@GMAIL.COM" != "user@gmail.com"
// Domain is case-insensitive per RFC. Local part technically is case-sensitive
// but virtually all providers treat it as case-insensitive.

 

From One Filter to a Complete Validation System

The original article’s approach — filter_var() + type=”email” + one JS regex — validates email format. This guide builds a complete email validation system.

Here’s the practical summary of what you need:

For most websites (contact forms, newsletters, blogs):

HTML5: type="email" required
JS: Real-time format + domain typo detection
PHP: filter_var() + MX record check + disposable detection

For SaaS and registration-critical apps:

All of the above +
DNS MX verification (checkdnsrr)
SMTP verification or API service
Email confirmation flow (still the most reliable check)

For existing email lists (cleaning bounce rates):

Bulk API service: ZeroBounce, NeverBounce, Kickbox

The most important principle in this guide: email validation is a spectrum, not a binary check. Format valid → domain valid → MX valid → mailbox valid → user confirmed. Each layer adds confidence, and the right combination depends on your specific use case, performance requirements, and user experience goals.

The EmailValidator class from Part 4 and the JavaScript EmailValidator class from Part 3 can be dropped into any PHP/JavaScript project today and handle every real-world scenario developers encounter.

 

Quick Reference Card

PHP FUNCTIONS
────────────────────────────────────────────────────────────────
filter_var($email, FILTER_VALIDATE_EMAIL)   → Format check (basic)
filter_var($email, FILTER_SANITIZE_EMAIL)   → Sanitize (remove unsafe chars)
checkdnsrr($domain, 'MX')                  → Check MX records exist
getmxrr($domain, $hosts, $weights)         → Get sorted MX records
is_email($email)                            → WordPress built-in

JAVASCRIPT
────────────────────────────────────────────────────────────────
/^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/.test(email)
input.validity.typeMismatch                 → HTML5 Constraint API

VALIDATION DECISIONS
────────────────────────────────────────────────────────────────
Format only?    → filter_var() is enough
Need real users?→ Add MX check + disposable detection
High value?     → Add SMTP/API verification
Max accuracy?   → Send confirmation email

COMMON MISTAKES
────────────────────────────────────────────────────────────────
❌ Blocking + signs in email addresses
❌ Limiting email to < 254 characters
❌ Only validating client-side
❌ Not normalising domain to lowercase
❌ Using MD5 to "hash" email for storage
❌ Logging partial emails for error recovery

 

Frequently Asked Questions

+

What is email address validation?

Email address validation is the process of checking whether an email follows the correct format before it is accepted or stored. Validation helps reduce invalid submissions and improves data quality.
+

Why should I validate email addresses on both the client and server?

Client-side validation provides instant feedback to users, while server-side validation ensures security because client-side checks can be bypassed. Using both methods is considered a best practice.
+

How do I validate an email address in HTML5?

Use the type="email" attribute along with required to perform built-in browser validation. You can also use the pattern attribute if you need stricter validation rules.
+

How can I validate an email address in PHP?

PHP provides the filter_var() function with FILTER_VALIDATE_EMAIL, which is the recommended and most reliable method for validating email formats on the server.
+

Can JavaScript validate email addresses in real time?

Yes. JavaScript can validate email addresses as users type, displaying instant error messages and improving the overall user experience before the form is submitted.
+

Is HTML5 email validation enough?

No. HTML5 only checks whether the email follows a valid format. It cannot verify that the email address actually exists or can receive emails. Server-side validation and email verification are still recommended.
+

Should I use regular expressions (Regex) for email validation?

Regex is useful for custom validation rules, but complex regex patterns may incorrectly reject valid email addresses. For PHP, filter_var() is generally preferred, while HTML5 and JavaScript can use regex when additional restrictions are required.
+

How can I check whether an email address actually exists?

Format validation alone cannot confirm that an email address exists. The most reliable approach is to send a verification email containing a confirmation link or one-time verification code.
+

What are common invalid email formats?

Examples include: Missing @ symbol Missing domain name Spaces within the email Multiple @ symbols Invalid special characters Empty email field
+

What are the best practices for email validation?

Use HTML5 type="email" Make the field required Validate with JavaScript for instant feedback Validate again using PHP on the server Store only validated email addresses Send a verification email before activating the account Sanitize all user input
Previous Article

How to Build a Password Strength Checker in JavaScript & jQuery — The Complete Guide

Next Article

MySQL Shutdown Unexpectedly— The Complete Fix Guide

Write a Comment

Leave a Comment

Your email address will not be published. Required fields are marked *

Subscribe to our Newsletter

Subscribe to our email newsletter to get the latest posts delivered right to your email.
Pure inspiration, zero spam ✨