@byspec/email
v0.1.0
Published
Spec-anchored email address test cases — RFC 5321 / RFC 5322
Downloads
90
Maintainers
Readme
@byspec/email
Spec-anchored email address test cases for validators and parsers.
Each case provides a string input, machine-readable metadata pointing back to the exact specification clause that governs it, and a character position identifying where any violation occurs. Consuming libraries use these cases to verify they handle every email edge case — and to know precisely which ones they are deliberately skipping.
Installation
npm install --save-dev @byspec/emailDesign contract
All inputs are strings. The library is designed for testing email validators and parsers, not for generating addresses. Your validator receives a string; these cases provide the strings worth testing.
meta.positionis the 0-based character index where the violation occurs. For valid cases it is always-1. For invalid cases it points to the first offending character in the full address string (including the@separator offset for domain-side errors).What a specific library rejects may differ from what this package marks invalid. Many validators allow lenient forms (e.g. quoted local parts, Punycode domains) that others reject. Use this package to enumerate the spec-defined cases; decide in your own tests which ones apply to your library's strictness level.
Each scenario is independent. Import only what your domain requires.
Scenarios
| Scenario | Named export | Description |
|---|---|---|
| valid | valid | RFC 5321 / RFC 5322 conformant addresses that every validator should accept |
| invalid-local-part | invalidLocalPart | Addresses whose local part (before @) violates RFC 5321 §4.1.2 |
| invalid-domain | invalidDomain | Addresses whose domain (after @) violates RFC 5321 §4.1.2 or RFC 1035 §2.3.4 |
valid
| Rule | Input example | Note |
|---|---|---|
| simple-address | [email protected] | |
| local-part-with-dot | [email protected] | |
| local-part-with-plus | [email protected] | Sub-addressing / tagged mailboxes |
| local-part-with-hyphen | [email protected] | |
| local-part-with-underscore | [email protected] | |
| local-part-with-digits | [email protected] | |
| case-insensitive-domain | [email protected] | Domain is case-insensitive per §2.4 |
| subdomain | [email protected] | |
| multi-label-tld | [email protected] | |
| quoted-local-part | "quoted local"@example.com | Quoted strings allow spaces |
| single-char-local-part | [email protected] | Minimal valid form |
| punycode-domain | [email protected] | IDN in ACE/Punycode form |
invalid-local-part
| Rule | Input example | Position |
|---|---|---|
| local-part-must-not-be-empty | @example.com | 0 |
| local-part-cannot-start-with-dot | [email protected] | 0 |
| local-part-cannot-end-with-dot | [email protected] | 4 |
| local-part-cannot-have-consecutive-dots | [email protected] | 3 |
| unquoted-local-part-cannot-contain-space | user [email protected] | 4 |
invalid-domain
| Rule | Input example | Position | Part |
|---|---|---|---|
| domain-must-not-be-empty | user@ | 5 | domain |
| domain-cannot-start-with-dot | [email protected] | 5 | domain |
| domain-cannot-end-with-dot | user@example. | 12 | domain |
| domain-cannot-have-consecutive-dots | [email protected] | 12 | domain |
| domain-label-cannot-contain-underscore | user@exam_ple.com | 9 | domain |
| tld-must-not-be-all-numeric | [email protected] | 13 | tld |
Usage
Validating against a single scenario
import { invalidLocalPart } from "@byspec/email";
for (const c of invalidLocalPart.cases) {
const result = myValidator(c.input);
if (result.valid !== false) {
console.error(
`FAIL [${c.meta.rule}] input="${c.input}" should have been rejected at position ${c.meta.position}`
);
}
}Iterating all scenarios at once
import { scenarios } from "@byspec/email";
for (const scenario of scenarios) {
for (const c of scenario.cases) {
const result = myValidator(c.input);
const expectValid = c.meta.scenario === "valid";
if (result.valid !== expectValid) {
console.error(
`FAIL [${scenario.name} / ${c.meta.rule}] input="${c.input}"`
);
}
}
}Filtering cases by tag
The filterCases utility lets you select cases across scenarios by tag, rule, part, or scenario name without writing .filter() manually.
import { filterCases, scenarios } from "@byspec/email";
// Only common, everyday address patterns
const commonCases = filterCases(scenarios, { tags: ["common"] });
// All domain-side invalid cases
const domainErrors = filterCases(scenarios, { part: "domain" });
// Quoted local part cases only
const quotedCases = filterCases(scenarios, { tags: ["quoted"] });
// Tagged (plus-sign) and IDN cases — OR logic
const specialCases = filterCases(scenarios, { anyTag: ["tagged", "idn"] });
// All cases for one scenario by name
const validOnly = filterCases(scenarios, { scenario: "valid" });
// A specific rule
const noEmptyDomain = filterCases(scenarios, { rule: "domain-must-not-be-empty" });Filtering cases by rule — direct array approach
The cases array is a plain array, so .filter() and .find() work directly too:
import { invalidDomain } from "@byspec/email";
const tldErrors = invalidDomain.cases.filter(c => c.meta.part === "tld");
const emptyDomain = invalidDomain.cases.find(c => c.meta.rule === "domain-must-not-be-empty");Handling cases where your validator intentionally diverges
import { invalidLocalPart } from "@byspec/email";
// A lenient validator that permits quoted local parts — skip those cases
const quotedRules = new Set(["quoted-local-part"]);
for (const c of invalidLocalPart.cases) {
if (quotedRules.has(c.meta.rule)) continue; // known divergence
const result = myLenientValidator(c.input);
expect(result.valid).toBe(false);
}filterCases(scenarios, opts?)
import { filterCases } from "@byspec/email";Parameters
| Option | Type | Logic | Description |
|---|---|---|---|
| scenario | string | exact match | Filter by meta.scenario name |
| rule | string | exact match | Filter by meta.rule identifier |
| part | string | exact match | Filter by meta.part — "local", "domain", "tld", or "full" |
| tags | string[] | ALL must match | Case must carry every tag listed |
| anyTag | string \| string[] | ANY must match | Case must carry at least one of the tags |
Omit opts (or pass {}) to get all cases across all provided scenarios.
Tags
Every case carries meta.tags?: string[]. The full tag vocabulary:
| Tag | Meaning |
|---|---|
| common | Typical everyday email pattern |
| minimal | Shortest valid or invalid representative |
| quoted | Local part uses RFC 5321 quoted-string form |
| tagged | Local part uses + sub-addressing |
| case-sensitive | Case-handling behaviour under test |
| idn | Internationalised domain (Punycode / IDNA) |
| long-local | Local part at or near the 64-octet limit |
| long-domain | Domain at or near the 255-octet limit |
| long-label | A single domain label at or near 63 octets |
TypeScript
Types are exported from the main entry point:
import type { EmailCase, EmailScenario, EmailMeta, EmailTag, FilterOptions }
from "@byspec/email";Key types:
interface EmailMeta {
scenario: string; // e.g. "invalid-domain"
spec: string; // e.g. "RFC 5321 §4.1.2"
rule: string; // e.g. "domain-cannot-end-with-dot"
position: number; // 0-based index of the violation; -1 for valid cases
part: "local" | "domain" | "tld" | "full";
tags?: EmailTag[]; // cross-cutting classification
note?: string; // human explanation for tricky or counter-intuitive cases
}
interface EmailCase {
input: string; // always a string — feed to your validator
meta: EmailMeta;
}
interface FilterOptions {
scenario?: string;
rule?: string;
part?: "local" | "domain" | "tld" | "full";
tags?: EmailTag[];
anyTag?: EmailTag | EmailTag[];
}Specification references
- RFC 5321 §4.1.2 — SMTP
addr-specgrammar (local part + domain) - RFC 5321 §2.4 — Case sensitivity rules for SMTP commands and addresses
- RFC 5322 §3.4.1 —
addr-specin message header fields - RFC 1035 §2.3.4 — DNS label length limits and character rules
