Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 31 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,26 +19,49 @@ Optional settings: `MAILCHECKR_BASE_URL`, `MAILCHECKR_TIMEOUT` (seconds, default

## Verify an address

Using the facade:

```php
use Dotmarn\MailCheckr\MailCheckrClient;
use Dotmarn\MailCheckr\Facades\MailCheckr;

$verification = app(MailCheckrClient::class)->verify(
$verification = MailCheckr::verify(
'person@example.com',
'verify-user-123' // persist and reuse this key for retries of the same email
);

if ($verification['state'] !== 'completed') {
$verification = app(MailCheckrClient::class)->find($verification['id']);
if ($verification->isPending()) {
$verification = MailCheckr::find($verification->id()); // Poll later, not in a tight loop.
}

if ($verification['state'] === 'completed') {
// Inspect $verification['status']: deliverable, undeliverable, risky, or unknown.
if ($verification->isDeliverable()) {
// The verification completed and MailCheckr marked it deliverable.
} elseif ($verification->isUnknown()) {
// The verification completed without a conclusive delivery result.
}
```

`verify()` returns the `data` object for HTTP 200 and 202. Pending states include `queued`, `processing`, and `retry_scheduled`; poll the ID or handle a webhook. Do not treat a pending or unknown result as deliverable.
Using the client class directly:

```php
use Dotmarn\MailCheckr\MailCheckrClient;

$client = new MailCheckrClient();
$verification = $client->verify('person@example.com', 'verify-user-123');

if ($verification->isPending()) {
$verification = $client->find($verification->id()); // Poll later, not in a tight loop.
}

if ($verification->isDeliverable()) {
// The verification completed and MailCheckr marked it deliverable.
}
```

`verify()` and `find()` return a `VerificationResult` for HTTP 200 and 202. Pending states include `queued`, `processing`, and `retry_scheduled`; poll the ID or handle a webhook. Do not treat a pending or unknown result as deliverable.

`VerificationResult` also provides `isUndeliverable()`, `isRisky()`, `isCompleted()`, and `isFailed()`. The four status helpers return `true` only when the state is `completed`; a queued result is never treated as deliverable. Use `id()`, `state()`, `status()`, or `toArray()` to inspect the response. The facade and `MailCheckrClient` expose the same methods.

The `MailCheckr` facade exposes the same `verify()` and `find()` methods. API errors throw `Dotmarn\MailCheckr\Exceptions\MailCheckrException`, with `status` and `response` properties. Network errors are raised by Laravel's HTTP client.
API errors throw `Dotmarn\MailCheckr\Exceptions\MailCheckrException`, with `status` and `response` properties. Network errors are raised by Laravel's HTTP client.

## Verify webhook deliveries

Expand Down
7 changes: 5 additions & 2 deletions src/Facades/MailCheckr.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@
namespace Dotmarn\MailCheckr\Facades;

use Dotmarn\MailCheckr\MailCheckrClient;
use Dotmarn\MailCheckr\VerificationResult;
use Illuminate\Support\Facades\Facade;

/** @method static array verify(string $email, string $idempotencyKey) */
/** @method static array find(string $id) */
/**
* @method static VerificationResult verify(string $email, string $idempotencyKey)
* @method static VerificationResult find(string $id)
*/
class MailCheckr extends Facade
{
protected static function getFacadeAccessor(): string
Expand Down
25 changes: 16 additions & 9 deletions src/MailCheckrClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,28 +9,35 @@

class MailCheckrClient
{
public function __construct(
private readonly ?string $apiKey,
private readonly string $baseUrl = 'https://mailcheckr.app/api/v1',
private readonly int $timeout = 30
) {}
private readonly ?string $apiKey;

public function verify(string $email, string $idempotencyKey): array
private readonly string $baseUrl;

private readonly int $timeout;

public function __construct()
{
$this->apiKey = config('mailcheckr.api_key');
$this->baseUrl = config('mailcheckr.base_url');
$this->timeout = config('mailcheckr.timeout');
}

public function verify(string $email, string $idempotencyKey): VerificationResult
{
if ($email === '' || $idempotencyKey === '') {
throw new InvalidArgumentException('Email and idempotency key are required.');
}

return $this->request('post', 'verifications', ['email' => $email], $idempotencyKey);
return new VerificationResult($this->request('post', 'verifications', ['email' => $email], $idempotencyKey));
}

public function find(string $id): array
public function find(string $id): VerificationResult
{
if ($id === '' || str_contains($id, '/')) {
throw new InvalidArgumentException('A valid verification ID is required.');
}

return $this->request('get', 'verifications/'.rawurlencode($id));
return new VerificationResult($this->request('get', 'verifications/'.rawurlencode($id)));
}

private function request(string $method, string $path, array $body = [], ?string $idempotencyKey = null): array
Expand Down
6 changes: 1 addition & 5 deletions src/MailCheckrServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,7 @@ public function register(): void
{
$this->mergeConfigFrom(__DIR__.'/../config/mailcheckr.php', 'mailcheckr');

$this->app->singleton(MailCheckrClient::class, fn ($app) => new MailCheckrClient(
$app['config']->get('mailcheckr.api_key'),
$app['config']->get('mailcheckr.base_url'),
$app['config']->get('mailcheckr.timeout')
));
$this->app->singleton(MailCheckrClient::class, fn () => new MailCheckrClient());

$this->app->singleton(WebhookVerifier::class, fn ($app) => new WebhookVerifier(
$app['config']->get('mailcheckr.webhook_secret'),
Expand Down
84 changes: 84 additions & 0 deletions src/VerificationResult.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php

namespace Dotmarn\MailCheckr;

use InvalidArgumentException;

final class VerificationResult
{
/** Create a result from the API data, which must include an ID and state. */
public function __construct(private readonly array $data)
{
if (! isset($data['id'], $data['state']) || ! is_string($data['id']) || ! is_string($data['state'])) {
throw new InvalidArgumentException('A verification result requires an ID and state.');
}
}

/** Return the verification ID used to retrieve an updated result. */
public function id(): string
{
return $this->data['id'];
}

/** Return the verification lifecycle state, such as queued or completed. */
public function state(): string
{
return $this->data['state'];
}

/** Return the delivery status when present, or null while it is unavailable. */
public function status(): ?string
{
return isset($this->data['status']) && is_string($this->data['status'])
? $this->data['status']
: null;
}

/** Determine whether verification has completed. */
public function isCompleted(): bool
{
return $this->state() === 'completed';
}

/** Determine whether verification is queued, processing, or scheduled for retry. */
public function isPending(): bool
{
return in_array($this->state(), ['queued', 'processing', 'retry_scheduled'], true);
}

/** Determine whether verification ended in the failed state. */
public function isFailed(): bool
{
return $this->state() === 'failed';
}

/** Determine whether a completed verification marked the address deliverable. */
public function isDeliverable(): bool
{
return $this->isCompleted() && $this->status() === 'deliverable';
}

/** Determine whether a completed verification marked the address undeliverable. */
public function isUndeliverable(): bool
{
return $this->isCompleted() && $this->status() === 'undeliverable';
}

/** Determine whether a completed verification marked the address risky. */
public function isRisky(): bool
{
return $this->isCompleted() && $this->status() === 'risky';
}

/** Determine whether a completed verification has an unknown delivery status. */
public function isUnknown(): bool
{
return $this->isCompleted() && $this->status() === 'unknown';
}

/** Return the original API data as an array. */
public function toArray(): array
{
return $this->data;
}
}
3 changes: 2 additions & 1 deletion tests/LaravelIntegrationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ public function test_provider_merges_configuration_and_resolves_the_client_and_w

Http::fake(['*' => Http::response(['data' => ['id' => 'integration-id', 'state' => 'queued']], 202)]);

$this->assertSame('integration-id', MailCheckr::verify('person@example.com', 'integration-1')['id']);
$this->assertSame('integration-id', MailCheckr::verify('person@example.com', 'integration-1')->id());
$this->assertTrue(MailCheckr::verify('person@example.com', 'integration-2')->isPending());
Http::assertSent(fn ($request) => $request->hasHeader('Authorization', 'Bearer integration-key')
&& $request->hasHeader('Idempotency-Key', 'integration-1'));

Expand Down
51 changes: 47 additions & 4 deletions tests/MailCheckrClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

use Dotmarn\MailCheckr\Exceptions\MailCheckrException;
use Dotmarn\MailCheckr\MailCheckrClient;
use Dotmarn\MailCheckr\VerificationResult;
use Illuminate\Container\Container;
use Illuminate\Config\Repository;
use Illuminate\Http\Client\Factory;
use Illuminate\Support\Facades\Facade;
use Illuminate\Support\Facades\Http;
Expand All @@ -16,6 +18,14 @@ protected function setUp(): void

$container = new Container();
$container->instance('http', new Factory());
$container->instance('config', new Repository([
'mailcheckr' => [
'api_key' => 'test-key',
'base_url' => 'https://mailcheckr.app/api/v1',
'timeout' => 30,
],
]));
Container::setInstance($container);
Facade::setFacadeApplication($container);
Facade::clearResolvedInstances();
}
Expand All @@ -24,6 +34,7 @@ protected function tearDown(): void
{
Facade::clearResolvedInstances();
Facade::setFacadeApplication(null);
Container::setInstance(null);

parent::tearDown();
}
Expand All @@ -35,10 +46,10 @@ public function test_it_sends_authenticated_idempotent_requests_and_preserves_pe
'mailcheckr.app/api/v1/verifications/id-1' => Http::response(['data' => ['id' => 'id-1', 'state' => 'completed', 'status' => 'deliverable']], 200),
]);

$client = new MailCheckrClient('test-key');
$client = new MailCheckrClient();

$this->assertSame('queued', $client->verify('person@example.com', 'person-1')['state']);
$this->assertSame('deliverable', $client->find('id-1')['status']);
$this->assertSame('queued', $client->verify('person@example.com', 'person-1')->state());
$this->assertTrue($client->find('id-1')->isDeliverable());

Http::assertSent(fn ($request) => $request->method() === 'POST'
&& $request->hasHeader('Authorization', 'Bearer test-key')
Expand All @@ -51,11 +62,43 @@ public function test_it_exposes_api_error_status_and_response(): void
Http::fake(['*' => Http::response(['code' => 'insufficient_credits'], 402)]);

try {
(new MailCheckrClient('test-key'))->verify('person@example.com', 'person-2');
(new MailCheckrClient())->verify('person@example.com', 'person-2');
$this->fail('Expected a MailCheckrException.');
} catch (MailCheckrException $exception) {
$this->assertSame(402, $exception->status);
$this->assertSame('insufficient_credits', $exception->response['code']);
}
}

public function test_direct_instantiation_uses_configured_base_url(): void
{
config()->set('mailcheckr.base_url', 'https://custom.example/api');
Http::fake(['custom.example/api/verifications' => Http::response([
'data' => ['id' => 'custom-id', 'state' => 'queued'],
], 202)]);

$result = (new MailCheckrClient())->verify('person@example.com', 'custom-1');

$this->assertSame('custom-id', $result->id());
Http::assertSent(fn ($request) => $request->url() === 'https://custom.example/api/verifications');
}

public function test_verify_and_find_return_result_objects(): void
{
Http::fake([
'mailcheckr.app/api/v1/verifications' => Http::response(['data' => ['id' => 'id-2', 'state' => 'queued']], 202),
'mailcheckr.app/api/v1/verifications/id-2' => Http::response(['data' => ['id' => 'id-2', 'state' => 'completed', 'status' => 'unknown']], 200),
]);

$client = new MailCheckrClient();

$pending = $client->verify('person@example.com', 'person-2');
$this->assertInstanceOf(VerificationResult::class, $pending);
$this->assertTrue($pending->isPending());
$this->assertFalse($pending->isUnknown());

$completed = $client->find($pending->id());
$this->assertTrue($completed->isUnknown());
$this->assertSame('unknown', $completed->toArray()['status']);
}
}
44 changes: 44 additions & 0 deletions tests/VerificationResultTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

use Dotmarn\MailCheckr\VerificationResult;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

class VerificationResultTest extends TestCase
{
#[DataProvider('resultStates')]
public function test_status_helpers_are_true_only_for_completed_matching_results(
string $state,
?string $status,
string $expectedMethod
): void {
$data = ['id' => 'verification-1', 'state' => $state, 'status' => $status, 'checks' => []];
$result = new VerificationResult($data);

$this->assertSame('verification-1', $result->id());
$this->assertSame($state, $result->state());
$this->assertSame($status, $result->status());
$this->assertSame($data, $result->toArray());

foreach (['isDeliverable', 'isUndeliverable', 'isRisky', 'isUnknown', 'isPending', 'isFailed'] as $method) {
$this->assertSame($method === $expectedMethod, $result->{$method}(), $method);
}

$this->assertSame($state === 'completed', $result->isCompleted());
}

public static function resultStates(): array
{
return [
'deliverable' => ['completed', 'deliverable', 'isDeliverable'],
'undeliverable' => ['completed', 'undeliverable', 'isUndeliverable'],
'risky' => ['completed', 'risky', 'isRisky'],
'unknown' => ['completed', 'unknown', 'isUnknown'],
'queued' => ['queued', null, 'isPending'],
'processing' => ['processing', null, 'isPending'],
'retry scheduled' => ['retry_scheduled', null, 'isPending'],
'failed' => ['failed', null, 'isFailed'],
'premature status' => ['queued', 'deliverable', 'isPending'],
];
}
}
Loading