Symfony + Email Validator API: Tame Your Newsletter Imports with Smart Review Queues
A newsletter import rarely fails in one dramatic way. It degrades quietly: duplicated contacts, malformed addresses, disposable accounts, domains without usable mail infrastructure, and ambiguous addresses that are neither safe enough to accept nor clear enough to discard.
The right response is not a single “valid” flag. It is a small decision pipeline. This Symfony implementation normalizes and deduplicates a CSV import, rejects obvious local errors, asks the Email Validator API about plausible addresses, and places uncertain results into a review file instead of making an irreversible decision.
The finished command produces three auditable CSV files: accepted contacts, rejected rows, and a manual review queue. Transient API failures also go to review, so an outage never silently deletes a subscriber.
Get access before writing integration code
- Register at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have an account.
- Open the Email Validator service page.
- Choose the available Free, Plus, or Pro plan and complete activation.
- Open the official Email Validator documentation.
- Find the Service token panel and copy the service-scoped token.
Regenerating that token revokes the previously active token. Treat rotation as a deployment change: update the secret in every running environment before depending on the new credential.
The exact API call is GET https://ai.mihajlo.mk/api/email-validator/v1/check-email. Authentication uses the required token query parameter, while the address goes in the email query parameter.
Make one minimal request before building the feature:
curl --get 'https://ai.mihajlo.mk/api/email-validator/v1/check-email' \
--data-urlencode 'token=YOUR_SERVICE_TOKEN' \
--data-urlencode '[email protected]'
The response supplies status, score, recommendation, checks, and quota. We will map all five defensively rather than assuming that an incomplete or changed response is trustworthy.
Create the Symfony project and environment configuration
This example targets PHP 8.3 or later and a current Symfony application with Console, HttpClient, DependencyInjection, and Monolog available. PHPUnit and Symfony’s test utilities provide deterministic HTTP tests.
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
Commit a harmless placeholder in .env, then inject the real value through the deployment platform or an uncommitted .env.local during local development:
# .env
EMAIL_VALIDATOR_TOKEN=YOUR_SERVICE_TOKEN
EMAIL_VALIDATOR_ACCEPT_STATUSES=valid
EMAIL_VALIDATOR_ACCEPT_RECOMMENDATIONS=accept
EMAIL_VALIDATOR_ACCEPT_SCORE=80
The accepted values and score threshold are application policy, not hard-coded assumptions about every future response. Confirm them against the official documentation and tune them to your risk tolerance. A publication list may favor review over aggressive acceptance; an internal notification list may use a different threshold.
A deliberately small architecture
The import has four boundaries:
- The command reads and writes CSV files.
- The API client owns HTTP, retries, authentication, and response validation.
- A domain result preserves the five service signals.
- A policy converts those signals into
acceptorreview.
Clearly malformed local input is rejected before consuming remote quota. Plausible addresses are never automatically rejected solely because a remote response is uncertain. They go to review, preserving the operator’s ability to inspect and retry them.
The relevant project structure is:
src/
Command/ImportNewsletterContactsCommand.php
EmailValidator/EmailValidationException.php
EmailValidator/EmailValidatorClient.php
EmailValidator/ValidationResult.php
Newsletter/ImportDecision.php
Newsletter/NewsletterImportPolicy.php
tests/
EmailValidator/EmailValidatorClientTest.php
Newsletter/NewsletterImportPolicyTest.php
var/import/
config/services.yaml
Map the API response at the boundary
The DTO rejects missing or mistyped fields. It deliberately keeps checks and quota structurally conservative because the supplied contract does not justify inventing nested keys.
<?php
// src/EmailValidator/ValidationResult.php
namespace App\EmailValidator;
final readonly class ValidationResult
{
public function __construct(
public string $status,
public float $score,
public string $recommendation,
public array $checks,
public array|string|int|float $quota,
) {
}
public static function fromArray(array $payload): self
{
if (!isset($payload['status']) || !is_string($payload['status'])
|| !isset($payload['score']) || !is_numeric($payload['score'])
|| !isset($payload['recommendation'])
|| !is_string($payload['recommendation'])
|| !isset($payload['checks']) || !is_array($payload['checks'])
|| !array_key_exists('quota', $payload)
|| !(is_array($payload['quota']) || is_scalar($payload['quota']))) {
throw new EmailValidationException(
'Malformed Email Validator response.',
EmailValidationException::MALFORMED_RESPONSE
);
}
return new self(
trim($payload['status']),
(float) $payload['score'],
trim($payload['recommendation']),
$payload['checks'],
$payload['quota'],
);
}
}
A typed exception gives the command structured failure states without exposing tokens or raw response bodies.
<?php
// src/EmailValidator/EmailValidationException.php
namespace App\EmailValidator;
final class EmailValidationException extends \RuntimeException
{
public const AUTHENTICATION = 'authentication';
public const INVALID_REQUEST = 'invalid_request';
public const QUOTA = 'quota_or_rate_limit';
public const UNAVAILABLE = 'unavailable';
public const MALFORMED_RESPONSE = 'malformed_response';
public function __construct(
string $message,
public readonly string $kind,
?\Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
}
Build a bounded, retry-aware HTTP client
The client retries transport failures, HTTP 429, and server errors. It does not retry authentication failures or request validation errors. Backoff is bounded at three attempts, preventing a struggling service from trapping an import indefinitely.
<?php
// src/EmailValidator/EmailValidatorClient.php
namespace App\EmailValidator;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final 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 $token,
) {
}
public function check(string $email): ValidationResult
{
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->http->request('GET', self::ENDPOINT, [
'query' => [
'token' => $this->token,
'email' => $email,
],
'connect_timeout' => 2.0,
'timeout' => 6.0,
]);
$statusCode = $response->getStatusCode();
if ($statusCode === 401 || $statusCode === 403) {
throw new EmailValidationException(
'Email Validator authentication failed.',
EmailValidationException::AUTHENTICATION
);
}
if ($statusCode === 400 || $statusCode === 422) {
throw new EmailValidationException(
'Email Validator rejected the request.',
EmailValidationException::INVALID_REQUEST
);
}
if ($statusCode === 429) {
if ($attempt < 3) {
$this->backoff($attempt);
continue;
}
throw new EmailValidationException(
'Email Validator quota or rate limit reached.',
EmailValidationException::QUOTA
);
}
if ($statusCode >= 500) {
if ($attempt < 3) {
$this->backoff($attempt);
continue;
}
throw new EmailValidationException(
'Email Validator is unavailable.',
EmailValidationException::UNAVAILABLE
);
}
if ($statusCode < 200 || $statusCode >= 300) {
throw new EmailValidationException(
'Unexpected Email Validator response.',
EmailValidationException::INVALID_REQUEST
);
}
return ValidationResult::fromArray($response->toArray(false));
} catch (TransportExceptionInterface $exception) {
$this->logger->warning('Email validation transport failure', [
'attempt' => $attempt,
'exception_class' => $exception::class,
]);
if ($attempt === 3) {
throw new EmailValidationException(
'Email Validator transport failure.',
EmailValidationException::UNAVAILABLE,
$exception
);
}
$this->backoff($attempt);
}
}
throw new \LogicException('Unreachable retry state.');
}
private function backoff(int $attempt): void
{
usleep(200_000 * (2 ** ($attempt - 1)));
}
}
Configure dependency injection without resolving the secret into source code:
# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
App\EmailValidator\EmailValidatorClient:
arguments:
$token: '%env(EMAIL_VALIDATOR_TOKEN)%'
App\Newsletter\NewsletterImportPolicy:
arguments:
$acceptedStatuses: '%env(csv:EMAIL_VALIDATOR_ACCEPT_STATUSES)%'
$acceptedRecommendations: '%env(csv:EMAIL_VALIDATOR_ACCEPT_RECOMMENDATIONS)%'
$minimumScore: '%env(float:EMAIL_VALIDATOR_ACCEPT_SCORE)%'
Turn service signals into a cautious business decision
The policy uses every returned signal. Missing quota information is not treated as trustworthy, any explicit Boolean failure inside checks blocks automatic acceptance, and status and recommendation must match configured allowlists.
<?php
// src/Newsletter/ImportDecision.php
namespace App\Newsletter;
enum ImportDecision: string
{
case ACCEPT = 'accept';
case REVIEW = 'review';
}
<?php
// src/Newsletter/NewsletterImportPolicy.php
namespace App\Newsletter;
use App\EmailValidator\ValidationResult;
final class NewsletterImportPolicy
{
public function __construct(
private array $acceptedStatuses,
private array $acceptedRecommendations,
private float $minimumScore,
) {
}
public function decide(ValidationResult $result): ImportDecision
{
$status = strtolower($result->status);
$recommendation = strtolower($result->recommendation);
$safe = in_array($status, $this->acceptedStatuses, true)
&& in_array($recommendation, $this->acceptedRecommendations, true)
&& $result->score >= $this->minimumScore
&& $result->quota !== ''
&& !$this->containsExplicitFailure($result->checks);
return $safe ? ImportDecision::ACCEPT : ImportDecision::REVIEW;
}
private function containsExplicitFailure(array $checks): bool
{
foreach ($checks as $value) {
if ($value === false) {
return true;
}
if (is_array($value) && $this->containsExplicitFailure($value)) {
return true;
}
}
return false;
}
}
Import, clean, and create the review queue
The input CSV must contain email and name headers. The command trims fields, rejects locally malformed addresses, deduplicates case-insensitively, and validates each remaining address.
<?php
// src/Command/ImportNewsletterContactsCommand.php
namespace App\Command;
use App\EmailValidator\EmailValidationException;
use App\EmailValidator\EmailValidatorClient;
use App\Newsletter\ImportDecision;
use App\Newsletter\NewsletterImportPolicy;
use Psr\Log\LoggerInterface;
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('app:newsletter:import')]
final class ImportNewsletterContactsCommand extends Command
{
public function __construct(
private EmailValidatorClient $validator,
private NewsletterImportPolicy $policy,
private LoggerInterface $logger,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addArgument('input', InputArgument::REQUIRED, 'Source CSV path');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$source = fopen((string) $input->getArgument('input'), 'rb');
if ($source === false) {
$output->writeln('<error>Cannot open input CSV.</error>');
return Command::FAILURE;
}
$headers = fgetcsv($source);
if ($headers === false || !in_array('email', $headers, true)
|| !in_array('name', $headers, true)) {
fclose($source);
$output->writeln('<error>CSV needs email and name headers.</error>');
return Command::INVALID;
}
$directory = dirname(__DIR__, 2).'/var/import';
if (!is_dir($directory)) {
mkdir($directory, 0770, true);
}
$accepted = fopen($directory.'/accepted.csv', 'wb');
$review = fopen($directory.'/review.csv', 'wb');
$rejected = fopen($directory.'/rejected.csv', 'wb');
fputcsv($accepted, ['email', 'name']);
fputcsv($review, [
'email', 'name', 'reason', 'status', 'score',
'recommendation', 'checks', 'quota'
]);
fputcsv($rejected, ['email', 'name', 'reason']);
$seen = [];
while (($row = fgetcsv($source)) !== false) {
if (count($row) !== count($headers)) {
fputcsv($rejected, ['', '', 'column_count']);
continue;
}
$contact = array_combine($headers, $row);
$email = trim((string) $contact['email']);
$name = trim((string) $contact['name']);
$dedupeKey = strtolower($email);
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
fputcsv($rejected, [$email, $name, 'local_syntax']);
continue;
}
if (isset($seen[$dedupeKey])) {
fputcsv($rejected, [$email, $name, 'duplicate']);
continue;
}
$seen[$dedupeKey] = true;
try {
$result = $this->validator->check($email);
$decision = $this->policy->decide($result);
if ($decision === ImportDecision::ACCEPT) {
fputcsv($accepted, [$email, $name]);
} else {
fputcsv($review, [
$email, $name, 'uncertain',
$result->status, $result->score,
$result->recommendation,
json_encode($result->checks, JSON_THROW_ON_ERROR),
json_encode($result->quota, JSON_THROW_ON_ERROR),
]);
}
} catch (EmailValidationException $exception) {
$this->logger->warning('Contact queued for review', [
'failure_kind' => $exception->kind,
'email_hash' => hash('sha256', strtolower($email)),
]);
fputcsv($review, [
$email, $name, $exception->kind, '', '', '', '', ''
]);
}
}
foreach ([$source, $accepted, $review, $rejected] as $handle) {
fclose($handle);
}
$output->writeln('Import complete. Inspect var/import/review.csv.');
return Command::SUCCESS;
}
}
Run it with:
php bin/console app:newsletter:import var/import/contacts.csv
Test the boundary and policy deterministically
MockHttpClient exercises the real mapping code without spending quota or contacting the service.
<?php
// tests/EmailValidator/EmailValidatorClientTest.php
namespace App\Tests\EmailValidator;
use App\EmailValidator\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 testMapsSuccessfulResponse(): void
{
$http = new MockHttpClient(function (string $method, string $url): MockResponse {
self::assertSame('GET', $method);
self::assertStringContainsString('email=reader%40example.com', $url);
self::assertStringContainsString('token=test-token', $url);
return new MockResponse(json_encode([
'status' => 'valid',
'score' => 92,
'recommendation' => 'accept',
'checks' => ['syntax' => true, 'mx' => true],
'quota' => ['remaining' => 10],
], JSON_THROW_ON_ERROR), [
'http_code' => 200,
'response_headers' => ['content-type: application/json'],
]);
});
$result = (new EmailValidatorClient(
$http, new NullLogger(), 'test-token'
))->check('[email protected]');
self::assertSame(92.0, $result->score);
self::assertSame('accept', $result->recommendation);
}
}
<?php
// tests/Newsletter/NewsletterImportPolicyTest.php
namespace App\Tests\Newsletter;
use App\EmailValidator\ValidationResult;
use App\Newsletter\ImportDecision;
use App\Newsletter\NewsletterImportPolicy;
use PHPUnit\Framework\TestCase;
final class NewsletterImportPolicyTest extends TestCase
{
public function testFailedCheckRequiresReview(): void
{
$policy = new NewsletterImportPolicy(['valid'], ['accept'], 80);
$result = new ValidationResult(
'valid',
95,
'accept',
['syntax' => true, 'mx' => false],
['remaining' => 9],
);
self::assertSame(ImportDecision::REVIEW, $policy->decide($result));
}
}
php bin/phpunit
Security, observability, and deployment
Because authentication is carried in a query parameter, URL logging deserves special attention. Do not log request URLs, HttpClient option arrays, exception traces containing URLs, or raw transport diagnostics in production. The example logs only an attempt number, failure category, exception class, and a one-way email hash.
Keep generated CSV files outside the public web root and restrict their permissions. They contain personal data even when the API token is absent. Define a retention period, transfer accepted contacts to the newsletter system, resolve review rows, and remove old artifacts through an operational process.
For deployment, provide EMAIL_VALIDATOR_TOKEN through the host’s secret manager, verify the three policy variables, create a writable var/import directory, and run imports under a dedicated operating-system identity. A scheduler or worker can invoke the command, but Symfony Messenger is unnecessary unless imports must be divided into independently retried messages.
Monitor counts of accepted, reviewed, rejected, rate-limited, malformed-response, and unavailable outcomes. Alert on sudden changes in proportions rather than logging every address. A sharp rise in review volume often reveals a policy mismatch, exhausted quota, provider failure, or changed upstream data.
Common failure modes
- Every request fails authentication: confirm plan activation and the service-scoped token. If the token was regenerated, the previous value is revoked.
- Requests receive HTTP 429: stop launching overlapping imports, inspect quota, and resume only when capacity is available. Repeated immediate retries amplify the problem.
- Everything enters review: compare the documented response values with the configured status, recommendation, and score policy.
- The response is reported as malformed: capture safe metadata such as the HTTP status and correlation information, but never log the token or full URL.
- Partial output exists after interruption: write each import into a uniquely named staging directory in production, then publish it atomically after command success.
Final verification checklist
- The token comes from environment-backed configuration and is absent from source control.
- The client calls the exact GET endpoint with
tokenandemailquery parameters. - Connection and response timeouts are bounded.
- Only transient transport, rate-limit, and server failures are retried.
status,score,recommendation,checks, andquotaall influence the decision.- Malformed, duplicate, accepted, and uncertain contacts land in distinct outputs.
- API failures preserve contacts in the review queue.
- Tests run without network access or real credentials.
- Logs contain neither tokens nor raw email addresses.
A dependable import is not the one that claims certainty about every address. It is the one that makes confident decisions narrowly, preserves ambiguous cases visibly, and fails without losing data. That is what turns email validation from a remote lookup into a trustworthy newsletter workflow.