emailverifier.dev
Posts

Are Email Addresses Case-Sensitive? A Safe Storage Model

| 6 min read | Usama Ejaz
Two different ivory email local-part token rows keep their shapes while identical lime domain tokens emerge from a normalization gate.

Email domains are case-insensitive. The part before the @ can technically be case-sensitive, even though most large providers treat it as case-insensitive.

So I would lowercase and ASCII-normalize the domain, preserve the local part, and keep account comparison separate from the address used for delivery. Lowercasing the whole address is convenient. It is not a protocol-safe definition of identity.

The safe rule is smaller than most normalization helpers

RFC 5321 requires SMTP systems to preserve local-part case and says mailbox domains follow case-insensitive DNS rules. The same section discourages providers from depending on case-sensitive mailboxes because they hurt interoperability, but that does not give an outside application permission to reinterpret them.

The practical rule is:

  • Trim whitespace around the form value.
  • Preserve the local part exactly for delivery.
  • Convert an internationalized domain to its ASCII form, then lowercase it.
  • Do not remove dots, plus tags, or other local-part characters globally.
  • Use a separate, explicitly documented key to detect possible account collisions.

This Node.js function applies that narrow rule after a proper email parser has already accepted the address. It intentionally handles DNS domains, not SMTP address literals such as user@[192.0.2.1].

import { domainToASCII } from 'node:url';

export function prepareEmail(input) {
  const emailInput = input.trim();
  const separator = emailInput.lastIndexOf('@');

  if (separator <= 0 || separator === emailInput.length - 1) {
    throw new Error('Malformed email address');
  }

  const local = emailInput.slice(0, separator);
  const unicodeDomain = emailInput.slice(separator + 1);
  const asciiDomain = domainToASCII(unicodeDomain).toLowerCase();

  if (!asciiDomain) {
    throw new Error('Invalid email domain');
  }

  const emailDelivery = `${local}@${asciiDomain}`;
  const asciiLocal = /^[\x00-\x7F]+$/.test(local);

  // Detection only. Never use this value to send mail or select an account.
  const emailCollisionKey = asciiLocal
    ? `${local.toLowerCase()}@${asciiDomain}`
    : null;

  return { emailInput, emailDelivery, emailCollisionKey };
}

Node’s current domainToASCII() documentation says the function returns the Punycode ASCII serialization of a domain and returns an empty string for an invalid domain.

Here are two hypothetical outputs:

prepareEmail('Usama@EXAMPLE.COM')
// {
//   emailInput: 'Usama@EXAMPLE.COM',
//   emailDelivery: 'Usama@example.com',
//   emailCollisionKey: 'usama@example.com'
// }

prepareEmail('u.sama+trial@EXAMPLE.COM')
// emailDelivery remains 'u.sama+trial@example.com'

The second result is deliberate. A dot or plus sign belongs to the local part, whose meaning is controlled by the receiving domain.

Why keep three values for one address?

Because one string is being asked to do three different jobs.

email_input preserves what the person entered for display and support. email_delivery is the address sent to the verifier and mail system. email_collision_key flags addresses that may belong to the same real inbox, without declaring that they do.

This hypothetical PostgreSQL table keeps those jobs separate:

CREATE TABLE account_email (
  user_id uuid PRIMARY KEY REFERENCES app_user(id),
  email_input text NOT NULL,
  email_delivery text COLLATE "C" NOT NULL UNIQUE,
  email_collision_key text,
  verified_at timestamptz
);

CREATE INDEX account_email_collision_idx
  ON account_email (email_collision_key);

The exact delivery value is unique. The collision key is indexed but intentionally not unique, because it is evidence of ambiguity rather than proof that two addresses are the same.

This is the distinction RFC 6943 makes awkwardly clear: an application does not know whether foo@example.com and FOO@example.com identify the same person. Automatic comparison can create false matches as well as missed matches.

What should happen when the collision key matches?

Do not merge accounts, pick an existing user, or send a password-reset link based on the collision key alone. Put the case variant into a small quarantine branch.

  1. If email_delivery matches exactly, follow the normal existing-account flow without revealing whether the account exists.
  2. If only email_collision_key matches, require proof before linking, merging, or creating a second confirmed identity.
  3. If the submitted mailbox is confirmed and is genuinely distinct, allow it as a separate address.
  4. Apply the same preparation function in registration, login, password reset, email change, and account linking.

The strongest objection is obvious: Gmail and many other providers ignore local-part case, so why make everyone suffer for a rare edge case?

Fair. I am not suggesting a case-sensitive login form as a hobby project in user frustration. I am separating collision detection from identity resolution. You can warn, confirm, or route the person to recovery without silently rewriting the delivery address or granting access to the wrong account.

The OWASP email identity guidance recommends storing the original and canonical forms, lowercasing the domain, avoiding provider-specific transformations, and applying one documented comparison policy across every identity flow.

Why not remove Gmail dots or plus tags?

Because your application does not own the local part of someone else’s domain.

Some providers treat dotted variants as one mailbox. Some domains use dots as ordinary characters. Plus addressing is also a provider feature, not a universal instruction to discard everything after +.

Removing either can merge unrelated accounts. It can also make recovery confusing because your database displays an address the person never entered. If you fully control the receiving domain, provider-specific alias rules can be safe inside that boundary. For third-party domains, preserve the address.

The same principle applies to typo handling. Suggest a correction and let the person accept it; do not mutate the account identifier behind the form. The signup typo-correction guide shows that reversible flow.

What changes for Unicode email addresses?

The domain and local part still need separate treatment.

RFC 6531 allows UTF-8 mailbox names when SMTPUTF8 is supported, while internationalized domains used for DNS must be processed through IDNA or converted to A-label form. That is why the example converts only the domain.

I would not add Unicode case folding to a generic local-part helper. Visually similar characters and normalization choices can create account collisions, and the receiving system still owns the mailbox semantics. If your product accepts internationalized local parts, define that comparison policy with the identity and mail architecture together.

The separate SMTPUTF8 signup guide covers browser validation, UTF-8 local parts, IDN domains, and transport support without folding them into one vague “Unicode email” switch.

Where does email verification fit?

After syntax parsing and conservative preparation, verify email_delivery. Verification can find confirmed failures, risk signals, or an inconclusive mailbox result. It does not decide whether two spellings belong to one account, and it does not prove inbox ownership.

Keep those states separate in the data model. The validation, verification, and confirmation guide explains why address evidence and ownership need different fields.

If you use the emailverifier.dev API, send the delivery value and keep its deliverable, risky, undeliverable, or unknown result beside your identity state. A verification result should never overwrite the original address or act as an account-merge decision.

Deploy the domain-only normalization first, then make registration, login, reset, email-change, and account-linking paths use the same function.

Continue reading