Потврдете го увозот на билтенот: Означете ги несигурните е-пораки за рачна проверка во PHP
Увозот на билтен изгледа безопасно сè додека не стигне во продукција. Една погрешно напишана домена троши квота, адресата за еднократна употреба ги ослабува податоците за ангажман, а премногу самоуверено правило за чистење може да отфрли вистински претплатник. Правилниот исход не е едноставно „валидно“ или „невалидно“. Тоа е контролиран процес што прифаќа силни кандидати, отфрла само експлицитни неуспеси и ја испраќа неизвесноста до човек.
Ова упатство го гради тој процес како апликација за командна линија во Native PHP 8.3. Таа чита CSV извоз, отстранува дупликати и очигледни проблеми со форматирањето, повикува услуга за валидација на е-пошта и создава посебни датотеки за прифатени, отфрлени и записи за рачна проверка. Интеграцијата користи native cURL, дефанзивен мапер на одговори, ограничени повторни обиди, мал прекинувач на колото, структурирани логови и детерминистички PHPUnit тестови.
Добијте пристап и копирајте го сервисниот токен
Регистрирајте се на https://ai.mihajlo.mk/register, или најавете се преку https://ai.mihajlo.mk/login.
Отворете ја страницата на услугата Email Validator, изберете го достапниот Free, Plus или Pro план и завршете ја активацијата. Потоа отворете ја официјалната документација на услугата. Најдете го панелот Service token и копирајте го неговиот токен ограничен на услугата.
Повторното генерирање на овој токен го поништува претходно активниот токен. Третирајте го повторното генерирање како ротација на ингеренции: ажурирајте ја тајната за распоредување и секоја конфигурација на локалната околина пред да очекувате постојните работници да продолжат успешно.
Потврдете го HTTP договорот
Интеграцијата испраќа точен GET повик до https://ai.mihajlo.mk/api/email-validator/v1/check-email. Автентикацијата го користи параметарот за пребарување token, додека адресата се доставува преку параметарот за пребарување email.
Направете еден минимален повик со местодржачи пред да го изградите увозникот:
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"
Одговорот ги доставува status, score, recommendation, checks и quota. Апликацијата ќе ги бара сите пет полиња, но нема да претпоставува недокументирани вгнездени полиња ниту тивко ќе принудува неправилни податоци во друг тип.
Бидејќи токенот се појавува во низата за пребарување, никогаш не ја логирајте целосната URL-адреса на повикот. Чувајте го надвор од пораки за исклучоци, аналитика, логови за пристап на обратен прокси каде што е можно, слики од екран и прилози за поддршка.
Предуслови и облик на проектот
Потребни ви се PHP 8.3 или понов, екстензијата cURL, Composer и влезен CSV чиј заглавен ред содржи email. Колоната name не е задолжителна. Единствениот пакет за извршување е vlucas/phpdotenv во опсегот на верзијата 5.6; PHPUnit 11 се користи при развој.
newsletter-import/
├── .env.local
├── .gitignore
├── composer.json
├── bin/import-newsletter.php
├── src/Email/EmailValidator.php
├── src/Email/DecisionPolicy.php
└── tests/EmailValidatorTest.php
Синхрон CLI процес е добар компромис за вообичаен увоз на мал тим: лесно се извршува рачно, од cron или во краткотраен контејнер. Исто така избегнува воведување база на податоци и редица само за обработка на датотека. Посебните излезни датотеки обезбедуваат трага за ревизија, додека привремените датотеки спречуваат неуспешното извршување да прикаже делумен излез како целосен.
Инсталирајте ги зависностите и конфигурирајте ја околината
{
"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
Ознаките за статус, ознаките за препорака и прагот на резултатот погоре се локална политика, а не тврдење дека ова се исцрпните вредности на услугата. Потврдете ги вредностите документирани или забележани за вашата активирана услуга и конфигурирајте ги соодветно. Непрепознаена вредност оди на рачна проверка наместо случајно да биде прифатена.
Изградете строга API граница
Транспортот го поседува однесувањето на cURL. Клиентот ги поседува повторните обиди, HTTP класификацијата, JSON декодирањето и мапирањето на одговорот. Ова раздвојување ги прави тестовите независни од мрежата и спречува деталите за транспортот да протечат во јамката за увоз.
<?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');
}
}
Само неуспесите во транспортот и одговорите 5xx од серверската страна се повторуваат, со вкупно три обиди и ограничен backoff. Неуспеси при автентикација, неправилни повици, невалидни товари и одговори за квота не се повторуваат слепо.
Претворете ги сигналите од услугата во претпазлива одлука
Услугата ја проценува синтаксата, информациите за доменот и MX, сигналите од добавувачот и практичниот ризик за испорака. Тие сигнали треба да ја информираат политиката, а не да ја заменат. Следнава политика прифаќа само експлицитно препознаен статус и препорака, доволен резултат, најмалку една булова проверка без неуспешни булови проверки и непразни метаподатоци за квота.
<?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');
}
}
Забележете ја асиметријата: неизвесноста никогаш не станува прифаќање. Непознати ознаки, неочекувани структури на проверки, ниски резултати и недостасувачки податоци за квота остануваат видливи за рачна проверка.
Обработете го CSV на билтенот
Командата ги нормализира празните места и големината на буквите во доменот, отстранува дупликати на адреси и го повикува валидаторот за преостанатите редови. Таа ги задржува вратените сигнали покрај секоја одлука за проверувачите да разберат зошто е задржан некој ред.
<?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");
Прекинувачот на колото ги запира повторените повици по неуспеси во автентикацијата или квотата, или по три последователни неуспеси во интеграцијата. Преостанатите контакти одат на проверка, зачувувајќи ги податоците и избегнувајќи бура од безнадежни повици.
Тестирајте повторни обиди и мапирање на одговори без мрежата
Детерминистички лажен транспорт ги прави патеките на неуспех брзи и повторливи. Овие вредности на фикстурите ја тестираат локалната политика; тие не се претставени како исцрпна шема на услугата.
<?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
Безбедност, набљудливост и распоредување
Вметнете го EMAIL_VALIDATOR_TOKEN од складиштето за тајни на платформата за распоредување, наместо да го вградувате .env.local во слика. Ограничете ги дозволите за датотеките на локалната околина, намерно ротирајте го токенот и осигурете се дека дијагностичките алатки никогаш не ги снимаат URL-адресите на повиците.
Адресите на е-пошта се лични податоци. Примерните логови користат само еднонасочен хаш за корелација и го изоставуваат токенот, сировиот одговор и адресата. Заштитете ги излезните датотеки со соодветни дозволи за датотечниот систем и правила за задржување. Ако CSV-датотеките ќе се отвораат во софтвер за табеларни пресметки, исто така неутрализирајте ги знаците што водат до формула во текстуалните полиња што не се е-пошта, во согласност со вашата политика за извоз.
Следете ги бројките на прифатени, отфрлени, проверени, неуспешно автентицирани, блокирани поради квота и редови со неуспех кај надворешната услуга. Нагло зголемување на проверките често укажува на отстапување во политиката или промена на обликот на одговорот пред да прерасне во губење претплатници. При распоредување, извршете ги тестовите, направете мал canary увоз, прегледајте ги трите излези и дури потоа обработете ја целосната датотека.
Конечна листа за верификација
- Планот за услугата е активен, а токенот ограничен на услугата е зачуван надвор од контрола на изворниот код.
- Конфигурираната политика за статус, препорака и резултат одговара на официјалната документација и на вашата толеранција на ризик.
- Тест-повикот ги враќа сите пет задолжителни полиња: status, score, recommendation, checks и quota.
- PHPUnit докажува успешно мапирање и ограничено однесување при повторен обид за 5xx.
- Случаите со дупликат, неизвесен резултат, блокирање поради квота и неправилен одговор завршуваат во очекуваните датотеки.
- Логовите содржат хашеви и структурирани исходи, но не и адреси на е-пошта, токени или целосни URL-адреси на повици.
- Само целосно запишаните привремени излези се преименуваат во конечни CSV-датотеки.
Сигурен увоз не е оној што носи најмногу автоматски одлуки. Тоа е оној што може да ја објасни секоја одлука, да го ограничи секој неуспех и да ја зачува неизвесноста за некој што е квалификуван да ја разреши. Со строга API граница и намерно претпазлива политика, рачната проверка станува корисен безбедносен вентил наместо импровизирана редица за чистење.