Laravel: Припитомување на увозите на билтени со испраќање сомнителни е-пораки на рачна проверка
Увозот на билтен изгледа безопасно сè додека не се соочи со податоци од реалниот свет: дуплирани контакти, залутани празни места, погрешно напишани домени, напуштени сандачиња и адреси што се технички валидни, но сепак ризични. Испраќањето на секој ред директно до мејлинг-листа троши квота, ја нарушува испорачливоста и ги претвора несигурните податоци во оперативен проблем.
Побезбедниот дизајн е мал процес на донесување одлуки. Laravel прво врши детерминистичко чистење, надворешен валидатор обезбедува докази за испорака, а само јасно прифатливите контакти стануваат подготвени за испраќање. Сè што е двосмислено влегува во редица за рачна проверка наместо тивко да биде прифатено или отфрлено.
Добијте пристап до Валидаторот за е-пошта
Регистрирајте се на https://ai.mihajlo.mk/register, или најавете се преку https://ai.mihajlo.mk/login.
Отворете ја страницата на услугата на https://ai.mihajlo.mk/api/email-validator. Изберете достапен Free, Plus или Pro план и завршете ја неговата активација. Потоа посетете ја официјалната документација на https://ai.mihajlo.mk/api/email-validator/documentation, пронајдете го панелот Service token и копирајте го токенот ограничен на услугата.
Услугата го бара тој токен; таа не е крајна точка без автентикација. Повторното генерирање на сервисниот токен го поништува претходно активниот токен, затоа третирајте го повторното генерирање како ротација на акредитиви што мора да се координира со распоредувањето.
Точното барање е HTTP GET до https://ai.mihajlo.mk/api/email-validator/v1/check-email. Автентикацијата го користи параметарот за пребарување token, додека адресата оди во параметарот за пребарување email. Тестирајте го без трајно да го ставате токенот во историјата на школката:
read -s SERVICE_TOKEN
curl --get 'https://ai.mihajlo.mk/api/email-validator/v1/check-email' \
--data-urlencode "token=${SERVICE_TOKEN}" \
--data-urlencode '[email protected]'
unset SERVICE_TOKEN
Успешниот товар обезбедува status, score, recommendation, checks и quota. Ќе ги валидираме сите пет на границата на апликацијата, наместо да претпоставиме дека секој HTTP 200 содржи употребливи податоци.
Складирајте ги акредитивите во конфигурацијата на околината на Laravel пред да ја напишете функционалноста:
# .env
EMAIL_VALIDATOR_TOKEN=YOUR_SERVICE_TOKEN
# These are application policy values. Match their spelling to the
# documented values returned for your activated service.
EMAIL_VALIDATOR_ALLOWED_STATUSES=YOUR_ACCEPTABLE_STATUS
EMAIL_VALIDATOR_ALLOWED_RECOMMENDATIONS=YOUR_ACCEPTABLE_RECOMMENDATION
EMAIL_VALIDATOR_MIN_SCORE=80
EMAIL_VALIDATOR_REQUIRED_CHECKS=syntax,domain,mx
Не го предавајте .env во репозиториумот. Горенаведените вредности на политиката намерно не се претставени како API енумерации: конфигурирајте ги според официјалната документација и потврдените одговори за вашата сметка.
Архитектура: детерминистичко чистење пред платена валидација
Оваа имплементација е наменета за PHP 8.3 и Laravel апликација со конфигурирани база на податоци и редица. CSV-командата ги нормализира адресите, отфрла неспорни синтаксички грешки, ги отстранува дупликатите од редовите и испраќа по една задача во редицата за секој нов контакт. Задачата повикува наменски HTTP-клиент, го мапира одговорот во доменски објект и применува конзервативна политика.
- Подготвен: секое поле од одговорот е структурно валидно и конфигурираните статус, препорака, резултат и проверки поминуваат.
- Отфрлен: локалната синтаксичка валидација не успева пред какво било далечинско барање.
- Рачна проверка: одговорот е двосмислен, погрешно форматиран, неовластен или надвор од политиката.
- Во исчекување: транспортен или серверски неуспех што може повторно да се обиде не го исцрпил својот ограничен буџет за повторни обиди.
Оваа пристрасност е намерна. Лажен негативен резултат чини една проверка; лажен позитивен резултат може да влијае врз секоја идна кампања.
Конфигурирајте ја границата на услугата
Додајте го следниов запис во config/services.php:
'email_validator' => [
'endpoint' => 'https://ai.mihajlo.mk/api/email-validator/v1/check-email',
'token' => env('EMAIL_VALIDATOR_TOKEN'),
'allowed_statuses' => array_values(array_filter(array_map(
'trim',
explode(',', env('EMAIL_VALIDATOR_ALLOWED_STATUSES', ''))
))),
'allowed_recommendations' => array_values(array_filter(array_map(
'trim',
explode(',', env('EMAIL_VALIDATOR_ALLOWED_RECOMMENDATIONS', ''))
))),
'minimum_score' => (float) env('EMAIL_VALIDATOR_MIN_SCORE', 80),
'required_checks' => array_values(array_filter(array_map(
'trim',
explode(',', env('EMAIL_VALIDATOR_REQUIRED_CHECKS', ''))
))),
],
Создадете миграција со php artisan make:model NewsletterContact -m, а потоа дефинирајте ја табелата:
<?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): void {
$table->id();
$table->string('source_email');
$table->string('normalized_email')->unique();
$table->string('state')->default('pending')->index();
$table->string('review_reason')->nullable();
$table->json('validation')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('newsletter_contacts');
}
};
Во app/Models/NewsletterContact.php, дозволете ги тие полиња и претворете ги доказите:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
final class NewsletterContact extends Model
{
protected $fillable = [
'source_email',
'normalized_email',
'state',
'review_reason',
'validation',
];
protected function casts(): array
{
return ['validation' => 'array'];
}
}
Мапирајте недоверлив JSON во доменски резултат
DTO ги отфрла недостигачките или погрешно типизираните полиња од договорот. Тој не ја претпоставува внатрешната структура на checks или quota; тие остануваат низи сè додека политиката на апликацијата не ги испита конфигурираните патеки за проверки.
<?php
// app/Domain/EmailValidation/EmailAssessment.php
namespace App\Domain\EmailValidation;
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 $data): self
{
foreach (['status', 'score', 'recommendation', 'checks', 'quota'] as $key) {
if (! array_key_exists($key, $data)) {
throw new UnexpectedValueException("Missing field: {$key}");
}
}
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 validation payload');
}
return new self(
$data['status'],
(float) $data['score'],
$data['recommendation'],
$data['checks'],
$data['quota'],
);
}
public function evidence(): array
{
return [
'status' => $this->status,
'score' => $this->score,
'recommendation' => $this->recommendation,
'checks' => $this->checks,
'quota' => $this->quota,
];
}
}
HTTP-клиентот користи кратки временски ограничувања и повторува само неуспеси на поврзувањето и серверски грешки. Неуспесите во автентикацијата, неуспесите во валидацијата од страна на клиентот и одговорите за квота не се повторуваат слепо.
<?php
// app/Services/EmailValidator.php
namespace App\Services;
use App\Domain\EmailValidation\EmailAssessment;
use Exception;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Throwable;
final readonly class ValidationAttempt
{
public function __construct(
public string $state,
public ?EmailAssessment $assessment = null,
) {}
}
final class EmailValidator
{
public function check(string $email): ValidationAttempt
{
$token = config('services.email_validator.token');
if (! is_string($token) || $token === '') {
return new ValidationAttempt('configuration_error');
}
try {
$response = Http::acceptJson()
->connectTimeout(2)
->timeout(6)
->retry(
[200, 500],
when: fn (Exception $e): bool =>
$e instanceof ConnectionException
|| ($e instanceof RequestException
&& $e->response?->serverError()),
throw: false,
)
->get(config('services.email_validator.endpoint'), [
'token' => $token,
'email' => $email,
]);
} catch (ConnectionException) {
return new ValidationAttempt('transient_failure');
}
if (in_array($response->status(), [401, 403], true)) {
return new ValidationAttempt('authentication_failure');
}
if ($response->status() === 429) {
return new ValidationAttempt('quota_or_rate_limited');
}
if ($response->serverError()) {
return new ValidationAttempt('transient_failure');
}
if (! $response->successful()) {
return new ValidationAttempt('request_rejected');
}
try {
$payload = $response->json();
if (! is_array($payload)) {
return new ValidationAttempt('malformed_response');
}
return new ValidationAttempt(
'ok',
EmailAssessment::fromPayload($payload),
);
} catch (Throwable) {
return new ValidationAttempt('malformed_response');
}
}
}
Претворете ги доказите во конзервативна одлука
Политиката ја користи секоја договорена компонента на одговорот. Статусот и препораката мора да одговараат на конфигурираните листи на дозволени вредности, нумеричкиот резултат мора да го достигне локалниот праг, секоја конфигурирана проверка мора да биде точно true, а квотата мора да биде присутна, непразна низа. Сè друго е несигурно.
<?php
// app/Domain/EmailValidation/NewsletterPolicy.php
namespace App\Domain\EmailValidation;
final class NewsletterPolicy
{
public function decide(EmailAssessment $result): array
{
$statuses = config('services.email_validator.allowed_statuses', []);
$recommendations = config(
'services.email_validator.allowed_recommendations',
[]
);
if (! in_array($result->status, $statuses, true)) {
return ['manual_review', 'status_not_allowed'];
}
if (! in_array($result->recommendation, $recommendations, true)) {
return ['manual_review', 'recommendation_not_allowed'];
}
if ($result->score < config('services.email_validator.minimum_score')) {
return ['manual_review', 'score_below_threshold'];
}
foreach (config('services.email_validator.required_checks', []) as $path) {
if (data_get($result->checks, $path) !== true) {
return ['manual_review', "check_failed:{$path}"];
}
}
if ($result->quota === []) {
return ['manual_review', 'quota_evidence_missing'];
}
return ['ready', null];
}
}
Безбедно обработувајте контакти во редицата
Редица е корисна затоа што голем CSV не треба да држи веб-барање или терминален процес во чекање поради далечинско доцнење. Задачата е единствена по контакт, користи одложени повторни обиди за повратни неуспеси и ги претвора трајните неуспеси во задачи за проверка.
<?php
// app/Jobs/ValidateNewsletterContact.php
namespace App\Jobs;
use App\Domain\EmailValidation\NewsletterPolicy;
use App\Models\NewsletterContact;
use App\Services\EmailValidator;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use Throwable;
final class ValidateNewsletterContact implements ShouldQueue, ShouldBeUnique
{
use Queueable;
public int $tries = 4;
public int $uniqueFor = 3600;
public function __construct(public readonly int $contactId) {}
public function uniqueId(): string
{
return (string) $this->contactId;
}
public function backoff(): array
{
return [60, 300, 900];
}
public function handle(
EmailValidator $validator,
NewsletterPolicy $policy,
): void {
$contact = NewsletterContact::findOrFail($this->contactId);
$attempt = $validator->check($contact->normalized_email);
if (in_array($attempt->state, [
'transient_failure',
'quota_or_rate_limited',
], true) && $this->attempts() < $this->tries) {
$delays = $this->backoff();
$this->release($delays[$this->attempts() - 1] ?? 900);
return;
}
if ($attempt->state !== 'ok' || $attempt->assessment === null) {
$contact->update([
'state' => 'manual_review',
'review_reason' => $attempt->state,
]);
Log::warning('Newsletter contact requires review', [
'contact_id' => $contact->id,
'reason' => $attempt->state,
]);
return;
}
[$state, $reason] = $policy->decide($attempt->assessment);
$contact->update([
'state' => $state,
'review_reason' => $reason,
'validation' => $attempt->assessment->evidence(),
]);
Log::info('Newsletter contact validation completed', [
'contact_id' => $contact->id,
'state' => $state,
'status' => $attempt->assessment->status,
'score' => $attempt->assessment->score,
]);
}
public function failed(Throwable $exception): void
{
NewsletterContact::whereKey($this->contactId)->update([
'state' => 'manual_review',
'review_reason' => 'unexpected_job_failure',
]);
}
}
Увезете и исчистете го CSV
Создадете app/Console/Commands/ImportNewsletterContacts.php. Очекуваниот CSV содржи заглавие email:
<?php
namespace App\Console\Commands;
use App\Jobs\ValidateNewsletterContact;
use App\Models\NewsletterContact;
use Illuminate\Console\Command;
use SplFileObject;
final class ImportNewsletterContacts extends Command
{
protected $signature = 'newsletter:import {path}';
protected $description = 'Import and validate newsletter contacts';
public function handle(): int
{
$path = $this->argument('path');
if (! is_string($path) || ! is_readable($path)) {
$this->error('CSV file is not readable.');
return self::FAILURE;
}
$csv = new SplFileObject($path);
$csv->setFlags(SplFileObject::READ_CSV | SplFileObject::SKIP_EMPTY);
$headers = $csv->fgetcsv();
$headers = array_map(
fn ($value) => strtolower(trim((string) $value)),
$headers ?: []
);
$emailColumn = array_search('email', $headers, true);
if ($emailColumn === false) {
$this->error('CSV must contain an email header.');
return self::FAILURE;
}
foreach ($csv as $row) {
$source = trim((string) ($row[$emailColumn] ?? ''));
if ($source === '') {
continue;
}
$normalized = mb_strtolower($source);
$validSyntax = filter_var($normalized, FILTER_VALIDATE_EMAIL) !== false;
$contact = NewsletterContact::firstOrCreate(
['normalized_email' => $normalized],
[
'source_email' => $source,
'state' => $validSyntax ? 'pending' : 'rejected',
'review_reason' => $validSyntax ? null : 'invalid_syntax',
],
);
if ($contact->wasRecentlyCreated && $validSyntax) {
ValidateNewsletterContact::dispatch($contact->id);
}
}
$this->info('Import accepted for processing.');
return self::SUCCESS;
}
}
Извршете ја миграцијата, увозот и worker-от со:
php artisan migrate
php artisan newsletter:import storage/app/imports/contacts.csv
php artisan queue:work --tries=4 --timeout=30
Тестирајте ја границата и однесувањето при увоз
Http::fake() ги одржува тестовите детерминистички и спречува трошење акредитиви или квота. Вокабуларот на фикстурата подолу ѝ припаѓа на локалната политика на тестот; тој не тврди недокументирани сервисни енумерации.
<?php
// tests/Feature/NewsletterImportTest.php
namespace Tests\Feature;
use App\Jobs\ValidateNewsletterContact;
use App\Services\EmailValidator;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
final class NewsletterImportTest extends TestCase
{
use RefreshDatabase;
public function test_validator_maps_a_complete_response(): void
{
config(['services.email_validator.token' => 'test-token']);
Http::fake([
'ai.mihajlo.mk/*' => Http::response([
'status' => 'accepted-fixture',
'score' => 92,
'recommendation' => 'send-fixture',
'checks' => ['syntax' => true],
'quota' => ['fixture' => true],
]),
]);
$result = app(EmailValidator::class)->check('[email protected]');
$this->assertSame('ok', $result->state);
$this->assertSame(92.0, $result->assessment?->score);
Http::assertSent(fn (Request $request): bool =>
$request->method() === 'GET'
&& $request->url()
=== 'https://ai.mihajlo.mk/api/email-validator/v1/check-email'
&& $request['token'] === 'test-token'
&& $request['email'] === '[email protected]'
);
}
public function test_import_rejects_bad_syntax_and_queues_valid_rows(): void
{
Queue::fake();
$path = tempnam(sys_get_temp_dir(), 'contacts-');
file_put_contents(
$path,
"email\n [email protected] \nnot-an-email\n"
);
$this->artisan('newsletter:import', ['path' => $path])
->assertSuccessful();
$this->assertDatabaseHas('newsletter_contacts', [
'normalized_email' => '[email protected]',
'state' => 'pending',
]);
$this->assertDatabaseHas('newsletter_contacts', [
'normalized_email' => 'not-an-email',
'state' => 'rejected',
]);
Queue::assertPushed(ValidateNewsletterContact::class, 1);
unlink($path);
}
public function test_malformed_success_payload_fails_closed(): void
{
config(['services.email_validator.token' => 'test-token']);
Http::fake([
'ai.mihajlo.mk/*' => Http::response(['status' => 'partial'], 200),
]);
$result = app(EmailValidator::class)->check('[email protected]');
$this->assertSame('malformed_response', $result->state);
}
}
Безбедност, операции и распоредување
Бидејќи потребниот механизам за автентикација го става токенот во низата за пребарување, конфигурирајте ги обратните проксија, алатките за перформанси на апликацијата и HTTP-дневниците за пристап да ги редигираат параметрите на барањето. Никогаш не ја евидентирајте целосната URL-адреса на барањето. Ограничете го пристапот до продукциската околина и ротирајте го сервисниот токен ако се сомневате на изложеност; запомнете дека повторното генерирање веднаш го поништува претходниот токен.
Адресите за е-пошта се лични податоци. Ограничете го пристапот до екранот за проверка, дефинирајте задржување за увезените изворни вредности и доказите за валидација и избегнувајте евидентирање необработени адреси. Идентификатор на контакт генерално е доволен за корелација.
Прво распоредете го кодот и миграциите, инсталирајте го продукцискиот токен преку складиштето за тајни на платформата, а потоа извршете php artisan config:cache. Рестартирајте ги worker-ите на редицата со php artisan queue:restart за долготрајните процеси да ја примат новата конфигурација. Следете го бројот по состојба, доцнењето на валидацијата, неуспесите во автентикацијата, одговорите за ограничување на стапката и староста на задачите во исчекување. Алармирајте за трендови наместо да евидентирате токени или целосни товари.
Чести неуспеси
- Секој контакт влегува во проверка: потврдете дека конфигурираниот статус, препораката и патеките на проверките точно се совпаѓаат со документираните вредности на одговорот.
- Неуспеси во автентикацијата: потврдете го токенот ограничен на услугата и повторно распоредете по ротацијата; не повторувајте одговори 401 или 403.
- Повторувано ограничување на стапката: намалете ја конкурентноста на worker-ите или паузирајте ги увозите. Backoff помага при нагли оптоварувања, но не може да го замени соодветниот капацитет на планот.
- Погрешно форматирани одговори: зачувајте ја категоријата на неуспех и прегледајте безбедно зачуван, редигиран товар. Не ја олабавувајте DTO-валидацијата само за да ја потиснете грешката.
- Застарена конфигурација: повторно изградете го кешот за конфигурација на Laravel и рестартирајте ги worker-ите на редицата.
Контролна листа за конечна проверка
- Токенот постои само во конфигурација поддржана од околината.
- Барањето користи
GETсо точните параметри за пребарувањеtokenиemail. - Синтаксичките неуспеси и дупликатите не трошат барање за валидација.
- Статусот, резултатот, препораката, проверките и квотата се валидираат и се зачувуваат како докази за одлуката.
- Само конфигурираните резултати што целосно поминуваат стануваат
ready. - Двосмислените и трајните состојби на неуспех стануваат
manual_review. - Повторните обиди се ограничени и ги исклучуваат неуспесите во автентикацијата и валидацијата од страна на клиентот.
- Тестовите поминуваат со
php artisan testи ниту еден тест не ја достигнува живата услуга.
Доверлив увоз не е оној што ја создава најголемата листа. Тој е оној што може да објасни зошто секоја адреса била прифатена, отфрлена или задржана. Со тоа што несигурноста станува првокласна состојба, овој Laravel-процес ги заштитува и испорачливоста на билтенот и луѓето одговорни за него.