Vodiči

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

Symfony provjera e-pošte: razvrstavanje uvoza newslettera pomoću umjetne inteligencije i ručnog pregleda

Uvoz newslettera izgleda bezopasno dok se na popisu ne nađu pogrešno upisana domena, jednokratni sandučić ili rizična adresa. Posljedice se zatim pojave kasnije: uzaludno slanje, narušena isporučivost i zapisi kontakata kojima nitko ne vjeruje.

Ovaj vodič izrađuje Symfony naredbu usmjerenu na produkciju koja čita CSV izvoz kontakata, provjerava svaku jedinstvenu adresu i stvara tri datoteke koje se mogu revidirati: prihvaćene kontakte, lokalno nevaljane kontakte i neizvjesne kontakte za ručnu provjeru. Dizajn namjerno radi zatvoreno u slučaju neuspjeha. Odgovor se automatski prihvaća samo kada se slažu svi konfigurirani signali pouzdanosti.

Dobijte pristup validatoru e-pošte

Registrirajte se putem stranice za registraciju ili upotrijebite stranicu za prijavu ako već imate račun.

  1. Otvorite stranicu usluge Email Validator.
  2. Odaberite dostupni plan Free, Plus ili Pro i dovršite njegovu aktivaciju.
  3. Otvorite službenu dokumentaciju usluge.
  4. Pronađite ploču Service token i kopirajte token ograničen na uslugu.
  5. Pohranite taj token u konfiguraciju projekta koja se oslanja na okruženje, nikada u predane PHP ili YAML datoteke.

Ova usluga zahtijeva token. Njegova regeneracija opoziva prethodno aktivni token, stoga uskladite rotaciju s implementacijom: ažurirajte produkcijsku tajnu, implementirajte ili ponovno pokrenite aplikaciju, a tek zatim uklonite pretpostavke da stari proces može nastaviti slati zahtjeve.

Potvrdite ugovor prije pisanja PHP-a

Točan zahtjev je GET https://ai.mihajlo.mk/api/email-validator/v1/check-email. Autentikacija koristi parametar upita token, dok se adresa šalje putem parametra email.

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

Pregledajte ovaj odgovor zajedno sa službenom dokumentacijom. Zabilježite točne uspješne vrijednosti za status i recommendation, ljestvicu rezultata i nazive provjera primjenjive na vašu politiku prihvaćanja. Ta su nabrajanja konfiguracija, a ne pretpostavke u kodu u nastavku.

Izradite Symfony projekt

Preduvjeti su PHP 8.3 ili noviji, Composer, PHP proširenje cURL ili drugi transport koji podržava Symfony HttpClient te ulazni CSV čiji prvi red sadrži email i opcionalno 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

Dovršena funkcionalnost ima uski oblik:

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

Sinkrona konzolna naredba ovdje je prikladna jer mali tim obično želi jedan potpun, pregledan artefakt prije uvoza bilo čega u svoju platformu za newsletter. Messenger bi postao koristan za vrlo velike skupove podataka ili podatke koji neprestano pristižu, ali bi također zahtijevao trajno stanje po kontaktu, idempotentnost i agregiranje rezultata. Ti troškovi ne poboljšavaju ovaj skupni radni tijek.

Konfigurirajte tajne i politiku

Razvojne vjerodajnice stavite u .env.local, koji ne bi trebao biti predan u repozitorij. U produkciji unesite ista imena putem upravitelja tajni platforme za implementaciju ili konfiguracije okruženja.

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

Prag rezultata mora koristiti ljestvicu dokumentiranu za uslugu. Nemojte pretpostaviti da svaki API za bodovanje koristi isti raspon. Isto tako, zamijenite primjer objekta provjere stvarnim ključevima odgovora i njihovim obveznim vrijednostima.

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

Mapirajte odgovor na granici aplikacije

Usluga vraća status, score, recommendation, checks i quota. Sav mrežni JSON tretirajte kao nepouzdan ulaz. Neispravan ili nepotpun odgovor nikada ne smije neprimjetno postati prihvaćen kontakt.

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

Izradite ograničenog klijenta svjesnog ponovnih pokušaja

Klijent radi najviše dva pokušaja. Jednom ponovno pokušava kod kvarova transporta i prolaznih odgovora pristupnika uz kratko čekanje. Ne pokušava ponovno kod lošeg ulaza, neuspjeha autentikacije, odgovora o kvoti ili ograničenju brzine, niti neispravnog JSON-a. Ponavljanje tih zahtjeva potrošilo bi vrijeme bez promjene uzroka.

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

Nijedan zapisnik ne sadrži e-poštu, tijelo odgovora ni token. Budući da je autentikacija nužno u nizu upita, također konfigurirajte obrnute proxyje i alate za praćenje performansi aplikacije da redigiraju parametar token. Držite Symfonyjev profiler onemogućenim u produkciji.

Pretvorite signale usluge u konzervativnu odluku

Politika koristi svako ugovoreno područje odgovora. Status i preporuka moraju odgovarati lokalno odobrenim vrijednostima, rezultat mora dosegnuti prag, konfigurirane provjere moraju se točno podudarati, a podaci o kvoti moraju biti prisutni. Nepoznati, nedostajući ili novopromijenjeni signali idu na provjeru umjesto da prođu na popis za slanje.

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

Cijeli objekt kvote zadržava se u izlazu za reviziju. Odgovor bez podataka o kvoti ne može se prihvatiti, dok HTTP 429 ostaje mjerodavan signal za zaustavljanje. Time se izbjegava izmišljanje potpolja kvote koje nije dio isporučenog ugovora.

Očistite uvoz Symfony naredbom

Naredba odbacuje očito neispravan lokalni ulaz prije trošenja kvote, uklanja duplikate neovisno o veličini slova te nesigurne ili privremeno neprovjerljive kontakte zapisuje u review.csv. Neuspjesi autentikacije i kvote zaustavljaju skup jer bi nastavak proizveo samo obmanjujući izlaz.

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

Testirajte bez kontaktiranja usluge

MockHttpClient testu pruža deterministički transport. Upotrijebite sintetički rječnik kako se fixtures ne bi mogle zamijeniti za tvrdnje o nedokumentiranim vrijednostima usluge.

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

Zaštitne mjere za produkciju i česti neuspjesi

Pokrenite naredbu u privatnom radnom okruženju, a ne iz web zahtjeva. Ograničite pristup ulaznim i izlaznim datotekama jer su adrese e-pošte osobni podaci. Definirajte razdoblje zadržavanja, šifrirajte pohranu gdje je prikladno i nikada ne predajte izvoze kontakata u repozitorij.

  • 401 ili 403: potvrdite varijablu okruženja i je li regeneracija tokena opozvala implementiranu vrijednost.
  • 429: zaustavite se, pregledajte korištenje plana i nastavite tek kada kvota ili ograničenja brzine to dopuste. Nemojte zatrpavati krajnju točku ponovnim pokušajima.
  • Sve završava na provjeri: usporedite konfigurirani status, preporuku, ljestvicu rezultata i vrijednosti provjere s trenutačnim dokumentiranim odgovorom.
  • Neuspjesi protokola: zadržite događaj i HTTP status u zapisnicima, ali ne i tijelo, token ili adresu. Istražite prije ublažavanja provjere.
  • Djelomičan izlaz: tretirajte nenultu izlaznu vrijednost naredbe kao neuspjelu skupnu obradu. Ispravite uzrok i ponovno pokrenite obradu iz izvornog ulaza prije objave prihvaćenih kontakata.

Za vidljivost bilježite skupne brojeve prihvaćenih, odbijenih, pregledanih, dupliciranih i neuspjelih kontakata; trajanje izvršavanja; broj ponovnih pokušaja; i vrste neuspjeha. Izbjegavajte upotrebu adresa e-pošte kao oznaka metrika. Postavite upozorenja za neuspjehe autentikacije i nagle promjene stope pregleda, jer oboje često ukazuje na odstupanje konfiguracije ili ugovora.

Završni popis za provjeru

  • Stvarni token postoji samo u tajnoj pohrani koja se oslanja na okruženje.
  • Minimalni zahtjev uspijeva s točnom GET krajnjom točkom i parametrima upita.
  • Rječnik prihvaćanja, prag rezultata i obvezne provjere odgovaraju dokumentaciji i opaženom odgovoru.
  • Automatizirani testovi prolaze, a neuspjesi autentikacije upućuju samo jedan zahtjev.
  • Uzorak CSV-a stvara accepted.csv, rejected.csv i review.csv.
  • Ni token, ni adresa, ni tijelo odgovora ne pojavljuju se u zapisnicima aplikacije, proxyja, profilera ili nadzora.
  • Platforma za newsletter prima kontakte tek nakon što naredba uspješno završi i recenzenti razriješe neizvjesne retke.

Važan rezultat nije samo čišći CSV. To je obranjiva granica između automatizirane pouzdanosti i ljudske prosudbe. Prihvatite adrese čiji se signali podudaraju, odbacite samo ono što je lokalno sigurno i učinite nejasnoću vidljivom. Tako mali radni tijek newslettera postaje pouzdan bez nepotrebnog kompliciranja.

Portret autora bloga

Mihajlo

Ja sam Mihajlo — programer vođen znatiželjom, disciplinom i stalnom željom da stvorim nešto smisleno. Dijelim uvide, tutorijale i besplatne usluge kako bih pomogao drugima da pojednostave svoj rad i rastu u svijetu softvera i umjetne inteligencije koji se neprestano razvija.