Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 35 additions & 32 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { TruelistProvider, useEmailValidation } from "@truelist/react";

function App() {
return (
<TruelistProvider apiKey="your-form-api-key">
<TruelistProvider apiKey="your-api-key">
<SignupForm />
</TruelistProvider>
);
Expand All @@ -30,7 +30,7 @@ function SignupForm() {
<input
type="email"
onChange={(e) => validate(e.target.value)}
aria-invalid={result?.state === "invalid"}
aria-invalid={result?.state === "email_invalid"}
/>
);
}
Expand Down Expand Up @@ -58,8 +58,8 @@ function EmailField() {
placeholder="you@example.com"
/>
{isValidating && <span>Checking...</span>}
{result?.state === "invalid" && <span>This email is not valid.</span>}
{result?.state === "risky" && <span>This email may not receive mail.</span>}
{result?.state === "email_invalid" && <span>This email is not valid.</span>}
{result?.state === "accept_all" && <span>This email may not be verifiable.</span>}
{result?.suggestion && <span>Did you mean {result.suggestion}?</span>}
{error && <span>{error}</span>}
<button onClick={reset}>Clear</button>
Expand Down Expand Up @@ -106,15 +106,15 @@ import { EmailInput } from "@truelist/react";
The input exposes a `data-validation-state` attribute for CSS styling:

```css
input[data-validation-state="valid"] {
input[data-validation-state="ok"] {
border-color: green;
}

input[data-validation-state="invalid"] {
input[data-validation-state="email_invalid"] {
border-color: red;
}

input[data-validation-state="risky"] {
input[data-validation-state="accept_all"] {
border-color: orange;
}

Expand All @@ -129,7 +129,7 @@ All standard `<input>` props are supported, plus:

| Prop | Type | Default | Description |
|---|---|---|---|
| `validateOn` | `"blur" \| "change" \| "submit"` | `"blur"` | When to trigger validation |
| `validateOn` | `"blur" \| "change"` | `"blur"` | When to trigger validation |
| `debounceMs` | `number` | `500` | Debounce delay for "change" mode |
| `onValidation` | `(result: ValidationResult) => void` | -- | Callback when validation completes |
| `renderSuggestion` | `(suggestion: string) => ReactNode` | -- | Custom suggestion renderer |
Expand All @@ -144,7 +144,7 @@ import { z } from "zod";
import { truelistEmail } from "@truelist/react/zod";

const schema = z.object({
email: truelistEmail({ apiKey: "your-form-api-key" }),
email: truelistEmail({ apiKey: "your-api-key" }),
});

// Async validation (required for API calls)
Expand All @@ -155,17 +155,17 @@ const result = await schema.parseAsync({ email: "user@example.com" });

| Option | Type | Default | Description |
|---|---|---|---|
| `apiKey` | `string` | *required* | Your Truelist form API key |
| `apiKey` | `string` | *required* | Your Truelist API key |
| `baseUrl` | `string` | `https://api.truelist.io` | Custom API base URL |
| `rejectStates` | `ValidationState[]` | `["invalid"]` | States that fail validation |
| `rejectStates` | `ValidationState[]` | `["email_invalid"]` | States that fail validation |
| `message` | `string` | `"This email address is not valid."` | Error message |

Reject risky emails too:
Reject unknown emails too:

```ts
truelistEmail({
apiKey: "your-form-api-key",
rejectStates: ["invalid", "risky"],
apiKey: "your-api-key",
rejectStates: ["email_invalid", "unknown"],
});
```

Expand All @@ -179,7 +179,7 @@ import { truelistFieldValidator } from "@truelist/react/react-hook-form";

function SignupForm() {
const { register, handleSubmit, formState: { errors } } = useForm();
const validate = truelistFieldValidator({ apiKey: "your-form-api-key" });
const validate = truelistFieldValidator({ apiKey: "your-api-key" });

return (
<form onSubmit={handleSubmit(onSubmit)}>
Expand All @@ -195,9 +195,9 @@ function SignupForm() {

| Option | Type | Default | Description |
|---|---|---|---|
| `apiKey` | `string` | *required* | Your Truelist form API key |
| `apiKey` | `string` | *required* | Your Truelist API key |
| `baseUrl` | `string` | `https://api.truelist.io` | Custom API base URL |
| `rejectStates` | `ValidationState[]` | `["invalid"]` | States that fail validation |
| `rejectStates` | `ValidationState[]` | `["email_invalid"]` | States that fail validation |
| `message` | `string` | `"This email address is not valid."` | Error message |

## Types
Expand All @@ -214,23 +214,23 @@ import type {
### `ValidationState`

```ts
type ValidationState = "valid" | "invalid" | "risky" | "unknown";
type ValidationState = "ok" | "email_invalid" | "accept_all" | "unknown";
```

### `ValidationSubState`

```ts
type ValidationSubState =
| "ok"
| "accept_all"
| "disposable_address"
| "role_address"
| "email_ok"
| "is_disposable"
| "is_role"
| "failed_smtp_check"
| "failed_mx_check"
| "failed_spam_trap"
| "failed_no_mailbox"
| "failed_greylisted"
| "failed_syntax_check"
| "unknown";
| "unknown_error";
```

### `ValidationResult`
Expand All @@ -240,9 +240,13 @@ type ValidationResult = {
state: ValidationState;
subState: ValidationSubState;
email: string;
suggestion?: string;
freeEmail?: boolean;
role?: boolean;
domain: string;
canonical: string;
mxRecord: string | null;
firstName: string | null;
lastName: string | null;
verifiedAt: string;
suggestion: string | null;
};
```

Expand All @@ -262,22 +266,21 @@ type TruelistConfig = {
Wrap your app with `TruelistProvider` to make the API key available to all hooks and components:

```tsx
<TruelistProvider apiKey="your-form-api-key" baseUrl="https://api.truelist.io">
<TruelistProvider apiKey="your-api-key" baseUrl="https://api.truelist.io">
<App />
</TruelistProvider>
```

| Prop | Type | Default | Description |
|---|---|---|---|
| `apiKey` | `string` | *required* | Your Truelist form API key |
| `apiKey` | `string` | *required* | Your Truelist API key |
| `baseUrl` | `string` | `https://api.truelist.io` | Custom API base URL |

### API Details

- **Endpoint**: `POST https://api.truelist.io/api/v1/form_verify`
- **Auth**: Bearer token (your form API key)
- **Rate limit**: 60 requests per minute for form keys
- **Billing**: Credits are only charged for definitive results (`valid`/`invalid`), not for `unknown`
- **Endpoint**: `POST https://api.truelist.io/api/v1/verify_inline?email=user@example.com`
- **Auth**: Bearer token (your API key)
- **Response**: `{ "emails": [{ "address": "...", "email_state": "ok", ... }] }`

Get your API key at [truelist.io](https://truelist.io).

Expand Down
71 changes: 56 additions & 15 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,40 +20,36 @@ export class TruelistApiError extends Error {
* @param email - The email address to validate.
* @param config - API key and optional base URL.
* @param signal - Optional AbortSignal to cancel the request.
* @param endpoint - Which API endpoint to use. `"form_verify"` for client-side, `"verify"` for server-side. Default: `"form_verify"`.
* @returns The validation result.
* @throws {TruelistApiError} When the API returns a non-OK response.
*/
export async function verifyEmail(
email: string,
config: TruelistConfig,
signal?: AbortSignal,
endpoint: "form_verify" | "verify" = "form_verify"
signal?: AbortSignal
): Promise<ValidationResult> {
const baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
const url = `${baseUrl}/api/v1/${endpoint}`;
const url = `${baseUrl}/api/v1/verify_inline?email=${encodeURIComponent(email)}`;

const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${config.apiKey}`,
},
body: JSON.stringify({ email }),
signal,
});

if (!response.ok) {
if (response.status === 429) {
throw new TruelistApiError(
"Rate limit exceeded. The form API allows 60 requests per minute.",
"Rate limit exceeded. Please try again later.",
429
);
}

if (response.status === 401) {
throw new TruelistApiError(
"Invalid API key. Check your Truelist form API key.",
"Invalid API key. Check your Truelist API key.",
401
);
}
Expand All @@ -65,14 +61,59 @@ export async function verifyEmail(
}

const data: ApiResponse = await response.json();
const record = data.emails[0];

if (!record) {
throw new TruelistApiError("No email record returned from API.");
}

return {
state: data.state,
subState: data.sub_state,
email: data.email,
suggestion: data.suggestion,
freeEmail: data.free_email,
role: data.role,
disposable: data.disposable,
state: record.email_state,
subState: record.email_sub_state,
email: record.address,
domain: record.domain,
canonical: record.canonical,
mxRecord: record.mx_record,
firstName: record.first_name,
lastName: record.last_name,
verifiedAt: record.verified_at,
suggestion: record.did_you_mean,
};
}

/**
* Fetches account information for the authenticated API key.
*
* @param config - API key and optional base URL.
* @returns The account data.
* @throws {TruelistApiError} When the API returns a non-OK response.
*/
export async function getAccount(
config: TruelistConfig
): Promise<Record<string, unknown>> {
const baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
const url = `${baseUrl}/me`;

const response = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${config.apiKey}`,
},
});

if (!response.ok) {
if (response.status === 401) {
throw new TruelistApiError(
"Invalid API key. Check your Truelist API key.",
401
);
}

throw new TruelistApiError(
`Truelist API error: ${response.status} ${response.statusText}`,
response.status
);
}

return response.json() as Promise<Record<string, unknown>>;
}
4 changes: 2 additions & 2 deletions src/email-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export type EmailInputProps = Omit<
onValidation?: (result: ValidationResult) => void;
/**
* Render function for the suggestion message.
* Receives the suggested domain (e.g. "gmail.com").
* Receives the suggested correction (e.g. "user@gmail.com").
* If not provided, a default message is rendered as a `<span>`.
*/
renderSuggestion?: (suggestion: string) => React.ReactNode;
Expand Down Expand Up @@ -118,7 +118,7 @@ export const EmailInput = forwardRef<HTMLInputElement, EmailInputProps>(
onChange={handleChange}
onBlur={handleBlur}
data-validation-state={dataState}
aria-invalid={result?.state === "invalid" || undefined}
aria-invalid={result?.state === "email_invalid" || undefined}
/>
{result?.suggestion &&
(renderSuggestion ? (
Expand Down
4 changes: 3 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@ export { EmailInput } from "./email-input";
export type { EmailInputProps, ValidateOn } from "./email-input";

// Client
export { verifyEmail, TruelistApiError } from "./client";
export { verifyEmail, getAccount, TruelistApiError } from "./client";

// Types
export type {
ValidationState,
ValidationSubState,
ValidationResult,
TruelistConfig,
ApiResponse,
ApiEmailRecord,
} from "./types";
4 changes: 2 additions & 2 deletions src/provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { TruelistConfig } from "./types";
const TruelistContext = createContext<TruelistConfig | null>(null);

export type TruelistProviderProps = {
/** Your Truelist form API key. */
/** Your Truelist API key. */
apiKey: string;
/** Base URL for the Truelist API. Defaults to `https://api.truelist.io`. */
baseUrl?: string;
Expand All @@ -20,7 +20,7 @@ export type TruelistProviderProps = {
*
* @example
* ```tsx
* <TruelistProvider apiKey="your-form-api-key">
* <TruelistProvider apiKey="your-api-key">
* <App />
* </TruelistProvider>
* ```
Expand Down
19 changes: 4 additions & 15 deletions src/react-hook-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,9 @@ import { verifyEmail } from "./client";
import type { TruelistConfig, ValidationState } from "./types";

export type TruelistFieldValidatorOptions = TruelistConfig & {
/**
* Which API endpoint to use. Default: `"verify"` (server-side).
* Use `"form_verify"` for client-side usage.
*/
endpoint?: "form_verify" | "verify";
/**
* Which validation states to treat as invalid.
* Default: `["invalid"]`
* Default: `["email_invalid"]`
*/
rejectStates?: ValidationState[];
/** Error message returned when validation fails. */
Expand All @@ -29,7 +24,7 @@ export type TruelistFieldValidatorOptions = TruelistConfig & {
* function MyForm() {
* const { register, handleSubmit } = useForm();
* const validate = truelistFieldValidator({
* apiKey: "your-form-api-key",
* apiKey: "your-api-key",
* });
*
* return (
Expand All @@ -46,8 +41,7 @@ export function truelistFieldValidator(
const {
apiKey,
baseUrl,
endpoint,
rejectStates = ["invalid"],
rejectStates = ["email_invalid"],
message = "This email address is not valid.",
} = options;

Expand All @@ -57,12 +51,7 @@ export function truelistFieldValidator(
}

try {
const result = await verifyEmail(
value,
{ apiKey, baseUrl },
undefined,
endpoint ?? "verify"
);
const result = await verifyEmail(value, { apiKey, baseUrl });

if (rejectStates.includes(result.state)) {
return message;
Expand Down
Loading