Email validation
Darvis\Mailtrap\Models\EmailValidation keeps one verdict per address in the email_validations table. The package calls it for every recipient while sending. You can also call it yourself, for example in a form or an import.
What is checked
EmailValidation::validateEmail($email) runs these steps and stops at the first answer:
- A stored verdict. If the address already has a row, that verdict is returned without any lookup. The exception is a block from a local check that is older than
MAILTRAP_VALIDATION_CACHE_DURATIONseconds: that one is checked again. - The format, with PHP’s
FILTER_VALIDATE_EMAIL. - A known domain. If another address on the same domain is already
valid, the address is stored asvalidwithout a DNS lookup. - The mail server. The domain must have an MX record, and at least one of those hosts must have an IPv4 or IPv6 address. Both come from
dns_get_record(). A Null MX (RFC 7505: one record pointing at.) means the domain accepts no mail and is refused.
A lookup that fails (a timeout or SERVFAIL) is not a verdict on the address. The address is let through, nothing is stored, and the next mail to it checks again. With MAILTRAP_LOG_TO_LARAVEL=true every such case is logged as a warning, Mailtrap: DNS lookup inconclusive, recipient let through, with the address, the domain and the reason (MX lookup failed or MX host lookup failed).
It does not connect to the mail server and does not know whether the mailbox exists. There is no call to Mailtrap.
The result is stored and returned:
| Outcome | Return value | Stored verdict | Stored status_code |
|---|---|---|---|
| Passed | null | valid, reason All checks passed or Domain already verified | 200 |
| Bad format | Invalid email format | blocked | 400 |
| No MX record | No valid mail server found for domain | blocked | 400 |
| Null MX | Domain does not accept mail (Null MX) | blocked | 400 |
| MX host without IP | MX record does not resolve to a valid IP address | blocked | 400 |
| DNS lookup failed | null | nothing stored | none |
A stored invalid or blocked verdict | The stored reason | unchanged | unchanged |
The DNS lookups are synchronous. A slow DNS server slows down the request that calls this method.
Check one address
use Darvis\Mailtrap\Models\EmailValidation;
$reason = EmailValidation::validateEmail('user@example.com');
if ($reason === null) {
// valid
} else {
// $reason says why not, e.g. "No valid mail server found for domain"
}
As a rule in a form request (a class that validates a form in Laravel):
<?php
// app/Http/Requests/SubscribeRequest.php
namespace App\Http\Requests;
use Closure;
use Darvis\Mailtrap\Models\EmailValidation;
use Illuminate\Foundation\Http\FormRequest;
class SubscribeRequest extends FormRequest
{
public function rules(): array
{
return [
'email' => [
'required',
'email',
function (string $attribute, mixed $value, Closure $fail): void {
if (EmailValidation::validateEmail((string) $value) !== null) {
$fail('We cannot send mail to this address.');
}
},
],
];
}
}
The closure stores a verdict for the address. A later send to it reuses that verdict.
Look up a verdict without checking
These methods only read the table. They never do a DNS lookup.
EmailValidation::isBlocked('user@example.com'); // true when the verdict is "blocked"
EmailValidation::getBlockReason('user@example.com'); // the reason, or null when not blocked
EmailValidation::isValid('user@example.com'); // true when this address, or another one on its domain, is "valid"
isValid() looks at the domain too; isBlocked() never does. An address without a row is neither valid nor blocked.
Addresses are trimmed and compared in lower case, on every database. User@Example.com and user@example.com are one address with one row, stored as user@example.com. A row that a version before 1.6.0 stored with capitals is still found, and is rewritten in lower case the next time its verdict is saved.
Block, unblock or mark an address
EmailValidation::markAsBlocked('complainer@example.com', 'Spam complaint'); // stops every send to this address
EmailValidation::markAsInvalid('old@example.com', 'Left the company'); // recorded, does not stop a send
EmailValidation::markAsValid('complainer@example.com'); // lifts a block
Each call replaces the row of that address. A block set this way never expires. Which verdict stops a send is explained on Sending, blocking and mail logs.
Get the status of a list
bulkValidationStatus() reads the verdicts of a list in one query, without lookups:
use Darvis\Mailtrap\Models\EmailValidation;
$result = EmailValidation::bulkValidationStatus([
'first@example.com',
'second@example.com',
'third@example.com',
]);
[
'valid' => 1, // verdict "valid"
'invalid' => 1, // verdict "invalid" or "blocked"
'not_exists' => 1, // no row yet
'total' => 3,
'details' => [
'first@example.com' => [
'status' => 'valid',
'reason' => 'All checks passed',
'last_checked_at' => Illuminate\Support\Carbon,
],
'second@example.com' => [
'status' => 'blocked',
'reason' => 'No valid mail server found for domain',
'last_checked_at' => Illuminate\Support\Carbon,
],
'third@example.com' => [
'status' => 'not_exists',
'reason' => 'Email address not found in database',
'last_checked_at' => null,
],
],
]
not_exists is not a stored verdict; it only appears in this result. The keys of details are the addresses as you passed them.
To also check the addresses that have no row yet, pass true as the second argument:
$result = EmailValidation::bulkValidationWithCheck($emails, true);
This calls validateEmail() for each unknown address, one after another, with DNS lookups. For a long list, run it in a queued job. With false (the default) it behaves like bulkValidationStatus().
Pick the addresses you can mail from the result with a collection:
$sendable = collect($result['details'])
->filter(fn (array $details): bool => $details['status'] === EmailValidation::VALID)
->keys()
->all();
All methods
All methods are static.
validateEmail(string $email): ?string:nullwhen valid, otherwise the reason. Stores the verdict.isBlocked(string $email): bool: whether the verdict isblocked.getBlockReason(string $email): ?string: the block reason, ornull.isValid(string $email): bool: whether the address, or another address on its domain, isvalid.markAsValid(string $email): self: storesvalidwith reasonEmail validated successfullyand status code200.markAsInvalid(string $email, string $reason, int|string|null $statusCode = null): self: storesinvalid.markAsBlocked(string $email, string $reason, int|string|null $statusCode = null): self: storesblocked.saveValidation(string $email, string $status, string $reason, int|string|null $statusCode): self: stores any verdict; themarkAs…methods call this.bulkValidationStatus(array $emails): array: counts and details, see above.bulkValidationWithCheck(array $emails, bool $validateMissing = false): array: the same, after validating unknown addresses when asked.domainOf(string $email): string: the lowercased domain, or an empty string.
Constants: EmailValidation::VALID ('valid'), EmailValidation::INVALID ('invalid'), EmailValidation::BLOCKED ('blocked'), and EmailValidation::LOCAL_CHECK_REASONS, the list of reasons whose blocks expire.
The email_validations table
| Column | Type | Contents |
|---|---|---|
id | big integer | Primary key |
email | string, nullable | The address, in lower case |
domain | string, nullable | The lowercased domain of the address |
status | enum | valid, invalid or blocked |
reason | string | Why the address has this verdict |
status_code | string, nullable | 200 or 400 from a local check, or the response code of a webhook event |
last_checked_at | timestamp | When the verdict was stored |
created_at, updated_at | timestamp |
Next steps
- Sending, blocking and mail logs
- Testing: give addresses a verdict up front so tests run no DNS lookups