Add typed error handling, retries, and webhook receiver

- Typed exceptions: LeadMailException base with LeadMailRequestException
  (structured statusCode/errorCode/logId/validationErrors) and
  LeadMailConnectionException; sendEmail/getDomains now throw these instead
  of raw Guzzle exceptions, and a malformed body is no longer a silent null.
- Automatic retry with exponential backoff on idempotent calls
  (getDomains, verifyEmail); sends are never retried to avoid duplicates.
- Webhook receiver: auto-registered route + LeadMailWebhookController that
  verifies the HMAC signature, logs failures, and dispatches a
  LeadMailWebhookReceived event. WebhookSignature/WebhookEvent/LeadMailWebhook
  helpers for manual handling.
- Webhook self-registration client methods (registerWebhook/getWebhook/
  deleteWebhook) and a promptless `leadmail:install` command that registers
  the URL and writes LEADMAIL_WEBHOOK_SECRET to .env.
- Null-safe client binding when LEADMAIL_TOKEN is unset.
- Test suite (Pest + Testbench) covering all of the above.
This commit is contained in:
netlas
2026-06-09 11:14:10 +03:00
parent bf8aac48e6
commit b2b61a26ed
24 changed files with 1467 additions and 21 deletions

View File

@@ -0,0 +1,118 @@
<?php
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use LeadM\LeadMail\LeadMailClient;
/**
* Bind a client backed by canned HTTP responses into the container so the
* command resolves it instead of making real requests.
*
* @param array<int, mixed> $queue
*/
function bindMockedClient(array $queue): void
{
app()->instance(LeadMailClient::class, new LeadMailClient(
baseUrl: 'https://mail.test',
token: 'lm_test',
retryDelayMs: 0,
handlerStack: HandlerStack::create(new MockHandler($queue)),
));
}
/**
* Point the application at a throwaway .env file and return its path.
*/
function useTempEnv(string $contents = "APP_NAME=Test\n"): string
{
$dir = sys_get_temp_dir().'/leadmail-test-'.uniqid();
mkdir($dir);
file_put_contents($dir.'/.env', $contents);
app()->useEnvironmentPath($dir);
return $dir.'/.env';
}
function registrationResponse(?string $secret): Response
{
$data = ['url' => 'https://app.test/webhooks/leadmail', 'has_secret' => true];
if ($secret !== null) {
$data['secret'] = $secret;
}
return new Response(200, [], json_encode(['success' => true, 'data' => $data]));
}
it('derives the webhook url from app.url and the configured route without prompting', function () {
config()->set('leadmail.token', 'lm_test');
config()->set('app.url', 'https://helios.test');
config()->set('leadmail.webhook_route', '/webhooks/leadmail');
useTempEnv();
$mock = new MockHandler([registrationResponse('whsec_x')]);
app()->instance(LeadMailClient::class, new LeadMailClient(
baseUrl: 'https://mail.test',
token: 'lm_test',
retryDelayMs: 0,
handlerStack: HandlerStack::create($mock),
));
$this->artisan('leadmail:install', ['--no-publish' => true])->assertSuccessful();
$sent = json_decode((string) $mock->getLastRequest()->getBody(), true);
expect($sent['url'])->toBe('https://helios.test/webhooks/leadmail');
});
it('registers the webhook and writes the secret to .env', function () {
config()->set('leadmail.token', 'lm_test');
$envPath = useTempEnv();
bindMockedClient([registrationResponse('whsec_written')]);
$this->artisan('leadmail:install', [
'--url' => 'https://app.test/webhooks/leadmail',
'--no-publish' => true,
])->assertSuccessful();
expect(file_get_contents($envPath))->toContain('LEADMAIL_WEBHOOK_SECRET=whsec_written');
});
it('replaces an existing secret line instead of duplicating it', function () {
config()->set('leadmail.token', 'lm_test');
$envPath = useTempEnv("APP_NAME=Test\nLEADMAIL_WEBHOOK_SECRET=whsec_old\n");
bindMockedClient([registrationResponse('whsec_new')]);
$this->artisan('leadmail:install', [
'--url' => 'https://app.test/webhooks/leadmail',
'--no-publish' => true,
])->assertSuccessful();
$contents = file_get_contents($envPath);
expect($contents)->toContain('whsec_new')
->and($contents)->not->toContain('whsec_old')
->and(substr_count($contents, 'LEADMAIL_WEBHOOK_SECRET='))->toBe(1);
});
it('does not touch .env when the url is updated without a new secret', function () {
config()->set('leadmail.token', 'lm_test');
$envPath = useTempEnv();
bindMockedClient([registrationResponse(null)]);
$this->artisan('leadmail:install', [
'--url' => 'https://app.test/webhooks/leadmail',
'--no-publish' => true,
])->assertSuccessful();
expect(file_get_contents($envPath))->not->toContain('LEADMAIL_WEBHOOK_SECRET');
});
it('fails when the api token is not configured', function () {
config()->set('leadmail.token', null);
useTempEnv();
$this->artisan('leadmail:install', [
'--url' => 'https://app.test/webhooks/leadmail',
'--no-publish' => true,
])->assertFailed();
});

View File

@@ -0,0 +1,166 @@
<?php
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Response;
use LeadM\LeadMail\Exceptions\LeadMailConnectionException;
use LeadM\LeadMail\Exceptions\LeadMailException;
use LeadM\LeadMail\Exceptions\LeadMailRequestException;
use LeadM\LeadMail\LeadMailClient;
/**
* @param array<int, mixed> $queue
*/
function clientWithResponses(array $queue): LeadMailClient
{
$mock = new MockHandler($queue);
return new LeadMailClient(
baseUrl: 'https://mail.test',
token: 'lm_test',
retries: 2,
retryDelayMs: 0,
handlerStack: HandlerStack::create($mock),
);
}
it('returns the decoded body on success', function () {
$client = clientWithResponses([
new Response(202, [], json_encode([
'success' => true,
'data' => ['log_id' => 42, 'status' => 'queued'],
])),
]);
$result = $client->sendEmail(['to' => []]);
expect($result['data']['log_id'])->toBe(42)
->and($result['success'])->toBeTrue();
});
it('throws a request exception with structured fields on a transport error', function () {
$client = clientWithResponses([
new Response(502, [], json_encode([
'success' => false,
'error' => ['code' => 'TRANSPORT_ERROR', 'message' => 'Sender address not verified'],
'data' => ['log_id' => 85, 'status' => 'failed'],
])),
]);
try {
$client->sendEmail(['to' => []]);
$this->fail('Expected LeadMailRequestException');
} catch (LeadMailRequestException $e) {
expect($e->statusCode())->toBe(502)
->and($e->errorCode())->toBe('TRANSPORT_ERROR')
->and($e->logId())->toBe(85)
->and($e->getMessage())->toBe('Sender address not verified');
}
});
it('never retries a send, even on a retryable 5xx', function () {
$client = clientWithResponses([
new Response(503, [], json_encode(['error' => ['code' => 'X', 'message' => 'down']])),
// If sends were retried this 200 would be consumed and the call would succeed.
new Response(200, [], json_encode(['success' => true, 'data' => ['log_id' => 1, 'status' => 'sent']])),
]);
expect(fn () => $client->sendEmail(['to' => []]))->toThrow(LeadMailRequestException::class);
});
it('parses laravel validation errors from a 422', function () {
$client = clientWithResponses([
new Response(422, [], json_encode([
'message' => 'The given data was invalid.',
'errors' => ['from.email' => ['The from.email field is required.']],
])),
]);
try {
$client->sendEmail(['to' => []]);
$this->fail('Expected LeadMailRequestException');
} catch (LeadMailRequestException $e) {
expect($e->isValidationError())->toBeTrue()
->and($e->validationErrors())->toHaveKey('from.email');
}
});
it('flags authentication errors from a string error envelope', function () {
$client = clientWithResponses([
new Response(401, [], json_encode(['error' => 'Invalid API token.'])),
]);
try {
$client->getDomains();
$this->fail('Expected LeadMailRequestException');
} catch (LeadMailRequestException $e) {
expect($e->isAuthenticationError())->toBeTrue()
->and($e->getMessage())->toBe('Invalid API token.');
}
});
it('retries transient 5xx responses then succeeds', function () {
$client = clientWithResponses([
new Response(503, [], 'service unavailable'),
new Response(200, [], json_encode([
'success' => true,
'data' => ['domains' => ['leadmagnet.dev']],
])),
]);
expect($client->getDomains())->toBe(['leadmagnet.dev']);
});
it('gives up after exhausting retries on persistent 5xx', function () {
$client = clientWithResponses([
new Response(500, [], json_encode(['error' => ['code' => 'X', 'message' => 'boom']])),
new Response(500, [], json_encode(['error' => ['code' => 'X', 'message' => 'boom']])),
new Response(500, [], json_encode(['error' => ['code' => 'X', 'message' => 'boom']])),
]);
expect(fn () => $client->getDomains())->toThrow(LeadMailRequestException::class);
});
it('does not retry 4xx responses', function () {
$client = clientWithResponses([
new Response(422, [], json_encode(['message' => 'nope', 'errors' => ['a' => ['b']]])),
new Response(200, [], json_encode(['success' => true, 'data' => ['domains' => []]])),
]);
// If it retried, it would consume the 200 and succeed. It must throw instead.
expect(fn () => $client->getDomains())->toThrow(LeadMailRequestException::class);
});
it('wraps connection failures in a connection exception', function () {
$client = clientWithResponses([
new ConnectException('Connection refused', new Request('GET', 'domains')),
new ConnectException('Connection refused', new Request('GET', 'domains')),
new ConnectException('Connection refused', new Request('GET', 'domains')),
]);
expect(fn () => $client->getDomains())->toThrow(LeadMailConnectionException::class);
});
it('throws on a malformed json body', function () {
$client = clientWithResponses([
new Response(200, [], '<html>not json</html>'),
]);
expect(fn () => $client->getDomains())->toThrow(LeadMailException::class, 'malformed');
});
it('fails open when verification cannot be reached', function () {
$client = clientWithResponses([
new ConnectException('Connection refused', new Request('POST', 'emails/verify')),
new ConnectException('Connection refused', new Request('POST', 'emails/verify')),
new ConnectException('Connection refused', new Request('POST', 'emails/verify')),
]);
$result = $client->verifyEmail('user@example.com');
expect($result['success'])->toBeTrue()
->and($result['data']['valid'])->toBeTrue()
->and($result['data']['status'])->toBe('unknown');
});

View File

@@ -0,0 +1,128 @@
<?php
use Illuminate\Http\Request;
use LeadM\LeadMail\Exceptions\InvalidWebhookSignatureException;
use LeadM\LeadMail\Webhooks\LeadMailWebhook;
use LeadM\LeadMail\Webhooks\WebhookEvent;
use LeadM\LeadMail\Webhooks\WebhookSignature;
const WEBHOOK_SECRET = 'whsec_test_secret';
/**
* Build a request the exact way the leadMail service signs its webhooks:
* body = json_encode($payload, JSON_UNESCAPED_SLASHES);
* signature = 'sha256=' . hash_hmac('sha256', $body, $secret).
*
* @param array<string, mixed> $payload
*/
function signedWebhookRequest(array $payload, string $secret = WEBHOOK_SECRET): Request
{
$body = json_encode($payload, JSON_UNESCAPED_SLASHES);
$signature = 'sha256='.hash_hmac('sha256', $body, $secret);
return Request::create(
'/webhooks/leadmail',
'POST',
[],
[],
[],
['HTTP_X_LEADMAIL_SIGNATURE' => $signature, 'CONTENT_TYPE' => 'application/json'],
$body,
);
}
/**
* @return array<string, mixed>
*/
function failurePayload(): array
{
return [
'event' => 'email.failed',
'delivery_id' => 'wd_01hxyz',
'occurred_at' => '2026-03-06T00:26:24+00:00',
'log_id' => 85,
'tenant_id' => 'tenant-7',
'status' => 'failed',
'error' => ['code' => 'TRANSPORT_ERROR', 'message' => 'Sender address not verified'],
'from' => 'noreply@leadmagnet.dev',
'to' => ['lead@example.com'],
'subject' => 'Welcome',
'metadata' => ['campaign' => 'spring'],
];
}
it('parses a genuinely signed webhook into a typed event', function () {
$webhook = new LeadMailWebhook(WEBHOOK_SECRET);
$event = $webhook->parse(signedWebhookRequest(failurePayload()));
expect($event)->toBeInstanceOf(WebhookEvent::class)
->and($event->isFailure())->toBeTrue()
->and($event->logId)->toBe(85)
->and($event->tenantId)->toBe('tenant-7')
->and($event->errorCode)->toBe('TRANSPORT_ERROR')
->and($event->errorMessage)->toBe('Sender address not verified')
->and($event->from)->toBe('noreply@leadmagnet.dev')
->and($event->to)->toBe(['lead@example.com'])
->and($event->metadata)->toBe(['campaign' => 'spring']);
});
it('rejects a tampered body', function () {
$webhook = new LeadMailWebhook(WEBHOOK_SECRET);
$request = signedWebhookRequest(failurePayload());
$tampered = Request::create(
'/webhooks/leadmail',
'POST',
[],
[],
[],
['HTTP_X_LEADMAIL_SIGNATURE' => $request->header(WebhookSignature::HEADER)],
json_encode(['event' => 'email.failed', 'log_id' => 999]),
);
expect($webhook->verify($tampered))->toBeFalse()
->and(fn () => $webhook->parse($tampered))
->toThrow(InvalidWebhookSignatureException::class);
});
it('rejects a request signed with the wrong secret', function () {
$webhook = new LeadMailWebhook(WEBHOOK_SECRET);
$request = signedWebhookRequest(failurePayload(), 'the_wrong_secret');
expect($webhook->verify($request))->toBeFalse();
});
it('rejects a request with no signature header', function () {
$webhook = new LeadMailWebhook(WEBHOOK_SECRET);
$request = Request::create('/webhooks/leadmail', 'POST', [], [], [], [], json_encode(failurePayload()));
expect($webhook->verify($request))->toBeFalse()
->and(fn () => $webhook->parse($request))
->toThrow(InvalidWebhookSignatureException::class);
});
it('treats an empty configured secret as never valid', function () {
$webhook = new LeadMailWebhook('');
expect($webhook->verify(signedWebhookRequest(failurePayload())))->toBeFalse();
});
it('resolves the webhook helper from the container with the configured secret', function () {
config()->set('leadmail.webhook_secret', WEBHOOK_SECRET);
$event = app(LeadMailWebhook::class)->parse(signedWebhookRequest(failurePayload()));
expect($event->logId)->toBe(85);
});
it('degrades gracefully when optional fields are missing', function () {
$event = WebhookEvent::fromArray(['event' => 'email.failed']);
expect($event->isFailure())->toBeTrue()
->and($event->logId)->toBeNull()
->and($event->to)->toBe([])
->and($event->metadata)->toBeNull();
});

View File

@@ -0,0 +1,68 @@
<?php
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use LeadM\LeadMail\LeadMailClient;
/**
* @param array<int, mixed> $queue
*/
function webhookClient(array $queue): LeadMailClient
{
return new LeadMailClient(
baseUrl: 'https://mail.test',
token: 'lm_test',
retryDelayMs: 0,
handlerStack: HandlerStack::create(new MockHandler($queue)),
);
}
it('registers a webhook and returns the generated secret', function () {
$client = webhookClient([
new Response(200, [], json_encode([
'success' => true,
'data' => ['url' => 'https://app.test/hook', 'has_secret' => true, 'secret' => 'whsec_abc'],
])),
]);
$result = $client->registerWebhook('https://app.test/hook');
expect($result['url'])->toBe('https://app.test/hook')
->and($result['has_secret'])->toBeTrue()
->and($result['secret'])->toBe('whsec_abc');
});
it('reads the current webhook configuration', function () {
$client = webhookClient([
new Response(200, [], json_encode([
'success' => true,
'data' => ['url' => 'https://app.test/hook', 'has_secret' => true],
])),
]);
$result = $client->getWebhook();
expect($result)->toBe(['url' => 'https://app.test/hook', 'has_secret' => true]);
});
it('deletes the webhook configuration', function () {
$client = webhookClient([
new Response(200, [], json_encode([
'success' => true,
'data' => ['url' => null, 'has_secret' => false],
])),
]);
expect($client->deleteWebhook())->toBe(['url' => null, 'has_secret' => false]);
});
it('does not retry webhook registration on a 5xx', function () {
$client = webhookClient([
new Response(503, [], json_encode(['error' => ['code' => 'X', 'message' => 'down']])),
new Response(200, [], json_encode(['success' => true, 'data' => ['url' => 'x', 'has_secret' => true]])),
]);
expect(fn () => $client->registerWebhook('https://app.test/hook'))
->toThrow(\LeadM\LeadMail\Exceptions\LeadMailRequestException::class);
});

View File

@@ -0,0 +1,69 @@
<?php
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Route;
use LeadM\LeadMail\Events\LeadMailWebhookReceived;
const ROUTE_SECRET = 'whsec_route_secret';
beforeEach(function () {
config()->set('leadmail.webhook_secret', ROUTE_SECRET);
});
/**
* @return array{0: string, 1: string} [body, signature]
*/
function signedBody(array $payload, string $secret = ROUTE_SECRET): array
{
$body = json_encode($payload, JSON_UNESCAPED_SLASHES);
return [$body, 'sha256='.hash_hmac('sha256', $body, $secret)];
}
function postWebhook(string $body, ?string $signature)
{
$server = ['CONTENT_TYPE' => 'application/json'];
if ($signature !== null) {
$server['HTTP_X_LEADMAIL_SIGNATURE'] = $signature;
}
return test()->call('POST', '/webhooks/leadmail', [], [], [], $server, $body);
}
it('auto-registers the webhook route', function () {
expect(Route::has('leadmail.webhook'))->toBeTrue();
});
it('accepts a validly signed webhook and dispatches the event', function () {
Event::fake([LeadMailWebhookReceived::class]);
[$body, $signature] = signedBody([
'event' => 'email.failed',
'log_id' => 85,
'error' => ['code' => 'TRANSPORT_ERROR', 'message' => 'Sender address not verified'],
'to' => ['lead@example.com'],
]);
postWebhook($body, $signature)->assertNoContent();
Event::assertDispatched(LeadMailWebhookReceived::class, function ($e) {
return $e->event->logId === 85 && $e->event->isFailure();
});
});
it('rejects an invalid signature with 403 and dispatches nothing', function () {
Event::fake([LeadMailWebhookReceived::class]);
[$body] = signedBody(['event' => 'email.failed', 'log_id' => 1]);
postWebhook($body, 'sha256=deadbeef')->assertForbidden();
Event::assertNotDispatched(LeadMailWebhookReceived::class);
});
it('rejects a request with no signature header', function () {
[$body] = signedBody(['event' => 'email.failed', 'log_id' => 1]);
postWebhook($body, null)->assertForbidden();
});

3
tests/Pest.php Normal file
View File

@@ -0,0 +1,3 @@
<?php
uses(LeadM\LeadMail\Tests\TestCase::class)->in(__DIR__);

17
tests/TestCase.php Normal file
View File

@@ -0,0 +1,17 @@
<?php
namespace LeadM\LeadMail\Tests;
use LeadM\LeadMail\LeadMailServiceProvider;
use Orchestra\Testbench\TestCase as Orchestra;
abstract class TestCase extends Orchestra
{
/**
* @return array<int, class-string>
*/
protected function getPackageProviders($app): array
{
return [LeadMailServiceProvider::class];
}
}