Туториали

Laravel Contact Forms: AI Email Validation with Caching and Graceful Fallbacks

Laravel Contact Forms: AI валидација на е-пошта со кеширање и непречени резервни опции

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

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

Добијте пристап до валидаторот на е-пошта

Регистрирајте се на https://ai.mihajlo.mk/register, или користете https://ai.mihajlo.mk/login ако веќе имате сметка.

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

Оваа услуга бара автентикација. Нејзиниот токен се испраќа преку параметарот за барање token={serviceToken}. Регенерирањето на сервисниот токен го поништува претходно активниот токен, па распоредувањата што ја користат старата вредност мора да се ажурираат заедно.

Потврдете го точниот HTTP договор

API повикот е GET https://ai.mihajlo.mk/api/email-validator/v1/check-email. Прифаќа параметри за барање email и token и враќа податоци за status, score, recommendation, checks и quota.

Тестирајте ги акредитивите од безбеден терминал пред да пишувате код за апликацијата:

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

Не ја вметнувајте добиената URL-адреса во тикети или логови: низите за барање често се појавуваат во записи на прокси, прелистувач и мониторинг.

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

Потребни ви се PHP 8.3 или понов, Composer, Laravel апликација и конфигурирана база на податоци. Споделен продукциски кеш како Redis е попожелен кога повеќе инстанци на апликацијата го опслужуваат формуларот, иако array кешот на Laravel е погоден за тестови.

За нова апликација, создајте го проектот и генерирајте ги почетните компоненти:

composer create-project laravel/laravel contact-site
cd contact-site
php artisan make:model ContactMessage -m
php artisan make:controller ContactController
php artisan make:test ContactFormTest

Ставете ја крајната точка, токенот, времетраењето на кешот и политиката на вашата апликација во .env:

EMAIL_VALIDATOR_URL=https://ai.mihajlo.mk/api/email-validator/v1/check-email
EMAIL_VALIDATOR_TOKEN=YOUR_SERVICE_TOKEN
EMAIL_VALIDATOR_CACHE_TTL=21600
EMAIL_VALIDATOR_MIN_SCORE=70

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

Додајте посебен запис во config/services.php. Повикувањето env() само од конфигурациски датотеки ја одржува апликацијата компатибилна со кешот на конфигурацијата на Laravel.

<?php

return [
    // Existing service configuration...

    'email_validator' => [
        'url' => env(
            'EMAIL_VALIDATOR_URL',
            'https://ai.mihajlo.mk/api/email-validator/v1/check-email'
        ),
        'token' => env('EMAIL_VALIDATOR_TOKEN'),
        'cache_ttl' => (int) env('EMAIL_VALIDATOR_CACHE_TTL', 21600),
        'min_score' => (float) env('EMAIL_VALIDATOR_MIN_SCORE', 70),
    ],
];

Архитектура: одлучувачки резултати, експлицитна деградација

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

Вратените полиња имаат одделни одговорности:

  • score ја управува конфигурабилната политика на оваа апликација за прифаќање или одбивање.
  • status и recommendation го зачувуваат заклучокот на услугата без нагаѓање на недокументирани вредности на низите.
  • checks зачувува детални докази за подоцнежен преглед.
  • quota обезбедува оперативен контекст и мора да биде структурно валидно пред резултатот да се смета за конечен.

Апликацијата намерно ја третира содржината на checks како непрозирна. Булова вредност како „disposable е false“ може да биде поволна, додека „MX exists е false“ не би била. Толкувањето на неименувани подполиња без документирана шема би создало суптилна продукциска грешка.

Постојат три доменски состојби: accepted, rejected и deferred. Одложен резултат значи дека оддалечената одлука не била достапна, а не дека посетителот доставил лоша адреса. Пораката се зачувува со таа состојба за преоден прекин да не отфрли вистинско барање.

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

Создадете app/Services/EmailValidationAssessment.php и app/Services/EmailValidator.php. Услугата хешира адреси на е-пошта во кеш-клучеви и логови, применува ограничени временски ограничувања, повторува неуспех на конекцијата или серверска грешка еднаш и никогаш слепо не повторува одговори за автентикација, погрешно барање или ограничување на стапката.

<?php
// app/Services/EmailValidationAssessment.php

namespace App\Services;

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

    public static function deferred(string $failure): self
    {
        return new self('deferred', failure: $failure);
    }
}
<?php
// app/Services/EmailValidator.php

namespace App\Services;

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
use UnexpectedValueException;

final class EmailValidator
{
    public function check(string $email): EmailValidationAssessment
    {
        $email = trim($email);
        $emailHash = hash('sha256', strtolower($email));
        $cacheKey = 'email-validator:v1:'.$emailHash;

        $cached = Cache::get($cacheKey);

        if ($cached instanceof EmailValidationAssessment) {
            return $cached;
        }

        $token = config('services.email_validator.token');

        if (! is_string($token) || $token === '') {
            Log::critical('Email validator token is not configured');

            return EmailValidationAssessment::deferred('misconfigured');
        }

        for ($attempt = 1; $attempt <= 2; $attempt++) {
            try {
                $response = Http::acceptJson()
                    ->connectTimeout(2)
                    ->timeout(5)
                    ->get(config('services.email_validator.url'), [
                        'email' => $email,
                        'token' => $token,
                    ]);
            } catch (ConnectionException $exception) {
                if ($attempt === 1) {
                    usleep(200_000);
                    continue;
                }

                Log::warning('Email validator connection failed', [
                    'email_hash' => $emailHash,
                    'failure' => $exception::class,
                ]);

                return EmailValidationAssessment::deferred('connection');
            }

            if ($response->serverError() && $attempt === 1) {
                usleep(200_000);
                continue;
            }

            if ($response->status() === 429) {
                Log::warning('Email validator rate limited the application', [
                    'email_hash' => $emailHash,
                    'quota' => $this->quotaFrom($response),
                ]);

                return EmailValidationAssessment::deferred('rate_limited');
            }

            if (in_array($response->status(), [401, 403], true)) {
                Log::critical('Email validator authentication failed', [
                    'http_status' => $response->status(),
                ]);

                return EmailValidationAssessment::deferred('authentication');
            }

            if (! $response->successful()) {
                Log::warning('Email validator returned an unsuccessful response', [
                    'email_hash' => $emailHash,
                    'http_status' => $response->status(),
                ]);

                return EmailValidationAssessment::deferred(
                    $response->serverError() ? 'upstream' : 'request_rejected'
                );
            }

            try {
                $assessment = $this->mapResponse($response);
            } catch (Throwable $exception) {
                Log::warning('Email validator response was malformed', [
                    'email_hash' => $emailHash,
                    'failure' => $exception::class,
                ]);

                return EmailValidationAssessment::deferred('invalid_response');
            }

            Cache::put(
                $cacheKey,
                $assessment,
                now()->addSeconds(
                    config('services.email_validator.cache_ttl')
                )
            );

            Log::info('Email validation completed', [
                'email_hash' => $emailHash,
                'decision' => $assessment->decision,
                'status' => $assessment->status,
                'score' => $assessment->score,
                'recommendation' => $assessment->recommendation,
                'quota' => $assessment->quota,
            ]);

            return $assessment;
        }

        return EmailValidationAssessment::deferred('upstream');
    }

    private function mapResponse(Response $response): EmailValidationAssessment
    {
        $data = $response->json();

        if (! is_array($data)) {
            throw new UnexpectedValueException('Expected a JSON object.');
        }

        foreach (['status', 'score', 'recommendation', 'checks', 'quota'] as $field) {
            if (! array_key_exists($field, $data)) {
                throw new UnexpectedValueException("Missing {$field}.");
            }
        }

        if (
            ! is_string($data['status']) ||
            $data['status'] === '' ||
            ! is_numeric($data['score']) ||
            ! is_string($data['recommendation']) ||
            $data['recommendation'] === '' ||
            ! is_array($data['checks']) ||
            ! is_array($data['quota'])
        ) {
            throw new UnexpectedValueException('Unexpected field types.');
        }

        $score = (float) $data['score'];
        $decision = $score >= config('services.email_validator.min_score')
            ? 'accepted'
            : 'rejected';

        return new EmailValidationAssessment(
            decision: $decision,
            status: $data['status'],
            score: $score,
            recommendation: $data['recommendation'],
            checks: $data['checks'],
            quota: $data['quota'],
        );
    }

    private function quotaFrom(Response $response): array
    {
        $quota = $response->json('quota');

        return is_array($quota) ? $quota : [];
    }
}

Единственото повторување има мало, ограничено одложување. Одговор 429 наместо тоа веднаш влегува во одложен режим: повторното трошење на ограничена услуга додека таа ја ограничува стапката на апликацијата обично го забавува опоравувањето. Неуспесите при автентикација исто така веднаш се враќаат бидејќи повторувањето на истиот поништен или неточен токен не може да успее.

Зачувајте го контактот и неговите докази за валидација

Користете ја генерираната миграција за да ги зачувате пораката и доменската проценка. Надворешниот одговор останува доказ наместо да стане дел од јавната порака за грешка.

<?php
// database/migrations/..._create_contact_messages_table.php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('contact_messages', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('email');
            $table->text('message');
            $table->string('validation_state', 20);
            $table->string('provider_status')->nullable();
            $table->decimal('provider_score', 8, 2)->nullable();
            $table->string('provider_recommendation')->nullable();
            $table->json('provider_checks');
            $table->json('provider_quota');
            $table->string('validation_failure')->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('contact_messages');
    }
};
<?php
// app/Models/ContactMessage.php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

final class ContactMessage extends Model
{
    protected $fillable = [
        'name',
        'email',
        'message',
        'validation_state',
        'provider_status',
        'provider_score',
        'provider_recommendation',
        'provider_checks',
        'provider_quota',
        'validation_failure',
    ];

    protected function casts(): array
    {
        return [
            'provider_score' => 'float',
            'provider_checks' => 'array',
            'provider_quota' => 'array',
        ];
    }
}

Поврзете ги контролерот, маршрутите и формуларот

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

<?php
// app/Http/Controllers/ContactController.php

namespace App\Http\Controllers;

use App\Models\ContactMessage;
use App\Services\EmailValidator;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;

final class ContactController extends Controller
{
    public function store(
        Request $request,
        EmailValidator $validator
    ): RedirectResponse {
        $data = $request->validate([
            'name' => ['required', 'string', 'max:120'],
            'email' => ['required', 'string', 'email', 'max:254'],
            'message' => ['required', 'string', 'max:5000'],
        ]);

        $assessment = $validator->check($data['email']);

        if ($assessment->decision === 'rejected') {
            return back()
                ->withErrors([
                    'email' => 'Please check this email address or use another one.',
                ])
                ->withInput();
        }

        ContactMessage::create([
            ...$data,
            'validation_state' => $assessment->decision,
            'provider_status' => $assessment->status,
            'provider_score' => $assessment->score,
            'provider_recommendation' => $assessment->recommendation,
            'provider_checks' => $assessment->checks,
            'provider_quota' => $assessment->quota,
            'validation_failure' => $assessment->failure,
        ]);

        return back()->with(
            'success',
            'Thanks. Your message has been received.'
        );
    }
}
<?php
// routes/web.php

use App\Http\Controllers\ContactController;
use Illuminate\Support\Facades\Route;

Route::view('/contact', 'contact')->name('contact.create');

Route::post('/contact', [ContactController::class, 'store'])
    ->middleware('throttle:10,1')
    ->name('contact.store');
<!-- resources/views/contact.blade.php -->
@if (session('success'))
    <p>{{ session('success') }}</p>
@endif

<form method="post" action="{{ route('contact.store') }}">
    @csrf

    <label for="name">Name</label>
    <input id="name" name="name" value="{{ old('name') }}" required>
    @error('name') <p>{{ $message }}</p> @enderror

    <label for="email">Email</label>
    <input id="email" name="email" type="email"
           value="{{ old('email') }}" required>
    @error('email') <p>{{ $message }}</p> @enderror

    <label for="message">Message</label>
    <textarea id="message" name="message"
              required>{{ old('message') }}</textarea>
    @error('message') <p>{{ $message }}</p> @enderror

    <button type="submit">Send message</button>
</form>

Извршете php artisan migrate, а потоа стилизирајте го Blade шаблонот во рамките на вашиот постоечки распоред. Тука не е потребна редица: на посетителот му треба непосреден резултат, а ограниченото временско ограничување на одговорот од пет секунди спречува барање што виси неодредено.

Тестирајте ги одлуките, кеширањето и резервното однесување

Http::fake() на Laravel го одржува пакетот детерминистички и гарантира дека ниту еден тест не троши квота. Низите во примерите подолу се намерно непрозирни; апликацијата не тврди дека се вистински enum вредности на провајдерот.

<?php
// tests/Feature/ContactFormTest.php

namespace Tests\Feature;

use App\Models\ContactMessage;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

final class ContactFormTest extends TestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();

        config([
            'cache.default' => 'array',
            'services.email_validator.token' => 'test-token',
            'services.email_validator.min_score' => 70,
        ]);

        Cache::flush();
    }

    public function test_it_accepts_and_caches_a_conclusive_result(): void
    {
        Http::fake([
            '*' => Http::response([
                'status' => 'test-status',
                'score' => 91,
                'recommendation' => 'test-recommendation',
                'checks' => ['test-check' => true],
                'quota' => ['test-quota' => 10],
            ]),
        ]);

        $payload = [
            'name' => 'Taylor',
            'email' => '[email protected]',
            'message' => 'Please send project details.',
        ];

        $this->post('/contact', $payload)->assertSessionHas('success');
        $this->post('/contact', $payload)->assertSessionHas('success');

        Http::assertSentCount(1);
        Http::assertSent(fn ($request) =>
            $request->method() === 'GET' &&
            $request['email'] === '[email protected]' &&
            $request['token'] === 'test-token'
        );

        $this->assertDatabaseCount('contact_messages', 2);
    }

    public function test_it_rejects_a_result_below_the_policy_score(): void
    {
        Http::fake([
            '*' => Http::response([
                'status' => 'test-status',
                'score' => 20,
                'recommendation' => 'test-recommendation',
                'checks' => [],
                'quota' => [],
            ]),
        ]);

        $this->post('/contact', [
            'name' => 'Taylor',
            'email' => '[email protected]',
            'message' => 'Hello',
        ])->assertSessionHasErrors('email');

        $this->assertDatabaseCount('contact_messages', 0);
    }

    public function test_it_stores_a_deferred_message_during_an_outage(): void
    {
        Http::fakeSequence()
            ->pushStatus(503)
            ->pushStatus(503);

        $this->post('/contact', [
            'name' => 'Taylor',
            'email' => '[email protected]',
            'message' => 'Hello',
        ])->assertSessionHas('success');

        $this->assertDatabaseHas('contact_messages', [
            'email' => '[email protected]',
            'validation_state' => 'deferred',
            'validation_failure' => 'upstream',
        ]);
    }
}

Извршете го фокусираниот пакет со php artisan test --filter=ContactFormTest. Дополнителните тестови треба да опфатат неправилен JSON, полиња што недостигаат, неуспешна автентикација, ограничување на стапката, локална валидација и празен токен.

Безбедност, набљудливост и распоредување

Сервисниот токен дава пристап до квота и мора да се управува како и секоја друга продукциска тајна. Чувајте го надвор од контрола на изворен код, исфрлања на исклучоци, URL-адреси на барања во системи за мониторинг, слики од екран и тест-фикстури. Бидејќи договорот за автентикација користи параметар за барање, конфигурирајте ги обратните проксија и алатките за перформанси на апликацијата да ги прикриваат низите за барање за оваа крајна точка.

Адресите на е-пошта се лични податоци. Имплементацијата користи SHA-256 дигест во кеш-клучевите и логовите, додека базата на податоци ја зачувува адресата само затоа што е потребна за одговор на барањето за контакт. Применете соодветна политика за задржување и ограничете го пристапот до зачуваните докази од провајдерот.

Следете го бројот на состојби accepted, rejected и deferred, како и неуспесите групирани по причина. Зголемувањето на неуспеси authentication обично укажува на недостасувачки, неточен или регенериран токен. Трајните резултати rate_limited укажуваат дека на сообраќајот, кеширањето или капацитетот на пакетот му треба внимание. Избегнувајте проверки на активни е-пошти во здравствени сонди бидејќи трошат капацитет на услугата.

За распоредување:

  1. Обезбедете EMAIL_VALIDATOR_TOKEN преку менаџерот за тајни на хостинг-платформата.
  2. Извршете php artisan migrate --force.
  3. Извршете php artisan config:cache откако вредностите на околината ќе бидат достапни.
  4. Користете споделен кеш-драјвер кога барањата можат да стигнат до повеќе инстанци на апликацијата.
  5. Поднесете еден контролиран тест-контакт, прегледајте ја неговата зачувана состојба и потврдете дека логовите не содржат адреса на е-пошта или токен.

Вообичаени продукциски неуспеси

  • Секој резултат е одложен како misconfigured: потврдете дека променливата на околината постои, а потоа повторно изградете го кешот на конфигурацијата на Laravel.
  • Автентикацијата одеднаш не успева: регенериран токен го поништил претходниот токен. Ажурирајте го секое активно распоредување што го користи.
  • Повиците за валидација се случуваат при секое поднесување: потврдете дека продукцискиот кеш-драјвер е траен и споделен и проверете дека се очекува да се кешираат само конечни резултати.
  • Валидни посетители се одбиваат: прегледајте ја документираната семантика на резултатот и приспособете го деловниот праг. Не извлекувајте значење од недокументирани имиња на проверки.
  • Формуларот станува бавен при инциденти: потврдете дека временските ограничувања од две секунди за конекција и пет секунди за одговор сè уште се применуваат и дека инфраструктурата не додава сопствен долг синџир на повторувања.

Конечна контролна листа за потврда

  • Барањето ја користи точната GET крајна точка со параметри за барање email и token.
  • Токенот доаѓа од конфигурација поддржана од околината и отсуствува од логовите и контролата на изворен код.
  • Локалната Laravel валидација се извршува пред надворешното барање.
  • Сите пет области на одговорот се валидираат и мапираат на API границата.
  • Конечните резултати се кешираат под хеширан клуч; неуспесите не се кешираат.
  • Временските ограничувања за конекција и одговор се ограничени, а само преодните неуспеси добиваат едно повторување.
  • Одговорите за автентикација и ограничување на стапката не се повторуваат слепо.
  • Одбиените адреси добиваат корисна грешка, додека прекините го зачувуваат контактот како одложен.
  • Автоматизираните тестови не прават вистински мрежни повици.
  • Продукциските метрики ги разликуваат одлуките за адреси од неуспесите на интеграцијата.

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

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

Mihajlo

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