Туториали

Symfony: Enhance Contact Forms with AI Email Validation, Caching, and Fallbacks

Symfony: Подобрете ги контактните формулари со AI валидација на е-пошта, кеширање и резервни опции

Формуларот за контакт може да го помине секое локално правило за валидација, а сепак да собере адреса што не може да прими одговор. Валидацијата на синтаксата открива печатни грешки како што е недостасувачко @, но не може да потврди дали доменот објавува записи за пошта, дали давателот изгледа веродостојно или дали адресата претставува практичен ризик за испорака.

Ова упатство ги вградува тие проверки во Symfony формулар за контакт без надворешната услуга да стане единствена точка на откажување. Успешните проценки се кешираат, јасните неуспеси се отфрлаат, неизвесните резултати се насочуваат на преглед, а привремените прекини на API-то се пропуштаат отворено за потенцијалниот клиент да не биде тивко изгубен.

Добијте пристап до услугата Email Validator

Пред да напишете код за интеграција, креирајте сметка на https://ai.mihajlo.mk/register, или користете https://ai.mihajlo.mk/login ако веќе имате.

  1. Отворете ја страницата на услугата Email Validator.
  2. Изберете го достапниот Free, Plus или Pro план и завршете ја неговата активација.
  3. Отворете ја официјалната документација за Email Validator.
  4. Најдете го панелот Service token и копирајте го токенот ограничен на услугата.

Оваа услуга бара токен. Неговото повторно генерирање го поништува претходно активниот токен, затоа третирајте ја ротацијата како координирано распоредување: ажурирајте ја тајната на апликацијата насекаде пред да ги отстраните претпоставките дека стариот токен сè уште работи.

Точното барање е HTTP GET до https://ai.mihajlo.mk/api/email-validator/v1/check-email. Автентикацијата го користи параметарот за барање token, додека адресата оди во email. Направете едно минимално барање со податоци за пристап како места за пополнување:

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

Договорот за одговор вклучува status, score, recommendation, checks и quota. Не претпоставувајте недокументирани enum-вредности или скала за резултатот. Граничниот код подолу ги валидира нивните типови, додека политика во сопственост на апликацијата толкува само експлицитни сигнали што ги разбира.

Креирајте Symfony проект и конфигурација

Имплементацијата е наменета за PHP 8.3 или понова верзија и тековна Symfony апликација. Доволни се првостепениот HTTP клиент на Symfony, кешот, формуларите, валидаторот, CSRF заштитата, Mailer и алатките за тестирање:

composer create-project symfony/skeleton contact-validator
cd contact-validator
composer require symfony/http-client symfony/cache symfony/form symfony/validator \
  symfony/twig-bundle symfony/security-csrf symfony/mailer
composer require --dev symfony/test-pack

Чувајте ги тајните надвор од контролата на изворниот код. Ставете ги локалните вредности во .env.local; вбризгајте еквивалентни тајни променливи на околината преку продукциската платформа:

EMAIL_VALIDATOR_TOKEN=YOUR_SERVICE_TOKEN
EMAIL_CACHE_HMAC_KEY=GENERATE_A_LONG_RANDOM_VALUE
[email protected]
[email protected]
MAILER_DSN=null://null

null://null е соодветно само за локален развој бидејќи ја отфрла поштата. За продукција е потребен вистинскиот Symfony Mailer DSN обезбеден од вашиот давател на пошта.

Регистрирајте експлицитни аргументи во config/services.yaml. HMAC клучот за кешот спречува нормализираните е-поштени адреси директно да се појавуваат во клучевите на кешот.

parameters:
    app.email_validator.token: '%env(string:EMAIL_VALIDATOR_TOKEN)%'
    app.email_cache_hmac_key: '%env(string:EMAIL_CACHE_HMAC_KEY)%'

services:
    _defaults:
        autowire: true
        autoconfigure: true

    App\:
        resource: '../src/'

    App\EmailValidation\EmailValidatorClient:
        arguments:
            $serviceToken: '%app.email_validator.token%'

    App\EmailValidation\ContactEmailVerifier:
        arguments:
            $cache: '@cache.app'
            $cacheHmacKey: '%app.email_cache_hmac_key%'

Изградете одбранбена API граница

Клиентот ги мапира оддалечените податоци во мал доменски објект. Повторува само транспортни неуспеси и избрани неуспеси на серверот, со ограничено повлекување. Грешките при автентикација, неправилно формираните барања и одговорите за квота не се повторуваат слепо.

<?php
// src/EmailValidation/EmailAssessment.php
namespace App\EmailValidation;

final readonly class EmailAssessment
{
    public function __construct(
        public string $providerState,
        public ?string $status = null,
        public ?float $score = null,
        public ?string $recommendation = null,
        public array $checks = [],
        public array $quota = [],
        public ?string $failureReason = null,
    ) {}

    public function isUsable(): bool
    {
        return $this->providerState === 'ok';
    }

    public static function unavailable(string $state, string $reason): self
    {
        return new self($state, failureReason: $reason);
    }
}
<?php
// src/EmailValidation/EmailValidatorClient.php
namespace App\EmailValidation;

use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
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 $httpClient,
        private LoggerInterface $logger,
        private string $serviceToken,
    ) {}

    public function check(string $email): EmailAssessment
    {
        $delays = [100_000, 250_000];

        for ($attempt = 0; $attempt < 3; $attempt++) {
            try {
                $response = $this->httpClient->request('GET', self::ENDPOINT, [
                    'query' => [
                        'token' => $this->serviceToken,
                        'email' => $email,
                    ],
                    'timeout' => 4.0,
                    'max_duration' => 6.0,
                ]);

                $httpStatus = $response->getStatusCode();
            } catch (TransportExceptionInterface) {
                if ($attempt < 2) {
                    usleep($delays[$attempt]);
                    continue;
                }

                $this->logger->warning('Email validation transport failure');
                return EmailAssessment::unavailable(
                    'temporary_failure',
                    'transport_failure'
                );
            }

            if ($httpStatus === 429) {
                return EmailAssessment::unavailable('quota', 'rate_limited');
            }

            if ($httpStatus === 401 || $httpStatus === 403) {
                return EmailAssessment::unavailable('auth', 'token_rejected');
            }

            if (in_array($httpStatus, [500, 502, 503, 504], true)) {
                $response->cancel();

                if ($attempt < 2) {
                    usleep($delays[$attempt]);
                    continue;
                }

                return EmailAssessment::unavailable(
                    'temporary_failure',
                    'upstream_server_error'
                );
            }

            if ($httpStatus < 200 || $httpStatus >= 300) {
                return EmailAssessment::unavailable(
                    'invalid_request',
                    'unexpected_http_status'
                );
            }

            try {
                $data = $response->toArray(false);
            } catch (DecodingExceptionInterface|TransportExceptionInterface) {
                return EmailAssessment::unavailable(
                    'malformed_response',
                    'response_not_valid_json'
                );
            }

            if (
                !is_string($data['status'] ?? null) ||
                !is_string($data['recommendation'] ?? null)
            ) {
                return EmailAssessment::unavailable(
                    'malformed_response',
                    'required_fields_missing'
                );
            }

            $score = $data['score'] ?? null;

            return new EmailAssessment(
                providerState: 'ok',
                status: $data['status'],
                score: is_numeric($score) ? (float) $score : null,
                recommendation: $data['recommendation'],
                checks: is_array($data['checks'] ?? null)
                    ? $data['checks']
                    : [],
                quota: is_array($data['quota'] ?? null)
                    ? $data['quota']
                    : [],
            );
        }

        return EmailAssessment::unavailable(
            'temporary_failure',
            'attempts_exhausted'
        );
    }
}

Токенот мора да се испрати во низа за барање бидејќи тоа е договорот на услугата. Следствено, HTTP дневниците за пристап, алатките за исклучоци и развојните профајлери заслужуваат посебно внимание: редактирајте го параметарот token и никогаш не ја евидентирајте целосната URL-адреса на барањето.

Кеширајте проценки и дефинирајте ја деловната одлука

Верификаторот кешира само успешни API одговори. Прекините, неуспесите при автентикација и неуспесите со квота остануваат некеширани за закрепнувањето брзо да се открие. Животен век од шест часа ги намалува дупликатните повици без да дозволи проценката практично да стане трајна.

<?php
// src/EmailValidation/ContactEmailVerifier.php
namespace App\EmailValidation;

use Psr\Cache\CacheItemPoolInterface;

final class ContactEmailVerifier
{
    public function __construct(
        private EmailValidatorClient $client,
        private CacheItemPoolInterface $cache,
        private string $cacheHmacKey,
    ) {}

    public function verify(string $email): EmailAssessment
    {
        $normalized = strtolower(trim($email));
        $fingerprint = hash_hmac('sha256', $normalized, $this->cacheHmacKey);
        $item = $this->cache->getItem('email_validation.v1.'.$fingerprint);

        if ($item->isHit() && $item->get() instanceof EmailAssessment) {
            return $item->get();
        }

        $assessment = $this->client->check($normalized);

        if ($assessment->isUsable()) {
            $item->set($assessment);
            $item->expiresAfter(21_600);
            $this->cache->save($item);
        }

        return $assessment;
    }
}

Политиката намерно избегнува измислување значење за произволни опсези на резултати. Таа отфрла експлицитни негативни вредности за статус или препорака што ги препознава оваа апликација, како и експлицитни неуспеси во основните проверки на синтаксата, доменот или MX. Недостасувачките информации за резултатот, проверките или квотата создаваат одлука за преглед наместо лажно тврдење за сигурност.

<?php
// src/EmailValidation/ContactEmailPolicy.php
namespace App\EmailValidation;

enum EmailOutcome: string
{
    case ACCEPT = 'accept';
    case REVIEW = 'review';
    case REJECT = 'reject';
    case UNAVAILABLE = 'unavailable';
}

final readonly class ContactEmailDecision
{
    public function __construct(
        public EmailOutcome $outcome,
        public string $reason,
        public ?float $score,
        public array $checks,
        public array $quota,
    ) {}
}

final class ContactEmailPolicy
{
    public function decide(EmailAssessment $assessment): ContactEmailDecision
    {
        if (!$assessment->isUsable()) {
            return $this->decision(
                EmailOutcome::UNAVAILABLE,
                $assessment->failureReason ?? 'provider_unavailable',
                $assessment
            );
        }

        $status = strtolower(trim($assessment->status ?? ''));
        $recommendation = strtolower(trim(
            $assessment->recommendation ?? ''
        ));

        if (
            in_array($status, ['invalid', 'undeliverable'], true) ||
            in_array($recommendation, ['reject', 'block'], true) ||
            $this->coreCheckFailed($assessment->checks)
        ) {
            return $this->decision(
                EmailOutcome::REJECT,
                'explicit_negative_signal',
                $assessment
            );
        }

        if (
            $assessment->score === null ||
            $assessment->checks === [] ||
            $assessment->quota === []
        ) {
            return $this->decision(
                EmailOutcome::REVIEW,
                'incomplete_evidence',
                $assessment
            );
        }

        if (
            in_array($status, ['valid', 'deliverable'], true) ||
            in_array($recommendation, ['accept', 'allow'], true)
        ) {
            return $this->decision(
                EmailOutcome::ACCEPT,
                'affirmative_signal',
                $assessment
            );
        }

        return $this->decision(
            EmailOutcome::REVIEW,
            'unrecognized_provider_signal',
            $assessment
        );
    }

    private function coreCheckFailed(array $checks): bool
    {
        foreach (['syntax', 'domain', 'mx'] as $name) {
            $value = $checks[$name] ?? null;

            if ($value === false) {
                return true;
            }

            if (is_array($value) && ($value['valid'] ?? null) === false) {
                return true;
            }
        }

        return false;
    }

    private function decision(
        EmailOutcome $outcome,
        string $reason,
        EmailAssessment $assessment,
    ): ContactEmailDecision {
        return new ContactEmailDecision(
            $outcome,
            $reason,
            $assessment->score,
            $assessment->checks,
            $assessment->quota,
        );
    }
}

Тие препознаени низи се локална политика, а не тврдење дека API-то ја гарантира секоја наведена вредност. Споредете ги со тековната официјална документација пред распоредување и приспособете ја политиката на едно место ако документираниот вокабулар се разликува.

Поврзете ја валидацијата со формуларот за контакт

Прво задржете ја локалната валидација на Symfony. Таа дава непосредна повратна информација и избегнува трошење квота за очигледно неправилно формиран внес.

<?php
// src/Form/ContactData.php
namespace App\Form;

use Symfony\Component\Validator\Constraints as Assert;

final class ContactData
{
    #[Assert\NotBlank]
    #[Assert\Length(max: 120)]
    public string $name = '';

    #[Assert\NotBlank]
    #[Assert\Email(mode: 'html5')]
    #[Assert\Length(max: 254)]
    public string $email = '';

    #[Assert\NotBlank]
    #[Assert\Length(max: 5000)]
    public string $message = '';
}

// src/Form/ContactType.php
namespace App\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

final class ContactType extends AbstractType
{
    public function buildForm(
        FormBuilderInterface $builder,
        array $options
    ): void {
        $builder
            ->add('name', TextType::class)
            ->add('email', EmailType::class)
            ->add('message', TextareaType::class)
            ->add('send', SubmitType::class);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults(['data_class' => ContactData::class]);
    }
}

Контролерот отфрла само цврст негативен резултат. Исходите за преглед и недостапност сè уште ја испорачуваат пораката, но нивниот префикс во предметот го прави видливо рачното сортирање. Ова е намерен избор на отворено пропуштање за обичен формулар за контакт; обновувањето лозинка или финансиските работни текови може да оправдаат построга политика.

<?php
// src/Controller/ContactController.php
namespace App\Controller;

use App\EmailValidation\ContactEmailPolicy;
use App\EmailValidation\ContactEmailVerifier;
use App\EmailValidation\EmailOutcome;
use App\Form\ContactData;
use App\Form\ContactType;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormError;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
use Symfony\Component\Routing\Attribute\Route;

final class ContactController extends AbstractController
{
    #[Route('/contact', name: 'contact', methods: ['GET', 'POST'])]
    public function __invoke(
        Request $request,
        ContactEmailVerifier $verifier,
        ContactEmailPolicy $policy,
        MailerInterface $mailer,
        LoggerInterface $logger,
    ): Response {
        $data = new ContactData();
        $form = $this->createForm(ContactType::class, $data);
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            $assessment = $verifier->verify($data->email);
            $decision = $policy->decide($assessment);

            if ($decision->outcome === EmailOutcome::REJECT) {
                $form->get('email')->addError(new FormError(
                    'Please enter an email address that can receive replies.'
                ));
            } else {
                $prefix = match ($decision->outcome) {
                    EmailOutcome::ACCEPT => '',
                    EmailOutcome::REVIEW => '[email review] ',
                    EmailOutcome::UNAVAILABLE => '[validation unavailable] ',
                    EmailOutcome::REJECT => '',
                };

                $mailer->send(
                    (new Email())
                        ->from((string) $_ENV['CONTACT_FROM'])
                        ->to((string) $_ENV['CONTACT_TO'])
                        ->replyTo($data->email)
                        ->subject($prefix.'Contact request from '.$data->name)
                        ->text($data->message)
                );

                $logger->info('Contact form accepted', [
                    'email_validation_outcome' => $decision->outcome->value,
                    'email_validation_reason' => $decision->reason,
                    'score' => $decision->score,
                    'check_count' => count($decision->checks),
                    'quota_fields' => array_keys($decision->quota),
                ]);

                $this->addFlash('success', 'Your message has been sent.');
                return $this->redirectToRoute('contact');
            }
        }

        return $this->render('contact/index.html.twig', [
            'form' => $form,
        ]);
    }
}

Креирајте templates/contact/index.html.twig. Symfony Forms обезбедува CSRF заштита кога е нормално овозможена:

{% for message in app.flashes('success') %}
    <p>{{ message }}</p>
{% endfor %}

{{ form(form) }}

Тестирајте ја границата и кешот детерминистички

MockHttpClient ја извршува вистинската логика за мапирање без пристап до мрежата. Вториот тест докажува дека повторените проверки го користат кешот наместо да трошат уште едно барање.

<?php
// tests/EmailValidation/EmailValidationTest.php
namespace App\Tests\EmailValidation;

use App\EmailValidation\ContactEmailVerifier;
use App\EmailValidation\EmailValidatorClient;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class EmailValidationTest extends TestCase
{
    public function testMapsACompleteResponse(): void
    {
        $response = new MockResponse(json_encode([
            'status' => 'valid',
            'score' => 92,
            'recommendation' => 'accept',
            'checks' => ['syntax' => true, 'domain' => true, 'mx' => true],
            'quota' => ['remaining' => 99],
        ]), ['http_code' => 200]);

        $client = new EmailValidatorClient(
            new MockHttpClient($response),
            new NullLogger(),
            'test-token'
        );

        $assessment = $client->check('[email protected]');

        self::assertTrue($assessment->isUsable());
        self::assertSame(92.0, $assessment->score);
        self::assertSame('accept', $assessment->recommendation);
        self::assertStringContainsString(
            'token=test-token',
            $response->getRequestUrl()
        );
    }

    public function testCachesSuccessfulAssessment(): void
    {
        $calls = 0;
        $transport = new MockHttpClient(
            function () use (&$calls): MockResponse {
                $calls++;

                return new MockResponse(json_encode([
                    'status' => 'valid',
                    'score' => 92,
                    'recommendation' => 'accept',
                    'checks' => ['syntax' => true],
                    'quota' => ['remaining' => 99],
                ]));
            }
        );

        $verifier = new ContactEmailVerifier(
            new EmailValidatorClient($transport, new NullLogger(), 'test-token'),
            new ArrayAdapter(),
            'test-hmac-key'
        );

        $verifier->verify('[email protected]');
        $verifier->verify('[email protected]');

        self::assertSame(1, $calls);
    }

    public function testRateLimitBecomesStructuredFallback(): void
    {
        $client = new EmailValidatorClient(
            new MockHttpClient(new MockResponse('', ['http_code' => 429])),
            new NullLogger(),
            'test-token'
        );

        $assessment = $client->check('[email protected]');

        self::assertSame('quota', $assessment->providerState);
        self::assertSame('rate_limited', $assessment->failureReason);
    }
}
php bin/phpunit
php bin/console debug:router contact
php bin/console lint:container
php bin/console lint:twig templates/

Продукциска доверливост и вообичаени неуспеси

Користете траен cache.app заднински систем. Датотечниот кеш на Symfony е соодветен на еден хост; повеќе инстанци на апликацијата имаат корист од веќе управуван заеднички кеш за секој јазол да не ја повторува истата валидација.

  • HTTP 401 или 403: потврдете го токенот ограничен на услугата и проверете дали ротацијата на токен не ја поништила распоредената вредност.
  • HTTP 429: проверете ги активираниот план и квотата. Формуларот продолжува во деградиран режим наместо да повторува познато ограничување на стапката.
  • Повторени неуспеси нагоре по системот: поставете предупредувања за бројот и латентноста на temporary_failure, а не за сурови адреси или URL-адреси на барања.
  • Секој резултат оди на преглед: споредете ги документираните облици на status, recommendation, checks, score и quota со локалното мапирање и политика.
  • Поштата изгледа успешно, но никогаш не пристигнува: заменете го локалниот null Mailer DSN и независно потврдете го продукцискиот транспорт за пошта.

Применете ги и вообичаените одбрани за формулари за контакт: CSRF заштита, ограничување на барања, ограничувања на должината на пораката, екранско прикажување на излезот и следење злоупотреби. Никогаш не користете резултат од валидација на е-пошта како доказ за идентитет или сопственост. Само порака за потврда со токен за еднократна употреба може да воспостави контрола над поштенско сандаче.

Пред распоредување, извршете тестови, загрејте го продукцискиот контејнер и обезбедете тајни преку платформата за хостирање:

php bin/phpunit
APP_ENV=prod APP_DEBUG=0 php bin/console cache:clear
APP_ENV=prod APP_DEBUG=0 php bin/console lint:container
composer install --no-dev --optimize-autoloader

Конечна контролна листа за верификација

  • Рутата за контакт се прикажува и се поднесува со важечки CSRF токен.
  • Неправилно формираните локални адреси се отфрлаат пред API барање.
  • Барањето користи GET, точниот краен пристап и параметрите за барање token и email.
  • Јасните негативни проценки додаваат грешка во полето за е-пошта.
  • Успешните проценки се кешираат под клучеви добиени со HMAC.
  • Истекувања, неуспеси на серверот, ограничувања на квотата и неважечки JSON создаваат структурирани резервни состојби.
  • Неуспесите при автентикација и валидација не се повторуваат слепо.
  • Дневниците содржат одлуки и оперативни метаподатоци, но не токен, целосна URL-адреса, тело на порака или е-поштена адреса.
  • Продукцискиот Mailer DSN, токенот, примателите и клучот на кешот се вбризгуваат надвор од контролата на изворниот код.

Најсилната интеграција не е таа што најентузијастички му верува на API-то. Таа точно знае што API-то може да утврди, го зачувува секој корисен сигнал и останува човечна кога мрежата има лош ден. Тука, подоброто филтрирање на е-пошта ги намалува одговорите што водат во ќорсокак, без корисниот формулар за контакт да се претвори во кршлив чувар на порта.

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

Mihajlo

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