Symfony валидација на е-пошта: тријажа на увози на билтени со ВИ и рачна проверка
Увозот на билтен изгледа безопасно сè додека во листата не влезе погрешно напишан домен, привремено сандаче или ризична адреса. Потоа последиците доаѓаат подоцна: залудно испраќање, нарушена испорачливост и записи за контакти на кои никој не им верува.
Овој туторијал создава Symfony команда ориентирана кон продукција што чита CSV извоз на контакти, ја проверува секоја единствена адреса и создава три датотеки што можат да се ревидираат: прифатени контакти, локално неважечки контакти и неизвесни контакти за рачна проверка. Дизајнот намерно е затворен при неуспех. Одговорот се прифаќа автоматски само кога секој конфигуриран сигнал за доверба се согласува.
Добијте пристап до Email Validator
Регистрирајте се преку страницата за регистрација, или користете ја страницата за најава ако веќе имате сметка.
- Отворете ја страницата на услугата Email Validator.
- Изберете го достапниот Free, Plus или Pro план и завршете ја неговата активација.
- Отворете ја официјалната документација за услугата.
- Пронајдете го панелот Service token и копирајте го токенот со опсег на услугата.
- Чувајте го тој токен во конфигурација на проектот поткрепена со променливи на околината, никогаш во комитирани PHP или YAML датотеки.
Оваа услуга бара токен. Неговото повторно генерирање го поништува претходно активниот токен, затоа координирајте ја ротацијата со распоредувањето: ажурирајте ја продукциската тајна, распоредете ја или рестартирајте ја апликацијата и дури потоа отфрлете ги претпоставките дека стариот процес може да продолжи да испраќа барања.
Потврдете го договорот пред да пишувате PHP
Точното барање е GET https://ai.mihajlo.mk/api/email-validator/v1/check-email. Автентикацијата го користи параметарот за барање token, додека адресата се доставува преку email.
curl --get 'https://ai.mihajlo.mk/api/email-validator/v1/check-email' \
--data-urlencode 'token=YOUR_SERVICE_TOKEN' \
--data-urlencode '[email protected]'
Прегледајте го овој одговор заедно со официјалната документација. Забележете ги точните успешни вредности за status и recommendation, скалата на резултатот и имињата на проверките што важат за вашата политика за прифаќање. Тие набројувања се конфигурација, а не претпоставки во кодот подолу.
Создадете Symfony проект
Предусловите се PHP 8.3 или понова верзија, Composer, PHP екстензијата cURL или друг транспорт поддржан од Symfony HttpClient и влезен CSV чиј прв ред содржи email и по избор name.
composer create-project symfony/skeleton newsletter-cleaner
cd newsletter-cleaner
composer require symfony/console symfony/http-client
composer require --dev symfony/phpunit-bridge
mkdir -p var/import var/export
php bin/console about
Завршената функционалност има тесна структура:
src/
Command/CleanNewsletterCommand.php
EmailValidation/
DecisionPolicy.php
EmailValidatorClient.php
ValidationResult.php
ValidatorFailure.php
tests/
EmailValidation/EmailValidatorClientTest.php
config/services.yaml
.env.local
Синхроната конзолна команда е соодветна тука бидејќи мал тим вообичаено сака еден целосен артефакт што може да се прегледа пред да внесе што било во својата платформа за билтен. Messenger би бил корисен за многу големи или постојано пристигнувачки множества податоци, но би барал и трајна состојба по контакт, идемпотентност и агрегација на резултати. Тие трошоци не го подобруваат овој сериски работен тек.
Конфигурирајте тајни и политика
Ставете ги развојните акредитиви во .env.local, кој треба да остане некомитиран. Во продукција, внесете ги истите имиња преку управувачот со тајни на платформата за распоредување или преку конфигурацијата на околината.
EMAIL_VALIDATOR_TOKEN=YOUR_SERVICE_TOKEN
# Replace these with exact values observed in the documented test response.
EMAIL_VALIDATOR_ACCEPT_STATUS=VALUE_FROM_TEST_RESPONSE
EMAIL_VALIDATOR_ACCEPT_RECOMMENDATION=VALUE_FROM_TEST_RESPONSE
EMAIL_VALIDATOR_ACCEPT_SCORE=0.90
EMAIL_VALIDATOR_REQUIRED_CHECKS='{"DOCUMENTED_POSITIVE_CHECK":true}'
Прагот на резултатот мора да ја користи скалата документирана од услугата. Не претпоставувајте дека секое API за оценување користи ист опсег. Исто така, заменете го примерниот објект за проверки со вистински клучеви од одговорот и нивните потребни вредности.
# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
App\EmailValidation\EmailValidatorClient:
arguments:
$serviceToken: '%env(string:EMAIL_VALIDATOR_TOKEN)%'
App\EmailValidation\DecisionPolicy:
arguments:
$acceptStatus: '%env(string:EMAIL_VALIDATOR_ACCEPT_STATUS)%'
$acceptRecommendation: '%env(string:EMAIL_VALIDATOR_ACCEPT_RECOMMENDATION)%'
$acceptScore: '%env(float:EMAIL_VALIDATOR_ACCEPT_SCORE)%'
$requiredChecks: '%env(json:EMAIL_VALIDATOR_REQUIRED_CHECKS)%'
Мапирајте го одговорот на границата на апликацијата
Услугата враќа status, score, recommendation, checks и quota. Третирајте го целиот мрежен JSON како недоверлив влез. Неисправен или нецелосен одговор никогаш не смее тивко да стане прифатен контакт.
<?php
// src/EmailValidation/ValidationResult.php
namespace App\EmailValidation;
final readonly class ValidationResult
{
public function __construct(
public string $status,
public float $score,
public string $recommendation,
public array $checks,
public array $quota,
) {}
public static function fromPayload(array $data): self
{
foreach (['status', 'score', 'recommendation', 'checks', 'quota'] as $key) {
if (!array_key_exists($key, $data)) {
throw new \InvalidArgumentException("Missing response field: {$key}");
}
}
if (!is_string($data['status']) || trim($data['status']) === ''
|| !is_string($data['recommendation']) || trim($data['recommendation']) === ''
|| (!is_int($data['score']) && !is_float($data['score']))
|| !is_finite((float) $data['score'])
|| !is_array($data['checks'])
|| !is_array($data['quota'])) {
throw new \InvalidArgumentException('Invalid validator response types.');
}
return new self(
trim($data['status']),
(float) $data['score'],
trim($data['recommendation']),
$data['checks'],
$data['quota'],
);
}
}
<?php
// src/EmailValidation/ValidatorFailure.php
namespace App\EmailValidation;
final class ValidatorFailure extends \RuntimeException
{
public function __construct(
public readonly string $kind,
string $message,
?\Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
}
Создадете ограничен клиент што е свесен за повторувања
Клиентот прави најмногу два обида. Тој еднаш повторува при неуспеси во транспортот и привремени одговори од мрежниот премин, со кратко чекање. Не повторува при лош влез, неуспеси на автентикација, одговори за квота или ограничување на стапката, ниту при неисправен JSON. Повторувањето на тие барања би трошело време без да ја промени причината.
<?php
// src/EmailValidation/EmailValidatorClient.php
namespace App\EmailValidation;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final readonly class EmailValidatorClient
{
private const ENDPOINT =
'https://ai.mihajlo.mk/api/email-validator/v1/check-email';
public function __construct(
private HttpClientInterface $http,
private LoggerInterface $logger,
private string $serviceToken,
) {
if (trim($serviceToken) === '') {
throw new \LogicException('EMAIL_VALIDATOR_TOKEN is empty.');
}
}
public function check(string $email): ValidationResult
{
for ($attempt = 1; $attempt <= 2; $attempt++) {
try {
$response = $this->http->request('GET', self::ENDPOINT, [
'query' => [
'token' => $this->serviceToken,
'email' => $email,
],
'timeout' => 3.0,
'max_duration' => 8.0,
]);
$status = $response->getStatusCode();
} catch (TransportExceptionInterface $e) {
if ($attempt === 1) {
usleep(250_000);
continue;
}
throw new ValidatorFailure('temporary', 'Validator unavailable.', $e);
}
if ($attempt === 1 && in_array($status, [502, 503, 504], true)) {
$response->cancel();
usleep(250_000);
continue;
}
if (in_array($status, [401, 403], true)) {
throw new ValidatorFailure('authentication', 'Token rejected.');
}
if ($status === 429) {
throw new ValidatorFailure('quota', 'Quota or rate limit reached.');
}
if ($status >= 400 && $status < 500) {
throw new ValidatorFailure('request', 'Request rejected.');
}
if ($status !== 200) {
throw new ValidatorFailure('temporary', "Unexpected HTTP {$status}.");
}
try {
$payload = json_decode(
$response->getContent(false),
true,
512,
JSON_THROW_ON_ERROR,
);
if (!is_array($payload)) {
throw new \InvalidArgumentException('JSON root is not an object.');
}
return ValidationResult::fromPayload($payload);
} catch (\JsonException|\InvalidArgumentException $e) {
$this->logger->warning('email_validator_protocol_failure', [
'attempt' => $attempt,
]);
throw new ValidatorFailure(
'protocol',
'Validator returned an invalid response.',
$e,
);
}
}
throw new ValidatorFailure('temporary', 'Validator unavailable.');
}
}
Ниту еден запис во логовите не ги содржи е-поштата, телото на одговорот или токенот. Бидејќи автентикацијата нужно е во низата за барање, конфигурирајте ги и обратните проксија и алатките за следење на перформансите на апликацијата да го прикриваат параметарот token. Држете го Symfony profiler исклучен во продукција.
Претворете ги сигналите на услугата во конзервативна одлука
Политиката ја користи секоја договорена област од одговорот. Статусот и препораката мора да се совпаѓаат со локално одобрените вредности, резултатот мора да го надмине прагот, конфигурираните проверки мора точно да се совпаѓаат, а податоците за квотата мора да бидат присутни. Непознати, недостасувачки или ново изменети сигнали одат на преглед наместо да се протнат во листата за испраќање.
<?php
// src/EmailValidation/DecisionPolicy.php
namespace App\EmailValidation;
enum ImportDecision: string
{
case Accept = 'accepted';
case Review = 'review';
}
final readonly class DecisionPolicy
{
public function __construct(
private string $acceptStatus,
private string $acceptRecommendation,
private float $acceptScore,
private array $requiredChecks,
) {
if ($requiredChecks === []) {
throw new \LogicException('Configure at least one required check.');
}
}
public function decide(ValidationResult $result): ImportDecision
{
if ($result->status !== $this->acceptStatus
|| $result->recommendation !== $this->acceptRecommendation
|| $result->score < $this->acceptScore
|| $result->quota === []) {
return ImportDecision::Review;
}
foreach ($this->requiredChecks as $name => $expected) {
if (!array_key_exists($name, $result->checks)
|| $result->checks[$name] !== $expected) {
return ImportDecision::Review;
}
}
return ImportDecision::Accept;
}
}
Целиот објект за квота се задржува во ревизискиот излез. Одговор без податоци за квота не може да биде прифатен, додека HTTP 429 останува авторитативниот сигнал за запирање. Така се избегнува измислување поле за квота што не е дел од дадениот договор.
Исчистете го увозот со Symfony команда
Командата отфрла очигледно неисправен локален влез пред да троши квота, отстранува дупликати без разлика на големината на буквите и ги запишува неизвесните или привремено непроверливите контакти во review.csv. Неуспесите на автентикација и квота ја запираат серијата бидејќи продолжувањето би создало само погрешен излез.
<?php
// src/Command/CleanNewsletterCommand.php
namespace App\Command;
use App\EmailValidation\DecisionPolicy;
use App\EmailValidation\EmailValidatorClient;
use App\EmailValidation\ImportDecision;
use App\EmailValidation\ValidatorFailure;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\{InputArgument, InputInterface};
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(name: 'app:clean-newsletter')]
final class CleanNewsletterCommand extends Command
{
public function __construct(
private readonly EmailValidatorClient $validator,
private readonly DecisionPolicy $policy,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addArgument('input', InputArgument::REQUIRED)
->addArgument('output-dir', InputArgument::REQUIRED);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$source = new \SplFileObject((string) $input->getArgument('input'), 'r');
$dir = rtrim((string) $input->getArgument('output-dir'), '/');
if (!is_dir($dir) && !mkdir($dir, 0770, true) && !is_dir($dir)) {
throw new \RuntimeException("Cannot create {$dir}");
}
$accepted = new \SplFileObject("{$dir}/accepted.csv", 'w');
$rejected = new \SplFileObject("{$dir}/rejected.csv", 'w');
$review = new \SplFileObject("{$dir}/review.csv", 'w');
$accepted->fputcsv(['email', 'name', 'score', 'status',
'recommendation', 'checks', 'quota'], ',', '"', '');
$rejected->fputcsv(['email', 'name', 'reason'], ',', '"', '');
$review->fputcsv(['email', 'name', 'reason', 'details'], ',', '"', '');
$header = $source->fgetcsv(',', '"', '');
if (!is_array($header) || !in_array('email', $header, true)) {
throw new \RuntimeException('CSV header must contain email.');
}
$columns = array_flip($header);
$seen = [];
while (!$source->eof()) {
$row = $source->fgetcsv(',', '"', '');
if (!is_array($row) || $row === [null]) {
continue;
}
$email = trim((string) ($row[$columns['email']] ?? ''));
$name = isset($columns['name'])
? trim((string) ($row[$columns['name']] ?? ''))
: '';
$key = strtolower($email);
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
$rejected->fputcsv([$email, $name, 'invalid_local_syntax'],
',', '"', '');
continue;
}
try {
$result = $this->validator->check($email);
} catch (ValidatorFailure $e) {
$review->fputcsv([$email, $name, $e->kind, ''], ',', '"', '');
if (in_array($e->kind, ['authentication', 'quota'], true)) {
$output->writeln("<error>Stopped: {$e->kind}</error>");
return Command::FAILURE;
}
continue;
}
$details = json_encode([
'status' => $result->status,
'recommendation' => $result->recommendation,
'checks' => $result->checks,
'quota' => $result->quota,
], JSON_THROW_ON_ERROR);
if ($this->policy->decide($result) === ImportDecision::Accept) {
$accepted->fputcsv([$email, $name, $result->score,
$result->status, $result->recommendation,
json_encode($result->checks, JSON_THROW_ON_ERROR),
json_encode($result->quota, JSON_THROW_ON_ERROR)],
',', '"', '');
} else {
$review->fputcsv([$email, $name, 'policy_uncertain', $details],
',', '"', '');
}
}
$output->writeln('<info>Import triage completed.</info>');
return Command::SUCCESS;
}
}
Тестирајте без да контактирате со услугата
MockHttpClient му обезбедува на тестот детерминистички транспорт. Користете синтетичка терминологија за фикстурите да не можат да се помешаат со тврдења за недокументирани вредности на услугата.
<?php
// tests/EmailValidation/EmailValidatorClientTest.php
namespace App\Tests\EmailValidation;
use App\EmailValidation\EmailValidatorClient;
use App\EmailValidation\ValidatorFailure;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
final class EmailValidatorClientTest extends TestCase
{
public function testMapsResponseAndSendsRequiredQueryParameters(): void
{
$http = new MockHttpClient(function (string $method, string $url): MockResponse {
self::assertSame('GET', $method);
parse_str((string) parse_url($url, PHP_URL_QUERY), $query);
self::assertSame('test-token', $query['token']);
self::assertSame('[email protected]', $query['email']);
return new MockResponse(json_encode([
'status' => 'configured-good',
'score' => 0.97,
'recommendation' => 'configured-send',
'checks' => ['configured-check' => true],
'quota' => ['synthetic' => 10],
], JSON_THROW_ON_ERROR), ['http_code' => 200]);
});
$result = (new EmailValidatorClient(
$http,
new NullLogger(),
'test-token',
))->check('[email protected]');
self::assertSame(0.97, $result->score);
self::assertTrue($result->checks['configured-check']);
}
public function testAuthenticationFailureIsNotRetried(): void
{
$calls = 0;
$http = new MockHttpClient(function () use (&$calls): MockResponse {
$calls++;
return new MockResponse('', ['http_code' => 401]);
});
try {
(new EmailValidatorClient($http, new NullLogger(), 'bad-token'))
->check('[email protected]');
self::fail('Expected ValidatorFailure.');
} catch (ValidatorFailure $e) {
self::assertSame('authentication', $e->kind);
}
self::assertSame(1, $calls);
}
}
./vendor/bin/simple-phpunit
php bin/console lint:container
php bin/console app:clean-newsletter \
var/import/contacts.csv \
var/export
Продукциски заштитни мерки и чести неуспеси
Извршувајте ја командата во приватна работничка околина, а не од веб-барање. Ограничете го пристапот до влезните и излезните датотеки бидејќи адресите за е-пошта се лични податоци. Дефинирајте период на задржување, шифрирајте го складирањето каде што е соодветно и никогаш не комитирајте извози на контакти.
- 401 или 403: потврдете ја променливата на околината и дали повторното генерирање на токенот ја поништило распоредeната вредност.
- 429: запрете, проверете ја искористеноста на планот и продолжете само кога квотата или ограничувањата на стапката го дозволуваат тоа. Не ја оптоварувајте крајната точка со повторувања.
- Сè завршува на преглед: споредете ги конфигурираните статус, препорака, скала на резултатот и вредности на проверките со тековен документиран одговор.
- Неуспеси на протоколот: задржете ги настанот и HTTP статусот во логовите, но не и телото, токенот или адресата. Истражете пред да ја олабавите валидацијата.
- Делумен излез: третирајте ненулта излезна состојба на командата како неуспешна серија. Поправете ја причината и повторно извршете од оригиналниот влез пред да ги објавите прифатените контакти.
За набљудливост, бележете сериски броеви за прифатени, отфрлени, прегледани, дупликати и неуспешни контакти; траење на извршувањето; број на повторувања; и видови неуспеси. Избегнувајте да користите адреси за е-пошта како ознаки на метрики. Поставете предупредување за неуспеси на автентикација и нагли промени во стапката на преглед, бидејќи и двете често укажуваат на отстапување на конфигурацијата или договорот.
Конечна контролна листа за проверка
- Вистинскиот токен постои само во складиште за тајни поткрепено со променливи на околината.
- Минималното барање успева со точната GET крајна точка и параметри за барање.
- Терминологијата за прифаќање, прагот на резултатот и потребните проверки се совпаѓаат со документацијата и набљудуваниот одговор.
- Автоматизираните тестови поминуваат и неуспесите на автентикација прават само едно барање.
- Примерок CSV создава
accepted.csv,rejected.csvиreview.csv. - Ниту токен, адреса или тело на одговор не се појавува во логовите на апликацијата, проксито, профајлерот или надзорот.
- Платформата за билтен добива контакти само откако командата успешно ќе заврши и проверувачите ќе ги разрешат неизвесните редови.
Важниот резултат не е само почист CSV. Тоа е одбранлива граница меѓу автоматизираната доверба и човечката проценка. Прифатете ги адресите чии сигнали се усогласени, отфрлете само она што е локално сигурно и направете ја двосмисленоста видлива. Така работниот тек на мал билтен станува сигурен без да стане непотребно сложен.