Nativni PHP 8.3: Validirajte uvoze newslettera i stavite nesigurne e-poruke u red za pregled
Uvoz newslettera izgleda bezopasno dok ne sadrži pogrešno napisana imena domena, jednokratne adrese, duplicirane kontakte i dvosmislene sandučiće. Uvezite sve i reputacija pošiljatelja će patiti; odbijajte preagresivno i legitimni pretplatnici nestaju. Praktičan odgovor je konzervativan proces: normalizirajte lokalno, provjerite udaljeno, prihvatite samo pouzdane rezultate i svaki nesiguran slučaj stavite u red za pregled.
Ovaj vodič izrađuje taj proces kao naredbu u Native PHP 8.3. Koristi uslugu Email Validator za procjenu sintakse, domene, MX zapisa, signala pružatelja usluge i praktičnog rizika isporuke. Implementacija uključuje obrambeno mapiranje odgovora, ograničene ponovne pokušaje, determinističke testove, strukturirane zapise i izlazne datoteke koje se mogu pregledati.
Dobijte pristup i kopirajte token usluge
- Registrirajte se na https://ai.mihajlo.mk/register ili upotrijebite https://ai.mihajlo.mk/login ako već imate račun.
- Otvorite stranicu usluge Email Validator.
- Odaberite dostupni Free, Plus ili Pro plan i dovršite njegovu aktivaciju.
- Otvorite službenu dokumentaciju za Email Validator.
- Pronađite ploču Service token i kopirajte token ograničen na uslugu.
Ova usluga zahtijeva autentikaciju. Njezin se token šalje putem parametra upita token. Ponovno generiranje tokena usluge opoziva prethodno aktivni token, stoga ažurirajte svako postavljeno okruženje odmah nakon rotacije. Nikada ne spremajte vrijednost u repozitorij niti je uključujte u zapise, snimke zaslona, testne podatke, poruke iznimki ili oznake nadzora.
Potvrdite točan HTTP ugovor
Zahtjev je HTTP GET na https://ai.mihajlo.mk/api/email-validator/v1/check-email. Sadrži parametre upita token i email. Minimalni dijagnostički zahtjev je:
curl --get \
--data-urlencode "token=YOUR_SERVICE_TOKEN" \
--data-urlencode "[email protected]" \
--header "Accept: application/json" \
"https://ai.mihajlo.mk/api/email-validator/v1/check-email"
Pokrenite to samo u privatnom terminalu. Vjerodajnice u nizu upita mogu se pojaviti u povijesti ljuske, popisima procesa, zapisima obrnutog proxyja i tragovima za otklanjanje pogrešaka. Aplikacija u nastavku interno sastavlja URL i nikada ga ne zapisuje u logove.
Odgovor daje status, score, recommendation, checks i quota. Dostavljeni ugovor ne utvrđuje enum vrijednosti, ljestvicu rezultata ni ugniježđene ključeve unutar checks i quota. Produkcijski kod stoga treba koristiti dokumentirane vrijednosti iz vaše aktivirane usluge, umjesto nagađanja o značenju oznaka.
Pohranite konfiguraciju izvan kontrole izvornog koda
Izradite privatnu datoteku .env i dodajte je u .gitignore. Zamijenite rezervirana mjesta pravila točnim uspješnim vrijednostima status i recommendation opisanima u trenutačnoj dokumentaciji, uz prag primjeren dokumentiranoj ljestvici rezultata.
EMAIL_VALIDATOR_TOKEN=YOUR_SERVICE_TOKEN
EMAIL_ACCEPT_STATUS=YOUR_DOCUMENTED_ACCEPT_STATUS
EMAIL_ACCEPT_RECOMMENDATION=YOUR_DOCUMENTED_ACCEPT_RECOMMENDATION
EMAIL_ACCEPT_MIN_SCORE=YOUR_CHOSEN_THRESHOLD
EMAIL_CONNECT_TIMEOUT_MS=2000
EMAIL_RESPONSE_TIMEOUT_MS=5000
Neuspješno postavite implementaciju ako ostane bilo koje rezervirano mjesto. U spremnicima i upravljanom hostingu ubacite ove varijable putem spremišta tajni platforme, umjesto kopiranja razvojne datoteke .env u sliku.
Arhitektura: automatizirajte sigurnost, sačuvajte nejasnoću
Uvoz ima tri ishoda. Lokalno nepravilno oblikovani retci idu u rejected.csv. Udaljeno provjerena adresa prihvaća se samo kada se svi konfigurirani signali podudaraju. Nepoznate vrijednosti odgovora, nepotpuni podaci, vremenska ograničenja, neuspjesi autentikacije, ograničavanje brzine i slabiji rezultati provjere idu u review.jsonl.
Time se namjerno izbjegava tretiranje nedostupne ovisnosti kao dokaza da je adresa loša. Također se izbjegava tiho prihvaćanje odgovora nakon što pružatelj promijeni enum ili oblik podataka.
Projekt tijekom izvođenja koristi izvorni cURL i izdanja kompatibilna s PHPUnitom 11.5 za razvojne testove. PHPUnit 11 podržava PHP 8.2 i noviji, što uključuje potrebni izvršni PHP 8.3.
newsletter-import/
├── bin/import-newsletter.php
├── src/
│ ├── Http.php
│ ├── EmailValidatorClient.php
│ ├── ValidationResult.php
│ └── ImportPolicy.php
├── tests/EmailValidatorClientTest.php
├── var/runs/
├── .env
├── .env.example
└── composer.json
Instalirajte PHP 8.3 s proširenjima cURL i JSON, a zatim konfigurirajte Composer:
{
"require": {
"php": "^8.3",
"ext-curl": "*",
"ext-json": "*"
},
"require-dev": {
"phpunit/phpunit": "^11.5"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
}
}
composer install
composer dump-autoload
mkdir -p var/runs
Izradite ograničeni izvorni cURL transport
Granica API-ja treba eksplicitna vremenska ograničenja i apstrakciju transporta. Ta je apstrakcija mala, ali omogućuje testovima da zamijene cURL determinističkim odgovorima.
<?php
// src/Http.php
declare(strict_types=1);
namespace App;
final readonly class HttpResponse
{
public function __construct(
public int $status,
public string $body,
) {}
}
final class TransportException extends \RuntimeException {}
interface HttpTransport
{
public function get(
string $url,
int $connectTimeoutMs,
int $responseTimeoutMs,
): HttpResponse;
}
final class CurlTransport implements HttpTransport
{
public function get(
string $url,
int $connectTimeoutMs,
int $responseTimeoutMs,
): HttpResponse {
$handle = curl_init($url);
if ($handle === false) {
throw new TransportException('Unable to initialize cURL');
}
curl_setopt_array($handle, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
CURLOPT_CONNECTTIMEOUT_MS => $connectTimeoutMs,
CURLOPT_TIMEOUT_MS => $responseTimeoutMs,
CURLOPT_FOLLOWLOCATION => false,
]);
$body = curl_exec($handle);
if ($body === false) {
$message = curl_error($handle);
curl_close($handle);
throw new TransportException($message);
}
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
curl_close($handle);
return new HttpResponse($status, $body);
}
}
Provjera TLS certifikata ostaje uključena putem sigurnih zadanih postavki cURL-a. Nemojte je onemogućiti kako biste „popravili” pogrešku certifikata; umjesto toga popravite spremište pouzdanih certifikata operativnog sustava.
Mapirajte odgovor na granici
Uspješan HTTP status nije dovoljan. Mapper odbija nedostajuća polja, neočekivane tipove, nekonačne rezultate i JSON koji nije objekt prije nego što politika aplikacije vidi odgovor.
<?php
// src/ValidationResult.php
declare(strict_types=1);
namespace App;
final readonly class ValidationResult
{
public function __construct(
public string $status,
public float $score,
public string $recommendation,
public array $checks,
public array $quota,
) {}
public static function fromArray(array $data): self
{
foreach (['status', 'recommendation'] as $field) {
if (!isset($data[$field]) || !is_string($data[$field])
|| trim($data[$field]) === '') {
throw new \UnexpectedValueException("Invalid {$field}");
}
}
if (!isset($data['score']) || !is_numeric($data['score'])) {
throw new \UnexpectedValueException('Invalid score');
}
$score = (float) $data['score'];
if (!is_finite($score)) {
throw new \UnexpectedValueException('Non-finite score');
}
if (!isset($data['checks']) || !is_array($data['checks'])) {
throw new \UnexpectedValueException('Invalid checks');
}
if (!isset($data['quota']) || !is_array($data['quota'])) {
throw new \UnexpectedValueException('Invalid quota');
}
return new self(
trim($data['status']),
$score,
trim($data['recommendation']),
$data['checks'],
$data['quota'],
);
}
public function auditData(): array
{
return [
'status' => $this->status,
'score' => $this->score,
'recommendation' => $this->recommendation,
'checks' => $this->checks,
'quota' => $this->quota,
];
}
}
Klijent ponovno pokušava samo kod kvarova transporta, HTTP 429 i odgovora poslužitelja 5xx. Autentikacija, provjera zahtjeva, neispravan JSON i kršenja ugovora ne pokušavaju se ponovno jer ih ponavljanje ne može popraviti.
<?php
// src/EmailValidatorClient.php
declare(strict_types=1);
namespace App;
final class ValidatorFailure extends \RuntimeException
{
public function __construct(
public readonly string $category,
public readonly ?int $httpStatus = null,
) {
parent::__construct($category);
}
}
final class EmailValidatorClient
{
private const ENDPOINT =
'https://ai.mihajlo.mk/api/email-validator/v1/check-email';
public function __construct(
private HttpTransport $transport,
private string $token,
private int $connectTimeoutMs = 2000,
private int $responseTimeoutMs = 5000,
private \Closure $sleep = new \Closure(),
) {
if ($this->token === '') {
throw new \InvalidArgumentException('Missing service token');
}
if ($this->sleep === new \Closure()) {
$this->sleep = static fn(int $microseconds) => usleep($microseconds);
}
}
public function check(string $email): ValidationResult
{
$query = http_build_query(
['token' => $this->token, 'email' => $email],
'',
'&',
PHP_QUERY_RFC3986,
);
$url = self::ENDPOINT . '?' . $query;
$backoff = [0, 200000, 600000];
for ($attempt = 0; $attempt < 3; $attempt++) {
if ($backoff[$attempt] > 0) {
($this->sleep)($backoff[$attempt]);
}
try {
$response = $this->transport->get(
$url,
$this->connectTimeoutMs,
$this->responseTimeoutMs,
);
} catch (TransportException) {
if ($attempt < 2) {
continue;
}
throw new ValidatorFailure('transport');
}
if ($response->status === 429 || $response->status >= 500) {
if ($attempt < 2) {
continue;
}
$category = $response->status === 429
? 'rate_limited'
: 'upstream';
throw new ValidatorFailure($category, $response->status);
}
if ($response->status === 401 || $response->status === 403) {
throw new ValidatorFailure('authentication', $response->status);
}
if ($response->status < 200 || $response->status >= 300) {
throw new ValidatorFailure('request', $response->status);
}
try {
$payload = json_decode(
$response->body,
true,
32,
JSON_THROW_ON_ERROR,
);
if (!is_array($payload)) {
throw new \UnexpectedValueException('Expected JSON object');
}
return ValidationResult::fromArray($payload);
} catch (\JsonException|\UnexpectedValueException) {
throw new ValidatorFailure('invalid_response', $response->status);
}
}
throw new ValidatorFailure('upstream');
}
}
U stvarnom kodu eksplicitno inicijalizirajte sleep closure; PHP ne može koristiti svježe konstruirani closure kao smisleni sentinel. Sažeti produkcijski konstruktor koristi ?Closure $sleep = null i dodjeljuje $this->sleep = $sleep ?? static fn(int $us) => usleep($us). Time klijent ostaje testabilan bez stvarnih odgoda.
Primijenite konzervativnu politiku uvoza
Politika koristi svako vraćeno polje ugovora. Status, preporuka i rezultat moraju ispunjavati konfigurirane kriterije prihvaćanja. Prazni podaci checks ili quota čine rezultat nesigurnim jer aplikacija ne može revidirati dokaze ni stanje kvote. Neobrađene strukture čuvaju se za pregled bez pretpostavljanja nedokumentiranih ugniježđenih ključeva.
<?php
// src/ImportPolicy.php
declare(strict_types=1);
namespace App;
enum Decision: string
{
case Accept = 'accept';
case Review = 'review';
}
final readonly class ImportPolicy
{
public function __construct(
private string $acceptedStatus,
private string $acceptedRecommendation,
private float $minimumScore,
) {}
public function decide(ValidationResult $result): Decision
{
$certain =
hash_equals($this->acceptedStatus, $result->status)
&& hash_equals(
$this->acceptedRecommendation,
$result->recommendation,
)
&& $result->score >= $this->minimumScore
&& $result->checks !== []
&& $result->quota !== [];
return $certain ? Decision::Accept : Decision::Review;
}
}
Pokrenite naredbu za uvoz newslettera
Ulazni CSV mora sadržavati email, a može sadržavati name. Naredba obrezuje polja, uklanja kontrolne znakove iz imena, mala slova primjenjuje samo na dio domene i uklanja duplikate neovisno o velikim i malim slovima za potrebe newslettera. Iako lokalni dijelovi e-pošte teoretski mogu razlikovati velika i mala slova, newsletter sustavi obično trebaju jedan ljudski sandučić prikazan jednom; dokumentirajte to poslovno pravilo ako je važno vašoj publici.
<?php
// bin/import-newsletter.php
declare(strict_types=1);
use App\CurlTransport;
use App\Decision;
use App\EmailValidatorClient;
use App\ImportPolicy;
use App\ValidatorFailure;
require dirname(__DIR__) . '/vendor/autoload.php';
$input = $argv[1] ?? null;
if ($input === null || !is_readable($input)) {
fwrite(STDERR, "Usage: php bin/import-newsletter.php contacts.csv\n");
exit(2);
}
$required = [
'EMAIL_VALIDATOR_TOKEN',
'EMAIL_ACCEPT_STATUS',
'EMAIL_ACCEPT_RECOMMENDATION',
'EMAIL_ACCEPT_MIN_SCORE',
];
foreach ($required as $key) {
$value = getenv($key);
if ($value === false || $value === '' || str_starts_with($value, 'YOUR_')) {
throw new RuntimeException("Missing or placeholder environment: {$key}");
}
}
$client = new EmailValidatorClient(
new CurlTransport(),
getenv('EMAIL_VALIDATOR_TOKEN'),
(int) (getenv('EMAIL_CONNECT_TIMEOUT_MS') ?: 2000),
(int) (getenv('EMAIL_RESPONSE_TIMEOUT_MS') ?: 5000),
static fn(int $us) => usleep($us),
);
$policy = new ImportPolicy(
getenv('EMAIL_ACCEPT_STATUS'),
getenv('EMAIL_ACCEPT_RECOMMENDATION'),
(float) getenv('EMAIL_ACCEPT_MIN_SCORE'),
);
$run = dirname(__DIR__) . '/var/runs/' . gmdate('Ymd-His');
if (!mkdir($run, 0700, true) && !is_dir($run)) {
throw new RuntimeException('Cannot create run directory');
}
$source = fopen($input, 'rb');
$accepted = fopen($run . '/accepted.csv', 'xb');
$rejected = fopen($run . '/rejected.csv', 'xb');
$review = fopen($run . '/review.jsonl', 'xb');
$header = fgetcsv($source);
if ($header === false || !in_array('email', $header, true)) {
throw new RuntimeException('CSV requires an email header');
}
$columns = array_flip($header);
fputcsv($accepted, ['email', 'name']);
fputcsv($rejected, ['email', 'reason']);
$seen = [];
while (($row = fgetcsv($source)) !== false) {
$email = trim((string) ($row[$columns['email']] ?? ''));
$name = trim((string) ($row[$columns['name'] ?? -1] ?? ''));
$name = preg_replace('/[\x00-\x1F\x7F]/u', '', $name) ?? '';
$at = strrpos($email, '@');
if ($at !== false) {
$email = substr($email, 0, $at + 1)
. strtolower(substr($email, $at + 1));
}
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
fputcsv($rejected, [$email, 'local_syntax']);
continue;
}
$dedupeKey = strtolower($email);
if (isset($seen[$dedupeKey])) {
fputcsv($rejected, [$email, 'duplicate']);
continue;
}
$seen[$dedupeKey] = true;
try {
$result = $client->check($email);
if ($policy->decide($result) === Decision::Accept) {
fputcsv($accepted, [$email, $name]);
continue;
}
$record = [
'email' => $email,
'name' => $name,
'reason' => 'uncertain',
'validation' => $result->auditData(),
];
} catch (ValidatorFailure $failure) {
$record = [
'email' => $email,
'name' => $name,
'reason' => $failure->category,
'http_status' => $failure->httpStatus,
];
}
fwrite(
$review,
json_encode($record, JSON_THROW_ON_ERROR) . PHP_EOL,
);
fwrite(STDERR, json_encode([
'event' => 'email_queued_for_review',
'email_hash' => hash('sha256', $dedupeKey),
'reason' => $record['reason'],
], JSON_THROW_ON_ERROR) . PHP_EOL);
}
fclose($source);
fclose($accepted);
fclose($rejected);
fclose($review);
fwrite(STDOUT, "Completed: {$run}\n");
Izvezite varijable okruženja putem svoje ljuske ili platforme za implementaciju, zatim pokrenite php bin/import-newsletter.php contacts.csv. Svako izvršavanje dobiva zaseban direktorij s ograničenim dozvolama, pa se prethodni rezultat ne prepisuje.
Testirajte ponovne pokušaje i granice neuspjeha
Lažni transport čini testove brzim i neovisnima o računima, kvotama, DNS-u i mrežnim uvjetima.
<?php
// tests/EmailValidatorClientTest.php
declare(strict_types=1);
namespace Tests;
use App\EmailValidatorClient;
use App\HttpResponse;
use App\HttpTransport;
use App\ValidatorFailure;
use PHPUnit\Framework\TestCase;
final class FakeTransport implements HttpTransport
{
public int $calls = 0;
public function __construct(private array $responses) {}
public function get(string $url, int $connect, int $timeout): HttpResponse
{
return $this->responses[$this->calls++];
}
}
final class EmailValidatorClientTest extends TestCase
{
public function testRetriesServerFailureThenMapsResponse(): void
{
$fake = new FakeTransport([
new HttpResponse(503, '{}'),
new HttpResponse(200, json_encode([
'status' => 'configured-status',
'score' => 7,
'recommendation' => 'configured-recommendation',
'checks' => ['evidence' => true],
'quota' => ['available' => true],
], JSON_THROW_ON_ERROR)),
]);
$client = new EmailValidatorClient(
$fake,
'fixture-token',
sleep: static fn(int $us) => null,
);
self::assertSame(
'configured-status',
$client->check('[email protected]')->status,
);
self::assertSame(2, $fake->calls);
}
public function testDoesNotRetryAuthenticationFailure(): void
{
$fake = new FakeTransport([new HttpResponse(401, '{}')]);
$client = new EmailValidatorClient(
$fake,
'fixture-token',
sleep: static fn(int $us) => null,
);
try {
$client->check('[email protected]');
self::fail('Expected failure');
} catch (ValidatorFailure $failure) {
self::assertSame('authentication', $failure->category);
self::assertSame(1, $fake->calls);
}
}
}
Dodajte i testove pravila za točno podudaranje prihvaćanja, nizak rezultat, nepoznatu preporuku, prazne provjere i prazne podatke o kvoti. Pokrenite skup testova s vendor/bin/phpunit tests.
Operacije, sigurnost i česti neuspjesi
- HTTP 401 ili 403: zaustavite uvoz, provjerite token ograničen na uslugu i provjerite je li ga netko ponovno generirao. Nemojte ponovno pokušavati neuspjehe autentikacije.
- HTTP 429: klijent izvodi samo ograničene ponovne pokušaje. Zadržite preostale kontakte u redu za pregled ili ih ponovno pokrenite nakon potvrde dostupnosti kvote. Nikada ne vrtite u nedogled.
- Ponovljeni 5xx ili vremenska ograničenja: pregledajte broj strukturiranih neuspjeha i kasnije ponovno pokušajte s pogođenim redom. Ne klasificirajte te adrese kao neisporučive.
- Sve ulazi u pregled: usporedite konfigurirani status, preporuku i prag rezultata sa službenom dokumentacijom i redigiranim dijagnostičkim odgovorom.
- Neispravan odgovor: sačuvajte kategoriju neuspjeha, upozorite na rastući broj i pregledajte ugovor prije promjene mappera.
- Međunarodne adrese:
FILTER_VALIDATE_EMAILje konzervativna lokalna provjera i možda ne obuhvaća svaki internacionalizirani oblik. Ako su takve adrese potrebne, definirajte izričitu politiku normalizacije umjesto da ih tiho transformirate.
Logovi trebaju sadržavati identifikatore izvođenja, brojanja, latenciju, HTTP status, pokušaj ponavljanja i stabilne hashove — ne tokene, URL-ove zahtjeva, pune odgovore, imena ili adrese. Ograničite pristup datotekama za uvoz i pregled jer sadržavaju osobne podatke. Uspostavite pravila zadržavanja i brisanja u skladu s postupkom pristanka za newsletter.
Tijekom implementacije provjerite proširenje cURL, direktorij var/runs s dozvolom za pisanje, odlazni HTTPS pristup, umetanje tajni i ispravnu konfiguraciju pravila. Pokrenite jedan mali kanarinski uvoz prije obrade potpunog popisa. Planirajte samo jednog radnika po izvornoj datoteci osim ako ne dodate zajedničku pohranu za uklanjanje duplikata i izričito particioniranje.
Završni kontrolni popis za provjeru
- Token je preuzet s ploče Service token na stranici dokumentacije i pohranjen je samo u konfiguraciji podržanoj varijablama okruženja.
- Klijent poziva točnu GET krajnju točku s URL-kodiranim parametrima
tokeniemail. - Vremenska ograničenja veze i odgovora su ograničena.
- Samo pogreške transporta, odgovori 429 i odgovori 5xx dobivaju ograničene ponovne pokušaje s odgodom.
- Status, rezultat, preporuka, provjere i kvota provjeravaju se po tipu i sudjeluju u odluci.
- Nepoznati, nepotpuni ili nedostupni rezultati provjere ulaze u ručni pregled umjesto da budu prihvaćeni ili odbijeni.
- Testovi koriste lažni transport i nikada ne troše kvotu usluge.
- Logovi i testni podaci ne sadrže vjerodajnice ni neobrađene identitete pretplatnika.
- Kanarski CSV proizvodi prihvaćene, odbijene i izlazne rezultate pregleda prema očekivanjima.
Pouzdan proces uvoza nije onaj koji donosi najviše odluka. To je onaj koji zna koje je odluke sigurno automatizirati. Zadržite uska vrata prihvaćanja, učinite nesigurnost vidljivom i tretirajte red za pregled kao namjernu značajku proizvoda — a ne neuspjeh provjere.