Gmail dots, plus addressing and duplicate accounts
· 6 min read · Email validation
jane.doe+trial2@gmail.com, janedoe@gmail.com and JaneDoe@googlemail.com all deliver to the same inbox. Gmail ignores dots in consumer addresses, treats googlemail.com as gmail.com, and delivers anything after a + to the base mailbox. To stop one person from creating many accounts this way, store the address exactly as typed, and add a second column holding a canonical key that applies these provider rules. Use the key for uniqueness checks, never for sending or login.
Which variations really reach the same inbox
These are the rules you can rely on, each from the provider's own documentation:
| Provider | Rule | Source |
|---|---|---|
| Gmail (consumer) | Dots in the local part are ignored. | Google: dots don't matter in Gmail addresses |
| Gmail via a work or school account | Dots do change the address. | Same page |
| Gmail | gmail.com and googlemail.com are the same. |
Google help center |
| Gmail and Google Workspace | name+anything@ arrives in the name@ inbox. |
Google Workspace help |
| Exchange Online (Microsoft 365) | Plus addressing is on by default; an admin can turn it off. | Microsoft Learn: plus addressing |
| Fastmail | Plus addressing, plus "subdomain addressing": anything@username.fastmail.com. |
Fastmail help |
The + convention has a standards-track description in RFC 5233, the Sieve subaddress extension, which splits a local part into a "user" and a "detail" around a separator. The RFC uses + as its example but says the encoding of detailed addresses is site- and implementation-specific. Postfix, for example, makes the delimiter a configuration setting (recipient_delimiter), and some servers use -. So "+ means subaddress" is a very good bet, but it's a bet.
And one thing that is not a safe normalization: case. RFC 5321 section 2.4 says the local part must be treated as case sensitive, while also saying that exploiting this is discouraged. Lowercasing is fine for a comparison key. Lowercasing the address you send to is technically not your call, though in practice almost every provider is case-insensitive.
Why this matters for abuse, and how much
Plus addressing is a privacy and filtering feature that a lot of honest people use. Someone signing up as jane+yourapp@gmail.com wants to know if you leak or sell their address. That's not abuse.
The trouble starts when uniqueness of the email string is the only thing enforcing "one per person":
- Free trials and free tiers.
jane+1@,jane+2@,jane+3@each get a fresh trial. We cover the wider problem in stopping free-trial abuse. - Referral and promo credits. A user refers themselves through a dozen variations of one inbox. See referral and promo-code abuse.
- Rate limits keyed on email. Password reset throttles, invite limits, "one vote per account".
The dot trick scales quietly. A Gmail username of n characters has n - 1 gaps where a dot could go, so there are 2^(n-1) dotted spellings. A 10-character username has 512. No plus sign required, and every one looks like an ordinary address to a naive regex.
Build a canonical key, keep the original
The rule that keeps you out of trouble: two columns. email is what the user typed, used for sending and display. email_canonical is derived, indexed, and used only for "have we seen this inbox before?" questions.
// canonical-email.ts
// Comparison key only. Never send mail to the result.
const GMAIL_DOMAINS = new Set(['gmail.com', 'googlemail.com']);
export function canonicalEmail(raw: string): string | null {
const trimmed = raw.trim().toLowerCase();
const at = trimmed.lastIndexOf('@');
if (at < 1 || at === trimmed.length - 1) return null;
let local = trimmed.slice(0, at);
let domain = trimmed.slice(at + 1).replace(/\.$/, '');
// 1. Drop a +tag on every domain. '+' as subaddress separator is the
// overwhelming convention (RFC 5233, Gmail, Exchange Online, Fastmail).
const plus = local.indexOf('+');
if (plus > 0) local = local.slice(0, plus);
// 2. Gmail consumer: googlemail.com == gmail.com, dots are ignored.
// Do NOT apply the dot rule to Google Workspace custom domains.
if (GMAIL_DOMAINS.has(domain)) {
domain = 'gmail.com';
local = local.replace(/\./g, '');
}
// 3. Fastmail subdomain addressing: anything@user.fastmail.com -> user@fastmail.com
const fm = domain.match(/^([a-z0-9-]+)\.fastmail\.com$/);
if (fm) {
local = fm[1]!;
domain = 'fastmail.com';
}
return `${local}@${domain}`;
}
A few deliberate choices in there:
- The dot rule is scoped to
gmail.comandgooglemail.com. You might be tempted to look up the MX record, see Google's mail servers, and strip dots for any Google-hosted domain. Don't. Google says dots change the address for organization accounts, andjane.doe@andjanedoe@at a company can be two different employees. - Plus stripping is global. The downside is two genuinely different mailboxes on some server that uses
+in real usernames colliding in your duplicate check. That is rare, and the cost is a false "you already have an account" message, which you should soften anyway (see below). If your user base is unusual, scope it to known providers instead. - No
-stripping. Hyphens are common in real names (mary-jane@), so treating-as a separator would merge distinct people. Not worth it. - IDN domains should be converted to punycode before you compare, or
üandxn--spellings of the same domain will never match. The details are in internationalized email domains.
Schema and migration
ALTER TABLE users ADD COLUMN email_canonical TEXT;
-- backfill in your app with canonicalEmail(), then:
CREATE INDEX users_email_canonical_idx ON users (email_canonical);
Make it a plain index, not a unique constraint, at least at first. Your existing data almost certainly contains legitimate pairs that collide (a person who signed up twice years ago, a shared family inbox). A unique constraint would turn the backfill into an incident. Query for collisions, look at them, then decide where uniqueness is actually enforced.
Decide what a collision means
Finding a match on email_canonical should change behaviour, not necessarily block. Some options, from gentlest to strictest:
- Login and password reset: ignore the canonical key entirely. Match on the exact stored address. Merging accounts behind a user's back is a support nightmare and a potential account-takeover vector.
- Signup: if the canonical key exists, say "It looks like you may already have an account" and offer sign-in or reset to the existing address. Let them continue if they insist. Shared inboxes exist.
- Trial or free-tier entitlement: one per canonical key. The account can exist; the second trial doesn't start.
- Referral payout: referrer and referee with the same canonical key gets no credit. This one you can be strict about.
That split keeps honest plus-address users completely unaffected (they sign up once, with a tag, and never notice) while removing the payoff from farming variations.
What normalization won't catch
Canonical keys defeat one person generating variations of one inbox. They do nothing against:
- Many separate inboxes. Someone with 50 real Gmail accounts, or a disposable-email service that hands out a fresh address per click. The domain is the giveaway there; see the disposable domain category.
- A custom domain with a catch-all.
a@their-domain.com,b@their-domain.comand so on are all different strings with no shared base. Look for many signups on one registrable domain instead; the catch-all email domains article covers that pattern. - Machine-generated local parts on free providers, like
ccjxx90sze@gmail.com.
pingutil helps with the last two, not the first. Its email_signals analysis strips the +tag and the ., _, - separators before scoring, so jane.doe+x7@ and janedoe@ get the same randomness treatment, and a gibberish base still trips verdict.is_spam with reason randomized_local_part. Results are keyed on the registrable domain, which makes per-domain counting easy. It does not compute a canonical mailbox key for you; that's the function above, and it belongs in your code next to your user table.
const res = await fetch('https://pingutil.com/v1/lookup', {
method: 'POST',
headers: {
authorization: `Bearer ${process.env.PINGUTIL_KEY}`,
'content-type': 'application/json',
},
body: JSON.stringify({ input: email, skip_enrichment: true }),
});
const data = await res.json();
const key = canonicalEmail(email);
const seenBefore = key ? await db.users.countByCanonical(key) : 0;
if (data.verdict.is_spam) return reject('Please use a different email address.');
if (seenBefore > 0) return offerSignIn();
The db.users.countByCanonical call stands in for whatever data layer you use. Response fields are documented in the API reference.
Checklist
- Store the raw address. Send to the raw address. Always.
- Add
email_canonical: lowercase, strip+tag, foldgooglemail.comintogmail.com, strip dots only for Gmail consumer domains. - Index it, backfill it, inspect existing collisions before enforcing anything.
- Enforce uniqueness on entitlements (trials, credits, referrals), not on login.
- Pair it with domain-level signals, because normalization only covers one inbox wearing disguises.
Questions people ask
- Does Gmail ignore dots in email addresses?
- For consumer gmail.com addresses, yes. Google's help center says that if someone adds dots to your address you still get the email. For Gmail used through a work or school organization, Google says dots do change the address.
- Should I strip the plus tag before saving a user's email?
- No. Save exactly what the user typed and send mail to that. Compute a separate canonical key with the tag removed and use it only for duplicate detection, such as one free trial per inbox.
- Are gmail.com and googlemail.com the same inbox?
- Yes. Google's help center states that messages sent to @gmail.com and @googlemail.com are the same.