Туториали

Native PHP: Bulletproof Newsletter Imports with AI Email Validation and Manual Review

Native PHP: Непробојни увози на билтени со AI валидација на е-пошта и рачна проверка

Увозот на билтен изгледа безопасно сè додека домен со печатна грешка, привремено поштенско сандаче, дупликат адреса или минлив неуспех на API не стигне до листата за испраќање. Безбедниот одговор не е да се избрише секој сомнителен запис. Тој е да се автоматизираат одлуките со висока сигурност и да се зачува неизвесноста за човек.

Овој туторијал го гради тој работен тек во Native PHP 8.3. Команда чита CSV-датотека, ги нормализира и дедуплицира адресите, ја повикува услугата Email Validator, увезува само записи што одговараат на експлицитна локална политика и сè друго го складира во редица за рачна проверка поддржана од SQLite. Дизајнот намерно останува мал: синхрона обработка, изворен cURL, PDO и транспортна граница што може да се тестира без мрежен пристап.

Добијте пристап пред да напишете код за интеграција

  1. Регистрирајте се на https://ai.mihajlo.mk/register, или користете https://ai.mihajlo.mk/login ако веќе имате сметка.
  2. Отворете ја страницата на услугата Email Validator. Изберете го достапниот Free, Plus или Pro план и завршете ја неговата активација.
  3. Отворете ја официјалната документација за услугата. Најдете го панелот Service token и копирајте го токенот ограничен на услугата.
  4. Складирајте го тој токен во конфигурацијата на околината на проектот. Никогаш не го додавајте во верзиска контрола, не го ставајте во фикстура и не го печатете во дневници.

Оваа услуга бара токен. Неговото повторно генерирање го поништува претходниот активен токен, па ротацијата мора да се координира со распоредувањето: инсталирајте ја новата вредност каде и да работи увезувачот, а потоа проверете ја командата со мала датотека пред да продолжи нормалната обработка.

Потврдете го точниот барање

Интеграцијата користи GET https://ai.mihajlo.mk/api/email-validator/v1/check-email. Автентикацијата се доставува преку параметарот за пребарување token, додека адресата се доставува преку email.

curl --get 'https://ai.mihajlo.mk/api/email-validator/v1/check-email' \
  --data-urlencode 'token=YOUR_SERVICE_TOKEN' \
  --data-urlencode '[email protected]' \
  --connect-timeout 3 \
  --max-time 10

Извршете го ова еднаш со непродукциска адреса и проверете ја документираната структура на одговорот. Апликацијата подолу дефанзивно ги валидира status, score, recommendation, checks и quota. Таа не нагаѓа недокументирани значења за вредностите на статусот или препораката; поволните вредности и прагот за резултат се политика на распоредувањето, изведена од официјалната документација и вашата толеранција на ризик.

Структура на проектот и компромиси

Увозувачот е CLI-процес, а не веб-барање. Тоа избегнува истекувања на времето во прелистувачот и го прави закажаното извршување едноставно. Секоја адреса се зачувува веднаш, така што подоцнежен неуспех не ја брише претходната работа. SQLite е погоден за мал тим или распоредување на еден хост; складиштата подоцна можат да преминат на друга PDO-база на податоци без промена на API-границата.

newsletter-import/
├── bin/import.php
├── src/EmailValidator.php
├── src/ImportPolicy.php
├── tests/EmailValidatorTest.php
├── var/
├── .env
└── composer.json

Создадете composer.json и инсталирајте ги зависностите. PHPUnit 11 се користи само за тестови; продукцискиот HTTP останува изворен cURL.

{
  "require": {
    "php": "^8.3",
    "ext-curl": "*",
    "ext-json": "*",
    "ext-pdo": "*",
    "ext-pdo_sqlite": "*"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.0"
  },
  "autoload": {
    "psr-4": {
      "Newsletter\\": "src/"
    }
  }
}
composer install
mkdir -p var
chmod 700 var
chmod 600 .env

Ставете ги вредностите специфични за околината во .env. Примерните местодржачи за политика се намерни: заменете ги со поволни вредности и праг потврдени од официјалната документација. Одбивањето да се стартува со местодржачи е побезбедно отколку тивко усвојување измислена семантика на услугата.

EMAIL_VALIDATOR_TOKEN=YOUR_SERVICE_TOKEN
EMAIL_VALIDATOR_AUTO_STATUS=YOUR_CONFIRMED_STATUS
EMAIL_VALIDATOR_AUTO_RECOMMENDATION=YOUR_CONFIRMED_RECOMMENDATION
EMAIL_VALIDATOR_MIN_SCORE=YOUR_DOCUMENTED_SCORE_THRESHOLD
IMPORT_DATABASE=var/newsletter.sqlite

Изградете строга API-граница

Транспортот ги враќа статусот, заглавијата и телото одделно. Валидаторот повторува само мрежни неуспеси, HTTP 429 и серверски грешки. Неуспесите при автентикација и вообичаените одговори за валидација никогаш не се повторуваат слепо. Повратното чекање е ограничено, а нумеричката вредност на Retry-After се почитува во рамките на тоа ограничување.

<?php
// src/EmailValidator.php
namespace Newsletter;

final readonly class HttpResponse
{
    public function __construct(
        public int $status,
        public array $headers,
        public string $body
    ) {}
}

interface Transport
{
    public function get(string $url, int $connectMs, int $totalMs): HttpResponse;
}

final class NetworkException extends \RuntimeException {}
final class ApiException extends \RuntimeException {}

final class CurlTransport implements Transport
{
    public function get(string $url, int $connectMs, int $totalMs): HttpResponse
    {
        $headers = [];
        $handle = curl_init($url);

        curl_setopt_array($handle, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT_MS => $connectMs,
            CURLOPT_TIMEOUT_MS => $totalMs,
            CURLOPT_HTTPHEADER => ['Accept: application/json'],
            CURLOPT_HEADERFUNCTION => static function ($curl, string $line) use (&$headers): int {
                $parts = explode(':', $line, 2);
                if (count($parts) === 2) {
                    $headers[strtolower(trim($parts[0]))] = trim($parts[1]);
                }
                return strlen($line);
            },
        ]);

        $body = curl_exec($handle);
        if ($body === false) {
            $message = curl_error($handle);
            curl_close($handle);
            throw new NetworkException($message);
        }

        $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
        curl_close($handle);

        return new HttpResponse($status, $headers, $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 fromJson(string $json): self
    {
        try {
            $data = json_decode($json, true, 32, JSON_THROW_ON_ERROR);
        } catch (\JsonException $e) {
            throw new ApiException('Validator returned invalid JSON.', 0, $e);
        }

        if (!is_array($data)
            || !is_string($data['status'] ?? null)
            || !is_numeric($data['score'] ?? null)
            || !is_string($data['recommendation'] ?? null)
            || !is_array($data['checks'] ?? null)
            || !is_array($data['quota'] ?? null)
        ) {
            throw new ApiException('Validator response does not match the required contract.');
        }

        return new self(
            trim($data['status']),
            (float) $data['score'],
            trim($data['recommendation']),
            $data['checks'],
            $data['quota']
        );
    }
}

final class EmailValidator
{
    private const ENDPOINT =
        'https://ai.mihajlo.mk/api/email-validator/v1/check-email';

    public function __construct(
        private Transport $transport,
        private string $token
    ) {}

    public function check(string $email): ValidationResult
    {
        $query = http_build_query(
            ['token' => $this->token, 'email' => $email],
            '',
            '&',
            PHP_QUERY_RFC3986
        );
        $url = self::ENDPOINT . '?' . $query;

        for ($attempt = 0; $attempt < 3; $attempt++) {
            try {
                $response = $this->transport->get($url, 3000, 10000);
            } catch (NetworkException $e) {
                if ($attempt === 2) {
                    throw $e;
                }
                usleep(200000 * (2 ** $attempt));
                continue;
            }

            if ($response->status === 200) {
                return ValidationResult::fromJson($response->body);
            }

            $retryable = $response->status === 429
                || ($response->status >= 500 && $response->status <= 599);

            if (!$retryable || $attempt === 2) {
                throw new ApiException(
                    'Validator HTTP failure: ' . $response->status
                );
            }

            $retryAfter = ctype_digit($response->headers['retry-after'] ?? '')
                ? (int) $response->headers['retry-after']
                : 0;
            $delayMs = min(2000, max(200 * (2 ** $attempt), $retryAfter * 1000));
            usleep($delayMs * 1000);
        }

        throw new ApiException('Validator retry loop ended unexpectedly.');
    }
}

Предавањето акредитиви во низа за пребарување е дел од бараниот договор, но URL-адресите често се снимаат од проксија и посреден софтвер за дебагирање. Не ја запишувајте целосната URL-адреса на барањето во дневник. Осигурете се дека секое обратно прокси или систем за следење на излезен сообраќај ги редигира низите за пребарување.

Претворете ги доказите од услугата во локална одлука

API-то доставува докази; вашата апликација ја поседува одлуката. Оваа политика увезува само кога конфигурираните статус и препорака се совпаѓаат, резултатот го исполнува конфигурираниот праг и се присутни и податоците за проверки и квота. Сè што е двосмислено станува достапно за преглед наместо да исчезне.

<?php
// src/ImportPolicy.php
namespace Newsletter;

enum ImportDecision: string
{
    case Import = 'import';
    case Review = 'review';
}

final readonly class ImportPolicy
{
    public function __construct(
        private string $acceptedStatus,
        private string $acceptedRecommendation,
        private float $minimumScore
    ) {}

    public function decide(ValidationResult $result): ImportDecision
    {
        $complete = $result->status !== ''
            && $result->recommendation !== ''
            && $result->checks !== []
            && $result->quota !== [];

        $approved = hash_equals($this->acceptedStatus, $result->status)
            && hash_equals(
                $this->acceptedRecommendation,
                $result->recommendation
            )
            && $result->score >= $this->minimumScore;

        return $complete && $approved
            ? ImportDecision::Import
            : ImportDecision::Review;
    }
}

Имплементирајте ја командата за увоз

Командата ја вчитува датотеката со околината без да ги препишува променливите доставени од менаџерот на процеси. Таа создава две табели, локално одбива невалидни CSV-редови, дедуплицира во рамките на датотеката и хешира адреси во дневниците. Базата на податоци ги задржува целосните докази од валидаторот што им се потребни на проверувачите.

<?php
// bin/import.php
use Newsletter\{
    ApiException, CurlTransport, EmailValidator,
    ImportDecision, ImportPolicy, NetworkException
};

require dirname(__DIR__) . '/vendor/autoload.php';

foreach (file(dirname(__DIR__) . '/.env', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
    if (str_starts_with(ltrim($line), '#') || !str_contains($line, '=')) {
        continue;
    }
    [$key, $value] = explode('=', $line, 2);
    if (getenv(trim($key)) === false) {
        putenv(trim($key) . '=' . trim($value));
    }
}

$required = [
    'EMAIL_VALIDATOR_TOKEN',
    'EMAIL_VALIDATOR_AUTO_STATUS',
    'EMAIL_VALIDATOR_AUTO_RECOMMENDATION',
    'EMAIL_VALIDATOR_MIN_SCORE',
    'IMPORT_DATABASE',
];
foreach ($required as $key) {
    if (($value = getenv($key)) === false || $value === '' || str_starts_with($value, 'YOUR_')) {
        throw new RuntimeException("Missing production configuration: {$key}");
    }
}
if (!is_numeric(getenv('EMAIL_VALIDATOR_MIN_SCORE'))) {
    throw new RuntimeException('EMAIL_VALIDATOR_MIN_SCORE must be numeric.');
}

$csvPath = $argv[1] ?? null;
if ($csvPath === null || !is_readable($csvPath)) {
    fwrite(STDERR, "Usage: php bin/import.php contacts.csv\n");
    exit(64);
}

$pdo = new PDO('sqlite:' . dirname(__DIR__) . '/' . getenv('IMPORT_DATABASE'), null, null, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$pdo->exec(
    'CREATE TABLE IF NOT EXISTS contacts (
        email TEXT PRIMARY KEY, imported_at TEXT NOT NULL
    );
     CREATE TABLE IF NOT EXISTS manual_review (
        email TEXT PRIMARY KEY, reason TEXT NOT NULL,
        evidence_json TEXT, created_at TEXT NOT NULL
    );'
);

$validator = new EmailValidator(
    new CurlTransport(),
    getenv('EMAIL_VALIDATOR_TOKEN')
);
$policy = new ImportPolicy(
    getenv('EMAIL_VALIDATOR_AUTO_STATUS'),
    getenv('EMAIL_VALIDATOR_AUTO_RECOMMENDATION'),
    (float) getenv('EMAIL_VALIDATOR_MIN_SCORE')
);

$import = $pdo->prepare(
    'INSERT OR IGNORE INTO contacts(email, imported_at) VALUES(?, ?)'
);
$review = $pdo->prepare(
    'INSERT OR REPLACE INTO manual_review
     (email, reason, evidence_json, created_at) VALUES(?, ?, ?, ?)'
);

$file = new SplFileObject($csvPath);
$file->setFlags(SplFileObject::READ_CSV | SplFileObject::SKIP_EMPTY);
$seen = [];

foreach ($file as $row) {
    $raw = is_array($row) ? trim((string) ($row[0] ?? '')) : '';
    if ($raw === '' || strtolower($raw) === 'email') {
        continue;
    }

    $at = strrpos($raw, '@');
    $email = $at === false
        ? $raw
        : substr($raw, 0, $at) . '@' . strtolower(substr($raw, $at + 1));
    $logId = hash('sha256', $email);

    if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
        $review->execute([$email, 'local_syntax_failure', null, gmdate(DATE_ATOM)]);
        continue;
    }
    if (isset($seen[$email])) {
        continue;
    }
    $seen[$email] = true;

    try {
        $result = $validator->check($email);
        $evidence = json_encode([
            'status' => $result->status,
            'score' => $result->score,
            'recommendation' => $result->recommendation,
            'checks' => $result->checks,
            'quota' => $result->quota,
        ], JSON_THROW_ON_ERROR);

        if ($policy->decide($result) === ImportDecision::Import) {
            $import->execute([$email, gmdate(DATE_ATOM)]);
        } else {
            $review->execute([$email, 'uncertain', $evidence, gmdate(DATE_ATOM)]);
        }
    } catch (NetworkException | ApiException $e) {
        $review->execute([
            $email,
            'validation_unavailable',
            null,
            gmdate(DATE_ATOM),
        ]);
        fwrite(STDERR, json_encode([
            'event' => 'validation_failed',
            'email_hash' => $logId,
            'error_type' => $e::class,
        ]) . PHP_EOL);
    }
}

Оваа имплементација не го претвора во мали букви локалниот дел пред @; се нормализира само доменот. Тој конзервативен избор избегнува наметнување претпоставки за еквивалентност на поштенски сандачиња, кои не се универзални.

Тестирајте без да ја контактирате услугата

Детерминистички лажен транспорт ја проверува конструкцијата на барањето, мапирањето на одговорот и однесувањето при неуспех без трошење квота или изложување токен.

<?php
// tests/EmailValidatorTest.php
use Newsletter\{
    EmailValidator, HttpResponse, Transport, ApiException
};
use PHPUnit\Framework\TestCase;

final class FakeTransport implements Transport
{
    public string $requestedUrl = '';

    public function __construct(private HttpResponse $response) {}

    public function get(string $url, int $connectMs, int $totalMs): HttpResponse
    {
        $this->requestedUrl = $url;
        return $this->response;
    }
}

final class EmailValidatorTest extends TestCase
{
    public function testMapsACompleteResponseAndEncodesQuery(): void
    {
        $fake = new FakeTransport(new HttpResponse(200, [], json_encode([
            'status' => 'documented-status',
            'score' => 42,
            'recommendation' => 'documented-recommendation',
            'checks' => ['documented-check' => true],
            'quota' => ['documented-quota-value' => 7],
        ], JSON_THROW_ON_ERROR)));

        $result = (new EmailValidator($fake, 'test-token'))
            ->check('[email protected]');

        self::assertSame(42.0, $result->score);
        self::assertStringContainsString(
            'email=name%2Bnews%40example.com',
            $fake->requestedUrl
        );
        self::assertStringContainsString('token=test-token', $fake->requestedUrl);
    }

    public function testRejectsAnIncompleteContract(): void
    {
        $fake = new FakeTransport(
            new HttpResponse(200, [], '{"status":"present"}')
        );

        $this->expectException(ApiException::class);
        (new EmailValidator($fake, 'test-token'))->check('[email protected]');
    }

    public function testDoesNotTreatAuthenticationFailureAsData(): void
    {
        $fake = new FakeTransport(new HttpResponse(401, [], '{}'));

        $this->expectException(ApiException::class);
        (new EmailValidator($fake, 'bad-token'))->check('[email protected]');
    }
}
vendor/bin/phpunit --bootstrap vendor/autoload.php tests
php bin/import.php contacts.csv
sqlite3 var/newsletter.sqlite \
  'SELECT email, reason, created_at FROM manual_review ORDER BY created_at;'

Зајакнување за продукција и чести неуспеси

Чувајте го .env надвор од објавените директориуми и читлив само за корисникот за распоредување. Третирајте ги складираните адреси и доказите за валидација како лични податоци: ограничете го пристапот до базата на податоци, дефинирајте период на задржување за записите за преглед и избришете ги доказите кога веќе не служат за оперативна цел.

Испраќајте бројачи за увезени, прегледани, локално невалидни, неуспешни API-редови, повторени и дупликат редови. Алармирајте при трајни неуспеси на автентикација, одговори поврзани со квота или ненадеен пораст на невалидни договори. Дневниците треба да содржат еднонасочен хеш на адресата и категорија на грешка, никогаш токен, целосна URL-адреса или необработена е-пошта.

401 или 403 обично укажува на отсутен, поништен или неточен токен за услуга; поправете ја конфигурацијата наместо да повторувате. Повторените одговори 429 значат дека обработката треба да паузира или да се презакаже според достапната квота и ограничувањата на планот. Истекувањата на времето и одговорите 5xx ги добиваат само веќе прикажаните ограничени повторувања. Неочекувана JSON-структура е неуспех на договорот и оди на преглед, спречувајќи случајни увози по промена нагоре по синџирот.

За поголеми датотеки, поставете контролни точки според изворната датотека и бројот на редот или додајте идентификатор за извршување на увоз и единствен изворен клуч. Избегнувајте да стартувате многу инстанци на команди само за да ја зголемите брзината: паралелноста може да ја исцрпи квотата и да создаде бура од повторувања. Почнете со еден закажан работник, измерете ја пропусноста и зголемувајте ја паралелноста само во рамките на документираните ограничувања.

Конечна контролна листа за проверка

  • Активираниот план и токенот ограничен на услугата ѝ припаѓаат на услугата Email Validator.
  • Токенот е поддржан од околината, исклучен од верзиска контрола и отсутен од дневниците.
  • Вредностите на политиката и семантиката на резултатот беа потврдени според официјалната документација.
  • Познато добар ред стигнува до contacts, додека неизвесните и невалидните редови стигнуваат до manual_review.
  • Повторувањата за мрежа, 429 и 5xx се ограничени; неуспесите при автентикација и договор не се повторуваат слепо.
  • PHPUnit поминува без да направи вистинско HTTP-барање.
  • Дозволите на базата на податоци, задржувањето докази, следењето на квота и ротацијата на токени се документирани за операторите.

Најсилниот канал за увоз не е оној што носи најмногу автоматски одлуки. Тој е оној што знае каде завршува неговата сигурност. Со комбинирање строга гранична валидација, конфигурирачка политика за прифаќање, ограничено справување со неуспеси и експлицитна редица за преглед, оваа мала PHP-команда претвора ненадежно предавање табеларна пресметка во контролиран портал за објавување.

Портрет на автор на блогот

Mihajlo

Јас сум Михајло - развивач поттикнат од љубопитност, дисциплина и постојаната желба да создадам нешто значајно. Споделувам увиди, упатства и бесплатни услуги за да им помогнам на другите да ја поедностават својата работа и да растат во постојано развивачкиот свет на софтверот и вештачката интелигенција.