Symfony: Ukrotite uvoz newslettera uz AI validaciju e-pošte za ručni pregled
Uvoz newslettera djeluje bezopasno sve dok prva kampanja ne otkrije duplikate kontakata, neispravne adrese, neaktivne domene i dvosmislene poštanske sandučiće. Slanje na sve narušava kvalitetu popisa; odbacivanje svega sumnjivog gubi legitimne pretplatnike. Korisna sredina je ponovljiv proces uvoza s tri ishoda: prihvatiti pouzdane adrese, odbaciti jasne neuspjehe i neizvjesne slučajeve poslati čovjeku.
Ovaj vodič gradi taj proces kao Symfony konzolnu naredbu. Najprije provjerava jeftina lokalna pravila, poziva Email Validator za vjerojatne adrese i stvara zasebne CSV datoteke za čiste, odbijene i ručno pregledane unose. Vanjski se neuspjesi obrađuju oprezno: nedostupan validator nikada nije razlog za odbacivanje kontakta.
Pribavite pristup prije pisanja integracijskog koda
Najprije registrirajte račun ili upotrijebite stranicu za prijavu ako ga već imate.
Otvorite stranicu usluge Email Validator, odaberite dostupni plan Free, Plus ili Pro i dovršite aktivaciju. Zatim posjetite službenu dokumentaciju. Pronađite ploču Service token i kopirajte njezin token ograničen na uslugu.
Ova usluga zahtijeva taj token. Njegovom ponovnom generacijom opoziva se prethodno aktivni token, stoga rotacija mora uključivati ažuriranje svakog implementiranog okruženja koje ga koristi. Pohranite ga u konfiguraciju okruženja poduprtu tajnama, nikada u PHP izvorni kod, fixturee, zapisnike ili predane .env datoteke.
Točan zahtjev je:
GET https://ai.mihajlo.mk/api/email-validator/v1/check-email
Query parameters:
token={serviceToken}
email={addressToCheck}
Potvrdite pristup jednokratnom testnom adresom koju kontrolirate:
curl --get \
--data-urlencode "token=YOUR_SERVICE_TOKEN" \
--data-urlencode "[email protected]" \
"https://ai.mihajlo.mk/api/email-validator/v1/check-email"
Odgovor pruža status, score, recommendation, checks i quota. Validator procjenjuje sintaksu, konfiguraciju domene i MX-a, signale pružatelja usluge i praktični rizik isporuke. Naš će adapter zahtijevati svih pet polja umjesto da vjeruje nepotpunom payloadu.
Oblikujte Symfony projekt
Potrebni su vam PHP 8.3 ili noviji, Composer i Symfony aplikacija s podrškom za Console, HttpClient i zapisivanje. Mali projekt može se stvoriti pomoću:
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
Važne datoteke bit će:
src/Email/ValidationResult.phpza mapiranje odgovora na razini domenesrc/Email/EmailValidationException.phpza strukturirane neuspjehesrc/Email/EmailValidatorClient.phpza HTTP granicusrc/Command/CleanNewsletterImportCommand.phpza orkestraciju uvozatests/Email/EmailValidatorClientTest.phpza determinističke testove prijenosa
Konzolna naredba bolje odgovara povremenom uvozu kontakata nego kontroler: izbjegava vremenska ograničenja zahtjeva i operateru daje izričite ulazne i izlazne putanje. Messenger postaje vrijedan za vrlo velike ili kontinuirano pristigle uvoze, ali dodavanje reda skromnoj seriji stvorilo bi više operativne infrastrukture nego vrijednosti.
Konfigurirajte vjerodajnicu i HTTP klijent
Stavite stvarni token u .env.local tijekom lokalnog razvoja. Ta datoteka ne bi smjela biti predana:
EMAIL_VALIDATOR_TOKEN=YOUR_SERVICE_TOKEN
U produkciji ubrizgajte istu varijablu putem upravitelja tajnama platforme za implementaciju. Konfigurirajte ograničeni klijent tako da su ograničeni i neaktivnost veze i ukupno trajanje zahtjeva:
# 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)%'
Preslikajte udaljeni odgovor u strogi domenski objekt
Udaljeni JSON je nepouzdani ulaz čak i kada je usluga ispravna. Uspješan HTTP status ne dokazuje da polje postoji ni da ima očekivani tip.
<?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';
}
}
Sirovi status i preporuka i dalje prate svaku odluku te se pojavljuju u izvozu za pregled. Prije implementacije kalibrirajte dva praga ocjene prema dokumentiranoj ljestvici ocjene i toleranciji vašeg popisa na lažno prihvaćanje. Ne izmišljajte mapiranja za nedokumentirane vrijednosti statusa ili preporuke.
Izgradite ograničen API klijent koji uzima u obzir neuspjehe
Klijent ponovno pokušava samo prolazne neuspjehe prijenosa i odgovore poslužitelja u stilu pristupnika. Neispravni zahtjevi i neuspjesi autentikacije neće se poboljšati nakon odgode. Odgovor o kvoti također odmah zaustavlja obradu kako serija ne bi opterećivala iscrpljenu kvotu.
<?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);
}
}
Zapisnik koristi jednosmjerni hash umjesto adrese. Tokeni se nikada ne stavljaju u poruke iznimki ni u kontekst zapisnika. Odgoda ponovnog pokušaja namjerno je kratka i ograničena; dug prekid usluge trebao bi stvoriti posao za pregled, a ne beskonačno zadržavati uvoz.
Očistite CSV i stvorite red za pregled
Ulazni format je email,name. Lokalni sintaksni neuspjesi i duplikati uklanjaju se bez trošenja API kvote. Nesigurni rezultati i operativni neuspjesi idu na ručni pregled.
<?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;
}
}
Vraćeni objekt kvote zadržava se u rezultatu domene i može se dodati strukturiranoj telemetriji serije bez pretpostavljanja nedokumentiranih naziva ključeva. HTTP 429 ovdje je mjerodavan signal obrade: nakon što se pojavi, preostali se kontakti usmjeravaju na pregled bez daljnjih poziva.
Testirajte bez pozivanja stvarne usluge
MockHttpClient čini API granicu determinističkom. Ovaj test provjerava metodu, krajnju točku, autentikaciju upita, mapiranje i odluku bez trošenja kvote.
<?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]');
}
}
Pokrenite skup testova, a zatim isprobajte naredbu s malom kontroliranom datotekom:
php bin/phpunit
mkdir -p var/import-output
php bin/console app:newsletter:clean \
var/import/contacts.csv \
var/import-output
Produkcijski detalji koji sprječavaju neugodna iznenađenja
Datoteke kontakata tretirajte kao osobne podatke. Ograničite dozvole datotečnog sustava, izvoze držite izvan javnih web korijena, definirajte razdoblje zadržavanja i brišite ih kroz uobičajeni kontrolirani životni ciklus podataka. Izbjegavajte bilježenje adresa, tijela odgovora, tokena ili potpunih CSV redaka.
Za nadzornosti bilježite zbirne brojeve prihvaćenih, odbijenih, pregledanih, dupliciranih, neispravnih, blokiranih kvotom i kontakata s neuspješnim prijenosom. Bilježite identifikator serije i hashiranu adresu samo kada je potrebna korelacija na razini retka. Upozorite na neuspjehe autentikacije jer oni često ukazuju na nedostajuću tajnu ili ponovnu generaciju tokena.
Implementirajte kod prije rotacije tokena, ažurirajte tajnu okruženja, ponovno pokrenite dugotrajne radnike ako kasnije uvedete Messenger i pokrenite test s jednom adresom. Nikada ne testirajte rotaciju tokena na velikom uvozu.
Česti neuspjesi
- Svaki zahtjev vraća 401 ili 403: potvrdite aktivaciju plana i token ograničen na uslugu. Ponovno generirani token poništava svoj prethodnik.
- HTTP 429 pojavljuje se usred uvoza: zaustavite pozive, sačuvajte napredak i nastavite tek kada su dostupni kapacitet kvote ili ograničenja brzine.
- Odgovori neočekivano završavaju na ručnom pregledu: pregledajte status, preporuku, provjere i ocjenu iz redigiranog kontroliranog uzorka, zatim namjerno kalibrirajte pragove aplikacije.
- Neispravan JSON ili nedostajuća polja: tretirajte to kao neuspjeh integracije, a ne kao dokaz da je adresa neispravna.
- Ponovljeni neuspjesi prijenosa: provjerite odlazni HTTPS, DNS, pravila proxyja i TLS pouzdanost prije povećanja vremenskih ograničenja.
Završni kontrolni popis
- Token dolazi iz konfiguracije tajni poduprte okruženjem i nikada ne ulazi u kontrolu izvornog koda.
- Klijent koristi točnu GET krajnju točku s parametrima upita
tokeniemail. - Vremenska ograničenja i ponovni pokušaji su ograničeni; neuspjesi autentikacije, zahtjeva i kvote ne pokušavaju se slijepo ponovno.
- Svih pet ugovorenih polja odgovora provjerava se na granici aplikacije.
- Duplikati i lokalni sintaksni neuspjesi ne troše udaljenu kvotu.
- Dvosmisleni rezultati i neuspjesi usluge ulaze u
review.csvumjesto da nestanu. - Testovi koriste
MockHttpClienti ne upućuju mrežne pozive. - Operater pregledava izlaz prije nego što
clean.csvpostane aktivni popis za slanje pošte.
Najjači proces uvoza nije onaj koji tvrdi da ima savršenu sigurnost. To je onaj koji jasno izražava pouzdanost, ograničava vanjske neuspjehe i čovjeku daje čisto mjesto za rješavanje ostatka. Time se provjera e-pošte pretvara iz API poziva punog nade u odgovoran produkcijski tijek rada.