Email-валидация в формах регистрации: от regex до API
Каждый невалидный email в базе подписчиков стоит денег. Не абстрактных, а вполне конкретных: ESP берёт плату за отправку, bounce rate растёт, репутация домена падает. При этом большинство плохих адресов попадают в базу через формы регистрации. Фильтровать мусор на входе дешевле, чем чистить базу потом.
Три слоя валидации: от быстрого к точному
Валидация email в форме не сводится к одному регулярному выражению. Надёжная проверка состоит из трёх слоёв, каждый из которых ловит свой тип ошибок.
Слой 1: клиентская валидация (regex). Работает мгновенно, без сетевых запросов. Отсекает очевидные ошибки: пустое поле, отсутствие @, пробелы, двойные точки в домене. Не защищает от несуществующих ящиков или одноразовых сервисов, но даёт пользователю моментальную обратную связь.
Слой 2: серверная валидация (бизнес-логика). Проверки на стороне бэкенда: нормализация (gmail.com vs googlemail.com), дедупликация, проверка по внутреннему чёрному списку доменов. Выполняется при отправке формы.
Слой 3: внешний API (real-time верификация). Запрос к сервису валидации, который проверяет MX-записи домена, SMTP-ответ сервера, наличие ящика, принадлежность к disposable-сервисам. Самый точный слой. Занимает 1-3 секунды, поэтому вызывается асинхронно, когда пользователь закончил ввод.
Все три слоя работают вместе. Regex ловит опечатки до отправки запроса. Сервер фильтрует дубли и известные мусорные домены. API верифицирует то, что прошло первые два фильтра.
Клиентская валидация: regex и UX
Регулярное выражение для email не должно быть идеальным. RFC 5322 допускает адреса вроде "quoted string"@example.com, но на практике такие адреса встречаются раз в никогда. Простой regex, который покрывает 99.9% реальных случаев:
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
function validateEmailFormat(email) {
if (!email) return 'Введите email';
if (!EMAIL_RE.test(email.trim())) return 'Некорректный формат email';
return null; // ошибок нет
}Этот regex не пытается учесть все крайние случаи из RFC. Он проверяет минимум: что-то до @, что-то после, точка и домен верхнего уровня длиной от двух символов. Достаточно для первой линии обороны.
Распространённая ошибка: показывать сообщение об ошибке, пока пользователь ещё печатает. Адрес user@ невалиден, но человек ещё не дописал. Красное сообщение в этот момент раздражает. Проверять формат нужно по событию blur (пользователь ушёл из поля) или с debounce в 300-500 мс.
const emailInput = document.getElementById('email');
const errorEl = document.getElementById('email-error');
emailInput.addEventListener('blur', () => {
const error = validateEmailFormat(emailInput.value);
errorEl.textContent = error || '';
emailInput.classList.toggle('border-red-400', !!error);
});Для React или Vue логика та же: валидация по blur, а не по каждому нажатию клавиши. Ошибка появляется, когда пользователь закончил ввод. Исчезает, когда он начинает исправлять.
Серверная валидация: нормализация и дедупликация
Клиентскую валидацию можно обойти, открыв DevTools и отправив POST-запрос напрямую. Поэтому серверная проверка обязательна, даже если на фронтенде всё идеально.
На сервере стоит добавить нормализацию. Пользователи пишут John.Doe@Gmail.Com и не подозревают, что регистр в домене не имеет значения, а точки в локальной части Gmail игнорирует. Без нормализации один человек может зарегистрироваться дважды.
function normalizeEmail(email) {
let [local, domain] = email.trim().toLowerCase().split('@');
// Gmail: точки в локальной части не имеют значения
if (domain === 'gmail.com' || domain === 'googlemail.com') {
local = local.replace(/\./g, '').split('+')[0];
domain = 'gmail.com';
}
return local + '@' + domain;
}
// normalizeEmail('John.Doe+spam@Gmail.Com')
// => 'johndoe@gmail.com'Также на сервере удобно проверять домен по списку известных одноразовых сервисов. Их несколько сотен, и открытые списки обновляются регулярно. Но полагаться только на списки рискованно: новые disposable-домены появляются ежедневно. Для надёжного определения нужен третий слой.
Real-time проверка через API
Внешний API валидации делает то, что невозможно в браузере: проверяет MX-записи домена, устанавливает SMTP-соединение, определяет catch-all серверы, классифицирует одноразовые адреса. Результат проверки можно получить за 1-3 секунды.
Схема интеграции: пользователь заполняет поле email и уходит на следующее поле (или нажимает кнопку). Фронтенд отправляет запрос на ваш бэкенд. Бэкенд вызывает API валидации. По результату показывается сообщение или блокируется отправка формы.
Вызов API лучше делать с бэкенда, а не из браузера. Причина простая: API-ключ не должен попадать в клиентский код. Любой пользователь может открыть Network tab и увидеть ключ, а потом использовать его в своих целях.
Пример бэкенд-эндпоинта на Node.js (Express):
// POST /api/validate-email
app.post('/api/validate-email', async (req, res) => {
const { email } = req.body;
// Слой 1: быстрая проверка формата
if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(email)) {
return res.json({ valid: false, reason: 'invalid_format' });
}
try {
// Слой 3: вызов API валидации
const response = await fetch(
'https://api.uchecker.net/api/v1/validate/single',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.UCHECKER_API_KEY,
},
body: JSON.stringify({ email }),
}
);
const data = await response.json();
return res.json({
valid: data.result === 'deliverable',
reason: data.result, // deliverable | undeliverable | risky | unknown
disposable: data.disposable,
catchAll: data.catch_all,
});
} catch (err) {
// API недоступен — пропускаем, проверим позже
console.error('Validation API error:', err.message);
return res.json({ valid: true, reason: 'api_unavailable' });
}
});Обратите внимание на блок catch. Если API валидации временно недоступен, форма не должна ломаться. Лучше пропустить сомнительный адрес и проверить его позже batch-запросом, чем показать пользователю ошибку сервера. Graceful degradation критически важна для конверсии.
Фронтенд: запрос к бэкенду и обработка ответа
На клиенте нужно решить две задачи: отправить запрос в правильный момент и показать результат без раздражения пользователя. Рабочий паттерн: debounce + состояние загрузки + чёткие сообщения.
const emailField = document.getElementById('email');
const statusEl = document.getElementById('email-status');
let debounceTimer = null;
let controller = null; // AbortController для отмены предыдущего запроса
emailField.addEventListener('blur', () => {
const email = emailField.value.trim();
const formatError = validateEmailFormat(email);
if (formatError) {
showStatus(formatError, 'error');
return;
}
// Показать индикатор проверки
showStatus('Проверяем...', 'loading');
// Отменяем предыдущий запрос, если есть
if (controller) controller.abort();
controller = new AbortController();
fetch('/api/validate-email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
signal: controller.signal,
})
.then(r => r.json())
.then(data => {
if (data.valid) {
showStatus('', 'ok');
} else if (data.reason === 'undeliverable') {
showStatus('Этот email не существует. Проверьте адрес.', 'error');
} else if (data.disposable) {
showStatus('Одноразовые email не принимаются.', 'error');
} else {
showStatus('Не удалось проверить email. Попробуйте другой.', 'warn');
}
})
.catch(err => {
if (err.name !== 'AbortError') {
showStatus('', 'ok'); // не блокируем при ошибке сети
}
});
});
function showStatus(message, type) {
statusEl.textContent = message;
statusEl.className = {
error: 'text-red-600 text-sm mt-1',
warn: 'text-amber-600 text-sm mt-1',
loading: 'text-gray-400 text-sm mt-1',
ok: '',
}[type] || '';
}Ключевой момент: AbortController. Если пользователь быстро переключается между полями или исправляет email, предыдущий запрос отменяется. Без этого ответы приходят в случайном порядке, и статус может показать результат для старого значения поля.
Пример для React
В React-приложениях логика та же, но обёрнута в хуки. Вот компонент поля email с валидацией:
import { useState, useRef, useCallback } from 'react';
function useEmailValidation() {
const [status, setStatus] = useState('idle');
// idle | checking | valid | invalid | error
const [message, setMessage] = useState('');
const controllerRef = useRef(null);
const validate = useCallback(async (email) => {
// Формат
if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(email)) {
setStatus('invalid');
setMessage('Некорректный формат email');
return;
}
setStatus('checking');
setMessage('');
if (controllerRef.current) controllerRef.current.abort();
controllerRef.current = new AbortController();
try {
const res = await fetch('/api/validate-email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
signal: controllerRef.current.signal,
});
const data = await res.json();
if (data.valid) {
setStatus('valid');
setMessage('');
} else {
setStatus('invalid');
setMessage(
data.disposable
? 'Одноразовые email не принимаются'
: 'Этот email недоступен'
);
}
} catch (err) {
if (err.name !== 'AbortError') {
setStatus('error');
setMessage('');
}
}
}, []);
return { status, message, validate };
}Использование в компоненте формы:
function SignupForm() {
const { status, message, validate } = useEmailValidation();
const [email, setEmail] = useState('');
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
onBlur={() => email && validate(email)}
className={status === 'invalid' ? 'border-red-400' : ''}
/>
{status === 'checking' && <span>Проверяем...</span>}
{message && <span className="text-red-600">{message}</span>}
<button
type="submit"
disabled={status === 'checking' || status === 'invalid'}
>
Зарегистрироваться
</button>
</form>
);
}UX-паттерны: что работает, а что бесит
Техническая реализация без продуманного UX приносит больше вреда, чем пользы. Вот конкретные наблюдения, основанные на A/B-тестах форм регистрации.
Не блокируйте ввод во время проверки. Пользователь заполнил email и перешёл к паролю. Проверка идёт в фоне. Если заблокировать форму спиннером на 2 секунды, конверсия падает. Пусть человек продолжает заполнять форму, а результат валидации появится, когда будет готов.
Конкретные сообщения об ошибках. "Некорректный email" не помогает. "Домен gmail.co не существует. Возможно, вы имели в виду gmail.com?" помогает. Чем точнее сообщение, тем выше вероятность, что пользователь исправит ошибку, а не уйдёт.
Подсказки при типичных опечатках. Домены gmial.com, yandex.tu, hotmal.com встречаются чаще, чем можно подумать. Простая функция с расстоянием Левенштейна или даже хардкод из 20 популярных доменов спасает десятки регистраций в день для сайтов с трафиком.
const COMMON_DOMAINS = [
'gmail.com', 'yahoo.com', 'hotmail.com', 'outlook.com',
'mail.ru', 'yandex.ru', 'rambler.ru', 'icloud.com',
];
function suggestDomain(inputDomain) {
const domain = inputDomain.toLowerCase();
if (COMMON_DOMAINS.includes(domain)) return null;
for (const known of COMMON_DOMAINS) {
if (levenshtein(domain, known) <= 2) {
return known; // "gmial.com" => подсказка "gmail.com"
}
}
return null;
}Визуальный статус, а не только текст. Зелёная галочка рядом с полем, когда email валиден. Красная обводка при ошибке. Серый спиннер во время проверки. Визуальные сигналы считываются быстрее текста.
Не отклоняйте risky-адреса молча. Если API вернул статус "risky", покажите предупреждение, но позвольте продолжить. Некоторые корпоративные адреса на catch-all доменах попадают в эту категорию. Жёсткая блокировка risky-адресов отпугнёт часть реальных пользователей.
Обработка ошибок и edge cases
API может вернуть ошибку. Сеть может быть недоступна. Таймаут может сработать. Каждый из этих случаев нужно обработать, иначе форма сломается в продакшене.
Таймаут запроса. Выставляйте таймаут в 5-8 секунд. Если API не ответил за это время, пропускайте пользователя и ставьте адрес в очередь на отложенную проверку.
async function validateWithTimeout(email, timeoutMs = 5000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch('/api/validate-email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
signal: controller.signal,
});
clearTimeout(timer);
return await res.json();
} catch (err) {
clearTimeout(timer);
// Таймаут или сетевая ошибка — пропускаем
return { valid: true, reason: 'timeout' };
}
}Rate limiting. Если форма на высоконагруженном сайте, запросы к API нужно ограничивать. Простой способ: кешировать результаты. Если пользователь дважды вводит один и тот же адрес, второй запрос к API не нужен.
const validationCache = new Map();
async function cachedValidate(email) {
const normalized = email.trim().toLowerCase();
if (validationCache.has(normalized)) {
return validationCache.get(normalized);
}
const result = await validateWithTimeout(normalized);
validationCache.set(normalized, result);
// Ограничим размер кеша
if (validationCache.size > 500) {
const firstKey = validationCache.keys().next().value;
validationCache.delete(firstKey);
}
return result;
}Повторные попытки (retry). Для HTTP 429 (rate limit) и 5xx ошибок имеет смысл повторить запрос один раз с задержкой в 1-2 секунды. Больше одного retry для формы регистрации избыточно: пользователь не будет ждать.
Fallback при недоступности API. Если API валидации лежит, форма должна работать. Пропускайте третий слой и полагайтесь на первые два. Адрес попадёт в базу без полной проверки, но вы сможете проверить его позже через bulk-валидацию. Потерять регистрацию хуже, чем пропустить один сомнительный email.
Какие поля ответа API использовать
API валидации возвращает несколько полей. Не все из них одинаково полезны для формы регистрации. Вот что проверять в первую очередь:
result (deliverable / undeliverable / risky / unknown). Основной статус. Для undeliverable показывайте ошибку и не давайте отправить форму. Для risky показывайте предупреждение. Для unknown пропускайте.
disposable (true / false). Одноразовый адрес. Если ваш продукт требует подтверждения email, disposable-адреса бесполезны: через час ящик перестанет существовать. Блокируйте или предупреждайте.
catch_all (true / false). Домен принимает письма на любой адрес. Ящик может существовать, а может нет. Для формы регистрации достаточно пропустить такой адрес с пометкой в базе.
reason. Детальная причина невалидности: mailbox_not_found, domain_not_found, smtp_error. Полезно для логирования и отладки. Пользователю показывать необязательно, но разработчику поможет понять, почему конкретный адрес отклонён.
Когда вызывать API: стратегии
Есть три подхода, и выбор зависит от конверсии формы и объёма трафика.
На blur (рекомендуемый). Проверка запускается, когда пользователь покидает поле email. К моменту нажатия кнопки"Зарегистрироваться" результат уже готов. Хороший баланс между скоростью обратной связи и количеством API-вызовов.
На submit. Проверка только при отправке формы. Экономит запросы: API вызывается один раз вместо нескольких (пользователь может исправлять email). Минус: задержка перед переходом на следующий экран. Подходит для форм с низким трафиком.
Отложенная. Форма принимает любой email, прошедший regex. Полная проверка запускается в фоне после регистрации. Невалидные адреса помечаются, и пользователю отправляется запрос на подтверждение. Самый щадящий подход к конверсии, но мусорные адреса попадают в базу.
Безопасность: чего не делать
Не вызывайте API валидации из браузера напрямую. API-ключ окажется в клиентском коде, и любой сможет его извлечь. Все вызовы к внешним API идут через ваш бэкенд.
Добавьте rate limit на свой эндпоинт /api/validate-email. Без ограничения бот может отправить тысячи запросов, выбрав ваш баланс API за минуты. Достаточно 5-10 запросов в минуту с одного IP.
Используйте CAPTCHA или honeypot-поля для защиты от ботов. Валидация email не заменяет защиту от автоматических регистраций. Бот может отправлять валидные адреса тысячами.
Чеклист интеграции
Краткий список того, что нужно реализовать для надёжной валидации email в форме регистрации:
1. Regex-проверка формата на клиенте (мгновенная обратная связь).
2. Нормализация email на сервере (lowercase, gmail-точки, +alias).
3. Вызов API валидации с бэкенда (API-ключ не в браузере).
4. Таймаут 5-8 секунд на запрос к API.
5. Graceful degradation: форма работает при недоступности API.
6. Кеширование результатов (не дёргать API повторно для того же адреса).
7. Rate limit на эндпоинте валидации (5-10 req/min на IP).
8. Конкретные сообщения об ошибках (не "invalid email").
9. Подсказки при опечатках в домене.
10. Отложенная bulk-проверка для адресов, принятых без полной валидации.
Десять пунктов. Большинство реализуется за один день. Результат: меньше мусора в базе, ниже bounce rate, выше репутация домена, больше писем доходит до inbox.
Фильтровать мусор на входе в 10 раз дешевле, чем разбираться с последствиями грязной базы. Один день интеграции экономит месяцы проблем с доставляемостью.
Валидация на уровне формы не заменяет периодическую проверку всей базы. Адреса устаревают: люди меняют работу, провайдеры удаляют неактивные ящики. Но если входной фильтр работает, база загрязняется значительно медленнее, и полная проверка раз в квартал может быть достаточной вместо ежемесячной.
Попробуйте API валидации в деле: зарегистрируйтесь в uChecker и получите 30 бесплатных проверок для тестирования интеграции.
