Validate emails in Framer forms
Check the email field of your Framer forms before they are sent. An invisible code component checks the address when the visitor leaves the field or presses submit, shows a message under the field when the address cannot receive mail, and lets every other submission through to Framer as usual.
Status: set up from this page. There is no Framer Marketplace plugin yet: you add the code file below to your project.
How it behaves
- Blocks only what is clearly wrong. By default only an
invalidresult stops the form, with the message "We could not deliver to this address. Please check it for typos." and, when the API has a suggestion (did_you_mean), "Did you mean …?". A toggle also blocksdisposableresults. - Never stops a real sign-up by mistake.
valid,catch_all,unknownanddisposableresults go through, and so does every submission when something goes wrong: an error, a refused key, no credits left, or no answer within 5 seconds. - One check per address. Each address is checked once while the page is open. Pressing submit again, after a refusal or after a success, never pays for the same address twice. Partial addresses are never sent.
- Accessible. The message appears right after the field, is announced by screen readers and is linked to the field (
aria-describedby); the field is marked invalid and gets the focus. - Quiet. The component writes nothing to the browser console, so an address is never logged.
Each completed check uses a credit (1 credit = 1 validated address); unknown results, errors and the test addresses are free.
Your key on a public page
The component runs in your visitors' browsers, so the key in its settings is public: anyone can read it in your published site's code. Never use your main key. Create a key just for this, on the API keys page:
- Permissions: only Validate addresses. The key then cannot read your balance or usage, or create bulk lists.
- Allowed origins: your site's addresses, one per line, such as
https://www.example.com, plus yourframer.websiteaddress if you use it. The API refuses the key when a browser sends it from any other site (403 forbidden_origin), so other websites cannot use it in their visitors' browsers. - Daily credit cap: a little above the sign-ups you get on a busy day.
What the allowed origins do not stop: they are checked against the Origin header, which a browser always sets truthfully but a script outside a browser can set to anything. Anyone who copies the key can still spend checks with it, up to its daily cap, and so can a bot that fills in your form. The daily cap is what limits the cost. If you see a key being misused, roll it on the API keys page and paste the new one. See Limits per key.
A Framer site has no server of its own, so the key cannot be kept off the page here. To check addresses with the key on a server instead, send your form's submissions to an automation (Framer forms can send to a webhook) and validate them there, as in the no-code recipes.
Set it up
- Create the key as described above.
- In Framer, open Assets → Code, create a new code file named
EmailCheck, replace its contents with the code below and save. - Drag EmailCheck from your assets onto each page that has a form; anywhere on the page will do. On the canvas it shows a small label; on the published site it is invisible.
- In its properties, paste your key into Browser key. Optionally turn on Block disposable and change the messages.
- Publish the site, then test it on the published site with free test addresses:
no_mailbox@sandbox.email-validation.orgshows the message,likely_typo@sandbox.email-validation.orgshows a suggestion andaccepted@sandbox.email-validation.orgis sent. In Framer's preview, the key may be refused for the preview's address; the form then goes through unchecked.
The component checks every field of the Email type (input type="email") in every form of its page, Framer's own forms included. If the account processes data in the EU only, set Region to EU; the checks then go to https://api-eu.email-validation.org.
The message is a paragraph with the class ev-email-message, placed right after the field.
The code
// EmailCheck: email check for Framer forms (email-validation.org, v1.0.0). Guide: https://email-validation.org/docs/guides/framer/
import { addPropertyControls, ControlType, RenderTarget } from 'framer';
import { useEffect } from 'react';
const EV_VERSION = '1.0.0';
/** The API's two endpoints. */
const EV_API = Object.freeze({
us: 'https://api.email-validation.org',
eu: 'https://api-eu.email-validation.org',
});
/** The five statuses an answer can have. */
const EV_STATUSES = Object.freeze(['valid', 'invalid', 'catch_all', 'unknown', 'disposable']);
/** The statuses a form may refuse. `valid` and `unknown` always pass. */
const EV_BLOCKABLE = Object.freeze(['invalid', 'disposable', 'catch_all']);
/** The API's shortest `timeout`, in seconds: a check that needs longer ends as a free `unknown`. */
const EV_SERVER_TIMEOUT_S = 5;
const EV_DEFAULTS = Object.freeze({
block: Object.freeze(['invalid']),
/** How long a submit waits for an answer before it goes through anyway. */
waitMs: 5000,
/** A request still unanswered after this is dropped (0: never). */
hardLimitMs: 15000,
/** How long an answer is remembered (0: as long as the page). */
cacheTtlMs: 0,
/** The most addresses remembered (the oldest is forgotten first). */
cacheMax: 500,
});
/** What the person sees when an address is refused. `{suggestion}` is the API's `did_you_mean`. */
const EV_MESSAGES = Object.freeze({
invalid: 'We could not deliver to this address. Please check it for typos.',
disposable: 'Please use an email address you will keep, not a disposable one.',
catch_all: 'We could not confirm this address. Please use another one.',
suggestion: 'Did you mean {suggestion}?',
});
/** The form under which two fields hold the same address: trimmed, NFC, lowercased. */
function evAddressKey(value) {
return String(value ?? '')
.trim()
.normalize('NFC')
.toLowerCase();
}
/** Whether a value is worth a check: something@domain.tld with no spaces (anything else is never sent). */
function evLooksLikeAddress(value) {
const v = String(value ?? '').trim();
return v.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@.]{2,}$/.test(v);
}
/** Resolves to `promise`'s value, or to `fallback` once `ms` have passed (whichever comes first). */
function evWithin(promise, ms, fallback) {
return new Promise((resolve) => {
const timer = setTimeout(() => resolve(fallback), ms);
Promise.resolve(promise).then(
(value) => {
clearTimeout(timer);
resolve(value);
},
() => {
clearTimeout(timer);
resolve(fallback);
},
);
});
}
/** An outcome that lets the address through; `error` says why there is no answer (never shown to the person). */
function evPass(error) {
return { status: null, block: false, message: '', suggestion: null, error };
}
/**
* A checker for one API key.
*
* options: `key` (required; without it every address passes and nothing is sent), `region` ('us' or 'eu') or
* `apiBase`, `block`, `messages`, `waitMs`, `hardLimitMs`, `cacheTtlMs`, `cacheMax`, `fetch` (default: the global
* fetch), `now` (default: Date.now).
*/
function evCreateChecker(options) {
const o = options || {};
const key = typeof o.key === 'string' ? o.key.trim() : '';
const base = String(o.apiBase || EV_API[o.region === 'eu' ? 'eu' : 'us']).replace(/\/+$/, '');
const block = (Array.isArray(o.block) ? o.block : EV_DEFAULTS.block).filter((s) => EV_BLOCKABLE.includes(s));
const messages = Object.assign({}, EV_MESSAGES, o.messages || {});
const waitMs = o.waitMs > 0 ? o.waitMs : EV_DEFAULTS.waitMs;
const hardLimitMs = o.hardLimitMs >= 0 ? o.hardLimitMs : EV_DEFAULTS.hardLimitMs;
const cacheTtlMs = o.cacheTtlMs > 0 ? o.cacheTtlMs : EV_DEFAULTS.cacheTtlMs;
const cacheMax = o.cacheMax > 0 ? o.cacheMax : EV_DEFAULTS.cacheMax;
const send = o.fetch || ((url, init) => globalThis.fetch(url, init));
const now = o.now || Date.now;
/** address key → { at, promise, outcome (null while on its way) } */
const memo = new Map();
function outcomeOf(result) {
const status = result && typeof result.status === 'string' ? result.status : '';
if (!EV_STATUSES.includes(status)) return evPass('unexpected_answer');
const refused = block.includes(status) || (block.includes('disposable') && result.disposable === true);
const reason = block.includes(status) ? status : 'disposable';
const suggestion = typeof result.did_you_mean === 'string' && result.did_you_mean ? result.did_you_mean : null;
let message = '';
if (refused) {
message = String(messages[reason] || EV_MESSAGES[reason]);
if (suggestion && messages.suggestion) {
message = `${message} ${String(messages.suggestion).split('{suggestion}').join(suggestion)}`.trim();
}
}
return { status, block: refused, message, suggestion, error: null };
}
async function request(address) {
let timer = null;
const init = {
method: 'POST',
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ email: address, timeout: EV_SERVER_TIMEOUT_S }),
};
if (hardLimitMs > 0 && typeof AbortController === 'function') {
const controller = new AbortController();
timer = setTimeout(() => controller.abort(), hardLimitMs);
init.signal = controller.signal;
}
try {
const res = await send(`${base}/v1/validate`, init);
let body = null;
try {
body = await res.json();
} catch {
body = null;
}
if (!res.ok) {
const code = body?.error && typeof body.error.code === 'string' ? body.error.code : `http_${res.status}`;
return evPass(code);
}
return outcomeOf(body);
} catch {
return evPass('connection_error');
} finally {
if (timer !== null) clearTimeout(timer);
}
}
function entry(value) {
const k = evAddressKey(value);
const found = memo.get(k);
if (found && (found.outcome === null || cacheTtlMs === 0 || now() - found.at < cacheTtlMs)) return found;
memo.delete(k);
const fresh = { at: now(), outcome: null, promise: null };
fresh.promise = request(String(value).trim()).then((outcome) => {
fresh.outcome = outcome;
fresh.at = now();
return outcome;
});
memo.set(k, fresh);
while (memo.size > cacheMax) memo.delete(memo.keys().next().value);
return fresh;
}
/** The outcome for an address: one request per address, shared by every ask. Never rejects. */
function check(value) {
if (!key) return Promise.resolve(evPass('missing_key'));
if (!evLooksLikeAddress(value)) return Promise.resolve(evPass('not_an_address'));
return entry(value).promise;
}
return {
/** False without a key: the checker then sends nothing and lets every address through. */
ready: key !== '',
/** The settled outcome for an address, or null when it was never asked or its answer is still on its way. */
peek(value) {
if (!key || !evLooksLikeAddress(value)) return null;
const found = memo.get(evAddressKey(value));
if (!found || found.outcome === null) return null;
if (cacheTtlMs > 0 && now() - found.at >= cacheTtlMs) return null;
return found.outcome;
},
check,
/** check(), but an address with no answer after `ms` (default `waitMs`) passes. The check itself goes on. */
decide(value, ms) {
return evWithin(check(value), ms > 0 ? ms : waitMs, evPass('timeout'));
},
};
}
const EV_FORM_DEFAULTS = Object.freeze({
emailSelector: 'input[type="email"]',
skipAttribute: 'data-ev-skip',
messageClass: 'ev-email-message',
debounceMs: 400,
});
const EV_GUARD_MARK = '__emailValidationFormCheck';
/** Starts guarding the forms of `doc` with `checker`; returns a function that stops it. */
function evGuardForms(doc, checker, options) {
const o = options || {};
const win = doc.defaultView;
if (!win || !checker?.ready || win[EV_GUARD_MARK]) return () => {};
const selector = o.emailSelector || EV_FORM_DEFAULTS.emailSelector;
const skipAttribute = o.skipAttribute || EV_FORM_DEFAULTS.skipAttribute;
const messageClass = o.messageClass || EV_FORM_DEFAULTS.messageClass;
const debounceMs = o.debounceMs >= 0 ? o.debounceMs : EV_FORM_DEFAULTS.debounceMs;
const passing = new WeakSet();
const waiting = new WeakSet();
const timers = new WeakMap();
const notes = new WeakMap();
let count = 0;
function formOf(el) {
const form = el?.form ? el.form : el?.closest ? el.closest('form') : null;
return form && !form.closest(`[${skipAttribute}]`) ? form : null;
}
function isEmailField(el) {
return Boolean(el?.matches?.(selector) && formOf(el));
}
function fieldsOf(form) {
return Array.from(form.querySelectorAll(selector)).filter((i) => !i.disabled && evLooksLikeAddress(i.value));
}
function show(input, outcome) {
let note = notes.get(input);
if (!outcome?.block) {
if (note) {
note.textContent = '';
note.hidden = true;
}
if (input.getAttribute('aria-invalid') === 'true') input.removeAttribute('aria-invalid');
return;
}
if (!note) {
count += 1;
note = doc.createElement('p');
note.id = `ev-email-message-${count}`;
note.className = messageClass;
note.setAttribute('role', 'alert');
input.insertAdjacentElement('afterend', note);
const described = (input.getAttribute('aria-describedby') || '').split(/\s+/).filter(Boolean);
input.setAttribute('aria-describedby', described.concat(note.id).join(' '));
notes.set(input, note);
}
note.hidden = false;
note.textContent = outcome.message;
input.setAttribute('aria-invalid', 'true');
}
/** Shows each field's outcome (a message, or none) and focuses the first refused field. */
function mark(fields, outcomes) {
let first = null;
fields.forEach((input, i) => {
show(input, outcomes[i]);
if (!first && outcomes[i] && outcomes[i].block) first = input;
});
if (first && typeof first.focus === 'function') first.focus();
}
function resubmit(form, submitter) {
passing.add(form);
try {
if (typeof form.requestSubmit === 'function') {
try {
form.requestSubmit(submitter && submitter.form === form ? submitter : undefined);
} catch {
form.requestSubmit();
}
} else if (form.dispatchEvent(new win.Event('submit', { bubbles: true, cancelable: true }))) {
form.submit();
}
} finally {
passing.delete(form);
}
}
function onInput(event) {
const input = event.target;
if (!isEmailField(input)) return;
clearTimeout(timers.get(input));
show(input, null);
}
function onChange(event) {
const input = event.target;
if (!isEmailField(input) || !evLooksLikeAddress(input.value)) return;
clearTimeout(timers.get(input));
const value = input.value;
timers.set(
input,
setTimeout(() => {
checker.check(value).then((outcome) => {
if (input.value === value) show(input, outcome);
});
}, debounceMs),
);
}
function onSubmit(event) {
const form = event.target;
if (form?.tagName !== 'FORM' || passing.has(form) || form.closest(`[${skipAttribute}]`)) return;
const fields = fieldsOf(form);
if (fields.length === 0) return;
const known = fields.map((input) => checker.peek(input.value));
if (known.every((k) => k && !k.block)) {
mark(fields, known);
return;
}
event.preventDefault();
event.stopImmediatePropagation();
if (known.every(Boolean)) {
mark(fields, known);
return;
}
if (waiting.has(form)) return;
waiting.add(form);
const values = fields.map((input) => input.value);
const submitter = event.submitter || null;
Promise.all(fields.map((input) => checker.decide(input.value))).then((outcomes) => {
waiting.delete(form);
if (fields.some((input, i) => input.value !== values[i])) return;
mark(fields, outcomes);
if (!outcomes.some((x) => x.block)) resubmit(form, submitter);
});
}
win.addEventListener('submit', onSubmit, true);
doc.addEventListener('input', onInput, true);
doc.addEventListener('change', onChange, true);
win[EV_GUARD_MARK] = true;
return () => {
win.removeEventListener('submit', onSubmit, true);
doc.removeEventListener('input', onInput, true);
doc.removeEventListener('change', onChange, true);
delete win[EV_GUARD_MARK];
};
}
/**
* @framerSupportedLayoutWidth any
* @framerSupportedLayoutHeight any
*/
export default function EmailCheck(props) {
const {
apiKey = '',
region = 'us',
blockDisposable = false,
invalidMessage = EV_MESSAGES.invalid,
disposableMessage = EV_MESSAGES.disposable,
} = props;
const onCanvas = RenderTarget.current() === RenderTarget.canvas;
useEffect(() => {
if (onCanvas) return undefined;
const settings = {
key: apiKey,
region,
block: blockDisposable ? ['invalid', 'disposable'] : ['invalid'],
messages: { invalid: invalidMessage, disposable: disposableMessage },
};
return evGuardForms(document, evCreateChecker(settings), settings);
}, [onCanvas, apiKey, region, blockDisposable, invalidMessage, disposableMessage]);
if (!onCanvas) return null;
return (
<div
style={{
padding: 8,
border: '1px dashed currentColor',
borderRadius: 6,
fontSize: 12,
opacity: 0.7,
}}
>
{apiKey ? 'Email check (invisible on the site)' : 'Email check: add your browser key'}
</div>
);
}
addPropertyControls(EmailCheck, {
apiKey: {
type: ControlType.String,
title: 'Browser key',
description: 'A key with allowed origins, the Validate permission only and a daily cap.',
defaultValue: '',
},
region: {
type: ControlType.Enum,
title: 'Region',
options: ['us', 'eu'],
optionTitles: ['US', 'EU'],
defaultValue: 'us',
},
blockDisposable: { type: ControlType.Boolean, title: 'Block disposable', defaultValue: false },
invalidMessage: {
type: ControlType.String,
title: 'Invalid message',
displayTextArea: true,
defaultValue: EV_MESSAGES.invalid,
},
disposableMessage: {
type: ControlType.String,
title: 'Disposable message',
displayTextArea: true,
defaultValue: EV_MESSAGES.disposable,
hidden: (p) => !p.blockDisposable,
},
});