Symfony: Припитомете го увозот на билтени со AI валидација на е-пошта за рачен преглед
Увозот на билтен изгледа безопасно сè додека првата кампања не открие дупликат контакти, погрешно форматирани адреси, неактивни домени и двосмислени сандачиња. Испраќањето до сите го нарушува квалитетот на листата; отфрлањето на сè што е сомнително губи легитимни претплатници. Корисната средина е повторлив процес за увоз со три исходи: прифати сигурни адреси, отфрли јасни неуспеси и испрати ја неизвесноста кај човек.
Ова упатство го гради тој процес како Symfony console команда. Најпрво ги валидира евтините локални правила, го повикува Email Validator за веродостојни адреси и создава посебни CSV-датотеки за чисти, отфрлени и записи за рачна проверка. Надворешните неуспеси се обработуваат претпазливо: недостапен валидатор никогаш не е причина за отфрлање контакт.
Обезбедете пристап пред да напишете код за интеграција
Најпрво, регистрирајте сметка, или користете ја страницата за најава ако веќе имате.
Отворете ја страницата на услугата Email Validator, изберете го достапниот Free, Plus или Pro план и завршете ја активацијата. Потоа посетете ја официјалната документација. Најдете го панелот Service token и копирајте го неговиот токен ограничен на услугата.
Оваа услуга го бара тој токен. Неговото повторно генерирање го поништува претходно активниот токен, па ротацијата мора да вклучува ажурирање на секоја распоредена околина што го користи. Чувајте го во конфигурација на околината поддржана со тајни, никогаш во PHP изворен код, fixtures, логови или комитирани .env датотеки.
Точното барање е:
GET https://ai.mihajlo.mk/api/email-validator/v1/check-email
Query parameters:
token={serviceToken}
email={addressToCheck}
Потврдете го пристапот со тест-адреса за еднократна употреба што ја контролирате:
curl --get \
--data-urlencode "token=YOUR_SERVICE_TOKEN" \
--data-urlencode "[email protected]" \
"https://ai.mihajlo.mk/api/email-validator/v1/check-email"
Одговорот доставува status, score, recommendation, checks и quota. Валидаторот ги оценува синтаксата, конфигурацијата на доменот и MX, сигналите од давателот и практичниот ризик при испорака. Нашиот адаптер ќе ги бара сите пет полиња, наместо да му верува на нецелосен payload.
Обликувајте го Symfony проектот
Ви треба PHP 8.3 или понов, Composer и Symfony апликација со поддршка за Console, HttpClient и логирање. Мал проект може да се создаде со:
composer create-project symfony/skeleton newsletter-cleaner
cd newsletter-cleaner
composer require symfony/console symfony/http-client symfony/monolog-bundle
composer require --dev symfony/test-pack
Важните датотеки ќе бидат:
src/Email/ValidationResult.phpза мапирање на одговори на ниво на доменsrc/Email/EmailValidationException.phpза структурирани неуспесиsrc/Email/EmailValidatorClient.phpза HTTP границатаsrc/Command/CleanNewsletterImportCommand.phpза оркестрација на увозотtests/Email/EmailValidatorClientTest.phpза детерминистички тестови на транспортот
Console командата одговара подобро за повремен увоз на контакти отколку контролер: ги избегнува временските ограничувања на барањата и на операторот му дава експлицитни влезни и излезни патеки. Messenger станува вреден за многу големи или континуирано пристигнувачки увози, но додавањето редица за умерена серија би создало повеќе оперативна механика отколку вредност.
Конфигурирајте ги акредитивот и HTTP клиентот
Ставете го вистинскиот токен во .env.local при локален развој. Таа датотека треба да остане некомитирана:
EMAIL_VALIDATOR_TOKEN=YOUR_SERVICE_TOKEN
Во продукција, внесете ја истата променлива преку управувачот со тајни на платформата за распоредување. Конфигурирајте ограничен клиент за неактивноста на конекцијата и вкупното времетраење на барањето да бидат ограничени:
# config/packages/framework.yaml
framework:
http_client:
scoped_clients:
email_validator.client:
base_uri: 'https://ai.mihajlo.mk'
timeout: 5
max_duration: 10
# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
App\Email\EmailValidatorClient:
arguments:
$httpClient: '@email_validator.client'
$serviceToken: '%env(EMAIL_VALIDATOR_TOKEN)%'
Мапирајте го оддалечениот одговор во строг доменски објект
Оддалечениот JSON е недоверлив влез дури и кога услугата е исправна. Успешен HTTP статус не докажува дека поле постои или дека го има очекуваниот тип.
<?php
// src/Email/ValidationResult.php
namespace App\Email;
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 fromArray(array $payload): self
{
foreach (['status', 'score', 'recommendation', 'checks', 'quota'] as $field) {
if (!array_key_exists($field, $payload)) {
throw new \UnexpectedValueException("Missing response field: {$field}");
}
}
if (!is_string($payload['status']) || trim($payload['status']) === ''
|| !is_numeric($payload['score'])
|| !is_string($payload['recommendation'])
|| trim($payload['recommendation']) === ''
|| !is_array($payload['checks'])
|| !is_array($payload['quota'])) {
throw new \UnexpectedValueException('Email validation response has invalid types.');
}
$score = (float) $payload['score'];
if (!is_finite($score)) {
throw new \UnexpectedValueException('Email validation score is not finite.');
}
return new self(
trim($payload['status']),
$score,
trim($payload['recommendation']),
$payload['checks'],
$payload['quota'],
);
}
public function importDecision(): string
{
/*
* These are application-owned thresholds for the service's numeric score.
* Unknown status/recommendation vocabulary remains reviewable rather than
* being guessed at the integration boundary.
*/
if ($this->checks === []) {
return 'review';
}
if ($this->score >= 80.0) {
return 'accept';
}
if ($this->score <= 30.0) {
return 'reject';
}
return 'review';
}
}
Суровите status и recommendation и понатаму ја придружуваат секоја одлука и се појавуваат во извозот за преглед. Пред распоредување, калибрирајте ги двата прага за резултатот според документираната скала за резултати и толеранцијата на вашата листа за лажно прифаќање. Не измислувајте мапирања за недокументирани вредности на status или recommendation.
Изградете ограничен API клиент свесен за неуспеси
Клиентот повторува само при минливи транспортни неуспеси и одговори на серверот од типот на gateway. Неважечките барања и неуспесите при автентикација нема да се подобрат по одложување. И одговор за квота веднаш запира, за серијата да не го преоптоварува исцрпеното ограничување.
<?php
// src/Email/EmailValidationException.php
namespace App\Email;
final class EmailValidationException extends \RuntimeException
{
public function __construct(
public readonly string $kind,
string $message,
?\Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
}
<?php
// src/Email/EmailValidatorClient.php
namespace App\Email;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final readonly class EmailValidatorClient
{
public function __construct(
private HttpClientInterface $httpClient,
private LoggerInterface $logger,
private string $serviceToken,
) {}
public function check(string $email): ValidationResult
{
for ($attempt = 1; $attempt <= 3; ++$attempt) {
try {
$response = $this->httpClient->request('GET', '/api/email-validator/v1/check-email', [
'query' => [
'token' => $this->serviceToken,
'email' => $email,
],
]);
$statusCode = $response->getStatusCode();
if ($statusCode === 429) {
throw new EmailValidationException(
'quota',
'Email validation quota or rate limit was reached.'
);
}
if (in_array($statusCode, [502, 503, 504], true) && $attempt < 3) {
$this->backoff($attempt);
continue;
}
if (in_array($statusCode, [401, 403], true)) {
throw new EmailValidationException(
'authentication',
'The Email Validator rejected its service token.'
);
}
if ($statusCode !== 200) {
throw new EmailValidationException(
'request',
"Email Validator returned HTTP {$statusCode}."
);
}
try {
$payload = $response->toArray(false);
return ValidationResult::fromArray($payload);
} catch (DecodingExceptionInterface|\UnexpectedValueException $exception) {
throw new EmailValidationException(
'malformed_response',
'Email Validator returned an unusable response.',
$exception
);
}
} catch (TransportExceptionInterface $exception) {
if ($attempt === 3) {
throw new EmailValidationException(
'transport',
'Email Validator was unreachable after bounded retries.',
$exception
);
}
$this->logger->warning('Transient email validation transport failure.', [
'attempt' => $attempt,
'email_hash' => hash('sha256', strtolower($email)),
]);
$this->backoff($attempt);
}
}
throw new EmailValidationException('transport', 'Retry loop ended unexpectedly.');
}
private function backoff(int $attempt): void
{
usleep($attempt * 200_000);
}
}
Логот користи еднонасочен хаш наместо адресата. Токените никогаш не се ставаат во пораки за исклучоци или во контекстот на логот. Одложувањето при повторување е намерно кратко и ограничено; долг прекин на услугата треба да создаде работа за преглед, а не да го задржи увозот на неодредено време.
Исчистете го CSV и создадете редица за преглед
Влезниот формат е email,name. Локалните синтаксни неуспеси и дупликатите се отстрануваат без да трошат API квота. Неизвесните резултати и оперативните неуспеси одат на рачен преглед.
<?php
// src/Command/CleanNewsletterImportCommand.php
namespace App\Command;
use App\Email\EmailValidationException;
use App\Email\EmailValidatorClient;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(
name: 'app:newsletter:clean',
description: 'Validate a newsletter CSV and create clean, rejected, and review files.'
)]
final class CleanNewsletterImportCommand extends Command
{
public function __construct(private readonly EmailValidatorClient $validator)
{
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('input', InputArgument::REQUIRED)
->addArgument('output-directory', InputArgument::REQUIRED);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$source = new \SplFileObject((string) $input->getArgument('input'), 'rb');
$directory = rtrim((string) $input->getArgument('output-directory'), '/');
if (!is_dir($directory) || !is_writable($directory)) {
throw new \RuntimeException('Output directory must exist and be writable.');
}
$clean = $this->open("{$directory}/clean.csv");
$review = $this->open("{$directory}/review.csv");
$rejected = $this->open("{$directory}/rejected.csv");
fputcsv($clean, ['email', 'name', 'status', 'score', 'recommendation']);
fputcsv($review, ['email', 'name', 'reason', 'status', 'score',
'recommendation', 'checks']);
fputcsv($rejected, ['email', 'name', 'reason']);
$headers = $source->fgetcsv(',', '"', '\\');
if (!is_array($headers) || !in_array('email', $headers, true)) {
throw new \RuntimeException('CSV header must contain an email column.');
}
$seen = [];
$validationPaused = null;
while (!$source->eof()) {
$row = $source->fgetcsv(',', '"', '\\');
if (!is_array($row) || $row === [null]) {
continue;
}
$row = array_pad($row, count($headers), '');
$record = array_combine($headers, array_slice($row, 0, count($headers)));
if ($record === false) {
continue;
}
$email = strtolower(trim((string) ($record['email'] ?? '')));
$name = trim((string) ($record['name'] ?? ''));
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
fputcsv($rejected, [$email, $name, 'local_syntax']);
continue;
}
if (isset($seen[$email])) {
fputcsv($rejected, [$email, $name, 'duplicate']);
continue;
}
$seen[$email] = true;
if ($validationPaused !== null) {
fputcsv($review, [$email, $name, $validationPaused, '', '', '', '']);
continue;
}
try {
$result = $this->validator->check($email);
$decision = $result->importDecision();
if ($decision === 'accept') {
fputcsv($clean, [$email, $name, $result->status,
$result->score, $result->recommendation]);
} elseif ($decision === 'reject') {
fputcsv($rejected, [$email, $name, 'low_validation_score']);
} else {
fputcsv($review, [$email, $name, 'uncertain_score',
$result->status, $result->score, $result->recommendation,
json_encode($result->checks, JSON_THROW_ON_ERROR)]);
}
} catch (EmailValidationException $exception) {
$reason = 'validator_'.$exception->kind;
fputcsv($review, [$email, $name, $reason, '', '', '', '']);
if (in_array($exception->kind, ['quota', 'authentication'], true)) {
$validationPaused = $reason;
}
}
}
$output->writeln('Import completed. Inspect review.csv before publishing the list.');
return Command::SUCCESS;
}
private function open(string $path)
{
$handle = fopen($path, 'wb');
if ($handle === false) {
throw new \RuntimeException("Cannot open output file: {$path}");
}
return $handle;
}
}
Вратениот објект quota се задржува во доменскиот резултат и може да се додаде во структурирана телеметрија за серијата без претпоставување недокументирани имиња на клучеви. HTTP 429 е авторитативниот сигнал за обработка тука: штом ќе се појави, преостанатите контакти се насочуваат на преглед без дополнителни повици.
Тестирајте без да ја повикате вистинската услуга
MockHttpClient ја прави API границата детерминистичка. Овој тест ги проверува методот, endpoint-от, автентикацијата преку query, мапирањето и одлуката без да троши квота.
<?php
// tests/Email/EmailValidatorClientTest.php
namespace App\Tests\Email;
use App\Email\EmailValidatorClient;
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 testItMapsACompleteResponse(): void
{
$transport = new MockHttpClient(
function (string $method, string $url): MockResponse {
self::assertSame('GET', $method);
self::assertStringContainsString(
'/api/email-validator/v1/check-email',
$url
);
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' => 'documented-status-value',
'score' => 88,
'recommendation' => 'documented-recommendation-value',
'checks' => ['provider_signal' => 'available'],
'quota' => ['observed' => true],
], JSON_THROW_ON_ERROR), [
'http_code' => 200,
'response_headers' => ['content-type: application/json'],
]);
},
'https://ai.mihajlo.mk'
);
$client = new EmailValidatorClient(
$transport,
new NullLogger(),
'test-token'
);
$result = $client->check('[email protected]');
self::assertSame(88.0, $result->score);
self::assertSame('accept', $result->importDecision());
}
public function testQuotaFailureIsStructured(): void
{
$transport = new MockHttpClient(
new MockResponse('', ['http_code' => 429])
);
$client = new EmailValidatorClient(
$transport,
new NullLogger(),
'test-token'
);
$this->expectExceptionMessage('quota or rate limit');
$client->check('[email protected]');
}
}
Извршете го пакетот тестови, а потоа испробајте ја командата со мала контролирана датотека:
php bin/phpunit
mkdir -p var/import-output
php bin/console app:newsletter:clean \
var/import/contacts.csv \
var/import-output
Продукциски детали што спречуваат непријатни изненадувања
Третирајте ги датотеките со контакти како лични податоци. Ограничете ги дозволите на датотечниот систем, чувајте ги извозите надвор од јавните веб-корени, дефинирајте период на задржување и бришете ги преку вашиот вообичаен контролиран животен циклус на податоците. Избегнувајте логирање адреси, тела на одговори, токени или цели CSV редови.
За набљудливост, бележете збирни броеви за прифатени, отфрлени, прегледани, дупликати, погрешно форматирани, блокирани поради квота и контакти со неуспешен транспорт. Логирајте идентификатор на серијата и хаширана адреса само кога е неопходна корелација на ниво на ред. Алармирајте при неуспеси на автентикација, бидејќи тие често укажуваат на недостасувачка тајна или повторно генерирање токен.
Распоредете го кодот пред да ротирате токен, ажурирајте ја тајната на околината, рестартирајте ги долготрајните workers ако подоцна воведете Messenger и извршете smoke test со една адреса. Никогаш не тестирајте ротација на токен со голем увоз.
Чести неуспеси
- Секое барање враќа 401 или 403: потврдете ја активацијата на планот и токенот ограничен на услугата. Повторно генериран токен го поништува неговиот претходник.
- HTTP 429 се појавува во средина на увозот: запрете ги повиците, зачувајте го напредокот и продолжете само откако ќе биде достапен капацитет за квота или ограничување на стапката.
- Одговорите неочекувано одат на рачен преглед: проверете ги status, recommendation, checks и score од редактиран контролиран примерок, па намерно калибрирајте ги праговите на апликацијата.
- Погрешно форматиран JSON или недостасувачки полиња: третирајте го како неуспех на интеграцијата, а не како доказ дека адресата е неважечка.
- Повторени транспортни неуспеси: проверете ги излезниот HTTPS, DNS, политиката за proxy и довербата во TLS пред да ги зголемите временските ограничувања.
Контролна листа за конечна проверка
- Токенот доаѓа од конфигурација на тајни поддржана од околината и никогаш не влегува во контрола на изворниот код.
- Клиентот го користи точниот GET endpoint со query параметри
tokenиemail. - Временските ограничувања и повторувањата се ограничени; неуспесите на автентикација, барање и квота не се повторуваат слепо.
- Сите пет договорени полиња на одговорот се валидираат на границата на апликацијата.
- Дупликатите и локалните синтаксни неуспеси не трошат оддалечена квота.
- Двосмислените резултати и неуспесите на услугата влегуваат во
review.csvнаместо да исчезнат. - Тестовите користат
MockHttpClientи не прават мрежни повици. - Оператор прво го прегледува излезот пред
clean.csvда стане активна листа за испраќање.
Најсилниот процес за увоз не е оној што тврди совршена сигурност. Тој е оној што ја прави довербата експлицитна, ги ограничува надворешните неуспеси и му дава на човек чисто место да го разреши остатокот. Така валидацијата на е-пошта се претвора од надежен API повик во одговорен продукциски работен процес.