Нежна валидација на е-пошта: одржувајте ги регистрациите во тек и покрај прекините на API-то
Формуларот за регистрација има две задачи што можат да влечат во спротивни насоки: да ги задржи адресите со слаб квалитет надвор и да им дозволи влез на легитимните луѓе. Надворешна услуга за валидација на е-пошта ја подобрува првата задача, но третирањето на таа услуга како непогрешлив чувар на влезот може тивко да ја саботира втората. Истечен рок, исцрпена квота или краткотраен инцидент во надворешната услуга не треба да се претвори во „Регистрацијата не успеа.“
Ова упатство гради крајна точка за регистрација во Native PHP 8.3 со намерно толерантна политика. Локално невалидната синтакса на е-пошта веднаш се отфрла. Email Validator ги испитува синтаксата, доменот, MX-записите, сигналите од давателот и практичниот ризик за испорака. Неговиот одговор се пресликува во типизиран резултат на апликацијата. Сепак, привремените неуспеси на API никогаш не го отфрлаат пријавениот корисник: сметката се создава како непотврдена и продолжува низ вообичаената потврда по е-пошта.
Надворешен валидатор треба да ги зајакне одлуките за регистрација, а не да стане единствена точка на одбивање.
Добијте пристап пред да напишете интеграциски код
Најпрво, регистрирајте сметка, или користете ја страницата за најава ако веќе имате.
- Отворете ја страницата на услугата Email Validator.
- Изберете го достапниот Free, Plus или Pro план и завршете ја неговата активација.
- Отворете ја официјалната документација за Email Validator.
- Пронајдете го панелот Service token и копирајте го токенот ограничен на услугата.
- Зачувајте го токенот во конфигурацијата на околината на проектот, никогаш во PHP изворниот код под контрола на верзии.
Оваа услуга бара токен. Автентикацијата го користи параметарот за пребарување token={serviceToken}. Регенерирањето на сервисниот токен го поништува претходно активниот токен, па ротацијата на токени мора да ја ажурира распоредената околина пред да се очекува старите ингеренции да работат.
Потврдете ја крајната точка со минимално барање
Точниот повик е GET https://ai.mihajlo.mk/api/email-validator/v1/check-email. Наведете ги и email и token како параметри за пребарување:
curl --silent --show-error \
--get 'https://ai.mihajlo.mk/api/email-validator/v1/check-email' \
--data-urlencode '[email protected]' \
--data-urlencode 'token=YOUR_SERVICE_TOKEN'
Извршете го тоа само во доверлива школка. Бидејќи ингеренцијата е во стрингот за пребарување, на историјата на команди, излезот за дебагирање, дневниците за пристап и дневниците на проксито треба да им се посвети посебно внимание. Апликацијата подолу никогаш не ја евидентира URL-адресата на барањето.
Создадете локална датотека .env и исклучете ја од контрола на верзии:
APP_ENV=local
EMAIL_VALIDATOR_TOKEN=YOUR_SERVICE_TOKEN
DB_DSN=sqlite:/absolute/path/to/project/var/app.sqlite
За локален развој, извезете ја датотеката во околината на процесот пред да стартувате PHP. Продукцијата треба да ги вметне истите променливи преку платформата за распоредување, наместо да копира .env во слика:
set -a
. ./.env
set +a
php -S 127.0.0.1:8080 -t public
Архитектура: дозволи при неуспех, но потврди пред доверба
„Дозволи при неуспех“ не значи секоја адреса да се третира како доверлива. Тоа значи да се дозволи регистрацијата да продолжи, додека сметката останува непотврдена. Корисникот сè уште мора да го заврши текот на апликацијата за потврда на е-пошта пред да добие привилегии за кои е потребна потврдена адреса.
Дизајнот има четири граници:
- Контролерот извршува CSRF-заштита, проверки на задолжителни полиња и природна валидација на синтаксата.
- Наменски cURL-транспорт ја поседува мрежната механика и ограничените истекувања на време.
- Клиентот за Email Validator пресликува далечински JSON во структурирани состојби како
checked,quota_limited,authentication_failureиunavailable. - Политиката за регистрација отфрла само детерминистички локални грешки. Секој далечински резултат, вклучително и успешна процена на ризик, се задржува како контекст за потврда и набљудливост.
Доставениот договор ги идентификува status, score, recommendation, checks и quota, но овде не ги утврдува нивните множества на вредности или скала за резултатот. Затоа адаптерот ги валидира нивните типови без да измислува прагови или значења на препораките. Ако официјалната документација дефинира правило за блокирање што сакате да го усвоите, кодирајте ги неговите точни документирани вредности во политиката и држете ги состојбите на прекин како неблокирачки.
Структура на проектот и зависности
graceful-registration/
├── composer.json
├── .env
├── public/
│ └── register.php
├── src/
│ ├── EmailValidator.php
│ └── RegistrationPolicy.php
├── tests/
│ └── EmailValidatorClientTest.php
└── var/
Користете Composer само за автоматско вчитување и PHPUnit. Мрежното поврзување при извршување останува природен cURL:
{
"name": "example/graceful-registration",
"type": "project",
"require": {
"php": ">=8.3",
"ext-curl": "*",
"ext-json": "*",
"ext-pdo": "*",
"ext-pdo_sqlite": "*"
},
"require-dev": {
"phpunit/phpunit": "^11.5"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}
composer install
mkdir -p var
chmod 700 var
Изградете одбранбена граница за API
Транспортот е заменлив, што ги прави тестовите детерминистички. Клиентот повторува само при исклучок во транспортот, HTTP 408 или одговор од серверот 5xx и извршува најмногу два обида. Неуспесите на автентикација, неправилно обликуваните барања и одговорите за квота не се повторуваат слепо. Времето за поврзување и вкупното време за одговор се ограничени.
<?php
// src/EmailValidator.php
declare(strict_types=1);
namespace App;
use JsonException;
use RuntimeException;
final readonly class HttpResponse
{
public function __construct(
public int $statusCode,
public string $body,
) {}
}
interface HttpTransport
{
public function get(
string $url,
int $connectTimeoutMs,
int $timeoutMs
): HttpResponse;
}
final class CurlTransport implements HttpTransport
{
public function get(
string $url,
int $connectTimeoutMs,
int $timeoutMs
): HttpResponse {
$handle = curl_init($url);
if ($handle === false) {
throw new RuntimeException('Unable to initialize cURL');
}
curl_setopt_array($handle, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT_MS => $connectTimeoutMs,
CURLOPT_TIMEOUT_MS => $timeoutMs,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
$body = curl_exec($handle);
if ($body === false) {
$message = curl_error($handle);
curl_close($handle);
throw new RuntimeException('Email validator transport failed: ' . $message);
}
$statusCode = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
curl_close($handle);
return new HttpResponse($statusCode, $body);
}
}
final readonly class EmailValidationOutcome
{
public function __construct(
public string $state,
public ?string $status = null,
public ?float $score = null,
public ?string $recommendation = null,
public array $checks = [],
public array $quota = [],
) {}
}
final readonly class EmailValidatorClient
{
private const ENDPOINT =
'https://ai.mihajlo.mk/api/email-validator/v1/check-email';
public function __construct(
private HttpTransport $transport,
private string $token,
private ?\Closure $sleep = null,
) {
if ($token === '' || $token === 'YOUR_SERVICE_TOKEN') {
throw new RuntimeException('EMAIL_VALIDATOR_TOKEN is not configured');
}
}
public function check(string $email): EmailValidationOutcome
{
$url = self::ENDPOINT . '?' . http_build_query(
['email' => $email, 'token' => $this->token],
'',
'&',
PHP_QUERY_RFC3986
);
for ($attempt = 1; $attempt <= 2; $attempt++) {
try {
$response = $this->transport->get($url, 2_000, 4_000);
} catch (RuntimeException) {
if ($attempt === 1) {
$this->backoff();
continue;
}
return new EmailValidationOutcome('unavailable');
}
$retryable = $response->statusCode === 408
|| $response->statusCode >= 500;
if ($retryable && $attempt === 1) {
$this->backoff();
continue;
}
return $this->map($response);
}
return new EmailValidationOutcome('unavailable');
}
private function map(HttpResponse $response): EmailValidationOutcome
{
if (in_array($response->statusCode, [401, 403], true)) {
return new EmailValidationOutcome('authentication_failure');
}
if ($response->statusCode === 429) {
return new EmailValidationOutcome('quota_limited');
}
if (in_array($response->statusCode, [400, 422], true)) {
return new EmailValidationOutcome('request_rejected');
}
if ($response->statusCode < 200 || $response->statusCode >= 300) {
return new EmailValidationOutcome('unavailable');
}
try {
$body = json_decode(
$response->body,
true,
512,
JSON_THROW_ON_ERROR
);
} catch (JsonException) {
return new EmailValidationOutcome('malformed_response');
}
if (!is_array($body)) {
return new EmailValidationOutcome('malformed_response');
}
$data = isset($body['data']) && is_array($body['data'])
? $body['data']
: $body;
$status = $body['status'] ?? $data['status'] ?? null;
$score = $data['score'] ?? null;
$recommendation = $data['recommendation'] ?? null;
$checks = $data['checks'] ?? null;
$quota = $body['quota'] ?? $data['quota'] ?? null;
if (
!is_string($status)
|| $status === ''
|| !is_int($score) && !is_float($score)
|| !is_string($recommendation)
|| $recommendation === ''
|| !is_array($checks)
|| !is_array($quota)
) {
return new EmailValidationOutcome('malformed_response');
}
return new EmailValidationOutcome(
state: 'checked',
status: $status,
score: (float) $score,
recommendation: $recommendation,
checks: $checks,
quota: $quota,
);
}
private function backoff(): void
{
$delay = 150_000 + random_int(0, 50_000);
$sleeper = $this->sleep
?? static fn (int $microseconds) => usleep($microseconds);
$sleeper($delay);
}
}
Адаптерот ги прифаќа задолжителните полиња или во коренот на одговорот или во објект data, а потоа ги отфрла нецелосните или погрешно типизираните товари како неправилно обликувани. Ова е зацврстување на границата, а не тврдење дека недокументираните облици на одговор се загарантирани.
Претворете ја валидацијата во одлука за регистрација
Политиката ја држи локалната сигурност одделена од далечинските докази. Сите прифатени сметки бараат потврда по е-пошта. Успешниот одговор од валидаторот го запишува неговиот целосен договор; прекинот запишува деградирана состојба без да ја изгуби регистрацијата.
<?php
// src/RegistrationPolicy.php
declare(strict_types=1);
namespace App;
final readonly class RegistrationDecision
{
public function __construct(
public bool $accept,
public string $validatorState,
public ?EmailValidationOutcome $outcome = null,
public ?string $reason = null,
) {}
}
final readonly class RegistrationPolicy
{
public function __construct(private EmailValidatorClient $validator) {}
public function decide(string $email): RegistrationDecision
{
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
return new RegistrationDecision(
accept: false,
validatorState: 'local_rejection',
reason: 'Enter a syntactically valid email address.'
);
}
$outcome = $this->validator->check($email);
return new RegistrationDecision(
accept: true,
validatorState: $outcome->state,
outcome: $outcome
);
}
}
Поврзете ја политиката со формуларот за регистрација
Контролерот ја создава SQLite-шемата за овој компактен проект, ја валидира CSRF-состојбата, ја хешира лозинката и ја зачувува сметката како непотврдена. Единственото ограничување спречува дупликати на адреси. Во поголема апликација, миграциите треба да ја поседуваат шемата, а постојна компонента за пошта треба да испрати еднократна врска за потврда откако трансакцијата ќе се изврши.
<?php
// public/register.php
declare(strict_types=1);
use App\CurlTransport;
use App\EmailValidatorClient;
use App\RegistrationPolicy;
require dirname(__DIR__) . '/vendor/autoload.php';
session_start();
$token = getenv('EMAIL_VALIDATOR_TOKEN');
$dsn = getenv('DB_DSN');
if (!is_string($token) || !is_string($dsn) || $dsn === '') {
http_response_code(500);
exit('Application configuration is incomplete.');
}
$pdo = new PDO($dsn, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$pdo->exec(
'CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL,
email_verified INTEGER NOT NULL DEFAULT 0,
validator_state TEXT NOT NULL,
validator_status TEXT NULL,
validator_score REAL NULL,
validator_recommendation TEXT NULL,
created_at TEXT NOT NULL
)'
);
$_SESSION['csrf'] ??= bin2hex(random_bytes(32));
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo '<form method="post">
<input type="hidden" name="csrf" value="' .
htmlspecialchars($_SESSION['csrf'], ENT_QUOTES, 'UTF-8') . '">
<label>Email <input type="email" name="email" required></label>
<label>Password <input type="password" name="password"
minlength="12" required></label>
<button type="submit">Create account</button>
</form>';
exit;
}
$submittedCsrf = $_POST['csrf'] ?? '';
if (!is_string($submittedCsrf)
|| !hash_equals($_SESSION['csrf'], $submittedCsrf)
) {
http_response_code(403);
exit('Invalid form session.');
}
$email = trim((string) ($_POST['email'] ?? ''));
$password = (string) ($_POST['password'] ?? '');
if (strlen($email) > 254 || strlen($password) < 12) {
http_response_code(422);
exit('Check the submitted email and password.');
}
$client = new EmailValidatorClient(new CurlTransport(), $token);
$decision = (new RegistrationPolicy($client))->decide($email);
if (!$decision->accept) {
http_response_code(422);
exit($decision->reason ?? 'Email cannot be accepted.');
}
$outcome = $decision->outcome;
try {
$statement = $pdo->prepare(
'INSERT INTO users (
email, password_hash, email_verified, validator_state,
validator_status, validator_score,
validator_recommendation, created_at
) VALUES (
:email, :password_hash, 0, :validator_state,
:validator_status, :validator_score,
:validator_recommendation, :created_at
)'
);
$statement->execute([
'email' => $email,
'password_hash' => password_hash($password, PASSWORD_DEFAULT),
'validator_state' => $decision->validatorState,
'validator_status' => $outcome?->status,
'validator_score' => $outcome?->score,
'validator_recommendation' => $outcome?->recommendation,
'created_at' => gmdate(DATE_ATOM),
]);
} catch (PDOException $exception) {
if ((string) $exception->getCode() === '23000') {
http_response_code(409);
exit('An account for that email already exists.');
}
throw $exception;
}
error_log(json_encode([
'event' => 'registration_created',
'validator_state' => $decision->validatorState,
'remote_status_present' => $outcome?->status !== null,
'score_present' => $outcome?->score !== null,
'recommendation_present' => $outcome?->recommendation !== null,
'checks_present' => ($outcome?->checks ?? []) !== [],
'quota_present' => ($outcome?->quota ?? []) !== [],
], JSON_THROW_ON_ERROR));
unset($_SESSION['csrf']);
http_response_code(201);
echo 'Account created. Check your inbox to verify your email address.';
Дневникот ја користи секоја област од далечинскиот договор за оперативна класификација без да ги запишува е-поштата, токенот, суровите проверки или содржината на квотата. Зачувувањето само на полињата потребни за политиката на производот, исто така, го намалува непотребното задржување податоци.
Тестирајте ги повторувањата и деградираната регистрација детерминистички
Лажен транспорт им овозможува на тестовите да управуваат со патеките за успех и неуспех без пристап до мрежа. Вредностите на примероците подолу се намерно непрозирни; тестот го проверува пресликувањето на границата наместо да тврди семантика специфична за услугата.
<?php
// tests/EmailValidatorClientTest.php
declare(strict_types=1);
use App\EmailValidatorClient;
use App\HttpResponse;
use App\HttpTransport;
use PHPUnit\Framework\TestCase;
final class QueueTransport implements HttpTransport
{
public int $calls = 0;
public function __construct(private array $items) {}
public function get(
string $url,
int $connectTimeoutMs,
int $timeoutMs
): HttpResponse {
$this->calls++;
$item = array_shift($this->items);
if ($item instanceof Throwable) {
throw $item;
}
return $item;
}
}
final class EmailValidatorClientTest extends TestCase
{
public function testItRetriesOneServerFailureAndMapsTheResponse(): void
{
$transport = new QueueTransport([
new HttpResponse(503, ''),
new HttpResponse(200, json_encode([
'status' => 'fixture-status',
'score' => 12.5,
'recommendation' => 'fixture-recommendation',
'checks' => ['fixture-check' => true],
'quota' => ['fixture-quota' => 7],
], JSON_THROW_ON_ERROR)),
]);
$client = new EmailValidatorClient(
$transport,
'test-token',
static fn (int $microseconds) => null
);
$outcome = $client->check('[email protected]');
self::assertSame('checked', $outcome->state);
self::assertSame(12.5, $outcome->score);
self::assertSame(2, $transport->calls);
}
public function testAnOutageBecomesUnavailableAfterTwoAttempts(): void
{
$transport = new QueueTransport([
new RuntimeException('timeout'),
new RuntimeException('timeout'),
]);
$client = new EmailValidatorClient(
$transport,
'test-token',
static fn (int $microseconds) => null
);
self::assertSame(
'unavailable',
$client->check('[email protected]')->state
);
self::assertSame(2, $transport->calls);
}
public function testQuotaResponseIsNotRetried(): void
{
$transport = new QueueTransport([new HttpResponse(429, '')]);
$client = new EmailValidatorClient($transport, 'test-token');
self::assertSame(
'quota_limited',
$client->check('[email protected]')->state
);
self::assertSame(1, $transport->calls);
}
}
vendor/bin/phpunit --testdox tests
Додадете тестови на ниво на контролер за невалидни CSRF-токени, неправилно обликувани адреси, дупликат-сметки, кратки лозинки, неуспех на автентикација, неправилен JSON и успешна регистрација за време на симулиран истечен рок. Клучното тврдење е дека недостапен валидатор сепак создава запис за непотврден корисник.
Безбедност, набљудливост и распоредување
Третирајте го сервисниот токен како тајна, иако патува во параметар за пребарување. Држете го надвор од складишта, пораки за исклучоци, траги за перформансите на апликацијата, дневници на стрингови за пребарување на обратни проксија и копирани URL-адреси на барања. Ограничете го пристапот до конфигурацијата за распоредување и намерно ротирајте го токенот, имајќи предвид дека регенерирањето го поништува стариот токен.
Ограничете ја стапката на рутата за регистрација според IP и пошироки сигнали за злоупотреба, но избегнувајте да се потпирате само на IP. Задржете ги CSRF-заштитата, хеширањето на лозинки, истекувањето на токените за потврда, справувањето со дупликати и генеричките одговори за сметки. Валидаторот ги надополнува овие контроли; не ги заменува.
Следете ги бројачите според validator_state, честотата на повторувања, латентноста, неправилно обликуваните одговори и присуството на податоци за квота. Алармирајте при траен authentication_failure, бидејќи тоа обично бара дејство од оператор. Пораст на повици quota_limited укажува дека треба да се прегледа планот или сообраќајот. Краток бран на состојби unavailable треба да ја деградира регистрацијата, а не на корисниците да им прикажува грешка од надворешна услуга.
При распоредување, потврдете дека cURL и PDO SQLite се овозможени, директориумот на базата на податоци е запишлив само од сметката на апликацијата, оптимизираниот автоматски вчитувач на Composer е изграден и околината го содржи активниот токен. Распоредете код што ги разбира и старите и новите оперативни состојби пред да ги ротирате ингеренциите. За повеќе инстанци на апликацијата, заменете го SQLite со заедничката база на податоци на апликацијата, задржувајќи ја истата политика и единственото ограничување.
Вообичаени режими на неуспех
- Секој повик враќа неуспех на автентикација: потврдете ги токенот ограничен на услугата, активацијата на планот, вметнувањето во околината и дали некој го регенерирал токенот.
- Одговорите за квота предизвикуваат бавни формулари: не повторувајте HTTP 429 во рамките на барањето. Запишете ја состојбата и продолжете со потврда по е-пошта.
- Истечените рокови го трошат базенот PHP-работници: задржете кратки истекувања на време за поврзување и вкупно време и одолејте на множењето повторувања.
- Успешните одговори стануваат неправилно обликувани: проверете безбедно редактиран одговор според официјалната документација. Не претворајте тивко полиња што недостигаат.
- Корисниците сè уште се блокирани при инциденти: проверете ја политиката на контролерот. Во овој дизајн треба да отфрла само детерминистичката локална валидација.
- Токените се појавуваат во дневници: оневозможете евидентирање на стрингови за пребарување за оваа надворешна дестинација и отстранете ги URL-вредностите од исклучоците и атрибутите за следење.
Конечна листа за потврда
- Сметката и Free, Plus или Pro планот се активни.
- Сервисниот токен доаѓа од панелот Service token на страницата за документација.
EMAIL_VALIDATOR_TOKENсе вметнува преку конфигурација на околината.- Барањето користи GET, точната крајна точка и параметрите за пребарување
emailиtoken. - Истекувањата на времето за поврзување и одговор се ограничени.
- Само привремените неуспеси во транспортот, 408 и 5xx добиваат едно повторување.
- Неуспесите на автентикација, валидација на барања и квота не се повторуваат.
status,score,recommendation,checksиquotaсе проверуваат според типот на границата.- Привремените неуспеси на API сепак создаваат непотврдена сметка.
- Дневниците содржат структурирани состојби, но не адреса на е-пошта, сурова URL-адреса или токен.
- Автоматизираните тестови покриваат успех, исцрпување на повторувањата и справување со квота.
- Вообичаениот тек на потврда по е-пошта останува задолжителен пред да се одобри доверба.
Најотпорниот систем за регистрација не е оној што се преправа дека зависностите никогаш не откажуваат. Тоа е оној што ја разликува сигурноста од доказот: го отфрла она што апликацијата може да докаже дека е неправилно обликувано, ги збогатува одлуките кога валидаторот одговара и зачувува безбеден пат напред кога не одговара. Така заштитата останува силна без достапноста да стане туѓо ветување.