classifier
The classifier's entry points, input handling, provider registry, role and disposable checks, typo suggestions, and options.
@email-utils/classifier answers what an address is: which mailbox provider
serves it, whether it's on a throwaway domain or names a role rather than a
person, and whether its domain looks like a typo. It's synchronous, does no
I/O, and runs in the browser and in Node.
npm install @email-utils/classifier@nextUntil 1.0.0, the v1 release candidates are on the next tag. The
API reference has every export's signature.
import { } from '@email-utils/classifier/classify';
('ceo@mailinator.com');
// => { provider: undefined, disposable: true, role: true, suggestion: undefined }
('ada@gmial.com').; // => 'ada@gmail.com'Entry points
The package has five entry points. The disposable-domain list is the largest
piece of data, so the root entry never loads it: isDisposable and
classify, which uses it, are in their own entries.
| Entry point | Exports | Loads |
|---|---|---|
@email-utils/classifier | getProvider, isRoleAccount, suggestCorrection, packageName; types ProviderId, ProviderInfo, ProviderKind | The provider registry, the role list, the typo tables, and validator-syntax's parser |
@email-utils/classifier/providers | providers, getProvider; the same three types | The provider registry and validator-syntax's parser |
@email-utils/classifier/disposable | isDisposable | The disposable-domain list and validator-syntax's parser |
@email-utils/classifier/classify | classify, createClassifier, defaultIgnore; types ClassifyOptions, Classification, Classifier, Keyboard | Everything the root and /disposable load |
@email-utils/classifier/sources | providerSources; types ProviderRule, ProviderSource | Only the citations, with no code from the other entries |
/sources maps each provider ID to the pages behind its registry entry: the
field each source backs, its URL (absent when the fact was read from DNS),
the date it was last checked, and a note. It's separate so that callers of
the registry don't ship the URLs. See
providerSources.
Input
Every lookup takes a string or a
ParsedAddress from
validator-syntax.
- Strings are parsed with validator-syntax's
practicalpreset, so the classifier splits an address exactly as the parser does. A string the preset rejects is an address the classifier knows nothing about: no provider, not disposable, not a role account. That covers display names, surrounding whitespace, comments, quoted local parts, domain literals, dotless domains, and unknown TLDs. - Strings over 254 characters are treated as unknown without being parsed.
- Internationalized domains in a string must be written as A-labels
(
xn--…). A string with a U-label domain, likeadmin@bücher.de, is treated as unknown, sincepracticaldoesn't allow non-ASCII. - A
ParsedAddressis used as given, without being checked again. One with an empty local part or domain is treated as unknown. A quoted local part keeps its quotes, so"admin"isn't a role account. - Anything else, such as a number,
null, or an object without stringlocalanddomain, throws aTypeError.
suggestCorrection is the one exception to the parse: it reads strings with
the TLD check turned off, since a mistyped TLD like .con is what it's there
to fix.
import { } from '@email-utils/classifier';
import { } from '@email-utils/validator-syntax';
const = ('Support+billing@example.com');
if (.) {
(.); // => true
}Provider registry
getProvider returns the registry entry
whose domains the address is on, compared without case, or undefined. The
entry it returns is the same object that's in
providers, not a copy, so don't
change it.
import { } from '@email-utils/classifier';
('ada@googlemail.com')?.; // => 'gmail.com'
('news@ada.fastmail.com')?.; // => 'fastmail'
('ada@example.com'); // => undefinedA custom domain hosted on a provider, such as a company's domain on Google
Workspace, can't be told from the domain alone, so getProvider returns
undefined for it. validator-dns's detectProviderByMx finds those by
matching the domain's MX hosts against each entry's mxPatterns, and
returns the same provider IDs. The sanitizer's provider rules read the
local-part fields.
Providers
| ID | Name | Kind | Domains | Canonical domain | Dots | Subaddress | Hyphens | Subdomain addressing |
|---|---|---|---|---|---|---|---|---|
gmail | Gmail | personal | gmail.com, googlemail.com | gmail.com | ignored | + | count | no |
google-workspace | Google Workspace | business | none; found by MX | count | + | count | no | |
outlook | Outlook.com | personal | outlook.com and 27 more | count | + | count | no | |
microsoft365 | Microsoft 365 | business | none; found by MX | count | + | count | no | |
yahoo | Yahoo Mail | personal | yahoo.com and 12 more | count | none | count | no | |
aol | AOL Mail | personal | aol.com, aim.com, aol.co.uk, aol.de, aol.fr | count | none | count | no | |
icloud | iCloud Mail | personal | icloud.com, me.com, mac.com | icloud.com | count | none | count | no |
proton | Proton Mail | personal | proton.me, protonmail.com, protonmail.ch, pm.me | count | + | count | no | |
fastmail | Fastmail | personal | fastmail.com and 129 more | count | + | count | yes | |
zoho | Zoho Mail | personal | zoho.com, zohomail.com | count | none | count | no | |
zoho-business | Zoho Mail for business | business | none; found by MX | count | none | count | no | |
yandex | Yandex Mail | personal | yandex.ru, yandex.com, yandex.by, yandex.kz, ya.ru | yandex.ru | count | + | same as dots | no |
yandex-360 | Yandex 360 for Business | business | none; found by MX | count | none | count | no | |
gmx | GMX | personal | gmx.net, gmx.de, gmx.at, gmx.ch, gmx.com, gmx.us, gmx.co.uk | count | none | count | no | |
web-de | WEB.DE | personal | web.de | count | none | count | no | |
mail-ru | Mail.ru | personal | mail.ru, inbox.ru, list.ru, bk.ru, internet.ru | count | none | count | no | |
tuta | Tuta Mail | personal | tuta.com, tutanota.com, tutanota.de, tutamail.com, tuta.io, keemail.me | count | none | count | no | |
hey | HEY | personal | hey.com | count | none | count | no | |
namecheap | Namecheap Email Forwarding | registrar | none; found by MX | count | none | count | no |
providers has every entry, in no particular order, with the full MX
lists. The support matrix cites the
source of each entry's domains, MX patterns, and rules. The IDs are stable: the sanitizer's provider option and
detectProviderByMx use them.
Fields
kind:personalfor a mailbox service anyone can sign up for, free or paid.businessfor hosting an organization's own domain; these entries have nodomains.registrarfor a registrar's default forwarding MX, which a domain has before its owner sets up mail, so mail may be forwarded anywhere or nowhere.canonicalDomain: when set, all the entry's domains share mailboxes, and keys use this one.dotsSignificant: whether dots in the local part change the mailbox.falseonly for Gmail; dots do count on Google Workspace domains.subaddressSeparator: the character that starts a subaddress tag, such as+. Absent when the provider has none.hyphensSignificant: whether a hyphen in the local part is its own character.falseonly for Yandex, wheremy-addressandmy.addressare one mailbox.subdomainAddressing: whethertag@user.<domain>delivers touser@<domain>. When it's true,getProvideralso matches one-label subdomains of the entry's domains, sonews@ada.fastmail.comis Fastmail butnews@a.b.fastmail.comisn't.
The registry records only what each provider documents. A rule a provider doesn't document keeps the default: dots and hyphens count, no subaddress separator, no subdomain addressing. Merging two people's addresses under one key is worse than missing a duplicate.
Providers that sell both personal mailboxes and hosting for custom domains
have two entries when the two use different MX hosts, so that an MX match
reports the right kind: gmail and google-workspace, outlook and
microsoft365, zoho and zoho-business, yandex and yandex-360.
Role accounts
isRoleAccount is true when the local
part names a function rather than a person. It compares the local part
without case and without a + tag, so Admin+alerts@example.com counts.
Separators aren't folded: each common spelling is listed, so no-reply,
no_reply, and noreply all count, but no.reply doesn't. The list:
- RFC 2142 and RFC 5321:
abuse,ftp,hostmaster,info,marketing,news,noc,postmaster,sales,security,support,usenet,uucp,webmaster,www. - Senders that take no replies:
donotreply,do-not-reply,do_not_reply,mailer-daemon,noreply,no-reply,no_reply. - Running the system:
admin,administrator,it,root,sysadmin. - Teams and desks:
accounting,accounts,billing,careers,compliance,contact,customerservice,enquiries,feedback,finance,hello,help,helpdesk,hr,inquiries,jobs,legal,media,office,orders,press,privacy,service,team. - Lists and notifications:
alerts,all,everyone,newsletter,notifications,staff. - Offices rather than their holders:
ceo,cfo,coo,cto.
Disposable domains
isDisposable is true when
the address's domain, or a parent of it, is a known throwaway-mailbox
domain: ada@mailinator.com and ada@x.mailinator.com both are.
- Source: the
disposable-email-domains
blocklist (CC0 1.0), about 9,000 domains, vendored into the package. A
newer package version brings a newer copy.
THIRD_PARTY_NOTICES.md, which ships with the package, attributes it. - Parent domains: the list holds registrable domains, so the check walks
from the full domain up to its last two labels. A TLD on its own never
matches, and a listed name in the middle of a domain doesn't either:
ada@mailinator.com.example.orgisn't disposable. - Case and IDNs: the domain is compared without case, and an
internationalized domain in a
ParsedAddressby its A-label. - Never disposable: domain literals, and any string the
practicalpreset rejects (see Input). No registry domain is on the list. - Loading: the list is one string in the module, split into a set on
the first call, so importing
/disposableor/classifycosts one string literal until you check an address.
Typo suggestions
suggestCorrection returns the
address with its domain corrected when the domain looks like a slip for a
common mailbox domain, and undefined otherwise. The local part is kept as
written; the domain comes back in lowercase, so Ada@GMIAL.COM gives
Ada@gmail.com. The root suggestCorrection uses the default options; to
change them, use the one on a classifier.
How a domain is matched
- A string is parsed with
practicalminus its TLD check. A string over 254 characters, or one that still doesn't parse, gets no suggestion. - A domain that's never corrected (see below) gets no suggestion.
- If the TLD is one of the slips in the table below, it's fixed first. If
the fixed domain is in
ignore, there's no suggestion:ada@mail.congets none. If the fixed domain is a registry domain or indomains, that's the suggestion. Otherwise the fixed domain is matched against the targets as in step 4, and suggested as it is if none is close enough:ada@gmial.congivesada@gmail.com, andada@example.congivesada@example.com. - Otherwise the domain is taken for the closest target within
maxEditsedits, the earlier one on a tie:ada@hotmail.drgivesada@hotmail.frrather thanada@hotmail.de. No target that close means no suggestion.
The distance is the optimal string alignment distance. Inserting a
character, deleting one, or swapping two neighboring characters is one
edit, so gamil.com is one edit from gmail.com. A substitution is one
edit when the two keys are close enough on the keyboard, and two otherwise,
the same as a deletion and an insertion.
| Typed TLD | Fixed to |
|---|---|
con, cmo, ocm, vom, xom, cpm, cim, ckm, clm, cmm, comm, coom, ccom, comn | com |
nte, ner, nwt, nett | net |
ogr, orh, prg, ort, orgg | org |
None of these is a real TLD.
Keyboard distance
Each layout places its letters and digits as on an ANSI or ISO board, each
row offset from the one above. Two keys are neighbors when they sit side by
side in a row or touch diagonally in the row above or below, and the
distance between two keys is the number of steps between neighbors. A
substitution counts as one edit when the keys are at most maxKeyDistance
apart, which is 1, neighbors only, by default:
gotmail.comgiveshotmail.com(G is beside H), butlastmail.com, a disposable service of its own, gets nothing: L is five keys from F. WithmaxKeyDistance: Infinityit givesfastmail.com.glail.comgets nothing by default, since L is two keys from M, and givesgmail.comwithmaxKeyDistance: 2.- The layout matters:
aahoo.comgivesyahoo.comonqwertz, where A touches Y, and nothing onqwerty.tahoo.comis the reverse.
- and . aren't on the layouts, since they sit in different places on
each one and rarely stand in for a letter. Substituting one, or any other
character off the layout, counts as one edit only when maxKeyDistance is
Infinity (any value of 255 or more acts the same).
Edit limit
maxEdits is the most edits a domain may be from a target. The default, 1,
catches one slip. 2 also catches double slips, like hotmal.co for
hotmail.com, but turns some distinct domains into common ones:
yopmail.com, a disposable service, becomes ymail.com. 0 turns name
corrections off and leaves only TLD fixes, so ada@gmial.con gives
ada@gmial.com.
Targets
A domain is only ever corrected toward a target. The domains option comes
first, then these, most used first, in this order:
gmail.com, yahoo.com, hotmail.com, outlook.com, icloud.com,
live.com, googlemail.com, ymail.com, rocketmail.com,
protonmail.com, proton.me, hotmail.co.uk, hotmail.fr, hotmail.de,
hotmail.it, hotmail.es, hotmail.ca, hotmail.com.br,
hotmail.com.au, hotmail.co.jp, live.co.uk, live.fr, live.de,
live.it, live.nl, live.ca, live.com.au, outlook.fr, outlook.de,
outlook.es, outlook.it, outlook.jp, outlook.com.br, yahoo.co.uk,
yahoo.fr, yahoo.de, yahoo.es, yahoo.it, yahoo.ca, yahoo.in,
yahoo.com.br, yahoo.com.au, mail.ru, inbox.ru, list.ru,
yandex.ru, yandex.com, zoho.com, zohomail.com, fastmail.com,
fastmail.fm, tutanota.com, tuta.com.
They're registry domains, but not all of them: most of Fastmail's alias
domains are left out, or gmail.co.uk would become fmail.co.uk. A domain
whose first label has three letters or fewer, like me.com or gmx.de, is
never a target, since it's one edit from too many real domains; that
applies to the domains option too, as does leaving out dotless domains.
A domain in ignore is never a target.
Never corrected
- Every registry domain, including those that aren't targets, and the
subdomains
getProvidermatches, such asada.fastmail.com. - Every domain in
domainsorignore. - Domain literals, dotless domains, and internationalized domains.
ignore defaults to defaultIgnore,
mail.com and email.com: real domains outside the registry that sit near
a common one. Without it, ada@mail.com would become ada@gmail.com.
Some typo domains, like gmial.com, are themselves on the disposable list,
so classify can report an address as both disposable and a likely typo.
The two checks don't consult each other.
Options
ClassifyOptions are taken
by classify and createClassifier. They change only the typo suggestion:
provider, disposable, and role lookups don't take options.
| Option | Type | Default | What it does |
|---|---|---|---|
domains | readonly string[] | [] | Domains corrected toward, ahead of the built-in targets, and never corrected away from, such as your own company's. Compared without case. |
ignore | readonly string[] | defaultIgnore | Domains never corrected and never corrected toward, compared without case. Replaces defaultIgnore; spread it in to keep it: ignore: [...defaultIgnore, 'example.org']. |
keyboard | Keyboard | 'qwerty' | The layout key distance is measured on: 'qwerty', 'qwertz', or 'azerty'. |
maxEdits | 0, 1, or 2 | 1 | The most edits a domain may be from a target. 0 leaves only TLD fixes. See Edit limit. |
maxKeyDistance | number | 1 | How many keys apart a typed letter may be from the intended one and still count as one edit. 1 is neighbors only; Infinity counts any substitution as one edit. See Keyboard distance. |
The options are checked when the classifier is built, and anything malformed
throws a TypeError:
optionsthat aren't an object;- a key that isn't one of the five above, such as a misspelled
maxedits; domainsorignorethat isn't an array of strings;- a
keyboardthat isn't one of the three layouts; - a
maxEditsother than the numbers 0, 1, or 2; - a
maxKeyDistancethat isn't a positive integer orInfinity.
A key set to undefined counts as not set.
Classification
classify parses a string once and runs all four checks on it. Every key is
present in the result, so an address the classifier knows nothing about is
{ provider: undefined, disposable: false, role: false, suggestion: undefined }.
interface Classification {
provider: ProviderInfo | undefined;
disposable: boolean;
role: boolean;
suggestion: string | undefined;
}| Field | Same as |
|---|---|
provider | getProvider: the registry entry, or undefined |
disposable | isDisposable |
role | isRoleAccount |
suggestion | suggestCorrection with the classifier's options |
When the practical preset rejects a string, the first three are
undefined, false, and false, but the suggestion is still worked out
from the lenient parse. classify('ada@gmail.con') knows nothing but the
suggestion, 'ada@gmail.com'.
Classifiers and per-call options
createClassifier checks
the options once and returns a
Classifier: classify,
isDisposable, isRoleAccount, suggestCorrection, and getProvider, with
the options applied. Only classify and suggestCorrection use them; the
other three are the standalone functions.
classify(email, options) builds a new classifier on every call that passes
options, checking the options and rebuilding the suggestion's domain sets
each time. Without options it reuses one shared default classifier. When you
classify many addresses with the same options, create a classifier once and
reuse it.
import {
,
,
} from '@email-utils/classifier/classify';
const = ({
: ['example-corp.com'],
: [..., 'gmial.com'],
});
.('ada@example-crop.com'); // => 'ada@example-corp.com'
.('ada@gmial.com'); // => undefinedReason codes
The classifier returns plain data rather than results, so it has no reason codes. See the catalogue.