Laravel: Tame Newsletter Imports by Sending Dubious Emails to Manual Review
A newsletter import looks harmless until it meets real-world data: duplicated contacts, stray whitespace, misspelled domains, abandoned mailboxes, and addresses that are technically valid but still risky. Sending every row straight to a mailing list wastes quota, damages deliverability, and turns uncertain data into an operational problem.
The safer design is a small decision pipeline. Laravel performs deterministic cleanup first, an external validator supplies delivery evidence, and only clearly acceptable contacts become sendable. Everything ambiguous enters a manual-review queue instead of being silently accepted or discarded.
Get access to the Email Validator
Register at https://ai.mihajlo.mk/register, or sign in through https://ai.mihajlo.mk/login.
Open the service page at https://ai.mihajlo.mk/api/email-validator. Choose an available Free, Plus, or Pro plan and complete its activation. Then visit the official documentation at https://ai.mihajlo.mk/api/email-validator/documentation, find the Service token panel, and copy the service-scoped token.
The service requires that token; it is not an unauthenticated endpoint. Regenerating the service token revokes the previously active token, so treat regeneration as a credential rotation that must be coordinated with deployment.
The exact request is an HTTP GET to https://ai.mihajlo.mk/api/email-validator/v1/check-email. Authentication uses the token query parameter, while the address goes in the email query parameter. Test it without putting the token in shell history permanently:
read -s SERVICE_TOKEN
curl --get 'https://ai.mihajlo.mk/api/email-validator/v1/check-email' \
--data-urlencode "token=${SERVICE_TOKEN}" \
--data-urlencode '[email protected]'
unset SERVICE_TOKEN
A successful payload supplies status, score, recommendation, checks, and quota. We will validate all five at the application boundary rather than assuming every HTTP 200 contains usable data.
Store the credential in Laravel’s environment configuration before writing the feature:
# .env
EMAIL_VALIDATOR_TOKEN=YOUR_SERVICE_TOKEN
# These are application policy values. Match their spelling to the
# documented values returned for your activated service.
EMAIL_VALIDATOR_ALLOWED_STATUSES=YOUR_ACCEPTABLE_STATUS
EMAIL_VALIDATOR_ALLOWED_RECOMMENDATIONS=YOUR_ACCEPTABLE_RECOMMENDATION
EMAIL_VALIDATOR_MIN_SCORE=80
EMAIL_VALIDATOR_REQUIRED_CHECKS=syntax,domain,mx
Do not commit .env. The policy values above are deliberately not presented as API enums: configure them from the official documentation and verified responses for your account.
Architecture: deterministic cleanup before paid validation
This implementation targets PHP 8.3 and a Laravel application with a database and queue configured. A CSV command normalizes addresses, rejects indisputable syntax errors, deduplicates rows, and dispatches one queue job per new contact. The job calls a dedicated HTTP client, maps the response into a domain object, and applies a conservative policy.
- Ready: every response field is structurally valid and the configured status, recommendation, score, and checks pass.
- Rejected: local syntax validation fails before any remote request.
- Manual review: the response is ambiguous, malformed, unauthorized, or outside policy.
- Pending: a retryable transport or server failure has not exhausted its bounded retry budget.
This bias is intentional. A false negative costs a review; a false positive can affect every future campaign.
Configure the service boundary
Add the following entry to config/services.php:
'email_validator' => [
'endpoint' => 'https://ai.mihajlo.mk/api/email-validator/v1/check-email',
'token' => env('EMAIL_VALIDATOR_TOKEN'),
'allowed_statuses' => array_values(array_filter(array_map(
'trim',
explode(',', env('EMAIL_VALIDATOR_ALLOWED_STATUSES', ''))
))),
'allowed_recommendations' => array_values(array_filter(array_map(
'trim',
explode(',', env('EMAIL_VALIDATOR_ALLOWED_RECOMMENDATIONS', ''))
))),
'minimum_score' => (float) env('EMAIL_VALIDATOR_MIN_SCORE', 80),
'required_checks' => array_values(array_filter(array_map(
'trim',
explode(',', env('EMAIL_VALIDATOR_REQUIRED_CHECKS', ''))
))),
],
Create a migration with php artisan make:model NewsletterContact -m, then define the 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): void {
$table->id();
$table->string('source_email');
$table->string('normalized_email')->unique();
$table->string('state')->default('pending')->index();
$table->string('review_reason')->nullable();
$table->json('validation')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('newsletter_contacts');
}
};
In app/Models/NewsletterContact.php, allow those fields and cast the evidence:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
final class NewsletterContact extends Model
{
protected $fillable = [
'source_email',
'normalized_email',
'state',
'review_reason',
'validation',
];
protected function casts(): array
{
return ['validation' => 'array'];
}
}
Map untrusted JSON into a domain result
The DTO rejects missing or mistyped contract fields. It does not guess the internal shape of checks or quota; those remain arrays until application policy inspects configured check paths.
<?php
// app/Domain/EmailValidation/EmailAssessment.php
namespace App\Domain\EmailValidation;
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 $data): self
{
foreach (['status', 'score', 'recommendation', 'checks', 'quota'] as $key) {
if (! array_key_exists($key, $data)) {
throw new UnexpectedValueException("Missing field: {$key}");
}
}
if (! is_string($data['status']) || $data['status'] === ''
|| ! is_numeric($data['score'])
|| ! is_string($data['recommendation']) || $data['recommendation'] === ''
|| ! is_array($data['checks'])
|| ! is_array($data['quota'])) {
throw new UnexpectedValueException('Unexpected validation payload');
}
return new self(
$data['status'],
(float) $data['score'],
$data['recommendation'],
$data['checks'],
$data['quota'],
);
}
public function evidence(): array
{
return [
'status' => $this->status,
'score' => $this->score,
'recommendation' => $this->recommendation,
'checks' => $this->checks,
'quota' => $this->quota,
];
}
}
The HTTP client uses short timeouts and retries only connection failures and server errors. Authentication failures, client validation failures, and quota responses are not blindly retried.
<?php
// app/Services/EmailValidator.php
namespace App\Services;
use App\Domain\EmailValidation\EmailAssessment;
use Exception;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Throwable;
final readonly class ValidationAttempt
{
public function __construct(
public string $state,
public ?EmailAssessment $assessment = null,
) {}
}
final class EmailValidator
{
public function check(string $email): ValidationAttempt
{
$token = config('services.email_validator.token');
if (! is_string($token) || $token === '') {
return new ValidationAttempt('configuration_error');
}
try {
$response = Http::acceptJson()
->connectTimeout(2)
->timeout(6)
->retry(
[200, 500],
when: fn (Exception $e): bool =>
$e instanceof ConnectionException
|| ($e instanceof RequestException
&& $e->response?->serverError()),
throw: false,
)
->get(config('services.email_validator.endpoint'), [
'token' => $token,
'email' => $email,
]);
} catch (ConnectionException) {
return new ValidationAttempt('transient_failure');
}
if (in_array($response->status(), [401, 403], true)) {
return new ValidationAttempt('authentication_failure');
}
if ($response->status() === 429) {
return new ValidationAttempt('quota_or_rate_limited');
}
if ($response->serverError()) {
return new ValidationAttempt('transient_failure');
}
if (! $response->successful()) {
return new ValidationAttempt('request_rejected');
}
try {
$payload = $response->json();
if (! is_array($payload)) {
return new ValidationAttempt('malformed_response');
}
return new ValidationAttempt(
'ok',
EmailAssessment::fromPayload($payload),
);
} catch (Throwable) {
return new ValidationAttempt('malformed_response');
}
}
}
Turn evidence into a conservative decision
The policy uses every contracted response component. Status and recommendation must match configured allow-lists, the numeric score must meet the local threshold, every configured check must be exactly true, and quota must be a present, non-empty array. Anything else is uncertain.
<?php
// app/Domain/EmailValidation/NewsletterPolicy.php
namespace App\Domain\EmailValidation;
final class NewsletterPolicy
{
public function decide(EmailAssessment $result): array
{
$statuses = config('services.email_validator.allowed_statuses', []);
$recommendations = config(
'services.email_validator.allowed_recommendations',
[]
);
if (! in_array($result->status, $statuses, true)) {
return ['manual_review', 'status_not_allowed'];
}
if (! in_array($result->recommendation, $recommendations, true)) {
return ['manual_review', 'recommendation_not_allowed'];
}
if ($result->score < config('services.email_validator.minimum_score')) {
return ['manual_review', 'score_below_threshold'];
}
foreach (config('services.email_validator.required_checks', []) as $path) {
if (data_get($result->checks, $path) !== true) {
return ['manual_review', "check_failed:{$path}"];
}
}
if ($result->quota === []) {
return ['manual_review', 'quota_evidence_missing'];
}
return ['ready', null];
}
}
Process contacts safely in the queue
A queue is worthwhile because a large CSV should not keep a web request or terminal process waiting on remote latency. The job is unique per contact, uses delayed retries for recoverable failures, and converts permanent failures into review work.
<?php
// app/Jobs/ValidateNewsletterContact.php
namespace App\Jobs;
use App\Domain\EmailValidation\NewsletterPolicy;
use App\Models\NewsletterContact;
use App\Services\EmailValidator;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use Throwable;
final class ValidateNewsletterContact implements ShouldQueue, ShouldBeUnique
{
use Queueable;
public int $tries = 4;
public int $uniqueFor = 3600;
public function __construct(public readonly int $contactId) {}
public function uniqueId(): string
{
return (string) $this->contactId;
}
public function backoff(): array
{
return [60, 300, 900];
}
public function handle(
EmailValidator $validator,
NewsletterPolicy $policy,
): void {
$contact = NewsletterContact::findOrFail($this->contactId);
$attempt = $validator->check($contact->normalized_email);
if (in_array($attempt->state, [
'transient_failure',
'quota_or_rate_limited',
], true) && $this->attempts() < $this->tries) {
$delays = $this->backoff();
$this->release($delays[$this->attempts() - 1] ?? 900);
return;
}
if ($attempt->state !== 'ok' || $attempt->assessment === null) {
$contact->update([
'state' => 'manual_review',
'review_reason' => $attempt->state,
]);
Log::warning('Newsletter contact requires review', [
'contact_id' => $contact->id,
'reason' => $attempt->state,
]);
return;
}
[$state, $reason] = $policy->decide($attempt->assessment);
$contact->update([
'state' => $state,
'review_reason' => $reason,
'validation' => $attempt->assessment->evidence(),
]);
Log::info('Newsletter contact validation completed', [
'contact_id' => $contact->id,
'state' => $state,
'status' => $attempt->assessment->status,
'score' => $attempt->assessment->score,
]);
}
public function failed(Throwable $exception): void
{
NewsletterContact::whereKey($this->contactId)->update([
'state' => 'manual_review',
'review_reason' => 'unexpected_job_failure',
]);
}
}
Import and clean the CSV
Create app/Console/Commands/ImportNewsletterContacts.php. The expected CSV contains an email header:
<?php
namespace App\Console\Commands;
use App\Jobs\ValidateNewsletterContact;
use App\Models\NewsletterContact;
use Illuminate\Console\Command;
use SplFileObject;
final class ImportNewsletterContacts extends Command
{
protected $signature = 'newsletter:import {path}';
protected $description = 'Import and validate newsletter contacts';
public function handle(): int
{
$path = $this->argument('path');
if (! is_string($path) || ! is_readable($path)) {
$this->error('CSV file is not readable.');
return self::FAILURE;
}
$csv = new SplFileObject($path);
$csv->setFlags(SplFileObject::READ_CSV | SplFileObject::SKIP_EMPTY);
$headers = $csv->fgetcsv();
$headers = array_map(
fn ($value) => strtolower(trim((string) $value)),
$headers ?: []
);
$emailColumn = array_search('email', $headers, true);
if ($emailColumn === false) {
$this->error('CSV must contain an email header.');
return self::FAILURE;
}
foreach ($csv as $row) {
$source = trim((string) ($row[$emailColumn] ?? ''));
if ($source === '') {
continue;
}
$normalized = mb_strtolower($source);
$validSyntax = filter_var($normalized, FILTER_VALIDATE_EMAIL) !== false;
$contact = NewsletterContact::firstOrCreate(
['normalized_email' => $normalized],
[
'source_email' => $source,
'state' => $validSyntax ? 'pending' : 'rejected',
'review_reason' => $validSyntax ? null : 'invalid_syntax',
],
);
if ($contact->wasRecentlyCreated && $validSyntax) {
ValidateNewsletterContact::dispatch($contact->id);
}
}
$this->info('Import accepted for processing.');
return self::SUCCESS;
}
}
Run the migration, import, and worker with:
php artisan migrate
php artisan newsletter:import storage/app/imports/contacts.csv
php artisan queue:work --tries=4 --timeout=30
Test the boundary and import behavior
Http::fake() keeps tests deterministic and prevents credentials or quota from being consumed. The fixture vocabulary below belongs to the test’s local policy; it does not assert undocumented service enums.
<?php
// tests/Feature/NewsletterImportTest.php
namespace Tests\Feature;
use App\Jobs\ValidateNewsletterContact;
use App\Services\EmailValidator;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
final class NewsletterImportTest extends TestCase
{
use RefreshDatabase;
public function test_validator_maps_a_complete_response(): void
{
config(['services.email_validator.token' => 'test-token']);
Http::fake([
'ai.mihajlo.mk/*' => Http::response([
'status' => 'accepted-fixture',
'score' => 92,
'recommendation' => 'send-fixture',
'checks' => ['syntax' => true],
'quota' => ['fixture' => true],
]),
]);
$result = app(EmailValidator::class)->check('[email protected]');
$this->assertSame('ok', $result->state);
$this->assertSame(92.0, $result->assessment?->score);
Http::assertSent(fn (Request $request): bool =>
$request->method() === 'GET'
&& $request->url()
=== 'https://ai.mihajlo.mk/api/email-validator/v1/check-email'
&& $request['token'] === 'test-token'
&& $request['email'] === '[email protected]'
);
}
public function test_import_rejects_bad_syntax_and_queues_valid_rows(): void
{
Queue::fake();
$path = tempnam(sys_get_temp_dir(), 'contacts-');
file_put_contents(
$path,
"email\n [email protected] \nnot-an-email\n"
);
$this->artisan('newsletter:import', ['path' => $path])
->assertSuccessful();
$this->assertDatabaseHas('newsletter_contacts', [
'normalized_email' => '[email protected]',
'state' => 'pending',
]);
$this->assertDatabaseHas('newsletter_contacts', [
'normalized_email' => 'not-an-email',
'state' => 'rejected',
]);
Queue::assertPushed(ValidateNewsletterContact::class, 1);
unlink($path);
}
public function test_malformed_success_payload_fails_closed(): void
{
config(['services.email_validator.token' => 'test-token']);
Http::fake([
'ai.mihajlo.mk/*' => Http::response(['status' => 'partial'], 200),
]);
$result = app(EmailValidator::class)->check('[email protected]');
$this->assertSame('malformed_response', $result->state);
}
}
Security, operations, and deployment
Because the required authentication mechanism places the token in the query string, configure reverse proxies, application-performance tools, and HTTP access logs to redact query parameters. Never log the complete request URL. Restrict production environment access and rotate the service token if exposure is suspected; remember that regeneration immediately invalidates the previous token.
Email addresses are personal data. Limit access to the review screen, define retention for imported source values and validation evidence, and avoid logging raw addresses. A contact ID is generally sufficient for correlation.
Deploy code and migrations first, install the production token through the platform’s secret store, then run php artisan config:cache. Restart queue workers with php artisan queue:restart so long-lived processes receive the new configuration. Monitor counts by state, validation latency, authentication failures, rate-limit responses, and the age of pending jobs. Alert on trends rather than logging tokens or complete payloads.
Common failures
- Every contact enters review: confirm that the configured status, recommendation, and check paths exactly match documented response values.
- Authentication failures: verify the service-scoped token and redeploy after rotation; do not retry 401 or 403 responses.
- Repeated rate limiting: reduce worker concurrency or pause imports. Backoff helps with bursts but cannot replace adequate plan capacity.
- Malformed responses: retain the failure category and inspect a securely captured, redacted payload. Do not loosen DTO validation merely to suppress the error.
- Stale configuration: rebuild Laravel’s configuration cache and restart queue workers.
Final verification checklist
- The token exists only in environment-backed configuration.
- The request uses
GETwith the exacttokenandemailquery parameters. - Syntax failures and duplicates consume no validation request.
- Status, score, recommendation, checks, and quota are validated and retained as decision evidence.
- Only configured, fully passing results become
ready. - Ambiguous and permanent failure states become
manual_review. - Retries are bounded and exclude authentication and client-validation failures.
- Tests pass with
php artisan test, and no test reaches the live service.
A trustworthy import is not one that makes the largest list. It is one that can explain why each address was accepted, rejected, or held back. By making uncertainty a first-class state, this Laravel pipeline protects both newsletter deliverability and the humans responsible for it.