Vodiči

Laravel: Tame Newsletter Imports by Sending Dubious Emails to Manual Review

Laravel: Ukrotite uvoz newslettera slanjem sumnjivih e-poruka na ručnu provjeru

Uvoz newslettera djeluje bezopasno dok se ne susretne sa stvarnim podacima: dupliciranim kontaktima, suvišnim razmacima, pogrešno napisanim domenama, napuštenim poštanskim sandučićima i adresama koje su tehnički valjane, ali i dalje rizične. Slanje svakog retka izravno na popis za slanje troši kvotu, narušava isporučivost i nesigurne podatke pretvara u operativni problem.

Sigurniji dizajn je mali cjevovod odluka. Laravel najprije obavlja determinističko čišćenje, vanjski validator pruža dokaze o isporuci, a samo jasno prihvatljivi kontakti postaju spremni za slanje. Sve nejasno ulazi u red za ručni pregled umjesto da bude tiho prihvaćeno ili odbačeno.

Dobijte pristup validatoru e-pošte

Registrirajte se na https://ai.mihajlo.mk/register ili se prijavite putem https://ai.mihajlo.mk/login.

Otvorite stranicu usluge na https://ai.mihajlo.mk/api/email-validator. Odaberite dostupni plan Free, Plus ili Pro i dovršite njegovu aktivaciju. Zatim posjetite službenu dokumentaciju na https://ai.mihajlo.mk/api/email-validator/documentation, pronađite ploču Service token i kopirajte token ograničen na uslugu.

Usluga zahtijeva taj token; nije neautentificirana krajnja točka. Ponovno generiranje servisnog tokena opoziva prethodno aktivni token, stoga regeneraciju tretirajte kao rotaciju vjerodajnice koja mora biti usklađena s implementacijom.

Točan zahtjev je HTTP GET prema https://ai.mihajlo.mk/api/email-validator/v1/check-email. Autentifikacija koristi parametar upita token, dok se adresa šalje u parametru upita email. Isprobajte ga bez trajnog upisivanja tokena u povijest ljuske:

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

Uspješan odgovor sadrži status, score, recommendation, checks i quota. Svih pet ćemo validirati na granici aplikacije, umjesto da pretpostavimo da svaki HTTP 200 sadrži upotrebljive podatke.

Prije pisanja funkcionalnosti spremite vjerodajnicu u Laravelovu konfiguraciju okruženja:

# .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

Nemojte predavati .env u repozitorij. Gore navedene vrijednosti pravila namjerno nisu predstavljene kao API enumeracije: konfigurirajte ih prema službenoj dokumentaciji i provjerenim odgovorima za svoj račun.

Arhitektura: determinističko čišćenje prije plaćene validacije

Ova implementacija cilja PHP 8.3 i Laravel aplikaciju s konfiguriranom bazom podataka i redom. CSV naredba normalizira adrese, odbacuje neosporne sintaksne pogreške, uklanja duplicirane retke i šalje jedan posao reda za svaki novi kontakt. Posao poziva namjenskog HTTP klijenta, preslikava odgovor u objekt domene i primjenjuje konzervativno pravilo.

  • Spremno: svako polje odgovora strukturno je valjano, a konfigurirani status, preporuka, rezultat i provjere prolaze.
  • Odbijeno: lokalna sintaksna validacija ne uspijeva prije bilo kakvog udaljenog zahtjeva.
  • Ručni pregled: odgovor je dvosmislen, neispravno oblikovan, neautoriziran ili izvan pravila.
  • Na čekanju: pokušaj ponavljanja zbog pogreške prijenosa ili poslužitelja još nije iscrpio ograničeni proračun ponavljanja.

Ova je pristranost namjerna. Lažno negativan rezultat košta pregled; lažno pozitivan može utjecati na svaku buduću kampanju.

Konfigurirajte granicu usluge

Dodajte sljedeći unos u 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', ''))
    ))),
],

Stvorite migraciju pomoću php artisan make:model NewsletterContact -m, a zatim definirajte tablicu:

<?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');
    }
};

U app/Models/NewsletterContact.php dopustite ta polja i pretvorite dokaze:

<?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'];
    }
}

Preslikajte nepouzdani JSON u rezultat domene

DTO odbacuje nedostajuća ili pogrešno tipizirana polja ugovora. Ne nagađa internu strukturu checks ni quota; oni ostaju polja sve dok pravilo aplikacije ne pregleda konfigurirane putanje provjera.

<?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 klijent koristi kratka vremenska ograničenja i ponavlja samo pogreške veze i pogreške poslužitelja. Pogreške autentifikacije, pogreške validacije klijenta i odgovori o kvoti ne ponavljaju se naslijepo.

<?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');
        }
    }
}

Pretvorite dokaze u konzervativnu odluku

Pravilo koristi svaku ugovorenu komponentu odgovora. Status i preporuka moraju odgovarati konfiguriranim popisima dopuštenih vrijednosti, brojčani rezultat mora doseći lokalni prag, svaka konfigurirana provjera mora biti točno true, a kvota mora biti prisutno, neprazno polje. Sve ostalo je nesigurno.

<?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];
    }
}

Sigurno obrađujte kontakte u redu

Red je koristan jer veliki CSV ne bi trebao držati web zahtjev ili terminalski proces u čekanju zbog udaljene latencije. Posao je jedinstven po kontaktu, koristi odgođena ponavljanja za oporavljive pogreške i trajne pogreške pretvara u zadatke za pregled.

<?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',
        ]);
    }
}

Uvezite i očistite CSV

Stvorite app/Console/Commands/ImportNewsletterContacts.php. Očekivani CSV sadržava zaglavlje 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;
    }
}

Pokrenite migraciju, uvoz i radnik pomoću:

php artisan migrate
php artisan newsletter:import storage/app/imports/contacts.csv
php artisan queue:work --tries=4 --timeout=30

Testirajte granicu i ponašanje uvoza

Http::fake() održava testove determinističkima i sprječava potrošnju vjerodajnica ili kvote. Dolje navedeni rječnik fikstura pripada lokalnom pravilu testa; ne tvrdi ništa o nedokumentiranim enumeracijama usluge.

<?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);
    }
}

Sigurnost, operacije i implementacija

Budući da potrebni mehanizam autentifikacije stavlja token u niz upita, konfigurirajte obrnute proxyje, alate za nadzor performansi aplikacije i HTTP zapisnike pristupa tako da prikriju parametre upita. Nikada nemojte bilježiti potpuni URL zahtjeva. Ograničite pristup produkcijskom okruženju i rotirajte servisni token ako sumnjate na izloženost; zapamtite da regeneracija odmah poništava prethodni token.

Adrese e-pošte osobni su podaci. Ograničite pristup zaslonu za pregled, definirajte zadržavanje uvezenih izvornih vrijednosti i dokaza validacije te izbjegavajte bilježenje neobrađenih adresa. ID kontakta općenito je dovoljan za korelaciju.

Najprije implementirajte kôd i migracije, instalirajte produkcijski token putem spremišta tajni platforme, a zatim pokrenite php artisan config:cache. Ponovno pokrenite radnike reda pomoću php artisan queue:restart kako bi dugotrajni procesi primili novu konfiguraciju. Pratite brojke po stanju, latenciju validacije, pogreške autentifikacije, odgovore ograničenja stope i starost poslova na čekanju. Upozoravajte na trendove umjesto bilježenja tokena ili potpunih odgovora.

Uobičajeni neuspjesi

  • Svaki kontakt ulazi u pregled: potvrdite da konfigurirani status, preporuka i putanje provjera točno odgovaraju dokumentiranim vrijednostima odgovora.
  • Pogreške autentifikacije: provjerite token ograničen na uslugu i ponovno implementirajte nakon rotacije; nemojte ponavljati odgovore 401 ili 403.
  • Ponovljeno ograničavanje stope: smanjite konkurentnost radnika ili pauzirajte uvoze. Odgoda pomaže kod naleta, ali ne može zamijeniti odgovarajući kapacitet plana.
  • Neispravno oblikovani odgovori: zadržite kategoriju neuspjeha i pregledajte sigurno snimljen, redigiran odgovor. Nemojte ublažavati DTO validaciju samo radi potiskivanja pogreške.
  • Zastarjela konfiguracija: ponovno izgradite Laravelovu predmemoriju konfiguracije i ponovno pokrenite radnike reda.

Završni kontrolni popis za provjeru

  • Token postoji samo u konfiguraciji podržanoj okruženjem.
  • Zahtjev koristi GET s točnim parametrima upita token i email.
  • Sintaksne pogreške i duplikati ne troše zahtjev za validaciju.
  • Status, rezultat, preporuka, provjere i kvota validiraju se i zadržavaju kao dokazi odluke.
  • Samo konfigurirani rezultati koji u potpunosti prolaze postaju ready.
  • Dvosmislena i trajna stanja neuspjeha postaju manual_review.
  • Ponavljanja su ograničena i isključuju pogreške autentifikacije i validacije klijenta.
  • Testovi prolaze pomoću php artisan test i nijedan test ne doseže aktivnu uslugu.

Pouzdan uvoz nije onaj koji stvara najveći popis. To je onaj koji može objasniti zašto je svaka adresa prihvaćena, odbijena ili zadržana. Time što nesigurnost čini stanjem prvog reda, ovaj Laravelov cjevovod štiti i isporučivost newslettera i ljude odgovorne za njega.

Portret autora bloga

Mihajlo

Ja sam Mihajlo — programer vođen znatiželjom, disciplinom i stalnom željom da stvorim nešto smisleno. Dijelim uvide, tutorijale i besplatne usluge kako bih pomogao drugima da pojednostave svoj rad i rastu u svijetu softvera i umjetne inteligencije koji se neprestano razvija.