Suggesting Email Domain Typo Corrections

How do you catch ada@gmial.com, ada@hotmail.con and ada@yahooo.com before a user signs up with an address that will never receive their confirmation email — without rejecting the perfectly valid ada@gmx.com or a company domain you have never heard of? The answer is to suggest, never to block. This recipe keeps the email field’s validity with the browser’s type="email" check and the Constraint Validation API, and separately compares the domain against a list of popular mail providers using edit distance. When a close match exists, it offers a one-click “Did you mean ada@gmail.com?” button that is announced once and returns focus to the field.

When to Offer Email Typo Suggestions

Domain typos are one of the most common reasons sign-up confirmation emails never arrive, and they are invisible to syntax validation: ada@gmial.com is a perfectly valid address that happens to belong to nobody. Offer suggestions when:

  • Most users have consumer email, so a short list of providers covers most addresses.
  • The address is critical — account recovery, order receipts, double opt-in newsletters.
  • You cannot easily correct it later, because the user will never see the confirmation.

Never turn a suggestion into an error. Plenty of real domains sit one edit away from a popular one (gmx.com, mail.com, ymail.com), and blocking them locks out real customers. The syntax check stays authoritative, exactly as described in email input validation attributes; the suggestion is advice layered on top, in the “likelihood” tier of the identity text field validation topic.

How a domain suggestion is produced The typed email's domain is split into name and suffix, compared with known providers using edit distance with a small threshold, and a suggestion is offered only for a close, unambiguous match that differs from the input. Split domain gmial.con → gmial + con Compare name Damerau- Levenshtein vs providers Compare suffix con → com, co.ukk → co.uk Threshold distance ≤ 2, unique best Suggest button, never an error
Exact matches and unknown-but-plausible domains produce no suggestion; only a near miss of a popular domain does.

Minimal Working Suggestion Engine

const PROVIDERS = [
  "gmail.com", "googlemail.com", "yahoo.com", "yahoo.co.uk", "hotmail.com", "hotmail.co.uk",
  "outlook.com", "live.com", "msn.com", "icloud.com", "me.com", "aol.com", "proton.me",
  "protonmail.com", "gmx.com", "gmx.de", "web.de", "mail.com", "ymail.com", "comcast.net",
];
const SUFFIXES = ["com", "net", "org", "co.uk", "de", "fr", "io", "me", "edu", "gov", "ca", "com.au"];

/** Optimal string alignment distance: insertions, deletions, substitutions, adjacent swaps. */
export function distance(a: string, b: string): number {
  const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
  for (let j = 1; j <= b.length; j++) d[0][j] = j;
  for (let i = 1; i <= a.length; i++) {
    for (let j = 1; j <= b.length; j++) {
      const cost = a[i - 1] === b[j - 1] ? 0 : 1;
      d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost);
      if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
        d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
      }
    }
  }
  return d[a.length][b.length];
}

function closest(input: string, candidates: string[], max: number): string | undefined {
  let best: string | undefined;
  let bestD = Infinity;
  let tie = false;
  for (const c of candidates) {
    const dist = distance(input, c);
    if (dist < bestD) { best = c; bestD = dist; tie = false; }
    else if (dist === bestD) tie = true;
  }
  return bestD > 0 && bestD <= max && !tie ? best : undefined;
}

export function suggestEmail(raw: string): string | undefined {
  const at = raw.lastIndexOf("@");
  if (at < 1) return undefined;
  const local = raw.slice(0, at);
  const domain = raw.slice(at + 1).trim().toLowerCase();
  if (!domain || PROVIDERS.includes(domain)) return undefined;

  // 1. Whole-domain match against popular providers (catches gmial.com, hotmial.co.uk).
  const whole = closest(domain, PROVIDERS, domain.length > 8 ? 2 : 1);
  if (whole) return `${local}@${whole}`;

  // 2. Suffix-only fix for unknown domains (acme.con → acme.com), keeping the name part.
  const dot = domain.indexOf(".");
  if (dot > 0) {
    const name = domain.slice(0, dot);
    const suffix = domain.slice(dot + 1);
    if (!SUFFIXES.includes(suffix)) {
      const fixed = closest(suffix, SUFFIXES, 1);
      if (fixed) return `${local}@${name}.${fixed}`;
    }
  }
  return undefined;
}

// Wiring: suggestion on blur, applied with one click, never affecting validity.
const email = document.querySelector<HTMLInputElement>("#email")!;
const box = document.querySelector<HTMLElement>("#email-suggest")!;       // referenced by aria-describedby
const live = document.querySelector<HTMLElement>("#email-suggest-live")!; // role="status"
let announced = "";

email.addEventListener("blur", () => {
  if (!email.validity.valid) return (box.hidden = true);   // syntax errors take priority
  const s = suggestEmail(email.value);
  if (!s) return (box.hidden = true);
  box.replaceChildren("Did you mean ");
  const btn = Object.assign(document.createElement("button"), { type: "button", textContent: s });
  btn.addEventListener("click", () => {
    email.value = s;
    email.dispatchEvent(new Event("input", { bubbles: true }));
    box.hidden = true;
    email.focus();
  });
  box.append(btn, "?");
  box.hidden = false;
  if (announced !== s) { live.textContent = `Did you mean ${s}?`; announced = s; }
});

email.addEventListener("input", () => { box.hidden = true; });

The local part is preserved exactly as typed — including case and any plus-tag — because only the domain is being corrected. The suggestion is appended to the field’s description rather than injected as an error, so reportValidity() never mentions it and the form submits normally if the user ignores it.

Email field with a domain suggestion A sign-up form where the email field contains a gmial.com address, with a suggestion button offering the gmail.com correction below it and the wiring annotated. Create your account Email address ada.lovelace@gmial.com 1 Did you mean ada.lovelace@gmail.com? Password •••••••••••••• Create account 1 The suggestion is a real button inside the field's aria-describedby text 2 Announced once via a polite status region, never on every blur 3 The field stays valid; ignoring the suggestion still submits
The suggestion is advice in the field's description, not an error; one click fixes the address and focus returns to the field.

Suggestion Engine Option Reference

Option Type Default Purpose
PROVIDERS string[] ~20 consumer domains Targets for whole-domain matching
SUFFIXES string[] common TLDs Targets for suffix-only fixes
Distance metric OSA (Damerau) swaps cost 1 gmialgmail is one edit, not two
Max distance number 1 for short, 2 for long domains Avoids suggesting gmail for gmx
Tie rule boolean no suggestion on ties Ambiguous matches are not guessed
Trigger event blur No suggestions while typing
Announcement status region once per distinct suggestion Avoids repeated interruptions
Example inputs and suggestions A table of typed email domains showing the suggestion produced, if any, and the reason. Suggestion Why gmial.com gmail.com adjacent swap, distance 1 hotmial.co.uk hotmail.co.uk swap, long domain acme.con acme.com unknown name, suffix fix gmx.com ✗ ne exact provider example.org ✗ ne ✗ t near any provider
Near misses of popular domains are corrected; real domains close to popular ones are left alone.

Verification Steps

import { describe, it, expect } from "vitest";
import { suggestEmail } from "./suggest";

describe("suggestEmail", () => {
  it.each([
    ["ada@gmial.com", "ada@gmail.com"],
    ["Ada+news@hotmial.co.uk", "Ada+news@hotmail.co.uk"],
    ["ada@yahooo.com", "ada@yahoo.com"],
    ["ada@acme.con", "ada@acme.com"],
  ])("%s → %s", (input, expected) => expect(suggestEmail(input)).toBe(expected));

  it.each(["ada@gmail.com", "ada@gmx.com", "ada@example.org", "ada@mail.com"])("leaves %s alone", (input) => {
    expect(suggestEmail(input)).toBeUndefined();
  });
});

Edge Cases and Failure Modes

Suggesting a different real domain. mail.com is a single insertion away from gmail.com, and ymail.com is one substitution away from it — yet all three are real providers. That is why the provider list contains all of them: an exact match to any listed domain short-circuits before suggestions are considered, so ada@mail.com is never “corrected” to Gmail. Extend the list with the providers your own users actually have; your sign-up data is the best source.

Company domains near popular ones. outlok.com might be a typo or a small business. Keep the maximum distance low (1 for short names) and never block. If complaints appear, add the domain to an allow-list that suppresses suggestions.

Suggestions on every keystroke. Suggesting while the user is still typing gmai produces noise. Only suggest on blur, and hide the suggestion as soon as the user edits the field.

Server-side confirmation is still required. Suggestions reduce typos; they do not prove the address works. Send a confirmation email and treat the address as unverified until the link is followed.

Server-Side Suggestions for Other Entry Points

Not every email address reaches you through this form. Addresses arrive from checkout pages, imported contact lists, support tools and mobile apps, and the same typos appear in all of them. Moving suggestEmail into a shared module lets the server flag probable typos on those paths too — not to reject them, but to mark them for a confirmation step or a gentle “please check your email address” prompt the next time the user signs in. Keep the provider list in one place so every entry point agrees on what counts as a near miss.

Measuring Whether Suggestions Help

Typo suggestions are cheap to ship and easy to get subtly wrong, so measure them. Log, without the address itself, three events: a suggestion was shown, it was accepted, and the confirmation email for the final address was eventually opened. A healthy implementation shows a high acceptance rate and a lower bounce rate among users who accepted than among those who ignored a suggestion. A low acceptance rate on a particular rule — say, suffix fixes — usually means it suggests corrections to real domains, and that rule should be tightened or removed. Pair this with your mail provider’s bounce reports: domains that bounce often and sit close to a popular provider are candidates for the list, while frequent real domains near providers belong on the allow-list. The testing approach for the suggestion UI itself, including checking that the status announcement fires once, is covered in asserting aria-live announcements in Playwright.

Frequently Asked Questions

Should a mistyped email domain block form submission?

No. Many valid domains are one or two edits away from popular ones. Show a "did you mean" suggestion the user can accept with one click, but let the form submit if they ignore it.

How do I detect typos like gmial.com?

Compare the typed domain with a list of popular providers using an edit distance that counts adjacent swaps as one edit, and suggest the closest match when it is within one or two edits and not tied with another candidate.

When should the email suggestion appear?

On blur, after the syntax check passes. Suggesting while the user is typing produces constant noise, and suggestions for syntactically invalid addresses compete with the real error.

Is the suggestion accessible to screen reader users?

Make it a real button inside the field's description, announce it once through a polite status region, and return focus to the email field after it is applied.

← Back to Identity Text Field Validation