# Zynor

[![npm version](https://badge.fury.io/js/zynor.svg)](https://badge.fury.io/js/zynor)

One promise-based DNS API that behaves the same way on every JavaScript runtime
your team deploys to — Node.js, Bun, Deno, Cloudflare Workers, and the browser.

Zynor resolves DNS through platform DNS or three encrypted public resolvers,
with explicit routing, bounded concurrency, caching, and automatic failover. It
also classifies email domains — provider identity, disposable and role
addresses, MX-backed deliverability signals, and bulk or streaming validation —
on servers, at the edge, and in the browser.

> **Email validation is classification, not proof.** A valid result does not
> mean a mailbox exists, accepts mail, or belongs to a person. Zynor does not
> perform SMTP recipient verification.

## Install

```bash
npm install zynor
```

Requires Node.js 22.15 or later. Bun and Deno are supported directly; browser
and Worker builds are selected automatically through conditional exports.

## Quick start

Resolve DNS with an explicit egress policy:

```typescript
import { Zynor } from "zynor";

const dns = new Zynor({
  native: { enabled: true },
  google: { enabled: true },
  cloudflare: { enabled: false },
  quad9: { enabled: false },
});

const mx = await dns.resolveMx("example.com", { timeout: 3_000 }, "google");
```

Classify an email domain:

```typescript
import { EmailValidator } from "zynor";

const validator = new EmailValidator({
  dns: { native: { enabled: true }, google: { enabled: true } },
});

const result = await validator.validate("user@gmail.com");

if (result.success) {
  console.log(result.data.provider); // "Gmail"
  console.log(result.data.loginUrl); // "https://mail.google.com"
}
```

Standalone helpers and CommonJS work too:

```typescript
import { resolve4 } from "zynor";

const addresses = await resolve4("example.com", { timeout: 3_000 }, "native");
```

```javascript
const { resolve4, Zynor } = require("zynor");
```

## Runtime support

| Runtime | DNS providers | Email | `zynor/native` |
| --- | --- | --- | --- |
| Node.js, Bun, Deno | All four | Full API | Available with a matching addon and runtime permissions |
| Cloudflare Workers | All four | Full API | Unavailable |
| Browser page or Worker | `google`, `cloudflare`, `quad9` | Core and MX-backed, memory-only | Unavailable |

Cloudflare Workers get the complete API through the ordinary `zynor` import,
including the provider named `native`; only the separately packaged
`zynor/native` entry is unavailable there.

Browsers get DNS over the three DoH providers and the same email API, with two
limits: the provider named `native` is unavailable, and caches are memory-only.
Deep HTML and IP enrichment is also constrained there — see
[Browser limits](#browser-limits).

Provider names — `native`, `google`, `cloudflare`, `quad9` — and public method
signatures are compatibility invariants. New capabilities may be added; existing
ones are never renamed or reshaped.

Distribution is ESM everywhere, with CommonJS additionally on Node.js and Bun.
Type declarations ship for both module systems.

## DNS

### Standalone functions

The record-specific helpers take a hostname, optional per-call options, and an
optional provider name. When you only need to pin a provider, pass its name as
the second argument. Generic `resolve` takes `(hostname, rrtype?, provider?)`
with no options object, and `reverse` takes an IP address.

```typescript
import { resolve4, resolveMx, resolveTxt, reverse } from "zynor";

await resolve4("example.com");
await resolveMx("example.com", { timeout: 5_000 });
await resolveTxt("example.com", "cloudflare");
await reverse("8.8.8.8");
```

Available: `resolve`, `resolve4`, `resolve6`, `resolveAny`, `resolveCaa`,
`resolveCname`, `resolveMx`, `resolveNaptr`, `resolveNs`, `resolvePtr`,
`resolveSoa`, `resolveSrv`, `resolveTlsa`, `resolveTxt`, and `reverse`.

Standalone helpers share the loaded entry module's default resolver. Use
`setDefaultResolver` to supply your own instance, or `resetDefaultResolver` to
restore the default. The ESM and CommonJS builds are separate modules and keep
separate defaults, as do `zynor` and `zynor/native`.

### Zynor class

```typescript
const dns = new Zynor({
  native: { enabled: true },
  google: { enabled: true },
  cloudflare: { enabled: true },
  quad9: { enabled: false },
  cache: { enabled: true, maxSize: 100_000, dnsTtl: 1_440_000 },
});

await dns.resolve("example.com", "MX");
await dns.resolve4("example.com", { ttl: true });
```

`cache.maxSize: 0` disables the resolver cache. The exported `LruCache` class
requires a positive capacity when constructed directly.

### Record types

`A`, `AAAA`, `CAA`, `CNAME`, `MX`, `NAPTR`, `NS`, `PTR`, `SOA`, `SRV`, `TLSA`,
`TXT`, and `ANY`, plus reverse lookup.

TLSA is unavailable on the platform resolver in Bun and Deno; use a DoH provider
for that record type there.

### Provider selection

Pin a request to one provider, either positionally or through the options
object. Without a provider, Zynor selects an enabled one and fails over on
provider-health failures.

```typescript
await dns.resolve4("example.com", {}, "quad9");             // positional
await dns.resolve4("example.com", { provider: "quad9" });   // options key
await dns.resolve4("example.com");                          // automatic
```

The two forms are **mutually exclusive** — supplying both throws a `TypeError`
before any queue, cache, or network work. An own `provider` property counts as
a selection even when its value is `undefined`.

Pinning a disabled provider throws `ProviderNotEnabledError`; an unknown name
throws `InvalidProviderError`. Both forms produce the same errors.

### Timeouts and cancellation

```typescript
const controller = new AbortController();

await dns.resolve4("example.com", {
  timeout: 2_000,
  signal: controller.signal,
});
```

`timeout` is one absolute deadline for the whole call, including time spent
queued and any failover attempt — a request that waits and then fails over does
not receive a fresh timeout.

Aborting rejects the caller promptly. A request still waiting in the queue is
removed immediately; one whose transport has already started keeps its provider
slot until that upstream operation settles, so replacement work cannot exceed
your configured concurrency.

## Caching and deduplication

Identical in-flight requests for the same hostname, record type, and provider
share a single upstream lookup. Calls that pass a `timeout` or `signal` are
deliberately excluded: they own their upstream operation, so one caller's
cancellation can never decide another caller's result. Results are cached per
provider under TTL and LRU bounds.

```typescript
const dns = new Zynor({
  cache: { enabled: true, maxSize: 50_000, dnsTtl: 300_000 },
});
```

`LruCache` is exported for direct use:

```typescript
import { LruCache } from "zynor";

const cache = new LruCache<string>(1_000); // max 1,000 entries
cache.set("key", "value", 60_000);         // expires after 60 seconds
cache.get("key");                          // => "value"
```

## Provider configuration

### Concurrency and rate limits

```typescript
const dns = new Zynor({
  google: {
    enabled: true,
    limit: { concurrency: 10, interval: 1_000, intervalCap: 20 },
  },
});
```

Every path — public DNS calls, the validator's internal lookups, and native bulk
MX warming — shares each provider's configured admission. These limits are an
operational contract, not advisory hints.

### Runtime updates

```typescript
dns.setConfigs({ quad9: { enabled: true } });
```

Configuration changes are validated as a whole and applied atomically. Callers
already queued under a previous configuration settle exactly once.

## Email validation

```typescript
const validator = new EmailValidator({
  dns: { native: { enabled: true } },
  options: { enableDeepValidation: true },
});

const result = await validator.validate("user@example.com");
```

A successful result carries `provider`, `isFree`, `role`, `websiteTitle`, and —
for mailbox providers — a `loginUrl`:

```typescript
{ provider: "Gmail", isFree: true, email: "user@gmail.com",
  role: true, websiteTitle: "Gmail", loginUrl: "https://mail.google.com" }
```

Rejections return `success: false` with a `type` explaining why —
`"Syntax"` for malformed input, `"Disposable"` for throwaway domains, and
`"Invalid"` for domains that cannot receive mail.

### Provider logos

Request a logo per call. It arrives as a data URL you can put straight into an
`<img>` tag — no asset hosting, no filesystem access, identical on every
runtime:

```typescript
const result = await validator.validate("user@gmail.com", { logo: true });

result.data.logo; // "data:image/png;base64,iVBORw0KGgo..."
```

### Deep validation

Deep validation is opt-in twice: `enableDeepValidation: true` permits it, and
each call must request it — `validate(email, true)`, or `{ deep: true }` for
`validateBulk` and `each`.

When active it adds MX-backed checks **and HTTPS probes of the domain**, so it
makes outbound HTTP requests beyond DNS. Budget for that latency and egress.
`detectMailProvider` uses the same probe path independently of the constructor
flag.

It never performs SMTP recipient verification and never contacts a mail server
over SMTP.

### Bulk and streaming

```typescript
const emails = ["alice@example.com", "bob@gmail.com"];

const results = await validator.validateBulk(emails);

await validator.each(
  emails,
  { concurrency: 50, perPage: 100 },
  async (batch) => { await persist(batch); },
);
```

`each` streams results in pages and applies backpressure: when the in-memory
buffer fills, workers pause until it drains. Choose concurrency deliberately —
public resolvers are shared infrastructure, and bulk workloads should stay
within their acceptable-use expectations.

### Detection helpers

```typescript
validator.isEmail("user@example.com");          // true
validator.isDisposable("user@mailinator.com");  // true
validator.isFreeDomain("gmail.com");            // true
validator.getProviderWebmailUrl("gmail.com");   // "https://mail.google.com"

validator.extractEmails("contact alice@example.com or bob@gmail.com");
validator.extractEmailsFromArray(["alice@example.com", "bob@gmail.com"]);

// Provider detection performs DNS lookups and returns a promise.
await validator.detectMailProvider("user@outlook.com");
await validator.detectHostingProvider("example.com");
```

Detection distinguishes mailbox providers from inbound mail infrastructure —
security gateways, filtering services, and transactional senders. Infrastructure
results carry no `webmail` or `loginUrl`, because there is no human mailbox to
sign in to. Only mailbox providers return a login destination.

### Persistent caching

Server runtimes persist validation results to a private temporary directory by
default. Disable or relocate it:

```typescript
new EmailValidator({
  options: {
    cache: {
      persistence: false,
      directory: "/var/lib/zynor",
      maxEntries: 100_000,
      maxFileBytes: 67_108_864,
    },
  },
});
```

With `persistence: false` no files are created. Cloudflare Workers are always
memory-only.

## Operational behavior

### Network egress

Zynor makes outbound requests on exactly three paths:

- **DNS** — to the providers you enable, and nowhere else;
- **Deep domain probes** — HTTPS requests to the domain being validated, when
  deep validation is active or `detectMailProvider` is called;
- **IP intelligence** — third-party services, only when you enable
  `ipResolver`.

The activation fields accept booleans only — a string such as `"false"` is
rejected rather than treated as enabled.

In a browser, that egress originates from your **visitor's** browser and carries
their IP, not your server's. Enabling `ipResolver` on a page is a decision about
the people using it. Browser and edge email-enrichment requests are sent with
`credentials: "omit"`, `referrerPolicy: "no-referrer"`, and `cache: "no-store"`.

### Provider transports

| Provider | Transport |
| --- | --- |
| `native` | Platform DNS (`node:dns` on Node, Bun, and Deno; Cloudflare's `node:dns` compatibility layer in Workers) |
| `google`, `cloudflare` | DNS over HTTPS |
| `quad9` | DNS over HTTPS, or DNS over TLS in Cloudflare Workers |

Quad9 [does not support HTTP/1.1](https://docs.quad9.net/FAQs/#dns-over-https---no-http11-support).
Node uses HTTP/2; Cloudflare Workers use DNS over TLS over
[TCP sockets](https://developers.cloudflare.com/workers/runtime-apis/tcp-sockets/),
because Worker `fetch` cannot require HTTP/2 to an origin.

### Failure handling

On automatic selection, provider-health failures fail over to another enabled
provider within the caller's deadline. A **pinned** request does not fail over —
you chose that provider, so its failure is returned to you.

Authoritative negative answers, caller cancellation, and HTTP 4xx responses are
treated as query-local and returned without retrying elsewhere.

### State and ownership

Each `Zynor` instance owns its queues and caches. Standalone helpers use the
default instance of whichever entry module is loaded — ESM and CommonJS keep
separate defaults, and so do `zynor` and `zynor/native`. Importing one never
affects the other, and an explicit `zynor/native` import never silently falls
back to the platform resolver.

### Cloudflare Workers

Requires the `nodejs_compat` flag and a compatibility date of 2024-09-23 or
later, so Cloudflare's [`node:dns`](https://developers.cloudflare.com/workers/runtime-apis/nodejs/dns/)
implementation is available. Without the flag the module fails loudly rather
than silently dropping a provider. Bundlers must honour
[conditional exports](https://developers.cloudflare.com/workers/wrangler/bundling/#conditional-exports)
so the `workerd` build is selected.

Worker [platform limits](https://developers.cloudflare.com/workers/platform/limits/)
apply: keep concurrency and cache sizes within your plan's memory and subrequest
budgets.

### Browser limits

The browser build carries the full email corpus so that `isDisposable` and
`isFreeDomain` stay exact and synchronous. That has a real transfer cost:
**about 489 KB gzipped**, against roughly 25 KB for DNS alone. Budget for it,
and prefer a server or Worker when only DNS is needed.

Caches are memory-only — no filesystem persistence exists in a browser. The
provider named `native` is unavailable.

Core classification and MX-backed validation work directly. Deep HTML and IP
enrichment is not universally dependable from a page, because CORS, opaque
redirects, and mixed-content rules govern those requests and a browser cannot
pin a lookup to a socket. Treat deep enrichment as best-effort on the web, or
route it through your own service.

### Deno and native addons

`zynor/native` requires
[native addon support](https://docs.deno.com/runtime/fundamentals/node/#use-packages-with-native-addons)
and the relevant runtime permissions. The ordinary `zynor` entry has no such
requirement.

## Errors

| Error | Cause |
| --- | --- |
| `InvalidHostnameError` | Hostname is not a valid DNS owner name |
| `InvalidProviderError` | Unknown provider name |
| `ProviderNotEnabledError` | Pinned provider is disabled |
| `NoEnabledProvidersError` | No provider is enabled for the request |

```typescript
import { InvalidHostnameError, ProviderNotEnabledError } from "zynor";

try {
  await dns.resolve4(hostname);
} catch (error) {
  if (error instanceof InvalidHostnameError) { /* reject the input */ }
  if (error instanceof ProviderNotEnabledError) { /* adjust configuration */ }
}
```

Diagnostics are bounded and redacted: upstream error text is truncated, and
credential-shaped values and email local parts are removed before anything
reaches logs, responses, or caches.

## TypeScript

Zynor ships declarations for both ESM and CommonJS. Record shapes, provider
names, record types, and configuration are fully typed.

```typescript
import type { ProviderName, RecordType, MxRecord, ZynorConfig } from "zynor";
```

## Development

```bash
npm run verify        # typecheck, lint, tests
npm run build         # canonical package build
npm run pack:dry-run  # packaging rehearsal, no publish
```

`npm run build` reads the published version from the npm registry and advances
the local version past it, so it needs network access. Set
`ZYNOR_SKIP_VERSION_SYNC=1` to build offline; do not publish such a build.

Dataset and logo provenance, including unresolved redistribution questions, is
documented in [Data and Asset Provenance](docs/data-and-asset-provenance.md).

## Support and security

Report issues through the repository tracker where available, or contact
`git@yuniq.solutions`. Include runtime and version, entry point, provider
selection, platform, and a minimal reproduction — never API keys or user data.

Security reports follow [SECURITY.md](SECURITY.md) rather than a public issue.

## License

Use, modification, and redistribution are governed by [LICENSE](./LICENSE).
Confirm those terms before redistributing; package and source license labels are
currently being reconciled with the governing text, and the asset rights gaps
noted above remain open.
