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.
- Otvorite stranicu usluge Email Validator.
- Odaberite dostupni Free, Plus ili Pro plan i dovršite njegovu aktivaciju.
- Otvorite službenu dokumentaciju za Email Validator.
- 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_failurestanja 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 upitatokeniemail. - 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.