@graphland/email-checker
v0.1.0
Published
Type-safe client for the email-checker Cloudflare Worker API
Readme
@graphland/email-checker
Type-safe client for the Email Checker API. Detects temporary/disposable email addresses and checks whether an email's domain can actually receive mail.
Blocks 8,900+ temporary email domains (and all their subdomains)
Zero dependencies
Works in browsers, Node.js 20+, Bun, Deno and Cloudflare Workers
Fully typed results — narrow on
verdictand TypeScript knows exactly which fields are setResponses are validated at runtime, so the types can't lie
Install
npm install @graphland/email-checker
# or
bun add @graphland/email-checkerQuick start
import { EmailChecker } from '@graphland/email-checker';
const checker = new EmailChecker();
const result = await checker.check('[email protected]');
if (result.verdict === 'deliverable') {
// safe to accept
}What gets checked
Each call runs these checks in order and stops at the first definitive answer:
- Syntax — is it a well-formed address?
- Disposable domain — is the domain (or a parent domain) one of 8,900+ known temporary email providers, like Mailinator or Yopmail? The blocklist comes from the community-maintained disposable-email-domains project.
- Disposable mail server — does the domain route its mail to a known temporary email provider? This catches custom domains that forward to throwaway inboxes.
- Domain accepts mail — does the domain exist and publish mail (MX) records, or fall back to an address record?
The individual mailbox (e.g. whether
jane@exists atgmail.com) is not verified. Adeliverableverdict means the domain accepts mail.
Verdicts
| Verdict | Meaning | Suggested action |
|---|---|---|
| deliverable | Domain is set up to receive mail | Accept |
| disposable | Temporary email provider | Reject |
| undeliverable | Domain doesn't exist or doesn't accept mail | Reject |
| invalid | Not a valid email address | Reject |
| unknown | DNS lookup failed | Accept, or retry later |
Type-safe results
check() returns a discriminated union. Switch on verdict and each branch gets its own precise type:
const result = await checker.check(email);
switch (result.verdict) {
case 'deliverable':
result.checks.mx; // MxRecord[]
break;
case 'disposable':
result.checks.disposable_match; // string — the blocklist entry that matched
break;
case 'invalid':
result.normalized; // null — nothing to normalize
break;
case 'undeliverable':
case 'unknown':
console.log(result.reason);
break;
}Every result also has:
result.email; // the input, as submitted
result.normalized; // trimmed + lowercased (null when invalid)
result.domain; // domain part (null when invalid)
result.reason; // human-readable explanationNeed the type for a single verdict? Use EmailCheckResultFor:
import type { EmailCheckResultFor } from '@graphland/email-checker';
function onDisposable(result: EmailCheckResultFor<'disposable'>) {
console.warn(`Blocked ${result.email} (${result.checks.disposable_match})`);
}Example: signup form validation
import { EmailChecker, isEmailCheckerError } from '@graphland/email-checker';
const checker = new EmailChecker({ timeoutMs: 3000 });
export async function validateSignupEmail(email: string): Promise<string | null> {
try {
const result = await checker.check(email);
switch (result.verdict) {
case 'invalid':
return 'Please enter a valid email address.';
case 'disposable':
return 'Temporary email addresses are not allowed.';
case 'undeliverable':
return "This email's domain can't receive mail.";
default:
return null;
}
} catch (err) {
// Fail open — don't block signups when the checker is unreachable.
if (isEmailCheckerError(err)) console.warn('email check skipped:', err.code);
return null;
}
}Error handling
Failures throw an EmailCheckerError with a typed code:
| Code | When |
|---|---|
| rate_limited | Too many requests. retryAfterSeconds says when to try again. |
| http_error | Server returned another non-2xx response. status is set. |
| timeout | Request exceeded timeoutMs. |
| aborted | You aborted the request via signal. |
| network_error | Server could not be reached. |
| invalid_response | Response didn't match the expected shape. |
import { isEmailCheckerError } from '@graphland/email-checker';
try {
await checker.check(email);
} catch (err) {
if (isEmailCheckerError(err) && err.code === 'rate_limited') {
console.log(`Retry in ${err.retryAfterSeconds}s`);
}
}Rate limits
The API allows 120 requests per minute per IP address. The counter resets at the start of each clock minute.
Every response includes rate limit headers:
| Header | Meaning |
|---|---|
| ratelimit-limit | Requests allowed per window |
| ratelimit-remaining | Requests left in the current window |
| ratelimit-reset | Seconds until the window resets |
| retry-after | Seconds to wait before retrying (only on 429) |
When the limit is exceeded, the SDK throws an EmailCheckerError with code: 'rate_limited' and retryAfterSeconds:
async function checkWithRetry(email: string) {
try {
return await checker.check(email);
} catch (err) {
if (isEmailCheckerError(err) && err.code === 'rate_limited') {
await new Promise((r) => setTimeout(r, (err.retryAfterSeconds ?? 1) * 1000));
return checker.check(email);
}
throw err;
}
}If you're checking emails from a backend, all your users share your server's IP, so the limit applies to your server as a whole. Check on signup rather than in bulk.
Options
const checker = new EmailChecker({
timeoutMs: 5000,
headers: { 'x-app': 'my-app' },
fetch: customFetch,
});| Option | Type | Default | Description |
|---|---|---|---|
| timeoutMs | number | 10000 | Per-request timeout. 0 disables it. |
| headers | Record<string, string> | {} | Extra headers sent with every request. |
| fetch | (input, init) => Promise<Response> | global fetch | Custom fetch implementation, e.g. a Cloudflare service binding. |
Cancelling a request
Both methods accept an AbortSignal:
const controller = new AbortController();
const pending = checker.check(email, { signal: controller.signal });
controller.abort(); // rejects with code 'aborted'API
new EmailChecker(options?)
Creates a client. See Options.
checker.check(email, { signal? }): Promise<EmailCheckResult>
Checks an email address and resolves with a result. Never throws for a bad email — that's an invalid verdict. Throws EmailCheckerError only when the request itself fails.
checker.health({ signal? }): Promise<{ ok: true }>
Checks that the API is reachable.
Exports
| Export | Kind |
|---|---|
| EmailChecker | class |
| EmailCheckerError, isEmailCheckerError | error class + type guard |
| VERDICTS | readonly array of all verdicts |
| EmailCheckResult, EmailCheckResultFor<V> | result types |
| DeliverableResult, DisposableResult, UndeliverableResult, InvalidResult, UnknownResult | per-verdict result types |
| Verdict, MxRecord, EmailCheckerOptions, RequestOptions, EmailCheckerErrorCode | supporting types |
