Vodiči

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

Symfony: Poboljšajte obrasce za kontakt uz AI provjeru e-pošte, predmemoriranje i rezervne opcije

Obrazac za kontakt može proći svako lokalno pravilo provjere, a ipak prikupiti adresu koja ne može primiti odgovor. Provjera sintakse otkriva pogreške pri upisu poput nedostajućeg znaka @, ali ne može potvrditi objavljuje li domena zapise za e-poštu, djeluje li pružatelj usluge vjerodostojno ili predstavlja li adresa praktičan rizik za isporuku.

Ovaj vodič ugrađuje te provjere u Symfony obrazac za kontakt, bez pretvaranja vanjske usluge u jedinstvenu točku kvara. Uspješne procjene spremaju se u predmemoriju, jasni neuspjesi odbijaju se, neizvjesni rezultati šalju se na pregled, a privremeni prekidi API-ja propuštaju se kako potencijalni kupac ne bi bio tiho izgubljen.

Dobijte pristup usluzi Email Validator

Prije pisanja integracijskog koda, izradite račun na https://ai.mihajlo.mk/register, ili upotrijebite https://ai.mihajlo.mk/login ako ga već imate.

  1. Otvorite stranicu usluge Email Validator.
  2. Odaberite dostupni Free, Plus ili Pro plan i dovršite njegovu aktivaciju.
  3. Otvorite službenu dokumentaciju za Email Validator.
  4. Pronađite ploču Service token i kopirajte token ograničen na uslugu.

Ova usluga zahtijeva token. Njegova ponovna generacija opoziva prethodno aktivni token, stoga rotaciju tretirajte kao koordinirano postavljanje: ažurirajte tajnu aplikacije posvuda prije uklanjanja pretpostavki da stari token i dalje radi.

Točan zahtjev jest HTTP GET na https://ai.mihajlo.mk/api/email-validator/v1/check-email. Autentikacija koristi parametar upita token, dok se adresa šalje u parametru email. Izvršite jedan minimalni zahtjev s vjerodajnicama rezerviranog mjesta:

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

Ugovor odgovora uključuje status, score, recommendation, checks i quota. Ne pretpostavljajte nedokumentirane enum vrijednosti ni ljestvicu rezultata. Donji granični kod provjerava njihove tipove, dok politika u vlasništvu aplikacije tumači samo izričite signale koje razumije.

Izradite Symfony projekt i konfiguraciju

Implementacija cilja na PHP 8.3 ili noviji i aktualnu Symfony aplikaciju. Symfonyjev HTTP klijent prve strane, predmemorija, obrasci, validator, CSRF zaštita, Mailer i alati za testiranje dovoljni su:

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

Čuvajte tajne izvan kontrole izvornog koda. Lokalne vrijednosti stavite u .env.local; putem produkcijske platforme umetnite ekvivalentne tajne varijable okruženja:

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 prikladan je samo za lokalni razvoj jer odbacuje poštu. Produkcija treba stvarni Symfony Mailer DSN koji pruža vaš pružatelj usluge e-pošte.

Registrirajte izričite argumente u config/services.yaml. HMAC ključ predmemorije sprječava da se normalizirane adrese e-pošte izravno pojavljuju u ključevima predmemorije.

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

Izgradite obrambenu granicu API-ja

Klijent preslikava udaljene podatke u mali objekt domene. Ponovno pokušava samo kod pogrešaka prijenosa i odabranih pogrešaka poslužitelja, uz ograničeno čekanje između pokušaja. Pogreške autentikacije, neispravni zahtjevi i odgovori o kvoti ne pokušavaju se naslijepo ponovo.

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

Token se mora poslati u nizu upita jer je to ugovor usluge. Stoga HTTP zapisnici pristupa, alati za iznimke i razvojni profiler zaslužuju posebnu pozornost: redigirajte parametar token i nikada ne bilježite potpuni URL zahtjeva.

Spremite procjene u predmemoriju i definirajte poslovnu odluku

Provjeravatelj sprema u predmemoriju samo uspješne odgovore API-ja. Prekidi rada, neuspjesi autentikacije i neuspjesi kvote ostaju bez predmemorije kako bi se oporavak brzo otkrio. Trajanje od šest sati smanjuje dvostruke pozive bez dopuštanja da procjena postane praktično trajna.

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

Pravila namjerno izbjegavaju izmišljanje značenja za proizvoljne raspone rezultata. Odbijaju izričite negativne vrijednosti statusa ili preporuke koje ova aplikacija prepoznaje, kao i izričite neuspjehe u osnovnim provjerama sintakse, domene ili MX-a. Nedostajuće informacije o rezultatu, provjerama ili kvoti daju odluku za pregled umjesto lažne tvrdnje o sigurnosti.

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

Ti prepoznati nizovi lokalna su pravila, a ne tvrdnja da API jamči svaku navedenu vrijednost. Usporedite ih s aktualnom službenom dokumentacijom prije postavljanja i prilagodite pravila na jednom mjestu ako se dokumentirani rječnik razlikuje.

Povežite provjeru s obrascem za kontakt

Najprije zadržite Symfonyjevu lokalnu provjeru. Ona pruža trenutačnu povratnu informaciju i izbjegava trošenje kvote na očito neispravan unos.

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

Kontroler odbija samo čvrst negativan rezultat. Ishodi pregleda i nedostupnosti i dalje isporučuju poruku, no njihov prefiks u predmetu čini ručno razvrstavanje vidljivim. To je namjeran izbor propuštanja pri pogrešci za običan obrazac za kontakt; oporavak lozinke ili financijski tokovi mogu opravdati stroža pravila.

<?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(
                    'Unesite adresu e-pošte koja može primati odgovore.'
                ));
            } 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', 'Vaša je poruka poslana.');
                return $this->redirectToRoute('contact');
            }
        }

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

Izradite templates/contact/index.html.twig. Symfony Forms pruža CSRF zaštitu kada je normalno omogućena:

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

{{ form(form) }}

Deterministički testirajte granicu i predmemoriju

MockHttpClient provjerava stvarnu logiku preslikavanja bez pristupa mreži. Drugi test dokazuje da se ponovljene provjere koriste predmemorijom umjesto trošenja još jednog zahtjeva.

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

Pouzdanost u produkciji i česti neuspjesi

Upotrijebite trajni pozadinski sustav cache.app. Symfonyjeva predmemorija datotečnog sustava prikladna je na jednom poslužitelju; više instanci aplikacije ima koristi od već upravljane zajedničke predmemorije kako svaki čvor ne bi ponavljao istu provjeru.

  • HTTP 401 ili 403: provjerite token ograničen na uslugu i potvrdite da rotacija tokena nije opozvala postavljenu vrijednost.
  • HTTP 429: pregledajte aktivirani plan i kvotu. Obrazac nastavlja u degradiranom načinu rada umjesto da ponovno pokušava pri poznatom ograničenju stope.
  • Ponovljeni neuspjesi uzvodne usluge: upozoravajte na broj temporary_failure stanja i latenciju, a ne na sirove adrese ili URL-ove zahtjeva.
  • Svaki rezultat ide na pregled: usporedite dokumentirane oblike statusa, preporuke, provjera, rezultata i kvote s lokalnim preslikavanjem i pravilima.
  • Pošta izgleda uspješno, ali nikada ne stiže: zamijenite lokalni null Mailer DSN i neovisno provjerite produkcijski prijenos pošte.

Primijenite i uobičajene zaštite obrasca za kontakt: CSRF zaštitu, ograničavanje zahtjeva, ograničenja duljine poruke, izbjegavanje izlaza i praćenje zloupotrebe. Nikada ne koristite rezultat provjere e-pošte kao dokaz identiteta ili vlasništva. Samo potvrdna poruka s jednokratnim tokenom može utvrditi kontrolu nad pristiglom poštom.

Prije postavljanja pokrenite testove, zagrijte produkcijski spremnik i osigurajte tajne putem platforme za hosting:

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

Završni kontrolni popis za provjeru

  • Ruta za kontakt prikazuje se i šalje s valjanim CSRF tokenom.
  • Neispravne lokalne adrese odbijaju se prije API zahtjeva.
  • Zahtjev koristi GET, točnu krajnju točku te parametre upita token i email.
  • Jasne negativne procjene dodaju pogrešku polju e-pošte.
  • Uspješne procjene spremaju se u predmemoriju pod ključevima izvedenima iz HMAC-a.
  • Istek vremena, pogreške poslužitelja, ograničenja kvote i nevaljani JSON stvaraju strukturirana pričuvna stanja.
  • Neuspjesi autentikacije i provjere ne pokušavaju se naslijepo ponovo.
  • Zapisnici sadržavaju odluke i operativne metapodatke, ali ne token, puni URL, tijelo poruke ni adresu e-pošte.
  • Produkcijski Mailer DSN, token, primatelji i ključ predmemorije umeću se izvan kontrole izvornog koda.

Najjača integracija nije ona koja API-ju vjeruje s najviše oduševljenja. To je ona koja točno zna što API može utvrditi, čuva svaki koristan signal i ostaje humana kada mreža ima loš dan. Ovdje bolja provjera e-pošte smanjuje odgovore bez izlaza, a da koristan obrazac za kontakt ne pretvara u krhkog čuvara vrata.

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.