Vodiči

Native PHP 8.3: Validate Newsletter Imports and Queue Uncertain Emails for Review

Nativni PHP 8.3: Validirajte uvoze newslettera i stavite nesigurne e-poruke u red za pregled

A newsletter import looks harmless until it contains misspelled domains, disposable addresses, duplicate contacts, and ambiguous mailboxes. Import everything and sender reputation suffers; reject too aggressively and legitimate subscribers disappear. The practical answer is a conservative pipeline: normalize locally, validate remotely, accept only strong results, and place every uncertain case into a review queue.

This tutorial builds that pipeline as a Native PHP 8.3 command. It uses the Email Validator service to assess syntax, domain, MX records, provider signals, and practical delivery risk. The implementation includes defensive response mapping, bounded retries, deterministic tests, structured logs, and reviewable output files.

Get access and copy the service token

  1. Register at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have an account.
  2. Open the Email Validator service page.
  3. Choose the available Free, Plus, or Pro plan and complete its activation.
  4. Open the official Email Validator documentation.
  5. Find the Service token panel and copy the service-scoped token.

This service requires authentication. Its token is supplied through the token query parameter. Regenerating the service token revokes the previously active token, so update every deployed environment immediately after a rotation. Never commit the value or include it in logs, screenshots, test fixtures, exception messages, or monitoring labels.

Confirm the exact HTTP contract

The request is an HTTP GET to https://ai.mihajlo.mk/api/email-validator/v1/check-email. It carries both token and email query parameters. A minimal diagnostic request is:

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"

Run that only in a private terminal. Query-string credentials can appear in shell history, process listings, reverse-proxy logs, and debugging traces. The application below constructs the URL internally and never logs it.

The response supplies status, score, recommendation, checks, and quota. The supplied contract does not establish enum values, score scale, or nested keys inside checks and quota. Production code should therefore use documented values from your activated service rather than guessing what labels mean.

Store configuration outside source control

Create a private .env file and add it to .gitignore. Replace the policy placeholders with the exact successful status and recommendation values described by the current documentation, plus a threshold appropriate to its documented score scale.

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

Fail deployment if any placeholder remains. In containers and managed hosting, inject these variables through the platform’s secret store instead of copying a development .env file into the image.

Architecture: automate certainty, preserve ambiguity

The import has three outcomes. Locally malformed rows go to rejected.csv. A remotely validated address is accepted only when every configured signal agrees. Unknown response values, incomplete payloads, timeouts, authentication failures, rate limiting, and weaker validation results go to review.jsonl.

This deliberately avoids treating an unavailable dependency as evidence that an address is bad. It also avoids silently accepting a response after the provider changes an enum or payload shape.

The project uses native cURL at runtime and PHPUnit 11.5-compatible releases for development tests. PHPUnit 11 supports PHP 8.2 and newer, which includes the required PHP 8.3 runtime.

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

Install PHP 8.3 with the cURL and JSON extensions, then configure 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

Build a bounded native cURL transport

The API boundary needs explicit timeouts and a transport abstraction. That abstraction is small, but it lets tests replace cURL with deterministic responses.

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

TLS certificate verification remains enabled through cURL’s secure defaults. Do not disable it to “fix” a certificate error; repair the operating system trust store instead.

Map the response at the boundary

A successful HTTP status is not enough. The mapper rejects missing fields, unexpected types, non-finite scores, and non-object JSON before application policy sees the response.

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

The client retries only transport failures, HTTP 429, and server-side 5xx responses. Authentication, request validation, malformed JSON, and contract violations are not retried because repetition cannot repair them.

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

In actual code, initialize the sleep closure explicitly; PHP cannot use a freshly constructed closure as a meaningful sentinel. A concise production constructor uses ?Closure $sleep = null and assigns $this->sleep = $sleep ?? static fn(int $us) => usleep($us). That keeps the client testable without real delays.

Apply a conservative import policy

The policy uses every returned contract field. Status, recommendation, and score must meet configured acceptance criteria. Empty checks or quota data makes the result uncertain because the application cannot audit the evidence or quota state. The raw structures are retained for review without assuming undocumented nested keys.

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

Run the newsletter import command

The input CSV must contain email and may contain name. The command trims fields, removes control characters from names, lowercases only the domain portion, and deduplicates case-insensitively for newsletter purposes. Although email local parts can theoretically be case-sensitive, newsletter systems commonly need one human mailbox represented once; document that business rule if it matters to your audience.

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

Export the environment variables through your shell or deployment platform, then run php bin/import-newsletter.php contacts.csv. Each execution gets a separate permission-restricted directory, so a previous result is not overwritten.

Test retries and failure boundaries

A fake transport makes the tests fast and independent of accounts, quotas, DNS, and network conditions.

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

Also add policy tests for an exact acceptance match, a low score, an unknown recommendation, empty checks, and empty quota data. Run the suite with vendor/bin/phpunit tests.

Operations, security, and common failures

  • HTTP 401 or 403: stop the import, verify the service-scoped token, and check whether somebody regenerated it. Do not retry authentication failures.
  • HTTP 429: the client performs only bounded retries. Keep remaining contacts in the review queue or rerun them after quota availability is confirmed. Never spin indefinitely.
  • Repeated 5xx or timeouts: review the structured failure count and retry the affected queue later. Do not classify those addresses as undeliverable.
  • Everything enters review: compare the configured status, recommendation, and score threshold with the official documentation and a redacted diagnostic response.
  • Malformed response: preserve the failure category, alert on a rising count, and inspect the contract before changing the mapper.
  • International addresses: FILTER_VALIDATE_EMAIL is a conservative local gate and may not cover every internationalized form. If those are required, define an explicit normalization policy instead of silently transforming them.

Logs should contain run identifiers, counts, latency, HTTP status, retry attempt, and stable hashes—not tokens, request URLs, full responses, names, or addresses. Restrict access to import and review files because they contain personal data. Establish retention and deletion rules consistent with the newsletter’s consent process.

During deployment, verify the cURL extension, writable var/runs directory, outbound HTTPS access, secret injection, and correct policy configuration. Run one small canary import before processing the complete list. Schedule only one worker per source file unless you add a shared deduplication store and explicit partitioning.

Final verification checklist

  • The token came from the documentation page’s Service token panel and is stored only in environment-backed configuration.
  • The client calls the exact GET endpoint with URL-encoded token and email parameters.
  • Connection and response timeouts are bounded.
  • Only transport errors, 429 responses, and 5xx responses receive limited backoff retries.
  • Status, score, recommendation, checks, and quota are type-checked and participate in the decision.
  • Unknown, incomplete, or unavailable validation results enter manual review rather than being accepted or rejected.
  • Tests use a fake transport and never consume service quota.
  • Logs and fixtures contain no credentials or raw subscriber identities.
  • A canary CSV produces accepted, rejected, and review outputs as expected.

A trustworthy import pipeline is not the one that makes the most decisions. It is the one that knows which decisions are safe to automate. Keep the acceptance gate narrow, make uncertainty visible, and treat the review queue as a deliberate product feature—not a failure of validation.

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.