Vodiči

Validate Newsletter Imports: Flag Uncertain Emails for Manual Review in PHP

Provjera uvoza newslettera: označavanje nesigurnih e-adresa za ručnu provjeru u PHP-u

A newsletter import looks harmless until it reaches production. One mistyped domain wastes quota, a disposable address weakens engagement data, and an overconfident cleanup rule can discard a real subscriber. The right outcome is not simply “valid” or “invalid.” It is a controlled pipeline that accepts strong candidates, rejects only explicit failures, and sends uncertainty to a human.

This tutorial builds that pipeline as a Native PHP 8.3 command-line application. It reads a CSV export, removes duplicates and obvious formatting problems, calls an email validation service, and produces separate accepted, rejected, and manual-review files. The integration uses native cURL, a defensive response mapper, bounded retries, a small circuit breaker, structured logs, and deterministic PHPUnit tests.

Get access and copy the service token

Register at https://ai.mihajlo.mk/register, or sign in through https://ai.mihajlo.mk/login.

Open the Email Validator service page, choose the available Free, Plus, or Pro plan, and complete activation. Then open the official service documentation. Find the Service token panel and copy its service-scoped token.

Regenerating this token revokes the previously active token. Treat regeneration as a credential rotation: update the deployment secret and any local environment configuration before expecting existing workers to continue successfully.

Confirm the HTTP contract

The integration sends an exact GET request to https://ai.mihajlo.mk/api/email-validator/v1/check-email. Authentication uses the token query parameter, while the address is supplied through the email query parameter.

Make one minimal request with placeholders before building the importer:

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"

The response supplies status, score, recommendation, checks, and quota. The application will require all five fields but will not presume undocumented nested fields or silently coerce malformed data.

Because the token appears in the query string, never log the complete request URL. Keep it out of exception messages, analytics, reverse-proxy access logs where possible, screenshots, and support attachments.

Prerequisites and project shape

You need PHP 8.3 or newer, the cURL extension, Composer, and an input CSV whose header contains email. A name column is optional. The only runtime package is vlucas/phpdotenv in the 5.6 version range; PHPUnit 11 is used during development.

newsletter-import/
├── .env.local
├── .gitignore
├── composer.json
├── bin/import-newsletter.php
├── src/Email/EmailValidator.php
├── src/Email/DecisionPolicy.php
└── tests/EmailValidatorTest.php

A synchronous CLI process is a good trade-off for an ordinary small-team import: it is easy to run manually, from cron, or inside a short-lived container. It also avoids introducing a database and queue merely to process a file. The separate output files provide an audit trail, while temporary files prevent a failed run from presenting partial output as complete.

Install dependencies and configure the environment

{
  "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

The status labels, recommendation labels, and score threshold above are local policy, not a claim that these are the service’s exhaustive values. Confirm the values documented or observed for your activated service and configure them accordingly. An unrecognized value goes to manual review rather than being accepted by accident.

Build a strict API boundary

The transport owns cURL behavior. The client owns retries, HTTP classification, JSON decoding, and response mapping. This separation makes tests independent of the network and prevents transport details from leaking into the import loop.

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

Only transport failures and server-side 5xx responses are retried, with three total attempts and bounded backoff. Authentication failures, malformed requests, invalid payloads, and quota responses are not blindly repeated.

Turn service signals into a conservative decision

The service assesses syntax, domain and MX information, provider signals, and practical delivery risk. Those signals should inform policy, not replace it. The following policy accepts only an explicitly recognized status and recommendation, a sufficient score, at least one boolean check with no failed boolean checks, and non-empty quota metadata.

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

Notice the asymmetry: uncertainty never becomes acceptance. Unknown labels, unexpected check structures, low scores, and missing quota data all remain visible for manual review.

Process the newsletter CSV

The command normalizes whitespace and the domain’s letter case, removes duplicate addresses, and calls the validator for the remaining rows. It retains the returned signals beside each decision so reviewers can understand why a row was held.

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

The circuit breaker stops repeated calls after authentication or quota failures, or after three consecutive integration failures. Remaining contacts go to review, preserving data while avoiding a storm of doomed requests.

Test retries and response mapping without the network

A deterministic fake transport makes failure paths fast and repeatable. These fixture values exercise local policy; they are not presented as an exhaustive service schema.

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

Security, observability, and deployment

Inject EMAIL_VALIDATOR_TOKEN from the deployment platform’s secret store instead of baking .env.local into an image. Restrict local environment-file permissions, rotate the token deliberately, and ensure diagnostic tooling never captures request URLs.

Email addresses are personal data. The sample logs only a one-way hash for correlation and omits the token, raw response, and address. Protect output files with appropriate filesystem permissions and retention rules. If CSV files will be opened in spreadsheet software, also neutralize formula-leading characters in non-email text fields according to your export policy.

Monitor counts of accepted, rejected, reviewed, authentication-failed, quota-blocked, and upstream-failed rows. A sudden rise in reviews often identifies policy drift or a response-shape change before it becomes subscriber loss. During deployment, run tests, execute a small canary import, inspect the three outputs, and only then process the complete file.

Final verification checklist

  • The service plan is active and the service-scoped token is stored outside source control.
  • The configured status, recommendation, and score policy matches the official documentation and your risk tolerance.
  • A test request returns all five required fields: status, score, recommendation, checks, and quota.
  • PHPUnit proves successful mapping and bounded 5xx retry behavior.
  • Duplicate, uncertain, quota-blocked, and malformed-response cases land in the expected files.
  • Logs contain hashes and structured outcomes, but no email addresses, tokens, or complete request URLs.
  • Only fully written temporary outputs are renamed to final CSV filenames.

A dependable import is not the one that makes the most automatic decisions. It is the one that can explain every decision, contain every failure, and preserve uncertainty for someone qualified to resolve it. With a strict API boundary and a deliberately cautious policy, manual review becomes a useful safety valve rather than an improvised cleanup queue.

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.