Туториали

Symfony Email Validation: Triage Newsletter Imports with AI and Manual Review

Symfony валидација на е-пошта: тријажа на увози на билтени со ВИ и рачна проверка

A newsletter import looks harmless until a mistyped domain, disposable mailbox, or risky address enters the list. Then the consequences arrive later: wasted sends, damaged deliverability, and contact records nobody trusts.

This tutorial builds a production-oriented Symfony command that reads a CSV contact export, validates each unique address, and produces three auditable files: accepted contacts, locally invalid contacts, and uncertain contacts for manual review. The design deliberately fails closed. A response is accepted automatically only when every configured confidence signal agrees.

Get access to the Email Validator

Register through the registration page, or use the sign-in page if you already have an account.

  1. Open the Email Validator service page.
  2. Choose the available Free, Plus, or Pro plan and complete its activation.
  3. Open the official service documentation.
  4. Find the Service token panel and copy the service-scoped token.
  5. Store that token in environment-backed project configuration, never in committed PHP or YAML files.

This service requires a token. Regenerating it revokes the previously active token, so coordinate rotation with deployment: update the production secret, deploy or restart the application, and only then retire assumptions that the old process can continue making requests.

Confirm the contract before writing PHP

The exact request is GET https://ai.mihajlo.mk/api/email-validator/v1/check-email. Authentication uses the token query parameter, while the address is supplied through email.

curl --get 'https://ai.mihajlo.mk/api/email-validator/v1/check-email' \
  --data-urlencode 'token=YOUR_SERVICE_TOKEN' \
  --data-urlencode '[email protected]'

Inspect this response alongside the official documentation. Record the exact successful status and recommendation values, the score scale, and the check names applicable to your acceptance policy. Those enumerations are configuration rather than guesses in the code below.

Create the Symfony project

The prerequisites are PHP 8.3 or later, Composer, the cURL PHP extension or another transport supported by Symfony HttpClient, and an input CSV whose first row contains email and optionally 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

The finished feature has a narrow shape:

src/
  Command/CleanNewsletterCommand.php
  EmailValidation/
    DecisionPolicy.php
    EmailValidatorClient.php
    ValidationResult.php
    ValidatorFailure.php
tests/
  EmailValidation/EmailValidatorClientTest.php
config/services.yaml
.env.local

A synchronous console command is appropriate here because a small team normally wants one complete, inspectable artifact before importing anything into its newsletter platform. Messenger would become useful for very large or continuously arriving datasets, but it would also require durable per-contact state, idempotency, and result aggregation. Those costs do not improve this batch workflow.

Configure secrets and policy

Put development credentials in .env.local, which should remain uncommitted. In production, inject the same names through the deployment platform’s secret manager or environment configuration.

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}'

The score threshold must use the scale documented by the service. Do not assume that every scoring API uses the same range. Likewise, replace the example check object with actual response keys and their required values.

# 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)%'

Map the response at the application boundary

The service returns status, score, recommendation, checks, and quota. Treat all network JSON as untrusted input. A malformed or incomplete response must never quietly become an accepted contact.

<?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);
    }
}

Build a bounded, retry-aware client

The client makes at most two attempts. It retries transport failures and transient gateway responses once with a short backoff. It does not retry bad input, authentication failures, quota or rate-limit responses, or malformed JSON. Repeating those requests would consume time without changing the cause.

<?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.');
    }
}

No log entry contains the email, response body, or token. Because authentication is necessarily in the query string, also configure reverse proxies and application-performance tools to redact the token parameter. Keep Symfony’s profiler disabled in production.

Turn service signals into a conservative decision

The policy uses every contracted response area. Status and recommendation must match locally approved values, score must clear the threshold, configured checks must match exactly, and quota data must be present. Unknown, missing, or newly changed signals go to review instead of slipping into the send list.

<?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;
    }
}

The entire quota object is retained in the audit output. A response without quota data cannot be accepted, while HTTP 429 remains the authoritative stop signal. This avoids inventing a quota subfield that is not part of the supplied contract.

Clean the import with a Symfony command

The command rejects obviously malformed local input before spending quota, deduplicates case-insensitively, and writes uncertain or temporarily uncheckable contacts to review.csv. Authentication and quota failures stop the batch because continuing would only produce misleading output.

<?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;
    }
}

Test without contacting the service

MockHttpClient gives the test a deterministic transport. Use synthetic vocabulary so fixtures cannot be mistaken for claims about undocumented service values.

<?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

Production safeguards and common failures

Run the command in a private worker environment, not from a web request. Restrict access to input and output files because email addresses are personal data. Define a retention period, encrypt storage where appropriate, and never commit contact exports.

  • 401 or 403: confirm the environment variable and whether token regeneration revoked the deployed value.
  • 429: stop, inspect plan usage, and resume only when quota or rate limits permit. Do not hammer the endpoint with retries.
  • Everything enters review: compare configured status, recommendation, score scale, and check values with a current documented response.
  • Protocol failures: retain the event and HTTP status in logs, but not the body, token, or address. Investigate before relaxing validation.
  • Partial output: treat a nonzero command exit as a failed batch. Correct the cause and rerun from the original input before publishing accepted contacts.

For observability, record batch counts for accepted, rejected, reviewed, duplicate, and failed contacts; execution duration; retry counts; and failure kinds. Avoid using email addresses as metric labels. Alert on authentication failures and sharp changes in review rate, because both often indicate configuration or contract drift.

Final verification checklist

  • The real token exists only in environment-backed secret storage.
  • The minimal request succeeds with the exact GET endpoint and query parameters.
  • Acceptance vocabulary, score threshold, and required checks match the documentation and observed response.
  • Automated tests pass and authentication failures make only one request.
  • A sample CSV produces accepted.csv, rejected.csv, and review.csv.
  • No token, address, or response body appears in application, proxy, profiler, or monitoring logs.
  • The newsletter platform receives contacts only after the command exits successfully and reviewers resolve uncertain rows.

The important result is not merely a cleaner CSV. It is a defensible boundary between automated confidence and human judgment. Accept the addresses whose signals align, reject only what is locally certain, and make ambiguity visible. That is how a small newsletter workflow becomes reliable without becoming needlessly complicated.

Портрет на автор на блогот

Mihajlo

Јас сум Михајло - развивач поттикнат од љубопитност, дисциплина и постојаната желба да создадам нешто значајно. Споделувам увиди, упатства и бесплатни услуги за да им помогнам на другите да ја поедностават својата работа и да растат во постојано развивачкиот свет на софтверот и вештачката интелигенција.