Vodiči

Laravel Newsletter: Validate and Triage Contacts with Email Intelligence

Laravel Newsletter: Provjerite i razvrstajte kontakte uz Email Intelligence

A newsletter import becomes risky the moment “email” is treated as a trustworthy field. Typos, nonexistent domains, missing mail exchangers, disposable providers, and ambiguous delivery signals can turn a harmless CSV into wasted sends and damaged sender reputation.

This tutorial builds a conservative Laravel import pipeline. It rejects obvious syntax errors locally, calls an email-intelligence service for plausible addresses, writes high-confidence contacts to a clean CSV, and routes every uncertain or failed assessment to a manual-review CSV. Crucially, an API outage never becomes an accidental approval.

Get access before writing integration code

Start by registering an account, or use the sign-in page if you already have one.

  1. Open the Email Validator service page.
  2. Choose the available Free, Plus, or Pro plan and complete activation.
  3. Open the official service documentation.
  4. 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 every deployed environment before depending on the new value, and never paste either token into source control, logs, screenshots, or test fixtures.

The exact request is an HTTPS GET to https://ai.mihajlo.mk/api/email-validator/v1/check-email. Authentication uses the token={serviceToken} query parameter, while the address goes in the email query parameter.

Make one minimal request from a trusted terminal:

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

Because authentication appears in the query string, avoid verbose command output and ensure reverse proxies, tracing agents, and HTTP diagnostics redact URLs or query parameters. The application itself will never log the request URL.

Now place the credential in Laravel’s environment configuration:

EMAIL_VALIDATOR_TOKEN=YOUR_SERVICE_TOKEN
EMAIL_VALIDATOR_TIMEOUT=8
EMAIL_VALIDATOR_CONNECT_TIMEOUT=3

# These are application policy values, not hard-coded API assumptions.
# Copy the exact clean classifications documented for your activated service.
EMAIL_VALIDATOR_CLEAN_STATUSES=YOUR_DOCUMENTED_CLEAN_STATUS
EMAIL_VALIDATOR_CLEAN_RECOMMENDATIONS=YOUR_DOCUMENTED_CLEAN_RECOMMENDATION
EMAIL_VALIDATOR_MINIMUM_SCORE=YOUR_CHOSEN_SCORE_THRESHOLD

The response contract provides status, score, recommendation, checks, and quota. Their exact value vocabulary should come from the official documentation, not guesses embedded in PHP. This implementation therefore makes accepted classifications and the score threshold environment-controlled. Until real values replace the placeholders, the policy deliberately sends every address to review.

Choose a small, failure-aware architecture

A scheduled Artisan command is a good fit for ordinary CSV imports: it can run under a scheduler, deployment job, or operator session without introducing queues, workers, or a web-upload surface. If imports later arrive through HTTP, the same service and policy classes can be called from a queued job.

The project adds four focused files:

  • app/Services/EmailValidator.php owns HTTP behavior and response validation.
  • app/Data/EmailAssessment.php maps untrusted JSON into a domain object.
  • app/Domain/ContactDecision.php applies the local acceptance policy.
  • app/Console/Commands/TriageNewsletterImport.php reads and writes CSV files.

Add the service configuration to config/services.php:

'email_validator' => [
    'endpoint' => 'https://ai.mihajlo.mk/api/email-validator/v1/check-email',
    'token' => env('EMAIL_VALIDATOR_TOKEN'),
    'timeout' => (int) env('EMAIL_VALIDATOR_TIMEOUT', 8),
    'connect_timeout' => (int) env('EMAIL_VALIDATOR_CONNECT_TIMEOUT', 3),
    'clean_statuses' => env('EMAIL_VALIDATOR_CLEAN_STATUSES', ''),
    'clean_recommendations' => env('EMAIL_VALIDATOR_CLEAN_RECOMMENDATIONS', ''),
    'minimum_score' => env('EMAIL_VALIDATOR_MINIMUM_SCORE'),
],

Map the response at the application boundary

Remote JSON is untrusted input even when the provider is healthy. Reject missing fields and unexpected container types before business code sees them. Do not force undocumented bounds onto score; validate that it is numeric and let configuration define the business threshold.

<?php

namespace App\Data;

use UnexpectedValueException;

final readonly class EmailAssessment
{
    public function __construct(
        public string $status,
        public float $score,
        public string $recommendation,
        public array $checks,
        public array $quota,
    ) {}

    public static function fromPayload(array $payload): self
    {
        if (
            !isset($payload['status']) || !is_string($payload['status']) ||
            !array_key_exists('score', $payload) || !is_numeric($payload['score']) ||
            !isset($payload['recommendation']) || !is_string($payload['recommendation']) ||
            !isset($payload['checks']) || !is_array($payload['checks']) ||
            !isset($payload['quota']) || !is_array($payload['quota'])
        ) {
            throw new UnexpectedValueException('Unexpected email-validator response.');
        }

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

Build the bounded HTTP client

Laravel’s built-in HTTP client supplies the connection and total-response timeouts. The client retries only connection failures and server-side 5xx responses, with two short backoffs. It does not blindly retry authentication failures, rejected input, or 429, which may represent either temporary rate limiting or exhausted quota.

<?php

namespace App\Services;

use App\Data\EmailAssessment;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;

final class EmailValidator
{
    public function check(string $email): array
    {
        $token = (string) config('services.email_validator.token');

        if ($token === '' || str_starts_with($token, 'YOUR_')) {
            return ['assessment' => null, 'failure' => 'configuration'];
        }

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = Http::acceptJson()
                    ->connectTimeout(config('services.email_validator.connect_timeout'))
                    ->timeout(config('services.email_validator.timeout'))
                    ->get(config('services.email_validator.endpoint'), [
                        'token' => $token,
                        'email' => $email,
                    ]);
            } catch (ConnectionException $exception) {
                Log::warning('Email validation connection failure.', [
                    'attempt' => $attempt,
                    'exception' => $exception::class,
                ]);

                if ($attempt < 3) {
                    usleep($attempt * 200_000);
                    continue;
                }

                return ['assessment' => null, 'failure' => 'connection'];
            }

            if ($response->successful()) {
                try {
                    $payload = $response->json();

                    if (!is_array($payload)) {
                        throw new \UnexpectedValueException('JSON object expected.');
                    }

                    return [
                        'assessment' => EmailAssessment::fromPayload($payload),
                        'failure' => null,
                    ];
                } catch (Throwable $exception) {
                    Log::warning('Malformed email validation response.', [
                        'http_status' => $response->status(),
                    ]);

                    return ['assessment' => null, 'failure' => 'malformed_response'];
                }
            }

            if (in_array($response->status(), [401, 403], true)) {
                return ['assessment' => null, 'failure' => 'authentication'];
            }

            if ($response->status() === 429) {
                return ['assessment' => null, 'failure' => 'quota_or_rate_limit'];
            }

            if ($response->serverError() && $attempt < 3) {
                usleep($attempt * 200_000);
                continue;
            }

            return [
                'assessment' => null,
                'failure' => $response->clientError()
                    ? 'request_rejected'
                    : 'upstream_unavailable',
            ];
        }

        return ['assessment' => null, 'failure' => 'upstream_unavailable'];
    }
}

Logs contain the attempt and failure category, but not the address, token, response body, or URL. That is enough for operational diagnosis without turning logs into a contact database.

Turn intelligence into a conservative decision

An address is clean only when its status and recommendation are explicitly allowed, its score meets the chosen threshold, its checks contain at least one Boolean signal with no explicit false, and quota data is present. Everything else becomes review material.

<?php

namespace App\Domain;

use App\Data\EmailAssessment;
use RecursiveArrayIterator;
use RecursiveIteratorIterator;

final class ContactDecision
{
    public function decide(EmailAssessment $assessment): array
    {
        $statuses = $this->csvConfig('clean_statuses');
        $recommendations = $this->csvConfig('clean_recommendations');
        $minimum = config('services.email_validator.minimum_score');

        if ($statuses === [] || $recommendations === [] || !is_numeric($minimum)) {
            return [false, 'policy_not_configured'];
        }

        if (!in_array(strtolower($assessment->status), $statuses, true)) {
            return [false, 'status_requires_review'];
        }

        if (!in_array(strtolower($assessment->recommendation), $recommendations, true)) {
            return [false, 'recommendation_requires_review'];
        }

        if ($assessment->score < (float) $minimum) {
            return [false, 'score_below_threshold'];
        }

        if ($assessment->quota === []) {
            return [false, 'quota_data_missing'];
        }

        $booleanSignals = 0;
        foreach (new RecursiveIteratorIterator(
            new RecursiveArrayIterator($assessment->checks)
        ) as $value) {
            if (is_bool($value)) {
                $booleanSignals++;

                if ($value === false) {
                    return [false, 'check_failed'];
                }
            }
        }

        return $booleanSignals > 0
            ? [true, 'clean']
            : [false, 'checks_unusable'];
    }

    private function csvConfig(string $key): array
    {
        return array_values(array_filter(array_map(
            static fn (string $value): string => strtolower(trim($value)),
            explode(',', (string) config("services.email_validator.$key"))
        )));
    }
}

Import contacts and produce the review queue

The input must have an email header. Obvious syntax failures and duplicates are handled locally, saving quota. Successful assessments retain all five service fields in the output so reviewers can understand the decision.

<?php

namespace App\Console\Commands;

use App\Domain\ContactDecision;
use App\Services\EmailValidator;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;

final class TriageNewsletterImport extends Command
{
    protected $signature = 'newsletter:triage {csv}';
    protected $description = 'Clean a newsletter CSV and route uncertain contacts to review.';

    public function handle(EmailValidator $validator, ContactDecision $policy): int
    {
        $path = realpath($this->argument('csv'));

        if ($path === false || !is_readable($path)) {
            $this->error('The CSV is not readable.');
            return self::FAILURE;
        }

        $input = fopen($path, 'rb');
        $headers = fgetcsv($input);
        $emailIndex = is_array($headers) ? array_search('email', $headers, true) : false;

        if ($emailIndex === false) {
            fclose($input);
            $this->error('The CSV must contain an email header.');
            return self::FAILURE;
        }

        $directory = 'import-results/'.now()->format('Ymd-His');
        Storage::disk('local')->makeDirectory($directory);
        $root = Storage::disk('local')->path($directory);
        $clean = fopen($root.'/clean.csv', 'wb');
        $review = fopen($root.'/review.csv', 'wb');
        $columns = ['email', 'reason', 'status', 'score', 'recommendation', 'checks', 'quota'];
        fputcsv($clean, $columns);
        fputcsv($review, $columns);

        $seen = [];
        $exit = self::SUCCESS;

        while (($row = fgetcsv($input)) !== false) {
            $email = trim((string) ($row[$emailIndex] ?? ''));
            $key = strtolower($email);

            if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
                fputcsv($review, [$email, 'invalid_syntax', '', '', '', '', '']);
                continue;
            }

            if (isset($seen[$key])) {
                fputcsv($review, [$email, 'duplicate', '', '', '', '', '']);
                continue;
            }

            $seen[$key] = true;
            $result = $validator->check($email);

            if ($result['assessment'] === null) {
                fputcsv($review, [$email, $result['failure'], '', '', '', '', '']);

                if (in_array($result['failure'], [
                    'configuration', 'authentication', 'quota_or_rate_limit',
                ], true)) {
                    $exit = self::FAILURE;
                    break;
                }

                continue;
            }

            $assessment = $result['assessment'];
            [$isClean, $reason] = $policy->decide($assessment);
            $record = [
                $email,
                $reason,
                $assessment->status,
                $assessment->score,
                $assessment->recommendation,
                json_encode($assessment->checks, JSON_THROW_ON_ERROR),
                json_encode($assessment->quota, JSON_THROW_ON_ERROR),
            ];

            fputcsv($isClean ? $clean : $review, $record);
        }

        fclose($input);
        fclose($clean);
        fclose($review);

        $this->info("Results written to storage/app/$directory");
        return $exit;
    }
}

Run it with php artisan newsletter:triage storage/app/imports/contacts.csv. Treat a nonzero exit code as a partial import: correct the token or quota problem and rerun the source file. Never publish clean.csv unless the command completed successfully.

Test success, malformed data, and retries

Http::fake() keeps tests deterministic and prevents real quota consumption. The fixture classifications below are deliberately local test values, not assertions about the service’s documented vocabulary.

<?php

namespace Tests\Feature;

use App\Services\EmailValidator;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

final class EmailValidatorTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();

        config([
            'services.email_validator.endpoint' =>
                'https://ai.mihajlo.mk/api/email-validator/v1/check-email',
            'services.email_validator.token' => 'test-token',
            'services.email_validator.timeout' => 8,
            'services.email_validator.connect_timeout' => 3,
        ]);
    }

    public function test_it_maps_a_complete_response(): void
    {
        Http::fake([
            '*' => Http::response([
                'status' => 'test-clean',
                'score' => 92,
                'recommendation' => 'test-accept',
                'checks' => ['syntax' => true, 'mx' => true],
                'quota' => ['test' => 'present'],
            ]),
        ]);

        $result = app(EmailValidator::class)->check('[email protected]');

        $this->assertNull($result['failure']);
        $this->assertSame(92.0, $result['assessment']->score);

        Http::assertSent(function ($request): bool {
            parse_str((string) parse_url($request->url(), PHP_URL_QUERY), $query);

            return $request->method() === 'GET'
                && $query['token'] === 'test-token'
                && $query['email'] === '[email protected]';
        });
    }

    public function test_it_rejects_a_malformed_response(): void
    {
        Http::fake(['*' => Http::response(['status' => 'incomplete'])]);

        $result = app(EmailValidator::class)->check('[email protected]');

        $this->assertSame('malformed_response', $result['failure']);
    }

    public function test_it_retries_a_server_failure_then_succeeds(): void
    {
        Http::fakeSequence()
            ->push([], 503)
            ->push([
                'status' => 'test-clean',
                'score' => 92,
                'recommendation' => 'test-accept',
                'checks' => ['syntax' => true],
                'quota' => ['test' => 'present'],
            ], 200);

        $result = app(EmailValidator::class)->check('[email protected]');

        $this->assertNull($result['failure']);
        Http::assertSentCount(2);
    }
}

Run the suite with php artisan test. A further command test should use a temporary CSV and assert that invalid syntax, duplicates, low-confidence assessments, and transport failures land in review.csv.

Security, deployment, and observability

  • Inject the token through the deployment platform’s secret store, then run php artisan config:cache. Application code reads only config(), so cached configuration remains reliable.
  • Restrict imported files and generated results because they contain personal data. Apply retention limits and do not place storage/app under the public web root.
  • Monitor counts by outcome and failure category, command duration, retry counts, and nonzero exits. Avoid labels containing email addresses because they create high-cardinality metrics and leak contacts.
  • Keep concurrency aligned with the activated plan. Parallelizing thousands of requests without confirmed limits can turn a routine import into repeated 429 failures.
  • When rotating the service token, remember that regeneration immediately invalidates the previous active token. Update secrets and rebuild cached configuration before the next import.

Common failure patterns

A 401 or 403 usually means the service token is absent, revoked, or incorrect; retrying cannot repair it. A 429 requires checking quota or rate-limit conditions before resuming. Repeated 5xx or connection failures should leave contacts in review, not clean. A successful HTTP response with missing fields is equally unsafe and becomes malformed_response.

If every contact receives policy_not_configured, the boundary is working as intended: replace the placeholder status, recommendation, and threshold with values selected from the official documentation and your sending policy, then refresh Laravel’s configuration cache.

Final verification checklist

  • The activated service token exists only in environment-backed configuration.
  • The request uses GET, the exact supplied endpoint, and the token and email query parameters.
  • All five response fields are mapped and retained for decisions or review.
  • Only explicitly approved classifications can enter clean.csv.
  • Invalid, duplicate, uncertain, malformed, and unavailable results enter review.csv.
  • Authentication and quota failures stop the import with a nonzero exit.
  • Tests use Http::fake() and never contact the live service.
  • Logs, metrics, and proxy configuration do not expose tokens or addresses.

The valuable output is not the largest possible clean list. It is a list whose approvals are explainable and whose uncertainty remains visible. By making review the default failure state, this Laravel pipeline protects both the newsletter audience and the reputation needed to reach them.

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.