Tutorials

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

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

A newsletter import looks harmless until a mistyped domain, disposable mailbox, duplicate address, or transient API failure reaches the sending list. The safe response is not to delete every suspicious record. It is to automate high-confidence decisions and preserve uncertainty for a human.

This tutorial builds that workflow in Native PHP 8.3. A command reads a CSV file, normalizes and deduplicates addresses, calls the Email Validator service, imports only records matching an explicit local policy, and stores everything else in a SQLite-backed manual-review queue. The design remains deliberately small: synchronous processing, native cURL, PDO, and a transport boundary that can be tested without network access.

Get access before writing integration code

  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. Choose the available Free, Plus, or Pro plan and complete its activation.
  3. Open the official service documentation. Find the Service token panel and copy the service-scoped token.
  4. Store that token in the project environment configuration. Never commit it, place it in a fixture, or print it in logs.

This service requires a token. Regenerating it revokes the previous active token, so rotation must be coordinated with deployment: install the new value wherever the importer runs, then verify the command with a small file before normal processing resumes.

Confirm the exact request

The integration uses GET https://ai.mihajlo.mk/api/email-validator/v1/check-email. Authentication is supplied through the token query parameter, while the address is supplied through 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

Run this once with a non-production address and inspect the documented response shape. The application below validates status, score, recommendation, checks, and quota defensively. It does not guess undocumented meanings for status or recommendation values; the favorable values and score threshold are deployment policy derived from the official documentation and your risk tolerance.

Project shape and trade-offs

The importer is a CLI process rather than a web request. That avoids browser timeouts and makes scheduled execution straightforward. Each address is persisted immediately, so a later failure does not erase earlier work. SQLite is suitable for a small team or single-host deployment; the repositories can later move to another PDO database without changing the API boundary.

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

Create composer.json and install the dependencies. PHPUnit 11 is used only for tests; production HTTP remains native 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

Put environment-specific values in .env. The example policy placeholders are intentional: replace them with favorable values and a threshold confirmed from the official documentation. Refusing to start with placeholders is safer than silently adopting invented service semantics.

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

Build a strict API boundary

The transport returns status, headers, and body separately. The validator retries only network failures, HTTP 429, and server errors. Authentication failures and ordinary validation responses are never blindly retried. Backoff is bounded, and a numeric Retry-After value is respected within that bound.

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

Passing credentials in a query string is part of the required contract, but URLs are commonly captured by proxies and debugging middleware. Do not log the complete request URL. Ensure any reverse proxy or outbound observability system redacts query strings.

Turn service evidence into a local decision

The API supplies evidence; your application owns the decision. This policy imports only when the configured status and recommendation match, the score meets the configured threshold, and both checks and quota data are present. Anything ambiguous becomes reviewable rather than disappearing.

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

Implement the import command

The command loads the environment file without overwriting variables supplied by the process manager. It creates two tables, rejects malformed CSV rows locally, deduplicates within the file, and hashes addresses in logs. The database retains the complete validator evidence needed by reviewers.

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

This implementation does not lowercase the local part before @; only the domain is normalized. That conservative choice avoids imposing mailbox-equivalence assumptions that are not universal.

Test without contacting the service

A deterministic fake transport verifies query construction, response mapping, and failure behavior without spending quota or exposing a token.

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

Production hardening and common failures

Keep .env outside published directories and readable only by the deployment user. Treat stored addresses and validation evidence as personal data: restrict database access, define a retention period for review records, and delete evidence when it no longer serves an operational purpose.

Emit counters for imported, reviewed, locally invalid, API-failed, retried, and duplicate rows. Alert on sustained authentication failures, quota-related responses, or a sudden rise in malformed contracts. Logs should contain a one-way address hash and error category, never the token, full URL, or raw email.

A 401 or 403 usually indicates an absent, revoked, or incorrect service token; fix configuration instead of retrying. Repeated 429 responses mean processing should pause or be rescheduled according to available quota and plan limits. Timeouts and 5xx responses receive only the bounded retries already shown. An unexpected JSON shape is a contract failure and goes to review, preventing accidental imports after an upstream change.

For larger files, checkpoint by source file and row number, or add an import-run identifier and unique source key. Avoid launching many command instances merely to increase speed: concurrency can exhaust quota and create a retry storm. Start with one scheduled worker, measure throughput, and raise concurrency only within documented limits.

Final verification checklist

  • The activated plan and service-scoped token belong to the Email Validator service.
  • The token is environment-backed, excluded from version control, and absent from logs.
  • Policy values and score semantics were confirmed against official documentation.
  • A known-good row reaches contacts, while uncertain and malformed rows reach manual_review.
  • Network, 429, and 5xx retries are bounded; authentication and contract failures are not blindly retried.
  • PHPUnit passes without making a real HTTP request.
  • Database permissions, evidence retention, quota monitoring, and token rotation are documented for operators.

The strongest import pipeline is not the one that makes the most automatic decisions. It is the one that knows where its certainty ends. By combining strict boundary validation, a configurable acceptance policy, bounded failure handling, and an explicit review queue, this small PHP command turns an unreliable spreadsheet handoff into a controlled publishing gate.

Blog author portrait

Mihajlo

I’m Mihajlo — a developer driven by curiosity, discipline, and the constant urge to create something meaningful. I share insights, tutorials, and free services to help others simplify their work and grow in the ever-evolving world of software and AI.