Provjera uvoza newslettera: označavanje nesigurnih e-adresa za ručnu provjeru u PHP-u
Uvoz newslettera izgleda bezazleno dok ne stigne u produkciju. Jedna pogrešno upisana domena troši kvotu, jednokratna adresa slabi podatke o angažmanu, a previše samouvjereno pravilo čišćenja može odbaciti stvarnog pretplatnika. Ispravan ishod nije jednostavno „valjano” ili „nevaljano”. To je kontrolirani cjevovod koji prihvaća jake kandidate, odbacuje samo izričite neuspjehe i neizvjesnost šalje čovjeku.
Ovaj vodič izrađuje taj cjevovod kao aplikaciju naredbenog retka u Native PHP-u 8.3. Čita CSV izvoz, uklanja duplikate i očite probleme oblikovanja, poziva uslugu za provjeru e-pošte te stvara zasebne datoteke za prihvaćene, odbijene i one za ručnu provjeru. Integracija koristi izvorni cURL, obrambeni preslikivač odgovora, ograničena ponavljanja, mali prekidač strujnog kruga, strukturirane zapisnike i determinističke PHPUnit testove.
Dobijte pristup i kopirajte token usluge
Registrirajte se na https://ai.mihajlo.mk/register ili se prijavite putem https://ai.mihajlo.mk/login.
Otvorite stranicu usluge Email Validator, odaberite dostupni plan Free, Plus ili Pro i dovršite aktivaciju. Zatim otvorite službenu dokumentaciju usluge. Pronađite ploču Service token i kopirajte njezin token ograničen na uslugu.
Ponovno generiranje ovog tokena opoziva prethodno aktivni token. Ponovno generiranje tretirajte kao rotaciju vjerodajnica: ažurirajte tajnu za implementaciju i svaku konfiguraciju lokalnog okruženja prije nego što očekujete da postojeći radnici nastave uspješno raditi.
Potvrdite HTTP ugovor
Integracija šalje točan zahtjev GET na https://ai.mihajlo.mk/api/email-validator/v1/check-email. Autentikacija koristi parametar upita token, dok se adresa dostavlja kroz parametar upita email.
Prije izrade uvoznika napravite jedan minimalan zahtjev s rezerviranim mjestima:
curl --get \
--connect-timeout 3 \
--max-time 10 \
--data-urlencode "token=YOUR_SERVICE_TOKEN" \
--data-urlencode "[email protected]" \
"https://ai.mihajlo.mk/api/email-validator/v1/check-email"
Odgovor pruža status, score, recommendation, checks i quota. Aplikacija će zahtijevati svih pet polja, ali neće pretpostavljati nedokumentirana ugniježđena polja niti tiho prisilno pretvarati neispravne podatke.
Budući da se token pojavljuje u nizu upita, nikada nemojte bilježiti potpuni URL zahtjeva. Držite ga izvan poruka iznimki, analitike, zapisnika pristupa obrnutog proxyja gdje je moguće, snimki zaslona i priloga za podršku.
Preduvjeti i struktura projekta
Potrebni su vam PHP 8.3 ili noviji, proširenje cURL, Composer i ulazni CSV čije zaglavlje sadrži email. Stupac name nije obavezan. Jedini paket tijekom izvođenja je vlucas/phpdotenv u rasponu verzije 5.6; PHPUnit 11 koristi se tijekom razvoja.
newsletter-import/
├── .env.local
├── .gitignore
├── composer.json
├── bin/import-newsletter.php
├── src/Email/EmailValidator.php
├── src/Email/DecisionPolicy.php
└── tests/EmailValidatorTest.php
Sinkroni CLI proces dobar je kompromis za uvoz u uobičajenom malom timu: lako ga je pokrenuti ručno, putem crona ili unutar kratkotrajnog spremnika. Također izbjegava uvođenje baze podataka i reda čekanja samo radi obrade datoteke. Zasebne izlazne datoteke pružaju revizijski trag, dok privremene datoteke sprječavaju da neuspjelo pokretanje prikaže djelomičan izlaz kao dovršen.
Instalirajte ovisnosti i konfigurirajte okruženje
{
"require": {
"php": "^8.3",
"ext-curl": "*",
"vlucas/phpdotenv": "^5.6"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"scripts": {
"test": "phpunit tests"
}
}
composer install
composer dump-autoload
# .env.local
EMAIL_VALIDATOR_TOKEN=YOUR_SERVICE_TOKEN
EMAIL_ACCEPT_STATUSES=valid
EMAIL_ACCEPT_RECOMMENDATIONS=accept
EMAIL_REJECT_STATUSES=invalid
EMAIL_REJECT_RECOMMENDATIONS=reject
EMAIL_MIN_SCORE=80
# .gitignore
/vendor/
/.env.local
/newsletter-output/
*.part
Gore navedene oznake statusa, oznake preporuka i prag rezultata lokalna su pravila, a ne tvrdnja da su to iscrpne vrijednosti usluge. Potvrdite vrijednosti dokumentirane ili opažene za svoju aktiviranu uslugu i konfigurirajte ih u skladu s tim. Neprepoznata vrijednost ide na ručnu provjeru umjesto da bude slučajno prihvaćena.
Izgradite strogu API granicu
Transport upravlja ponašanjem cURL-a. Klijent upravlja ponavljanjima, HTTP klasifikacijom, dekodiranjem JSON-a i preslikavanjem odgovora. Ovo razdvajanje čini testove neovisnima o mreži i sprječava da detalji transporta procure u petlju uvoza.
<?php
// src/Email/EmailValidator.php
namespace App\Email;
use Closure;
use JsonException;
use RuntimeException;
final readonly class HttpResponse
{
public function __construct(
public int $status,
public string $body,
) {}
}
interface Transport
{
public function get(string $url, array $query): HttpResponse;
}
final class ApiException extends RuntimeException
{
public function __construct(public readonly string $kind, string $message)
{
parent::__construct($message);
}
}
final class CurlTransport implements Transport
{
public function get(string $url, array $query): HttpResponse
{
$requestUrl = $url . '?' . http_build_query(
$query,
'',
'&',
PHP_QUERY_RFC3986
);
$handle = curl_init($requestUrl);
if ($handle === false) {
throw new ApiException('transport', 'Could not initialize cURL');
}
curl_setopt_array($handle, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
CURLOPT_CONNECTTIMEOUT_MS => 3000,
CURLOPT_TIMEOUT_MS => 10000,
CURLOPT_FOLLOWLOCATION => false,
]);
$body = curl_exec($handle);
if ($body === false) {
$message = curl_error($handle);
curl_close($handle);
throw new ApiException('transport', 'Network request failed: ' . $message);
}
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
curl_close($handle);
return new HttpResponse($status, $body);
}
}
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', 'score', 'recommendation', 'checks', 'quota'] as $field) {
if (!array_key_exists($field, $data)) {
throw new ApiException('response', "Missing response field: {$field}");
}
}
if (!is_string($data['status']) || $data['status'] === '' ||
!is_string($data['recommendation']) || $data['recommendation'] === '' ||
!is_array($data['checks']) || !is_array($data['quota']) ||
(!is_int($data['score']) && !is_float($data['score']))) {
throw new ApiException('response', 'Response fields have unexpected types');
}
$score = (float) $data['score'];
if (!is_finite($score)) {
throw new ApiException('response', 'Response score is not finite');
}
return new self(
$data['status'],
$score,
$data['recommendation'],
$data['checks'],
$data['quota'],
);
}
}
final class EmailValidator
{
private Closure $sleep;
public function __construct(
private readonly Transport $transport,
private readonly string $token,
?Closure $sleep = null,
) {
if ($token === '') {
throw new ApiException('configuration', 'Service token is missing');
}
$this->sleep = $sleep ?? static fn (int $ms) => usleep($ms * 1000);
}
public function check(string $email): ValidationResult
{
$url = 'https://ai.mihajlo.mk/api/email-validator/v1/check-email';
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->transport->get($url, [
'token' => $this->token,
'email' => $email,
]);
} catch (ApiException $exception) {
if ($exception->kind !== 'transport' || $attempt === 3) {
throw $exception;
}
($this->sleep)(200 * $attempt);
continue;
}
if (in_array($response->status, [401, 403], true)) {
throw new ApiException('authentication', 'Service authentication failed');
}
if ($response->status === 429) {
throw new ApiException('quota', 'Service quota or rate limit reached');
}
if ($response->status >= 500) {
if ($attempt < 3) {
($this->sleep)(200 * $attempt);
continue;
}
throw new ApiException('upstream', 'Service remained unavailable');
}
if ($response->status !== 200) {
throw new ApiException('request', 'Validation request was rejected');
}
try {
$data = json_decode($response->body, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
throw new ApiException('response', 'Service returned invalid JSON');
}
if (!is_array($data)) {
throw new ApiException('response', 'Service response is not an object');
}
return ValidationResult::fromArray($data);
}
throw new ApiException('upstream', 'Retry loop ended unexpectedly');
}
}
Ponavljaju se samo neuspjesi transporta i odgovori poslužiteljske strane 5xx, s ukupno tri pokušaja i ograničenim odgodama. Neuspjesi autentikacije, neispravni zahtjevi, nevaljani tereti i odgovori o kvoti ne ponavljaju se naslijepo.
Pretvorite signale usluge u konzervativnu odluku
Usluga procjenjuje sintaksu, podatke o domeni i MX-u, signale pružatelja usluge i praktični rizik isporuke. Ti signali trebaju informirati pravila, a ne zamijeniti ih. Sljedeća pravila prihvaćaju samo izričito prepoznat status i preporuku, dovoljan rezultat, najmanje jednu logičku provjeru bez neuspjelih logičkih provjera i neprazne metapodatke o kvoti.
<?php
// src/Email/DecisionPolicy.php
namespace App\Email;
final readonly class Decision
{
public function __construct(
public string $outcome,
public string $reason,
) {}
}
final readonly class DecisionPolicy
{
public function __construct(
private array $acceptStatuses,
private array $acceptRecommendations,
private array $rejectStatuses,
private array $rejectRecommendations,
private float $minimumScore,
) {}
public function decide(ValidationResult $result): Decision
{
if (in_array($result->status, $this->rejectStatuses, true) ||
in_array($result->recommendation, $this->rejectRecommendations, true)) {
return new Decision('rejected', 'explicit_service_rejection');
}
$booleanChecks = [];
array_walk_recursive(
$result->checks,
static function (mixed $value) use (&$booleanChecks): void {
if (is_bool($value)) {
$booleanChecks[] = $value;
}
}
);
$trustedLabels =
in_array($result->status, $this->acceptStatuses, true) &&
in_array($result->recommendation, $this->acceptRecommendations, true);
if (!$trustedLabels ||
$result->score < $this->minimumScore ||
$booleanChecks === [] ||
in_array(false, $booleanChecks, true) ||
$result->quota === []) {
return new Decision('review', 'uncertain_service_result');
}
return new Decision('accepted', 'policy_passed');
}
}
Primijetite asimetriju: neizvjesnost nikada ne postaje prihvaćanje. Nepoznate oznake, neočekivane strukture provjera, niski rezultati i nedostajući podaci o kvoti ostaju vidljivi za ručnu provjeru.
Obradite CSV newslettera
Naredba normalizira razmake i velika/mala slova domene, uklanja duplicirane adrese i poziva validator za preostale retke. Zadržava vraćene signale uz svaku odluku kako bi provjeravatelji mogli razumjeti zašto je redak zadržan.
<?php
// bin/import-newsletter.php
declare(strict_types=1);
use App\Email\ApiException;
use App\Email\CurlTransport;
use App\Email\DecisionPolicy;
use App\Email\EmailValidator;
use Dotenv\Dotenv;
require dirname(__DIR__) . '/vendor/autoload.php';
Dotenv::createImmutable(dirname(__DIR__), '.env.local')->safeLoad();
$input = $argv[1] ?? '';
if ($input === '' || !is_readable($input)) {
fwrite(STDERR, "Usage: php bin/import-newsletter.php contacts.csv\n");
exit(2);
}
$envList = static fn (string $key): array => array_values(array_filter(
array_map('trim', explode(',', $_ENV[$key] ?? ''))
));
$validator = new EmailValidator(
new CurlTransport(),
$_ENV['EMAIL_VALIDATOR_TOKEN'] ?? ''
);
$policy = new DecisionPolicy(
$envList('EMAIL_ACCEPT_STATUSES'),
$envList('EMAIL_ACCEPT_RECOMMENDATIONS'),
$envList('EMAIL_REJECT_STATUSES'),
$envList('EMAIL_REJECT_RECOMMENDATIONS'),
(float) ($_ENV['EMAIL_MIN_SCORE'] ?? 80),
);
$source = fopen($input, 'rb');
$header = $source === false ? false : fgetcsv($source);
if ($header === false) {
throw new RuntimeException('Input CSV is empty or unreadable');
}
$normalizedHeader = array_map(
static fn ($value) => strtolower(trim((string) $value)),
$header
);
$emailColumn = array_search('email', $normalizedHeader, true);
$nameColumn = array_search('name', $normalizedHeader, true);
if ($emailColumn === false) {
throw new RuntimeException('Input CSV must contain an email column');
}
$outputDirectory = dirname($input) . '/newsletter-output';
if (!is_dir($outputDirectory) && !mkdir($outputDirectory, 0770, true)) {
throw new RuntimeException('Could not create output directory');
}
$definitions = [
'accepted' => ['email', 'name', 'status', 'score', 'recommendation', 'checks', 'quota'],
'review' => ['email', 'name', 'reason', 'status', 'score', 'recommendation', 'checks', 'quota'],
'rejected' => ['email', 'name', 'reason'],
];
$files = [];
foreach ($definitions as $kind => $columns) {
$files[$kind] = fopen("{$outputDirectory}/{$kind}.csv.part", 'wb');
if ($files[$kind] === false) {
throw new RuntimeException("Could not open {$kind} output");
}
fputcsv($files[$kind], $columns);
}
$seen = [];
$blockedReason = null;
$consecutiveFailures = 0;
while (($row = fgetcsv($source)) !== false) {
$rawEmail = trim((string) ($row[$emailColumn] ?? ''));
$name = trim((string) ($nameColumn === false ? '' : ($row[$nameColumn] ?? '')));
$parts = explode('@', $rawEmail, 2);
$email = count($parts) === 2
? $parts[0] . '@' . strtolower($parts[1])
: $rawEmail;
$key = strtolower($email);
if ($email === '' || isset($seen[$key])) {
fputcsv($files['rejected'], [$email, $name, $email === '' ? 'empty_email' : 'duplicate']);
continue;
}
$seen[$key] = true;
if ($blockedReason !== null) {
fputcsv($files['review'], [$email, $name, $blockedReason, '', '', '', '', '']);
continue;
}
try {
$result = $validator->check($email);
$decision = $policy->decide($result);
$consecutiveFailures = 0;
$checks = json_encode($result->checks, JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE);
$quota = json_encode($result->quota, JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE);
if ($decision->outcome === 'accepted') {
fputcsv($files['accepted'], [
$email, $name, $result->status, $result->score,
$result->recommendation, $checks, $quota,
]);
} elseif ($decision->outcome === 'rejected') {
fputcsv($files['rejected'], [$email, $name, $decision->reason]);
} else {
fputcsv($files['review'], [
$email, $name, $decision->reason, $result->status,
$result->score, $result->recommendation, $checks, $quota,
]);
}
error_log(json_encode([
'event' => 'email_validation_decision',
'email_hash' => hash('sha256', $key),
'outcome' => $decision->outcome,
'status' => $result->status,
'score' => $result->score,
], JSON_UNESCAPED_SLASHES));
} catch (ApiException $exception) {
$consecutiveFailures++;
$reason = 'api_' . $exception->kind;
fputcsv($files['review'], [$email, $name, $reason, '', '', '', '', '']);
if (in_array($exception->kind, ['authentication', 'quota'], true) ||
$consecutiveFailures >= 3) {
$blockedReason = $reason;
}
}
}
fclose($source);
foreach (array_keys($definitions) as $kind) {
fclose($files[$kind]);
if (!rename(
"{$outputDirectory}/{$kind}.csv.part",
"{$outputDirectory}/{$kind}.csv"
)) {
throw new RuntimeException("Could not publish {$kind} output");
}
}
fwrite(STDOUT, "Import completed in {$outputDirectory}\n");
Prekidač strujnog kruga zaustavlja ponovljene pozive nakon neuspjeha autentikacije ili kvote, odnosno nakon tri uzastopna neuspjeha integracije. Preostali kontakti odlaze na provjeru, čuvajući podatke uz izbjegavanje bujice bezizglednih zahtjeva.
Testirajte ponavljanja i preslikavanje odgovora bez mreže
Deterministički lažni transport čini putanje neuspjeha brzim i ponovljivim. Ove vrijednosti testnih podataka ispituju lokalna pravila; nisu predstavljene kao iscrpna shema usluge.
<?php
// tests/EmailValidatorTest.php
use App\Email\EmailValidator;
use App\Email\HttpResponse;
use App\Email\Transport;
use PHPUnit\Framework\TestCase;
final class FakeTransport implements Transport
{
public int $calls = 0;
public function __construct(private array $responses) {}
public function get(string $url, array $query): HttpResponse
{
$this->calls++;
return array_shift($this->responses);
}
}
final class EmailValidatorTest extends TestCase
{
public function testItMapsAllDecisionFields(): void
{
$fake = new FakeTransport([
new HttpResponse(200, json_encode([
'status' => 'valid',
'score' => 92,
'recommendation' => 'accept',
'checks' => ['syntax' => true, 'mx' => true],
'quota' => ['present' => true],
], JSON_THROW_ON_ERROR)),
]);
$result = (new EmailValidator($fake, 'test-token', static fn () => null))
->check('[email protected]');
self::assertSame('valid', $result->status);
self::assertSame(92.0, $result->score);
self::assertTrue($result->checks['mx']);
self::assertSame(1, $fake->calls);
}
public function testItRetriesOneServerFailure(): void
{
$fake = new FakeTransport([
new HttpResponse(503, '{}'),
new HttpResponse(200, json_encode([
'status' => 'valid',
'score' => 90,
'recommendation' => 'accept',
'checks' => ['syntax' => true],
'quota' => ['present' => true],
], JSON_THROW_ON_ERROR)),
]);
(new EmailValidator($fake, 'test-token', static fn () => null))
->check('[email protected]');
self::assertSame(2, $fake->calls);
}
}
composer test
php bin/import-newsletter.php contacts.csv
Sigurnost, vidljivost i implementacija
Ubrizgajte EMAIL_VALIDATOR_TOKEN iz spremišta tajni platforme za implementaciju umjesto da .env.local ugrađujete u sliku. Ograničite dopuštenja lokalne datoteke okruženja, namjerno rotirajte token i osigurajte da dijagnostički alati nikada ne snimaju URL-ove zahtjeva.
Adrese e-pošte osobni su podaci. Primjer zapisnika za korelaciju bilježi samo jednosmjerni hash te izostavlja token, neobrađeni odgovor i adresu. Zaštitite izlazne datoteke odgovarajućim dopuštenjima datotečnog sustava i pravilima zadržavanja. Ako će se CSV datoteke otvarati u proračunskom softveru, također neutralizirajte znakove koji započinju formule u tekstualnim poljima koja nisu e-pošta, u skladu sa svojim pravilima izvoza.
Pratite broj prihvaćenih, odbijenih, pregledanih, neuspjelih autentikacija, blokiranih zbog kvote i redaka s neuspjehom uzvodne usluge. Nagli porast pregleda često otkriva odstupanje pravila ili promjenu oblika odgovora prije nego što postane gubitak pretplatnika. Tijekom implementacije pokrenite testove, izvršite mali kanarski uvoz, pregledajte tri izlaza i tek zatim obradite potpunu datoteku.
Završni popis za provjeru
- Plan usluge je aktivan, a token ograničen na uslugu pohranjen je izvan kontrole izvornog koda.
- Konfigurirana pravila za status, preporuku i rezultat odgovaraju službenoj dokumentaciji i vašoj toleranciji rizika.
- Testni zahtjev vraća svih pet obaveznih polja: status, score, recommendation, checks i quota.
- PHPUnit potvrđuje uspješno preslikavanje i ograničeno ponašanje ponavljanja za 5xx.
- Slučajevi duplikata, neizvjesnosti, blokade kvote i neispravnog odgovora završavaju u očekivanim datotekama.
- Zapisnici sadrže hasheve i strukturirane ishode, ali ne i adrese e-pošte, tokene ni potpune URL-ove zahtjeva.
- Samo potpuno zapisani privremeni izlazi preimenuju se u konačne nazive CSV datoteka.
Pouzdan uvoz nije onaj koji donosi najviše automatskih odluka. To je onaj koji može objasniti svaku odluku, obuzdati svaki neuspjeh i sačuvati neizvjesnost za nekoga kvalificiranog da je riješi. Uz strogu API granicu i namjerno oprezna pravila, ručna provjera postaje koristan sigurnosni ventil umjesto improviziranog reda za čišćenje.