diff --git a/README.md b/README.md index bf71de0..1101088 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/Facades/MailCheckr.php b/src/Facades/MailCheckr.php index 596d0d5..534a312 100644 --- a/src/Facades/MailCheckr.php +++ b/src/Facades/MailCheckr.php @@ -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 diff --git a/src/MailCheckrClient.php b/src/MailCheckrClient.php index 88b614b..0d3a3ac 100644 --- a/src/MailCheckrClient.php +++ b/src/MailCheckrClient.php @@ -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 diff --git a/src/MailCheckrServiceProvider.php b/src/MailCheckrServiceProvider.php index 90ed974..e1c53c9 100644 --- a/src/MailCheckrServiceProvider.php +++ b/src/MailCheckrServiceProvider.php @@ -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'), diff --git a/src/VerificationResult.php b/src/VerificationResult.php new file mode 100644 index 0000000..3ce20ea --- /dev/null +++ b/src/VerificationResult.php @@ -0,0 +1,84 @@ +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; + } +} diff --git a/tests/LaravelIntegrationTest.php b/tests/LaravelIntegrationTest.php index 14dce32..08c6f52 100644 --- a/tests/LaravelIntegrationTest.php +++ b/tests/LaravelIntegrationTest.php @@ -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')); diff --git a/tests/MailCheckrClientTest.php b/tests/MailCheckrClientTest.php index 3c7e47c..e0f038f 100644 --- a/tests/MailCheckrClientTest.php +++ b/tests/MailCheckrClientTest.php @@ -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; @@ -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(); } @@ -24,6 +34,7 @@ protected function tearDown(): void { Facade::clearResolvedInstances(); Facade::setFacadeApplication(null); + Container::setInstance(null); parent::tearDown(); } @@ -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') @@ -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']); + } } diff --git a/tests/VerificationResultTest.php b/tests/VerificationResultTest.php new file mode 100644 index 0000000..79f75a5 --- /dev/null +++ b/tests/VerificationResultTest.php @@ -0,0 +1,44 @@ + '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'], + ]; + } +}