Туториали

Laravel: Validate Newsletter Imports, Flagging Dubious Emails for Manual Review

Laravel: Валидација на увозот на билтени, означување сомнителни е-пошта за рачна проверка

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

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

Добијте пристап до 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 и копирајте го токенот со опсег на услугата.
  5. Зачувајте го во конфигурацијата на околината на Laravel, никогаш во изворен код што се комитира.

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

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

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

Извршете едно минимално барање од безбедна школка пред да го изградите увозникот:

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

Успешниот одговор обезбедува status, score, recommendation, checks и quota. Услугата ги испитува синтаксата, информациите за доменот и MX, сигналите од давателите и практичниот ризик при испорака. Нашата граница ќе ги бара сите пет полиња, но нема да претпоставува недокументирани значења за поединечните клучеви за проверки.

Создадете Laravel проект и конфигурација

Ви треба PHP 8.3 или понов, Composer, поддржано издание на Laravel и конфигурирана база на податоци. Започнете нова апликација или применете ја следнава структура на постоечка:

composer create-project laravel/laravel newsletter-cleaner
cd newsletter-cleaner
php artisan make:model NewsletterContact -m
php artisan make:command ImportNewsletterContacts
php artisan make:test EmailValidatorClientTest --unit

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

EMAIL_VALIDATOR_TOKEN=YOUR_SERVICE_TOKEN
EMAIL_VALIDATOR_CONNECT_TIMEOUT=3
EMAIL_VALIDATOR_TIMEOUT=8
EMAIL_VALIDATOR_ACCEPT_SCORE=85
EMAIL_VALIDATOR_REJECT_SCORE=45

Додадете го следниов запис во config/services.php:

'email_validator' => [
    'base_url' => 'https://ai.mihajlo.mk/api/email-validator',
    'token' => env('EMAIL_VALIDATOR_TOKEN'),
    'connect_timeout' => (int) env('EMAIL_VALIDATOR_CONNECT_TIMEOUT', 3),
    'timeout' => (int) env('EMAIL_VALIDATOR_TIMEOUT', 8),
    'accept_score' => (float) env('EMAIL_VALIDATOR_ACCEPT_SCORE', 85),
    'reject_score' => (float) env('EMAIL_VALIDATOR_REJECT_SCORE', 45),
],

Конфигурацијата на Laravel е единствената патека до тајната што е видлива во изворниот код. Не повикувајте env() од класите на апликацијата бидејќи кеширањето на конфигурацијата менува како се однесува директниот пристап до околината.

Моделирајте го увозот како докази плус одлука

Увозникот работи како Artisan команда наместо како HTTP контролер. Обработката на CSV може да трае подолго од веб-барање, а командата е лесна за закажување, набљудување и повторно извршување. Upsert-операциите ги прават повторните извршувања идемпотентни на ниво на контакт. За многу големи датотеки, подоцна може да се додадат редици во серии, но тогаш паралелноста мора да се координира со квотата на услугата.

Создадете ги табелата и моделот за контакти:

<?php
// database/migrations/xxxx_xx_xx_create_newsletter_contacts_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('newsletter_contacts', function (Blueprint $table) {
            $table->id();
            $table->string('email', 320)->unique();
            $table->string('decision', 20);
            $table->json('validation_evidence')->nullable();
            $table->string('failure_code')->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('newsletter_contacts');
    }
};

// app/Models/NewsletterContact.php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

final class NewsletterContact extends Model
{
    protected $fillable = [
        'email',
        'decision',
        'validation_evidence',
        'failure_code',
    ];

    protected function casts(): array
    {
        return ['validation_evidence' => 'array'];
    }
}

Трите одлуки се accepted, rejected и review. Привремениот прекин никогаш не станува отфрлање. Таа разлика е важна: неможноста за валидација не е доказ дека адресата е лоша.

Валидирајте го одговорот на границата на апликацијата

Создадете доменски резултат и политика во app/Domain/EmailValidation. Маперот отфрла полиња што недостигаат или се со погрешен тип. Политиката бара целосни докази, го користи резултатот за праговите на апликацијата и го испраќа неизвесниот интервал на проверка. Оригиналните status, recommendation, checks и quota остануваат достапни за проверувачите и операциите.

<?php
// app/Domain/EmailValidation/EmailValidationResult.php

namespace App\Domain\EmailValidation;

use InvalidArgumentException;

final readonly class EmailValidationResult
{
    public function __construct(
        public string $status,
        public float $score,
        public string $recommendation,
        public array $checks,
        public array $quota,
    ) {}

    public static function fromPayload(array $payload): self
    {
        foreach (['status', 'score', 'recommendation', 'checks', 'quota'] as $key) {
            if (!array_key_exists($key, $payload)) {
                throw new InvalidArgumentException("Missing response field: {$key}");
            }
        }

        $numericScore = is_int($payload['score']) || is_float($payload['score']);

        if (!is_string($payload['status'])
            || trim($payload['status']) === ''
            || !$numericScore
            || !is_finite((float) $payload['score'])
            || !is_string($payload['recommendation'])
            || trim($payload['recommendation']) === ''
            || !is_array($payload['checks'])
            || !is_array($payload['quota'])) {
            throw new InvalidArgumentException('Invalid Email Validator response');
        }

        return new self(
            trim($payload['status']),
            (float) $payload['score'],
            trim($payload['recommendation']),
            $payload['checks'],
            $payload['quota'],
        );
    }

    public function evidence(): array
    {
        return [
            'status' => $this->status,
            'score' => $this->score,
            'recommendation' => $this->recommendation,
            'checks' => $this->checks,
            'quota' => $this->quota,
        ];
    }
}

// app/Domain/EmailValidation/ImportDecisionPolicy.php

namespace App\Domain\EmailValidation;

final class ImportDecisionPolicy
{
    public function decide(EmailValidationResult $result): string
    {
        $completeEvidence = $result->status !== ''
            && $result->recommendation !== ''
            && $result->checks !== []
            && $result->quota !== [];

        if (!$completeEvidence) {
            return 'review';
        }

        if ($result->score >= config('services.email_validator.accept_score')) {
            return 'accepted';
        }

        if ($result->score <= config('services.email_validator.reject_score')) {
            return 'rejected';
        }

        return 'review';
    }
}

Ова конзервативно мапирање го користи секое вратено поле без да им доделува измислени значења на вредностите за status, recommendation, check или quota специфични за давателот. Ако официјалната документација дефинира вредности што сакате да ги применувате, додадете експлицитна листа на дозволени вредности и покријте ја со тестови за договорот.

Изградете ограничен API-клиент што е свесен за повторни обиди

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

<?php
// app/Services/EmailValidatorClient.php

namespace App\Services;

use App\Domain\EmailValidation\EmailValidationResult;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use InvalidArgumentException;
use RuntimeException;
use Throwable;

final class ValidationUnavailable extends RuntimeException
{
    public function __construct(
        public readonly string $reason,
        ?Throwable $previous = null,
    ) {
        parent::__construct($reason, 0, $previous);
    }
}

final class EmailValidatorClient
{
    public function check(string $email): EmailValidationResult
    {
        $delays = [0, 200_000, 600_000];

        for ($attempt = 1; $attempt <= count($delays); $attempt++) {
            if ($delays[$attempt - 1] > 0) {
                usleep($delays[$attempt - 1]);
            }

            try {
                $response = Http::baseUrl(config('services.email_validator.base_url'))
                    ->acceptJson()
                    ->connectTimeout(config('services.email_validator.connect_timeout'))
                    ->timeout(config('services.email_validator.timeout'))
                    ->get('/v1/check-email', [
                        'token' => config('services.email_validator.token'),
                        'email' => $email,
                    ]);
            } catch (ConnectionException $exception) {
                Log::warning('email_validation_connection_failure', [
                    'attempt' => $attempt,
                    'email_hash' => hash('sha256', $email),
                ]);

                if ($attempt === count($delays)) {
                    throw new ValidationUnavailable('connection_failure', $exception);
                }

                continue;
            }

            if ($response->successful()) {
                $payload = $response->json();

                if (!is_array($payload)) {
                    throw new ValidationUnavailable('malformed_response');
                }

                try {
                    return EmailValidationResult::fromPayload($payload);
                } catch (InvalidArgumentException $exception) {
                    throw new ValidationUnavailable('malformed_response', $exception);
                }
            }

            if ($response->status() === 429) {
                throw new ValidationUnavailable('rate_or_quota_limited');
            }

            if (in_array($response->status(), [401, 403], true)) {
                throw new ValidationUnavailable('authentication_failure');
            }

            if ($response->serverError() && $attempt < count($delays)) {
                Log::warning('email_validation_server_failure', [
                    'attempt' => $attempt,
                    'status' => $response->status(),
                ]);
                continue;
            }

            throw new ValidationUnavailable(
                $response->serverError() ? 'upstream_failure' : 'request_rejected'
            );
        }

        throw new ValidationUnavailable('upstream_failure');
    }
}

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

Увезете го CSV и создајте ја редицата за проверка

Влезната датотека бара заглавие email. Командата ги отстранува празните места, го претвора во мали букви само делот со доменот, локално отфрла јасни синтаксички неуспеси и ја повикува услугата за веродостојни адреси.

<?php
// app/Console/Commands/ImportNewsletterContacts.php

namespace App\Console\Commands;

use App\Domain\EmailValidation\ImportDecisionPolicy;
use App\Models\NewsletterContact;
use App\Services\EmailValidatorClient;
use App\Services\ValidationUnavailable;
use Illuminate\Console\Command;
use SplFileObject;

final class ImportNewsletterContacts extends Command
{
    protected $signature = 'newsletter:import {file}';
    protected $description = 'Validate and import newsletter contacts';

    public function handle(
        EmailValidatorClient $client,
        ImportDecisionPolicy $policy,
    ): int {
        $path = $this->argument('file');

        if (!is_readable($path)) {
            $this->error('The CSV file is not readable.');
            return self::FAILURE;
        }

        $csv = new SplFileObject($path);
        $csv->setFlags(
            SplFileObject::READ_CSV
            | SplFileObject::SKIP_EMPTY
            | SplFileObject::DROP_NEW_LINE
        );

        $headers = $csv->fgetcsv();
        $headers = is_array($headers)
            ? array_map(fn ($value) => strtolower(trim((string) $value)), $headers)
            : [];

        $emailColumn = array_search('email', $headers, true);

        if ($emailColumn === false) {
            $this->error('The CSV must contain an email header.');
            return self::FAILURE;
        }

        $counts = ['accepted' => 0, 'rejected' => 0, 'review' => 0];

        foreach ($csv as $row) {
            if (!is_array($row) || !isset($row[$emailColumn])) {
                continue;
            }

            $email = $this->normalize((string) $row[$emailColumn]);

            if ($email === '') {
                continue;
            }

            if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
                $this->save($email, 'rejected', null, 'local_syntax');
                $counts['rejected']++;
                continue;
            }

            try {
                $result = $client->check($email);
                $decision = $policy->decide($result);
                $this->save($email, $decision, $result->evidence(), null);
            } catch (ValidationUnavailable $exception) {
                $decision = 'review';
                $this->save($email, $decision, null, $exception->reason);
            }

            $counts[$decision]++;
        }

        $this->line(json_encode($counts, JSON_THROW_ON_ERROR));

        return self::SUCCESS;
    }

    private function normalize(string $email): string
    {
        $email = trim($email);
        $separator = strrpos($email, '@');

        if ($separator === false) {
            return $email;
        }

        return substr($email, 0, $separator + 1)
            . strtolower(substr($email, $separator + 1));
    }

    private function save(
        string $email,
        string $decision,
        ?array $evidence,
        ?string $failure,
    ): void {
        NewsletterContact::updateOrCreate(
            ['email' => $email],
            [
                'decision' => $decision,
                'validation_evidence' => $evidence,
                'failure_code' => $failure,
            ],
        );
    }
}

Тестирајте успех, неизвесност, повторни обиди и неуспеси со квота

HTTP fake на Laravel ги прави тестовите детерминистички и гарантира дека ниту еден тест не стигнува до вистинската услуга. Додадете репрезентативни тестови за договорот:

<?php
// tests/Unit/EmailValidatorClientTest.php

namespace Tests\Unit;

use App\Domain\EmailValidation\ImportDecisionPolicy;
use App\Services\EmailValidatorClient;
use App\Services\ValidationUnavailable;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

final class EmailValidatorClientTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();

        config([
            'services.email_validator.base_url' =>
                'https://ai.mihajlo.mk/api/email-validator',
            'services.email_validator.token' => 'TEST_SERVICE_TOKEN',
            'services.email_validator.connect_timeout' => 1,
            'services.email_validator.timeout' => 2,
            'services.email_validator.accept_score' => 85,
            'services.email_validator.reject_score' => 45,
        ]);

        Http::preventStrayRequests();
    }

    public function test_high_score_with_complete_evidence_is_accepted(): void
    {
        Http::fake(['*' => Http::response($this->payload(92), 200)]);

        $result = app(EmailValidatorClient::class)->check('[email protected]');

        $this->assertSame(
            'accepted',
            app(ImportDecisionPolicy::class)->decide($result)
        );
    }

    public function test_uncertain_score_is_sent_to_review(): void
    {
        Http::fake(['*' => Http::response($this->payload(65), 200)]);

        $result = app(EmailValidatorClient::class)->check('[email protected]');

        $this->assertSame(
            'review',
            app(ImportDecisionPolicy::class)->decide($result)
        );
    }

    public function test_server_error_is_retried(): void
    {
        Http::fake([
            '*' => Http::sequence()
                ->push([], 500)
                ->push($this->payload(90), 200),
        ]);

        app(EmailValidatorClient::class)->check('[email protected]');

        Http::assertSentCount(2);
    }

    public function test_rate_limit_becomes_structured_failure(): void
    {
        Http::fake(['*' => Http::response([], 429)]);

        try {
            app(EmailValidatorClient::class)->check('[email protected]');
            $this->fail('Expected ValidationUnavailable');
        } catch (ValidationUnavailable $exception) {
            $this->assertSame('rate_or_quota_limited', $exception->reason);
        }

        Http::assertSentCount(1);
    }

    private function payload(int $score): array
    {
        return [
            'status' => 'test-status',
            'score' => $score,
            'recommendation' => 'test-recommendation',
            'checks' => ['test-check' => true],
            'quota' => ['test-quota' => 1],
        ];
    }
}

Распоредете и потврдете безбедно

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

php artisan test
php artisan migrate --force
php artisan config:cache
php artisan newsletter:import storage/app/imports/subscribers.csv

Ограничете ги дозволите за CSV-датотеките и датотеките со околина, барајте TLS и конфигурирајте ги проксите и алатките за перформанси на апликацијата да ги редигираат низите за пребарување. Следете ги бројките по одлука, кодовите на неуспех, HTTP 429 одговорите, неуспесите при автентикација, неуспесите нагоре по системот и промените во односот прифатени-наспроти-проверка. Алармирајте при ненадејни промени наместо да евидентирате лични податоци.

Вообичаените неуспеси имаат различни решенија. Неуспехот при автентикација обично значи дека конфигурираниот токен недостига, е застарен или е поништен. Одговор 429 бара проверка на капацитетот на планот и темпото на увозот. Повторените серверски неуспеси или неуспеси на поврзувањето треба да ги остават контактите на проверка до контролирано повторно извршување. Неправилно форматиран одговор треба да поттикне истрага пред промени на политиката. Недостасувачко CSV-заглавие е проблем со влезниот договор и правилно ја запира целата команда.

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

  • Токенот постои само во конфигурација поддржана од околината и во продукциско складиште за тајни.
  • Минималното API-барање успева со точната GET крајна точка и параметри за пребарување.
  • Конфигурацијата се кешира по промени на токенот.
  • Автоматизираните тестови не можат да направат неочекувани мрежни барања.
  • Примероците со висока доверба, ниска доверба и неизвесност ги достигнуваат наменетите состојби.
  • Патеките за HTTP 429, автентикација, истек на време, серверска грешка и неправилно форматиран одговор се набљудливи.
  • Проверувачите можат да ги прегледаат доказите за status, score, recommendation, checks и quota.
  • Само контактите со одлука accepted се извезуваат на платформата за испраќање.

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

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

Mihajlo

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