Symfony: Ukrotite uvoz newslettera uz AI validaciju e-pošte za ručni pregled
A newsletter import looks harmless until the first campaign exposes duplicate contacts, malformed addresses, dead domains, and ambiguous mailboxes. Sending everything damages list quality; rejecting everything suspicious loses legitimate subscribers. The useful middle ground is a repeatable import pipeline with three outcomes: accept strong addresses, reject clear failures, and send uncertainty to a human.
This tutorial builds that pipeline as a Symfony console command. It validates inexpensive local rules first, calls the Email Validator for plausible addresses, and produces separate clean, rejected, and manual-review CSV files. External failures are handled conservatively: an unavailable validator never becomes a reason to discard a contact.
Get access before writing integration code
First, register an account, or use the sign-in page if you already have one.
Open the Email Validator service page, choose the available Free, Plus, or Pro plan, and complete activation. Then visit the official documentation. Find the Service token panel and copy its service-scoped token.
This service requires that token. Regenerating it revokes the previously active token, so rotation must include updating every deployed environment that uses it. Store it in secret-backed environment configuration, never in PHP source, fixtures, logs, or committed .env files.
The exact request is:
GET https://ai.mihajlo.mk/api/email-validator/v1/check-email
Query parameters:
token={serviceToken}
email={addressToCheck}
Verify access with a disposable test address that you control:
curl --get \
--data-urlencode "token=YOUR_SERVICE_TOKEN" \
--data-urlencode "[email protected]" \
"https://ai.mihajlo.mk/api/email-validator/v1/check-email"
The response supplies status, score, recommendation, checks, and quota. The validator evaluates syntax, domain and MX configuration, provider signals, and practical delivery risk. Our adapter will require all five fields instead of trusting an incomplete payload.
Shape the Symfony project
You need PHP 8.3 or newer, Composer, and a Symfony application with Console, HttpClient, and logging support. A small project can be created with:
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
The important files will be:
src/Email/ValidationResult.phpfor domain-level response mappingsrc/Email/EmailValidationException.phpfor structured failuressrc/Email/EmailValidatorClient.phpfor the HTTP boundarysrc/Command/CleanNewsletterImportCommand.phpfor import orchestrationtests/Email/EmailValidatorClientTest.phpfor deterministic transport tests
A console command fits an occasional contact import better than a controller: it avoids request time limits and gives an operator explicit input and output paths. Messenger becomes worthwhile for very large or continuously arriving imports, but adding a queue to a modest batch would create more operational machinery than value.
Configure the credential and HTTP client
Put the real token in .env.local during local development. That file should remain uncommitted:
EMAIL_VALIDATOR_TOKEN=YOUR_SERVICE_TOKEN
In production, inject the same variable through the deployment platform’s secret manager. Configure a scoped client so connection inactivity and total request duration are both bounded:
# 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)%'
Map the remote response into a strict domain object
Remote JSON is untrusted input even when the service is healthy. A successful HTTP status does not prove that a field exists or has the expected type.
<?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';
}
}
The raw status and recommendation still accompany every decision and appear in the review export. Before deployment, calibrate the two score thresholds against the documented score scale and your list’s tolerance for false acceptance. Do not fabricate mappings for undocumented status or recommendation values.
Build a bounded, failure-aware API client
The client retries only transient transport failures and gateway-style server responses. Invalid requests and authentication failures will not improve after a delay. A quota response also stops immediately so a batch cannot hammer an exhausted allowance.
<?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);
}
}
The log uses a one-way hash instead of the address. Tokens are never placed in exception messages or log context. The retry delay is deliberately short and bounded; a long service disruption should produce review work, not hold the import indefinitely.
Clean the CSV and create the review queue
The input format is email,name. Local syntax failures and duplicates are removed without consuming API quota. Uncertain results and operational failures go to manual review.
<?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;
}
}
The returned quota object is retained by the domain result and can be added to structured batch telemetry without assuming undocumented key names. HTTP 429 is the authoritative processing signal here: once encountered, remaining contacts are routed to review without further calls.
Test without calling the real service
MockHttpClient makes the API boundary deterministic. This test verifies the method, endpoint, query authentication, mapping, and decision without consuming quota.
<?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]');
}
}
Run the suite and then exercise the command with a small controlled file:
php bin/phpunit
mkdir -p var/import-output
php bin/console app:newsletter:clean \
var/import/contacts.csv \
var/import-output
Production details that prevent unpleasant surprises
Treat contact files as personal data. Restrict filesystem permissions, keep exports outside public web roots, define a retention period, and delete them through your normal controlled data lifecycle. Avoid logging addresses, response bodies, tokens, or complete CSV rows.
For observability, record aggregate counts for accepted, rejected, reviewed, duplicate, malformed, quota-blocked, and transport-failed contacts. Log a batch identifier and hashed address only when row-level correlation is necessary. Alert on authentication failures because they commonly indicate a missing secret or token regeneration.
Deploy the code before rotating a token, update the environment secret, restart long-running workers if you later introduce Messenger, and run a one-address smoke test. Never test token rotation against a large import.
Common failures
- Every request returns 401 or 403: confirm plan activation and the service-scoped token. A regenerated token invalidates its predecessor.
- HTTP 429 appears mid-import: stop calls, preserve progress, and resume only after quota or rate-limit capacity is available.
- Responses reach manual review unexpectedly: inspect status, recommendation, checks, and score from a redacted controlled sample, then calibrate application thresholds deliberately.
- Malformed JSON or missing fields: treat it as an integration failure, not as evidence that the address is invalid.
- Repeated transport failures: check outbound HTTPS, DNS, proxy policy, and TLS trust before increasing timeouts.
Final verification checklist
- The token comes from environment-backed secret configuration and never enters source control.
- The client uses the exact GET endpoint with
tokenandemailquery parameters. - Timeouts and retries are bounded; authentication, request, and quota failures are not blindly retried.
- All five contracted response fields are validated at the application boundary.
- Duplicates and local syntax failures do not consume remote quota.
- Ambiguous results and service failures enter
review.csvrather than disappearing. - Tests use
MockHttpClientand make no network calls. - An operator reviews the output before
clean.csvbecomes a live mailing list.
The strongest import pipeline is not the one that claims perfect certainty. It is the one that makes confidence explicit, contains external failures, and gives a human a clean place to resolve the remainder. That turns email validation from a hopeful API call into an accountable production workflow.