Laravel: Validate Newsletter Imports, Flagging Dubious Emails for Manual Review
A newsletter import looks harmless until the list contains typos, abandoned domains, disposable accounts, and addresses that are technically plausible but operationally risky. Sending everything immediately can damage deliverability; rejecting every ambiguous address can discard real subscribers.
This tutorial builds a production-oriented Laravel importer that normalizes a CSV file, rejects obvious local syntax errors, validates plausible addresses through an email-validation service, accepts high-confidence results, rejects low-confidence results, and places the uncertain middle in a manual-review queue. It also preserves the returned evidence so a reviewer can make an informed decision.
Get access to the Email Validator
Before writing integration code, create an account at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have one.
- Open the Email Validator service page.
- Choose the available Free, Plus, or Pro plan and complete its activation.
- Open the official Email Validator documentation.
- Find the Service token panel and copy the service-scoped token.
- Store it in Laravel’s environment configuration, never in committed source code.
Regenerating the service token revokes the previously active token. Treat rotation as a deployment change: update every environment that uses the old value, rebuild Laravel’s configuration cache, verify the new token, and only then consider the rotation complete.
Confirm the exact HTTP contract
The API call is an HTTP GET request to https://ai.mihajlo.mk/api/email-validator/v1/check-email. Authentication uses the token={serviceToken} query parameter, while the address is supplied in the email query parameter.
Run one minimal request from a secure shell before building the importer:
curl --get \
--data-urlencode "token=YOUR_SERVICE_TOKEN" \
--data-urlencode "[email protected]" \
https://ai.mihajlo.mk/api/email-validator/v1/check-email
A successful response provides status, score, recommendation, checks, and quota. The service examines syntax, domain and MX information, provider signals, and practical delivery risk. Our boundary will require all five fields, but it will not guess undocumented meanings for individual check keys.
Create the Laravel project and configuration
You need PHP 8.3 or newer, Composer, a supported Laravel release, and a configured database. Start a new application or apply the following structure to an existing one:
composer create-project laravel/laravel newsletter-cleaner
cd newsletter-cleaner
php artisan make:model NewsletterContact -m
php artisan make:command ImportNewsletterContacts
php artisan make:test EmailValidatorClientTest --unit
Place the credential and application-owned score thresholds in .env. The thresholds are deliberately business policy, not claims about mailbox deliverability. Start conservatively and review the resulting distribution before enabling automatic sends.
EMAIL_VALIDATOR_TOKEN=YOUR_SERVICE_TOKEN
EMAIL_VALIDATOR_CONNECT_TIMEOUT=3
EMAIL_VALIDATOR_TIMEOUT=8
EMAIL_VALIDATOR_ACCEPT_SCORE=85
EMAIL_VALIDATOR_REJECT_SCORE=45
Add the following entry to config/services.php:
'email_validator' => [
'base_url' => 'https://ai.mihajlo.mk/api/email-validator',
'token' => env('EMAIL_VALIDATOR_TOKEN'),
'connect_timeout' => (int) env('EMAIL_VALIDATOR_CONNECT_TIMEOUT', 3),
'timeout' => (int) env('EMAIL_VALIDATOR_TIMEOUT', 8),
'accept_score' => (float) env('EMAIL_VALIDATOR_ACCEPT_SCORE', 85),
'reject_score' => (float) env('EMAIL_VALIDATOR_REJECT_SCORE', 45),
],
Laravel configuration is the only source-code-visible path to the secret. Do not call env() from application classes because configuration caching changes how direct environment access behaves.
Model the import as evidence plus a decision
The importer runs as an Artisan command instead of an HTTP controller. CSV processing may take longer than a web request, and a command is easy to schedule, observe, and rerun. Upserts make reruns idempotent at the contact level. For very large files, queue batches can be added later, but concurrency must then be coordinated with the service quota.
Create the contact table and model:
<?php
// database/migrations/xxxx_xx_xx_create_newsletter_contacts_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('newsletter_contacts', function (Blueprint $table) {
$table->id();
$table->string('email', 320)->unique();
$table->string('decision', 20);
$table->json('validation_evidence')->nullable();
$table->string('failure_code')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('newsletter_contacts');
}
};
// app/Models/NewsletterContact.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
final class NewsletterContact extends Model
{
protected $fillable = [
'email',
'decision',
'validation_evidence',
'failure_code',
];
protected function casts(): array
{
return ['validation_evidence' => 'array'];
}
}
The three decisions are accepted, rejected, and review. A transient outage never becomes a rejection. That distinction matters: inability to validate is not evidence that an address is bad.
Validate the response at the application boundary
Create a domain result and policy in app/Domain/EmailValidation. The mapper rejects missing or incorrectly typed fields. The policy requires complete evidence, uses the score for the application’s thresholds, and sends the uncertain interval to review. The original status, recommendation, checks, and quota remain available to reviewers and operations.
<?php
// app/Domain/EmailValidation/EmailValidationResult.php
namespace App\Domain\EmailValidation;
use InvalidArgumentException;
final readonly class EmailValidationResult
{
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
{
foreach (['status', 'score', 'recommendation', 'checks', 'quota'] as $key) {
if (!array_key_exists($key, $payload)) {
throw new InvalidArgumentException("Missing response field: {$key}");
}
}
$numericScore = is_int($payload['score']) || is_float($payload['score']);
if (!is_string($payload['status'])
|| trim($payload['status']) === ''
|| !$numericScore
|| !is_finite((float) $payload['score'])
|| !is_string($payload['recommendation'])
|| trim($payload['recommendation']) === ''
|| !is_array($payload['checks'])
|| !is_array($payload['quota'])) {
throw new InvalidArgumentException('Invalid Email Validator response');
}
return new self(
trim($payload['status']),
(float) $payload['score'],
trim($payload['recommendation']),
$payload['checks'],
$payload['quota'],
);
}
public function evidence(): array
{
return [
'status' => $this->status,
'score' => $this->score,
'recommendation' => $this->recommendation,
'checks' => $this->checks,
'quota' => $this->quota,
];
}
}
// app/Domain/EmailValidation/ImportDecisionPolicy.php
namespace App\Domain\EmailValidation;
final class ImportDecisionPolicy
{
public function decide(EmailValidationResult $result): string
{
$completeEvidence = $result->status !== ''
&& $result->recommendation !== ''
&& $result->checks !== []
&& $result->quota !== [];
if (!$completeEvidence) {
return 'review';
}
if ($result->score >= config('services.email_validator.accept_score')) {
return 'accepted';
}
if ($result->score <= config('services.email_validator.reject_score')) {
return 'rejected';
}
return 'review';
}
}
This conservative mapping uses every returned field without assigning invented semantics to provider-specific status, recommendation, check, or quota values. If the official documentation defines values you want to enforce, add an explicit allowlist and cover it with contract tests.
Build a bounded, retry-aware API client
The client below retries connection failures and server errors twice, using short bounded backoff. It does not retry authentication failures, validation failures, or HTTP 429 responses. A rate or quota response requires capacity management, not a tight retry loop.
<?php
// app/Services/EmailValidatorClient.php
namespace App\Services;
use App\Domain\EmailValidation\EmailValidationResult;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use InvalidArgumentException;
use RuntimeException;
use Throwable;
final class ValidationUnavailable extends RuntimeException
{
public function __construct(
public readonly string $reason,
?Throwable $previous = null,
) {
parent::__construct($reason, 0, $previous);
}
}
final class EmailValidatorClient
{
public function check(string $email): EmailValidationResult
{
$delays = [0, 200_000, 600_000];
for ($attempt = 1; $attempt <= count($delays); $attempt++) {
if ($delays[$attempt - 1] > 0) {
usleep($delays[$attempt - 1]);
}
try {
$response = Http::baseUrl(config('services.email_validator.base_url'))
->acceptJson()
->connectTimeout(config('services.email_validator.connect_timeout'))
->timeout(config('services.email_validator.timeout'))
->get('/v1/check-email', [
'token' => config('services.email_validator.token'),
'email' => $email,
]);
} catch (ConnectionException $exception) {
Log::warning('email_validation_connection_failure', [
'attempt' => $attempt,
'email_hash' => hash('sha256', $email),
]);
if ($attempt === count($delays)) {
throw new ValidationUnavailable('connection_failure', $exception);
}
continue;
}
if ($response->successful()) {
$payload = $response->json();
if (!is_array($payload)) {
throw new ValidationUnavailable('malformed_response');
}
try {
return EmailValidationResult::fromPayload($payload);
} catch (InvalidArgumentException $exception) {
throw new ValidationUnavailable('malformed_response', $exception);
}
}
if ($response->status() === 429) {
throw new ValidationUnavailable('rate_or_quota_limited');
}
if (in_array($response->status(), [401, 403], true)) {
throw new ValidationUnavailable('authentication_failure');
}
if ($response->serverError() && $attempt < count($delays)) {
Log::warning('email_validation_server_failure', [
'attempt' => $attempt,
'status' => $response->status(),
]);
continue;
}
throw new ValidationUnavailable(
$response->serverError() ? 'upstream_failure' : 'request_rejected'
);
}
throw new ValidationUnavailable('upstream_failure');
}
}
Logs contain a one-way email hash, attempt number, and status—not the address, token, full URL, or response body. This is especially important because query-string authentication can otherwise leak into proxy, tracing, or exception logs.
Import the CSV and create the review queue
The input file needs an email header. The command trims whitespace, lowercases only the domain portion, rejects clear syntax failures locally, and calls the service for plausible addresses.
<?php
// app/Console/Commands/ImportNewsletterContacts.php
namespace App\Console\Commands;
use App\Domain\EmailValidation\ImportDecisionPolicy;
use App\Models\NewsletterContact;
use App\Services\EmailValidatorClient;
use App\Services\ValidationUnavailable;
use Illuminate\Console\Command;
use SplFileObject;
final class ImportNewsletterContacts extends Command
{
protected $signature = 'newsletter:import {file}';
protected $description = 'Validate and import newsletter contacts';
public function handle(
EmailValidatorClient $client,
ImportDecisionPolicy $policy,
): int {
$path = $this->argument('file');
if (!is_readable($path)) {
$this->error('The CSV file is not readable.');
return self::FAILURE;
}
$csv = new SplFileObject($path);
$csv->setFlags(
SplFileObject::READ_CSV
| SplFileObject::SKIP_EMPTY
| SplFileObject::DROP_NEW_LINE
);
$headers = $csv->fgetcsv();
$headers = is_array($headers)
? array_map(fn ($value) => strtolower(trim((string) $value)), $headers)
: [];
$emailColumn = array_search('email', $headers, true);
if ($emailColumn === false) {
$this->error('The CSV must contain an email header.');
return self::FAILURE;
}
$counts = ['accepted' => 0, 'rejected' => 0, 'review' => 0];
foreach ($csv as $row) {
if (!is_array($row) || !isset($row[$emailColumn])) {
continue;
}
$email = $this->normalize((string) $row[$emailColumn]);
if ($email === '') {
continue;
}
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
$this->save($email, 'rejected', null, 'local_syntax');
$counts['rejected']++;
continue;
}
try {
$result = $client->check($email);
$decision = $policy->decide($result);
$this->save($email, $decision, $result->evidence(), null);
} catch (ValidationUnavailable $exception) {
$decision = 'review';
$this->save($email, $decision, null, $exception->reason);
}
$counts[$decision]++;
}
$this->line(json_encode($counts, JSON_THROW_ON_ERROR));
return self::SUCCESS;
}
private function normalize(string $email): string
{
$email = trim($email);
$separator = strrpos($email, '@');
if ($separator === false) {
return $email;
}
return substr($email, 0, $separator + 1)
. strtolower(substr($email, $separator + 1));
}
private function save(
string $email,
string $decision,
?array $evidence,
?string $failure,
): void {
NewsletterContact::updateOrCreate(
['email' => $email],
[
'decision' => $decision,
'validation_evidence' => $evidence,
'failure_code' => $failure,
],
);
}
}
Test success, uncertainty, retries, and quota failures
Laravel’s HTTP fake keeps tests deterministic and guarantees that no test reaches the real service. Add representative contract tests:
<?php
// tests/Unit/EmailValidatorClientTest.php
namespace Tests\Unit;
use App\Domain\EmailValidation\ImportDecisionPolicy;
use App\Services\EmailValidatorClient;
use App\Services\ValidationUnavailable;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
final class EmailValidatorClientTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config([
'services.email_validator.base_url' =>
'https://ai.mihajlo.mk/api/email-validator',
'services.email_validator.token' => 'TEST_SERVICE_TOKEN',
'services.email_validator.connect_timeout' => 1,
'services.email_validator.timeout' => 2,
'services.email_validator.accept_score' => 85,
'services.email_validator.reject_score' => 45,
]);
Http::preventStrayRequests();
}
public function test_high_score_with_complete_evidence_is_accepted(): void
{
Http::fake(['*' => Http::response($this->payload(92), 200)]);
$result = app(EmailValidatorClient::class)->check('[email protected]');
$this->assertSame(
'accepted',
app(ImportDecisionPolicy::class)->decide($result)
);
}
public function test_uncertain_score_is_sent_to_review(): void
{
Http::fake(['*' => Http::response($this->payload(65), 200)]);
$result = app(EmailValidatorClient::class)->check('[email protected]');
$this->assertSame(
'review',
app(ImportDecisionPolicy::class)->decide($result)
);
}
public function test_server_error_is_retried(): void
{
Http::fake([
'*' => Http::sequence()
->push([], 500)
->push($this->payload(90), 200),
]);
app(EmailValidatorClient::class)->check('[email protected]');
Http::assertSentCount(2);
}
public function test_rate_limit_becomes_structured_failure(): void
{
Http::fake(['*' => Http::response([], 429)]);
try {
app(EmailValidatorClient::class)->check('[email protected]');
$this->fail('Expected ValidationUnavailable');
} catch (ValidationUnavailable $exception) {
$this->assertSame('rate_or_quota_limited', $exception->reason);
}
Http::assertSentCount(1);
}
private function payload(int $score): array
{
return [
'status' => 'test-status',
'score' => $score,
'recommendation' => 'test-recommendation',
'checks' => ['test-check' => true],
'quota' => ['test-quota' => 1],
];
}
}
Deploy and verify safely
Run migrations, cache configuration after injecting the production token, execute tests, and then import a small representative file:
php artisan test
php artisan migrate --force
php artisan config:cache
php artisan newsletter:import storage/app/imports/subscribers.csv
Restrict CSV and environment-file permissions, require TLS, and configure proxies and application-performance tools to redact query strings. Monitor counts by decision, failure codes, HTTP 429 responses, authentication failures, upstream failures, and changes in the accepted-to-review ratio. Alert on sudden shifts rather than logging personal data.
Common failures have distinct remedies. An authentication failure usually means the configured token is missing, stale, or revoked. A 429 response calls for checking plan capacity and import pacing. Repeated server or connection failures should leave contacts in review until a controlled rerun. A malformed response should trigger investigation before policy changes. A missing CSV header is an input-contract problem and correctly stops the entire command.
Final verification checklist
- The token exists only in environment-backed configuration and production secret storage.
- The minimal API request succeeds with the exact GET endpoint and query parameters.
- Configuration is cached after token changes.
- Automated tests cannot make stray network requests.
- High-confidence, low-confidence, and uncertain samples reach the intended states.
- HTTP 429, authentication, timeout, server-error, and malformed-response paths are observable.
- Reviewers can inspect status, score, recommendation, checks, and quota evidence.
- Only contacts with an
accepteddecision are exported to the sending platform.
A reliable newsletter import is not a binary email checker wrapped in a loop. It is a decision pipeline that distinguishes evidence from policy, temporary failure from rejection, and automation from justified uncertainty. Preserve those distinctions, and the manual-review queue becomes a safety feature rather than a dumping ground.