Туториали

Laravel Newsletter: Validate and Triage Contacts with Email Intelligence

Laravel Newsletter: Потврдете и сортирајте контакти со е-поштенска интелигенција

Увозот на билтен станува ризичен во моментот кога „е-поштата“ се третира како доверливо поле. Грешки при пишување, непостоечки домени, недостаток на поштенски разменувачи, еднократни провајдери и нејасни сигнали за испорака можат да претворат безопасен CSV во изгубени испраќања и оштетена репутација на испраќачот.

Овој туторијал гради конзервативен Laravel увозен процес. Тој отфрла очигледни синтаксни грешки локално, повикува услуга за интелигенција за е-пошта за веродостојни адреси, запишува контакти со висока доверба во чист CSV, и ги насочува сите несигурни или неуспешни проценки во CSV за рачен преглед. Клучно, прекин на API никогаш не станува случајно одобрување.

Добијте пристап пред да пишувате код за интеграција

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

  1. Отворете ја страницата за услугата Валидатор на е-пошта.
  2. Изберете го достапниот бесплатен, плус или про план и завршете ја активацијата.
  3. Отворете ја официјалната документација за услугата.
  4. Најдете го панелот Токен за услуга и копирајте го неговиот токен со опсег на услугата.

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

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

Направете едно минимално барање од доверлив терминал:

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

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

Сега ставете ги ингеренциите во конфигурацијата на околината на Laravel:

EMAIL_VALIDATOR_TOKEN=YOUR_SERVICE_TOKEN
EMAIL_VALIDATOR_TIMEOUT=8
EMAIL_VALIDATOR_CONNECT_TIMEOUT=3

# Овие се вредности на политиката на апликацијата, а не тврдо кодирани претпоставки на API.
# Копирајте ги точните чисти класификации документирани за вашата активирана услуга.
EMAIL_VALIDATOR_CLEAN_STATUSES=YOUR_DOCUMENTED_CLEAN_STATUS
EMAIL_VALIDATOR_CLEAN_RECOMMENDATIONS=YOUR_DOCUMENTED_CLEAN_RECOMMENDATION
EMAIL_VALIDATOR_MINIMUM_SCORE=YOUR_CHOSEN_SCORE_THRESHOLD

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

Изберете мала архитектура свесна за грешки

Закажана команда на Artisan е добар избор за обични увозни CSV: може да се извршува под планер, работа за распоредување или сесија на оператор без воведување редици, работници или површина за веб-увоз. Ако увозите подоцна пристигнуваат преку HTTP, истите класи на услуги и политики можат да се повикаат од редизирана работа.

Проектот додава четири фокусирани датотеки:

  • app/Services/EmailValidator.php поседува HTTP однесување и валидација на одговорот.
  • app/Data/EmailAssessment.php го мапира недоверливиот JSON во објект на домен.
  • app/Domain/ContactDecision.php ја применува локалната политика за прифаќање.
  • app/Console/Commands/TriageNewsletterImport.php чита и пишува CSV датотеки.

Додадете ја конфигурацијата на услугата во config/services.php:

'email_validator' => [
    'endpoint' => 'https://ai.mihajlo.mk/api/email-validator/v1/check-email',
    'token' => env('EMAIL_VALIDATOR_TOKEN'),
    'timeout' => (int) env('EMAIL_VALIDATOR_TIMEOUT', 8),
    'connect_timeout' => (int) env('EMAIL_VALIDATOR_CONNECT_TIMEOUT', 3),
    'clean_statuses' => env('EMAIL_VALIDATOR_CLEAN_STATUSES', ''),
    'clean_recommendations' => env('EMAIL_VALIDATOR_CLEAN_RECOMMENDATIONS', ''),
    'minimum_score' => env('EMAIL_VALIDATOR_MINIMUM_SCORE'),
],

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

Далечинскиот JSON е недоверлив влез дури и кога провајдерот е здрав. Одбивајте недостасувачки полиња и неочекувани типови на контејнери пред деловниот код да ги види. Не форсирајте недокументирани граници на score; валидирајте дека е нумерички и оставете ја конфигурацијата да го дефинира деловниот праг.

<?php

namespace App\Data;

use UnexpectedValueException;

final readonly class EmailAssessment
{
    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
    {
        if (
            !isset($payload['status']) || !is_string($payload['status']) ||
            !array_key_exists('score', $payload) || !is_numeric($payload['score']) ||
            !isset($payload['recommendation']) || !is_string($payload['recommendation']) ||
            !isset($payload['checks']) || !is_array($payload['checks']) ||
            !isset($payload['quota']) || !is_array($payload['quota'])
        ) {
            throw new UnexpectedValueException('Unexpected email-validator response.');
        }

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

Изградете го ограничен HTTP клиент

Вградениот HTTP клиент на Laravel ги обезбедува тајмаутите за конекција и целосен одговор. Клиентот повторно се обидува само при неуспеси на конекцијата и одговори 5xx од страна на серверот, со два кратки одложувања. Тој не се обидува повторно слепо при неуспеси на автентикација, отфрлен влез или 429, што може да претставува привремено ограничување на стапката или исцрпен квота.

<?php

namespace App\Services;

use App\Data\EmailAssessment;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;

final class EmailValidator
{
    public function check(string $email): array
    {
        $token = (string) config('services.email_validator.token');

        if ($token === '' || str_starts_with($token, 'YOUR_')) {
            return ['assessment' => null, 'failure' => 'configuration'];
        }

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = Http::acceptJson()
                    ->connectTimeout(config('services.email_validator.connect_timeout'))
                    ->timeout(config('services.email_validator.timeout'))
                    ->get(config('services.email_validator.endpoint'), [
                        'token' => $token,
                        'email' => $email,
                    ]);
            } catch (ConnectionException $exception) {
                Log::warning('Email validation connection failure.', [
                    'attempt' => $attempt,
                    'exception' => $exception::class,
                ]);

                if ($attempt < 3) {
                    usleep($attempt * 200_000);
                    continue;
                }

                return ['assessment' => null, 'failure' => 'connection'];
            }

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

                    if (!is_array($payload)) {
                        throw new \UnexpectedValueException('JSON object expected.');
                    }

                    return [
                        'assessment' => EmailAssessment::fromPayload($payload),
                        'failure' => null,
                    ];
                } catch (Throwable $exception) {
                    Log::warning('Malformed email validation response.', [
                        'http_status' => $response->status(),
                    ]);

                    return ['assessment' => null, 'failure' => 'malformed_response'];
                }
            }

            if (in_array($response->status(), [401, 403], true)) {
                return ['assessment' => null, 'failure' => 'authentication'];
            }

            if ($response->status() === 429) {
                return ['assessment' => null, 'failure' => 'quota_or_rate_limit'];
            }

            if ($response->serverError() && $attempt < 3) {
                usleep($attempt * 200_000);
                continue;
            }

            return [
                'assessment' => null,
                'failure' => $response->clientError()
                    ? 'request_rejected'
                    : 'upstream_unavailable',
            ];
        }

        return ['assessment' => null, 'failure' => 'upstream_unavailable'];
    }
}

Логовите содржат обид и категорија на неуспех, но не и адреса, токен, тело на одговор или URL. Тоа е доволно за оперативно дијагностицирање без претворање на логовите во база на податоци за контакти.

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

Адресата е чиста само кога нејзиниот статус и препорака се експлицитно дозволени, нејзиниот резултат го исполнува избраниот праг, нејзините проверки содржат барем еден булев сигнал без експлицитно false, и се присутни податоци за квотата. Сè друго станува материјал за преглед.

<?php

namespace App\Domain;

use App\Data\EmailAssessment;
use RecursiveArrayIterator;
use RecursiveIteratorIterator;

final class ContactDecision
{
    public function decide(EmailAssessment $assessment): array
    {
        $statuses = $this->csvConfig('clean_statuses');
        $recommendations = $this->csvConfig('clean_recommendations');
        $minimum = config('services.email_validator.minimum_score');

        if ($statuses === [] || $recommendations === [] || !is_numeric($minimum)) {
            return [false, 'policy_not_configured'];
        }

        if (!in_array(strtolower($assessment->status), $statuses, true)) {
            return [false, 'status_requires_review'];
        }

        if (!in_array(strtolower($assessment->recommendation), $recommendations, true)) {
            return [false, 'recommendation_requires_review'];
        }

        if ($assessment->score < (float) $minimum) {
            return [false, 'score_below_threshold'];
        }

        if ($assessment->quota === []) {
            return [false, 'quota_data_missing'];
        }

        $booleanSignals = 0;
        foreach (new RecursiveIteratorIterator(
            new RecursiveArrayIterator($assessment->checks)
        ) as $value) {
            if (is_bool($value)) {
                $booleanSignals++;

                if ($value === false) {
                    return [false, 'check_failed'];
                }
            }
        }

        return $booleanSignals > 0
            ? [true, 'clean']
            : [false, 'checks_unusable'];
    }

    private function csvConfig(string $key): array
    {
        return array_values(array_filter(array_map(
            static fn (string $value): string => strtolower(trim($value)),
            explode(',', (string) config("services.email_validator.$key"))
        )));
    }
}

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

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

<?php

namespace App\Console\Commands;

use App\Domain\ContactDecision;
use App\Services\EmailValidator;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;

final class TriageNewsletterImport extends Command
{
    protected $signature = 'newsletter:triage {csv}';
    protected $description = 'Clean a newsletter CSV and route uncertain contacts to review.';

    public function handle(EmailValidator $validator, ContactDecision $policy): int
    {
        $path = realpath($this->argument('csv'));

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

        $input = fopen($path, 'rb');
        $headers = fgetcsv($input);
        $emailIndex = is_array($headers) ? array_search('email', $headers, true) : false;

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

        $directory = 'import-results/'.now()->format('Ymd-His');
        Storage::disk('local')->makeDirectory($directory);
        $root = Storage::disk('local')->path($directory);
        $clean = fopen($root.'/clean.csv', 'wb');
        $review = fopen($root.'/review.csv', 'wb');
        $columns = ['email', 'reason', 'status', 'score', 'recommendation', 'checks', 'quota'];
        fputcsv($clean, $columns);
        fputcsv($review, $columns);

        $seen = [];
        $exit = self::SUCCESS;

        while (($row = fgetcsv($input)) !== false) {
            $email = trim((string) ($row[$emailIndex] ?? ''));
            $key = strtolower($email);

            if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
                fputcsv($review, [$email, 'invalid_syntax', '', '', '', '', '']);
                continue;
            }

            if (isset($seen[$key])) {
                fputcsv($review, [$email, 'duplicate', '', '', '', '', '']);
                continue;
            }

            $seen[$key] = true;
            $result = $validator->check($email);

            if ($result['assessment'] === null) {
                fputcsv($review, [$email, $result['failure'], '', '', '', '', '']);

                if (in_array($result['failure'], [
                    'configuration', 'authentication', 'quota_or_rate_limit',
                ], true)) {
                    $exit = self::FAILURE;
                    break;
                }

                continue;
            }

            $assessment = $result['assessment'];
            [$isClean, $reason] = $policy->decide($assessment);
            $record = [
                $email,
                $reason,
                $assessment->status,
                $assessment->score,
                $assessment->recommendation,
                json_encode($assessment->checks, JSON_THROW_ON_ERROR),
                json_encode($assessment->quota, JSON_THROW_ON_ERROR),
            ];

            fputcsv($isClean ? $clean : $review, $record);
        }

        fclose($input);
        fclose($clean);
        fclose($review);

        $this->info("Results written to storage/app/$directory");
        return $exit;
    }
}

Стартирајте го со php artisan newsletter:triage storage/app/imports/contacts.csv. Третирајте код за излез што не е нула како делумен увоз: поправете го проблемот со токенот или квотата и повторно стартувајте ја изворната датотека. Никогаш не објавувајте clean.csv освен ако командата не заврши успешно.

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

Http::fake() ги одржува тестовите детерминистички и спречува реална потрошувачка на квота. Фиктивните класификации подолу се намерно локални тест-вредности, а не тврдења за документираниот вокабулар на услугата.

<?php

namespace Tests\Feature;

use App\Services\EmailValidator;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

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

        config([
            'services.email_validator.endpoint' =>
                'https://ai.mihajlo.mk/api/email-validator/v1/check-email',
            'services.email_validator.token' => 'test-token',
            'services.email_validator.timeout' => 8,
            'services.email_validator.connect_timeout' => 3,
        ]);
    }

    public function test_it_maps_a_complete_response(): void
    {
        Http::fake([
            '*' => Http::response([
                'status' => 'test-clean',
                'score' => 92,
                'recommendation' => 'test-accept',
                'checks' => ['syntax' => true, 'mx' => true],
                'quota' => ['test' => 'present'],
            ]),
        ]);

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

        $this->assertNull($result['failure']);
        $this->assertSame(92.0, $result['assessment']->score);

        Http::assertSent(function ($request): bool {
            parse_str((string) parse_url($request->url(), PHP_URL_QUERY), $query);

            return $request->method() === 'GET'
                && $query['token'] === 'test-token'
                && $query['email'] === '[email protected]';
        });
    }

    public function test_it_rejects_a_malformed_response(): void
    {
        Http::fake(['*' => Http::response(['status' => 'incomplete'])]);

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

        $this->assertSame('malformed_response', $result['failure']);
    }

    public function test_it_retries_a_server_failure_then_succeeds(): void
    {
        Http::fakeSequence()
            ->push([], 503)
            ->push([
                'status' => 'test-clean',
                'score' => 92,
                'recommendation' => 'test-accept',
                'checks' => ['syntax' => true],
                'quota' => ['test' => 'present'],
            ], 200);

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

        $this->assertNull($result['failure']);
        Http::assertSentCount(2);
    }
}

Стартувајте го сетот со php artisan test. Дополнителен тест за команда треба да користи привремен CSV и да потврди дека невалидната синтакса, дупликатите, проценките со ниска доверба и транспортните неуспеси завршуваат во review.csv.

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

  • Вметнете го токенот преку продавницата за тајни на платформата за распоредување, потоа стартувајте php artisan config:cache. Кодот на апликацијата чита само config(), така што кешираната конфигурација останува сигурна.
  • Ограничете ги увезените датотеки и генерираните резултати бидејќи тие содржат лични податоци. Применете ги ограничувањата за задржување и не ставајте storage/app под јавниот веб корен.
  • Набљудувајте ги броевите по исход и категорија на неуспех, времетраење на командата, броеви на повторни обиди и излези што не се нула. Избегнувајте етикети што содржат адреси на е-пошта бидејќи тие создаваат метрики со висок кардиналитет и протекуваат контакти.
  • Одржувајте ја конкурентноста усогласена со активираниот план. Паралелизирањето на илјадници барања без потврдени ограничувања може да претвори рутински увоз во повторени 429 неуспеси.
  • Кога го ротирате токенот на услугата, запомнете дека повторното генерирање веднаш го поништува претходниот активен токен. Ажурирајте ги тајните и повторно изградете ја кешираната конфигурација пред следниот увоз.

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

401 или 403 обично значи дека токенот на услугата е отсутен, поништен или неточен; повторните обиди не можат да го поправат. 429 бара проверка на квотата или условите за ограничување на стапката пред да се продолжи. Повторените 5xx или неуспесите на конекцијата треба да ги остават контактите на преглед, а не чисти. Успешниот HTTP одговор со недостасувачки полиња е исто така несигурен и станува malformed_response.

Ако секој контакт добие policy_not_configured, границата работи како што е предвидено: заменете го статус-симболот, препораката и прагот со вредности избрани од официјалната документација и вашата политика за испраќање, а потоа освежете ја кешот на конфигурацијата на Laravel.

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

  • Активираниот токен на услугата постои само во конфигурацијата базирана на околината.
  • Барањето користи GET, точниот испорачан крај, и параметрите на прашање token и email.
  • Сите пет полиња за одговор се мапирани и задржани за одлуки или преглед.
  • Само експлицитно одобрените класификации можат да влезат во clean.csv.
  • Невалидни, дупликат, несигурни, невалидни и недостапни резултати влегуваат во review.csv.
  • Неуспесите на автентикацијата и квотата го запираат увозот со излез што не е нула.
  • Тестовите користат Http::fake() и никогаш не контактираат со живата услуга.
  • Логовите, метриките и конфигурацијата на прокси не откриваат токени или адреси.

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

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

Mihajlo

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