diff --git a/README.md b/README.md
index 99c27d8..cef54b9 100644
--- a/README.md
+++ b/README.md
@@ -34,6 +34,15 @@ Optional settings:
LEADMAIL_TIMEOUT=30
LEADMAIL_VERIFY_SSL=true
LEADMAIL_AUTO_TENANT=true
+
+# Retry transient failures (connection errors, 429/5xx) on idempotent calls.
+# Sends are never retried automatically, to avoid duplicate emails.
+LEADMAIL_RETRIES=2
+LEADMAIL_RETRY_DELAY_MS=200
+
+# Required only if you receive failure webhooks. Must match the webhook secret
+# shown for this client app in the leadMail admin dashboard.
+LEADMAIL_WEBHOOK_SECRET=whsec_...
```
## Usage
@@ -93,6 +102,85 @@ $domains = LeadMail::getDomains();
// ['yourdomain.com', 'anotherdomain.com']
```
+## Error Handling
+
+Every API failure is raised as a typed exception so you can handle it reliably from the client side. `LeadMail::verifyEmail()` is the exception — it fails open (returns `status: "unknown"`) so a verification outage never blocks sign-ups.
+
+| Exception | When |
+|-----------|------|
+| `LeadM\LeadMail\Exceptions\LeadMailRequestException` | The service returned an error response (4xx/5xx). |
+| `LeadM\LeadMail\Exceptions\LeadMailConnectionException` | The service could not be reached (DNS/connection/timeout). |
+| `LeadM\LeadMail\Exceptions\LeadMailException` | Base class for both of the above; catch this to handle any failure. |
+
+```php
+use LeadM\LeadMail\Exceptions\LeadMailRequestException;
+use LeadM\LeadMail\Exceptions\LeadMailConnectionException;
+
+try {
+ LeadMail::sendEmail([...]);
+} catch (LeadMailRequestException $e) {
+ $e->statusCode(); // e.g. 422, 502
+ $e->errorCode(); // e.g. "TRANSPORT_ERROR" (from the API envelope)
+ $e->logId(); // the email log id, when available
+ $e->validationErrors(); // ['from.email' => ['...']] on a 422
+ $e->isValidationError();
+ $e->isAuthenticationError();
+} catch (LeadMailConnectionException $e) {
+ // Service unreachable — safe to queue and retry yourself.
+}
+```
+
+Idempotent calls (`getDomains`, `verifyEmail`) automatically retry transient failures (connection errors and `429`/`5xx`) with exponential backoff. **Sends are never retried automatically** — a retry after a dropped connection could deliver the same email twice. Retry sends yourself via a queued job if you need to.
+
+## Receiving Failure Webhooks
+
+leadMail POSTs a signed `email.failed` webhook to your app when a send ultimately fails. **This works out of the box — no route or config required.**
+
+### Setup: one command
+
+With `LEADMAIL_TOKEN` set in your `.env`, run:
+
+```bash
+php artisan leadmail:install
+```
+
+It runs without prompts (safe for Ploi/CI) and:
+
+1. publishes the config,
+2. registers your webhook URL with the leadMail service over the API (authenticated by your token), derived from `APP_URL` + the configured webhook route,
+3. writes the generated `LEADMAIL_WEBHOOK_SECRET` into your `.env`.
+
+That's it. The SDK **auto-registers the receiving route** (`/webhooks/leadmail` by default), which verifies the HMAC signature and **logs every failure by default**. Nothing else to wire up.
+
+Options:
+
+- `--url=https://your-app.com/custom/path` — override the derived URL.
+- `--rotate` — generate and store a fresh signing secret.
+
+The secret is generated server-side and returned only once, at registration.
+
+### Custom handling
+
+To do more than log (e.g. flag a contact, alert a channel), listen for the `LeadMailWebhookReceived` event:
+
+```php
+use LeadM\LeadMail\Events\LeadMailWebhookReceived;
+
+Event::listen(function (LeadMailWebhookReceived $received) {
+ $event = $received->event;
+
+ if ($event->isFailure()) {
+ // $event->logId, $event->errorCode, $event->errorMessage,
+ // $event->from, $event->to, $event->subject, $event->metadata
+ }
+});
+```
+
+### Customising or replacing the route
+
+- `LEADMAIL_WEBHOOK_ROUTE` — change the path (keep it in sync with the registered URL via `leadmail:install`).
+- Set `leadmail.webhook_route` to `null` to disable auto-registration and handle the request yourself with `LeadMailWebhook::parse($request)` (verifies the signature against the raw body, throws `InvalidWebhookSignatureException` on mismatch; `verify()` returns a boolean instead).
+
## Multi-Tenancy
If your app uses [stancl/tenancy](https://tenancyforlaravel.com), the SDK automatically includes the current tenant ID in API requests via the `X-Tenant-Id` header. Disable this with:
diff --git a/composer.json b/composer.json
index 327625b..175d68b 100644
--- a/composer.json
+++ b/composer.json
@@ -17,6 +17,8 @@
"require": {
"php": "^8.2",
"guzzlehttp/guzzle": "^7.0",
+ "illuminate/console": "^11.0|^12.0|^13.0",
+ "illuminate/http": "^11.0|^12.0|^13.0",
"illuminate/mail": "^11.0|^12.0|^13.0",
"illuminate/support": "^11.0|^12.0|^13.0"
},
@@ -45,5 +47,10 @@
}
},
"minimum-stability": "dev",
- "prefer-stable": true
+ "prefer-stable": true,
+ "config": {
+ "allow-plugins": {
+ "pestphp/pest-plugin": true
+ }
+ }
}
diff --git a/config/leadmail.php b/config/leadmail.php
index 44368b8..35adee3 100644
--- a/config/leadmail.php
+++ b/config/leadmail.php
@@ -52,4 +52,55 @@ return [
|
*/
'auto_tenant' => env('LEADMAIL_AUTO_TENANT', true),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Retries
+ |--------------------------------------------------------------------------
+ |
+ | How many times to retry a request that fails transiently (connection
+ | errors and 429/5xx responses) before giving up. The delay between
+ | attempts grows exponentially from the base delay (in milliseconds).
+ |
+ */
+ 'retries' => env('LEADMAIL_RETRIES', 2),
+
+ 'retry_delay' => env('LEADMAIL_RETRY_DELAY_MS', 200),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Webhook Secret
+ |--------------------------------------------------------------------------
+ |
+ | The signing secret for verifying incoming failure webhooks. This must
+ | match the webhook secret shown for this client app in the leadMail admin
+ | dashboard. Set automatically by `php artisan leadmail:install`.
+ |
+ */
+ 'webhook_secret' => env('LEADMAIL_WEBHOOK_SECRET'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Webhook Route
+ |--------------------------------------------------------------------------
+ |
+ | The path the SDK automatically registers to receive failure webhooks.
+ | The registered endpoint verifies the signature, logs failures, and fires
+ | a LeadMailWebhookReceived event you can listen for. Set to null to
+ | disable auto-registration and handle the route yourself.
+ |
+ */
+ 'webhook_route' => env('LEADMAIL_WEBHOOK_ROUTE', '/webhooks/leadmail'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Webhook Route Middleware
+ |--------------------------------------------------------------------------
+ |
+ | Middleware applied to the auto-registered webhook route. The endpoint is
+ | already authenticated by HMAC signature, so it deliberately runs outside
+ | the "web" group (no CSRF, no session).
+ |
+ */
+ 'webhook_middleware' => ['throttle:60,1'],
];
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..2dbe577
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,16 @@
+
+
+
+
+ tests
+
+
+
+
+ src
+
+
+
diff --git a/src/Console/InstallCommand.php b/src/Console/InstallCommand.php
new file mode 100644
index 0000000..247cccc
--- /dev/null
+++ b/src/Console/InstallCommand.php
@@ -0,0 +1,96 @@
+option('no-publish')) {
+ $this->call('vendor:publish', ['--tag' => 'leadmail-config']);
+ }
+
+ if (blank(config('leadmail.token'))) {
+ $this->components->error('LEADMAIL_TOKEN is not set. Add it to your .env, then re-run this command.');
+
+ return self::FAILURE;
+ }
+
+ $url = $this->option('url') ?: $this->defaultWebhookUrl();
+
+ try {
+ $result = $client->registerWebhook($url, regenerateSecret: (bool) $this->option('rotate'));
+ } catch (LeadMailException $e) {
+ $this->components->error('Could not register the webhook: '.$e->getMessage());
+
+ return self::FAILURE;
+ }
+
+ if (! empty($result['secret'])) {
+ $written = $this->writeEnv('LEADMAIL_WEBHOOK_SECRET', $result['secret']);
+
+ $written
+ ? $this->components->info('Webhook registered. Signing secret written to your .env file.')
+ : $this->components->warn('Webhook registered. Add this to your .env: LEADMAIL_WEBHOOK_SECRET='.$result['secret']);
+ } else {
+ $this->components->info('Webhook URL updated. The existing signing secret was kept (use --rotate to replace it).');
+ }
+
+ $this->components->bulletList(array_filter([
+ 'Webhook URL: '.($result['url'] ?? $url),
+ 'This URL is served automatically by the SDK — failures are verified and logged out of the box.',
+ 'Add a LeadMailWebhookReceived listener if you want custom handling.',
+ ]));
+
+ return self::SUCCESS;
+ }
+
+ /**
+ * Derive the webhook URL from the app URL and the configured webhook route.
+ */
+ protected function defaultWebhookUrl(): string
+ {
+ $base = rtrim((string) config('app.url'), '/');
+ $path = '/'.ltrim((string) (config('leadmail.webhook_route') ?: '/webhooks/leadmail'), '/');
+
+ return $base.$path;
+ }
+
+ /**
+ * Write or replace a key in the application's .env file.
+ */
+ protected function writeEnv(string $key, string $value): bool
+ {
+ $path = $this->laravel->environmentFilePath();
+
+ if (! is_file($path) || ! is_writable($path)) {
+ return false;
+ }
+
+ $contents = (string) file_get_contents($path);
+ $line = $key.'='.$this->escapeEnvValue($value);
+ $pattern = '/^'.preg_quote($key, '/').'=.*$/m';
+
+ $contents = preg_match($pattern, $contents)
+ ? (string) preg_replace($pattern, $line, $contents)
+ : rtrim($contents, "\n")."\n".$line."\n";
+
+ return file_put_contents($path, $contents) !== false;
+ }
+
+ protected function escapeEnvValue(string $value): string
+ {
+ return preg_match('/\s|[#"\']/', $value) ? '"'.addcslashes($value, '"\\').'"' : $value;
+ }
+}
diff --git a/src/Events/LeadMailWebhookReceived.php b/src/Events/LeadMailWebhookReceived.php
new file mode 100644
index 0000000..baf8b68
--- /dev/null
+++ b/src/Events/LeadMailWebhookReceived.php
@@ -0,0 +1,20 @@
+event->isFailure()) {
+ * // ...
+ * }
+ * });
+ */
+class LeadMailWebhookReceived
+{
+ public function __construct(public readonly WebhookEvent $event) {}
+}
diff --git a/src/Exceptions/InvalidWebhookSignatureException.php b/src/Exceptions/InvalidWebhookSignatureException.php
new file mode 100644
index 0000000..35617a0
--- /dev/null
+++ b/src/Exceptions/InvalidWebhookSignatureException.php
@@ -0,0 +1,9 @@
+getMessage(),
+ previous: $e,
+ );
+ }
+}
diff --git a/src/Exceptions/LeadMailException.php b/src/Exceptions/LeadMailException.php
new file mode 100644
index 0000000..c37db9f
--- /dev/null
+++ b/src/Exceptions/LeadMailException.php
@@ -0,0 +1,75 @@
+> $validationErrors
+ * @param array|null $response
+ */
+ public function __construct(
+ string $message,
+ protected readonly ?int $statusCode = null,
+ protected readonly ?string $errorCode = null,
+ protected readonly ?int $logId = null,
+ protected readonly array $validationErrors = [],
+ protected readonly ?array $response = null,
+ ?Throwable $previous = null,
+ ) {
+ parent::__construct($message, 0, $previous);
+ }
+
+ /**
+ * The HTTP status code returned by the service, when the failure was a response.
+ */
+ public function statusCode(): ?int
+ {
+ return $this->statusCode;
+ }
+
+ /**
+ * The machine-readable error code from the API envelope (e.g. "TRANSPORT_ERROR").
+ */
+ public function errorCode(): ?string
+ {
+ return $this->errorCode;
+ }
+
+ /**
+ * The email log id, when the API associated the failure with a log row.
+ */
+ public function logId(): ?int
+ {
+ return $this->logId;
+ }
+
+ /**
+ * Field-level validation errors from a 422 response.
+ *
+ * @return array>
+ */
+ public function validationErrors(): array
+ {
+ return $this->validationErrors;
+ }
+
+ /**
+ * The full decoded response body, when available.
+ *
+ * @return array|null
+ */
+ public function response(): ?array
+ {
+ return $this->response;
+ }
+}
diff --git a/src/Exceptions/LeadMailRequestException.php b/src/Exceptions/LeadMailRequestException.php
new file mode 100644
index 0000000..6614cb1
--- /dev/null
+++ b/src/Exceptions/LeadMailRequestException.php
@@ -0,0 +1,83 @@
+|null $body
+ */
+ public static function fromResponse(int $statusCode, ?array $body, ?Throwable $previous = null): self
+ {
+ $errorCode = null;
+ $message = null;
+ $validationErrors = [];
+
+ if (is_array($body)) {
+ $error = $body['error'] ?? null;
+
+ if (is_array($error)) {
+ $errorCode = isset($error['code']) ? (string) $error['code'] : null;
+ $message = isset($error['message']) ? (string) $error['message'] : null;
+ } elseif (is_string($error)) {
+ $message = $error;
+ }
+
+ if ($message === null && isset($body['message']) && is_string($body['message'])) {
+ $message = $body['message'];
+ }
+
+ if (isset($body['errors']) && is_array($body['errors'])) {
+ /** @var array> $validationErrors */
+ $validationErrors = $body['errors'];
+ }
+ }
+
+ $logId = is_array($body) && isset($body['data']['log_id'])
+ ? (int) $body['data']['log_id']
+ : null;
+
+ $message ??= "The LeadMail service returned an error (HTTP {$statusCode}).";
+
+ return new self(
+ message: $message,
+ statusCode: $statusCode,
+ errorCode: $errorCode,
+ logId: $logId,
+ validationErrors: $validationErrors,
+ response: is_array($body) ? $body : null,
+ previous: $previous,
+ );
+ }
+
+ /**
+ * Whether the request failed because of input validation (HTTP 422 with field errors).
+ */
+ public function isValidationError(): bool
+ {
+ return $this->statusCode() === 422 && $this->validationErrors() !== [];
+ }
+
+ /**
+ * Whether the request failed because of authentication/authorization (HTTP 401/403).
+ */
+ public function isAuthenticationError(): bool
+ {
+ return in_array($this->statusCode(), [401, 403], true);
+ }
+}
diff --git a/src/Http/Controllers/LeadMailWebhookController.php b/src/Http/Controllers/LeadMailWebhookController.php
new file mode 100644
index 0000000..96d74a8
--- /dev/null
+++ b/src/Http/Controllers/LeadMailWebhookController.php
@@ -0,0 +1,42 @@
+parse($request);
+ } catch (InvalidWebhookSignatureException) {
+ Log::warning('LeadMail webhook rejected: invalid or missing signature.');
+
+ return response('Invalid signature.', 403);
+ }
+
+ if ($event->isFailure()) {
+ Log::warning('LeadMail reported a failed email.', [
+ 'log_id' => $event->logId,
+ 'to' => $event->to,
+ 'error_code' => $event->errorCode,
+ 'error_message' => $event->errorMessage,
+ ]);
+ }
+
+ event(new LeadMailWebhookReceived($event));
+
+ return response()->noContent();
+ }
+}
diff --git a/src/LeadMailClient.php b/src/LeadMailClient.php
index ae0e9ad..16ab791 100644
--- a/src/LeadMailClient.php
+++ b/src/LeadMailClient.php
@@ -3,8 +3,14 @@
namespace LeadM\LeadMail;
use GuzzleHttp\Client;
+use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\GuzzleException;
+use GuzzleHttp\Exception\RequestException;
+use GuzzleHttp\HandlerStack;
use Illuminate\Support\Facades\Log;
+use LeadM\LeadMail\Exceptions\LeadMailConnectionException;
+use LeadM\LeadMail\Exceptions\LeadMailException;
+use LeadM\LeadMail\Exceptions\LeadMailRequestException;
class LeadMailClient
{
@@ -16,12 +22,21 @@ class LeadMailClient
protected readonly int $timeout = 30,
protected readonly bool $verifySsl = true,
protected readonly bool $autoTenant = true,
+ protected readonly int $retries = 2,
+ protected readonly int $retryDelayMs = 200,
+ ?HandlerStack $handlerStack = null,
) {
- $this->http = new Client([
+ $config = [
'base_uri' => rtrim($this->baseUrl, '/').'/api/v1/',
'timeout' => $this->timeout,
'verify' => $this->verifySsl,
- ]);
+ ];
+
+ if ($handlerStack !== null) {
+ $config['handler'] = $handlerStack;
+ }
+
+ $this->http = new Client($config);
}
/**
@@ -30,16 +45,17 @@ class LeadMailClient
* @param array{from: array, to: array, cc?: array, bcc?: array, reply_to?: array, subject: string, html_body?: string, text_body?: string, attachments?: array, metadata?: array, options?: array} $data
* @return array{success: bool, data: array{log_id: int, status: string}}
*
- * @throws GuzzleException
+ * @throws LeadMailRequestException when the service returns an error response
+ * @throws LeadMailConnectionException when the service cannot be reached
*/
public function sendEmail(array $data): array
{
- $response = $this->http->post('emails/send', [
+ // Sends are never retried automatically: a retry after a 5xx or a
+ // dropped connection could deliver the same email twice.
+ return $this->request('POST', 'emails/send', [
'headers' => $this->headers(),
'json' => $data,
- ]);
-
- return json_decode($response->getBody()->getContents(), true);
+ ], retryable: false);
}
/**
@@ -47,37 +63,97 @@ class LeadMailClient
*
* @return string[]
*
- * @throws GuzzleException
+ * @throws LeadMailRequestException when the service returns an error response
+ * @throws LeadMailConnectionException when the service cannot be reached
*/
public function getDomains(): array
{
- $response = $this->http->get('domains', [
+ $data = $this->request('GET', 'domains', [
'headers' => $this->headers(),
- ]);
-
- $data = json_decode($response->getBody()->getContents(), true);
+ ], retryable: true);
return $data['data']['domains'] ?? [];
}
+ /**
+ * Register (or update) the URL leadMail should POST failure webhooks to.
+ *
+ * The service returns the signing secret in plaintext exactly once — on the
+ * first registration, or when $regenerateSecret is true. Read it from the
+ * returned array's "secret" key when present.
+ *
+ * @return array{url: string, has_secret: bool, secret?: string}
+ *
+ * @throws LeadMailRequestException when the service returns an error response
+ * @throws LeadMailConnectionException when the service cannot be reached
+ */
+ public function registerWebhook(string $url, bool $regenerateSecret = false): array
+ {
+ $response = $this->request('PUT', 'webhook', [
+ 'headers' => $this->headers(),
+ 'json' => [
+ 'url' => $url,
+ 'regenerate_secret' => $regenerateSecret,
+ ],
+ ], retryable: false);
+
+ return $response['data'] ?? [];
+ }
+
+ /**
+ * Get the current webhook configuration (never includes the secret).
+ *
+ * @return array{url: ?string, has_secret: bool}
+ *
+ * @throws LeadMailRequestException when the service returns an error response
+ * @throws LeadMailConnectionException when the service cannot be reached
+ */
+ public function getWebhook(): array
+ {
+ $response = $this->request('GET', 'webhook', [
+ 'headers' => $this->headers(),
+ ], retryable: true);
+
+ return $response['data'] ?? [];
+ }
+
+ /**
+ * Remove the registered webhook URL and signing secret.
+ *
+ * @return array{url: ?string, has_secret: bool}
+ *
+ * @throws LeadMailRequestException when the service returns an error response
+ * @throws LeadMailConnectionException when the service cannot be reached
+ */
+ public function deleteWebhook(): array
+ {
+ $response = $this->request('DELETE', 'webhook', [
+ 'headers' => $this->headers(),
+ ], retryable: false);
+
+ return $response['data'] ?? [];
+ }
+
/**
* Verify an email address through the leadMail service.
*
+ * Fails open: if the verification service is unreachable or errors, the
+ * address is treated as valid (status "unknown") so sign-ups are never
+ * blocked by a verification outage.
+ *
* @return array{success: bool, data: array{email: string, valid: bool, status: string, reason: ?string, cached: bool}}
*/
public function verifyEmail(string $email, bool $allowDisposable = false): array
{
try {
- $response = $this->http->post('emails/verify', [
+ return $this->request('POST', 'emails/verify', [
'headers' => $this->headers(),
'json' => [
'email' => $email,
'allow_disposable' => $allowDisposable,
],
- ]);
-
- return json_decode($response->getBody()->getContents(), true);
- } catch (GuzzleException $e) {
+ ], retryable: true);
+ } catch (LeadMailException $e) {
Log::warning('LeadMail verification failed, failing open', [
'email' => $email,
'error' => $e->getMessage(),
@@ -96,6 +172,98 @@ class LeadMailClient
}
}
+ /**
+ * Perform a request, retrying transient failures, and normalise every
+ * failure into a typed {@see LeadMailException}.
+ *
+ * @param array $options
+ * @return array
+ *
+ * @throws LeadMailException
+ */
+ protected function request(string $method, string $uri, array $options, bool $retryable = false): array
+ {
+ $attempt = 0;
+
+ while (true) {
+ try {
+ $response = $this->http->request($method, $uri, $options + ['http_errors' => true]);
+
+ return $this->decode((string) $response->getBody());
+ } catch (ConnectException $e) {
+ if ($retryable && $attempt < $this->retries) {
+ $this->pause($attempt++);
+
+ continue;
+ }
+
+ throw LeadMailConnectionException::from($e);
+ } catch (RequestException $e) {
+ $response = $e->getResponse();
+
+ if ($response === null) {
+ throw LeadMailConnectionException::from($e);
+ }
+
+ $status = $response->getStatusCode();
+
+ if ($retryable && $this->isRetryable($status) && $attempt < $this->retries) {
+ $this->pause($attempt++);
+
+ continue;
+ }
+
+ throw LeadMailRequestException::fromResponse(
+ $status,
+ $this->tryDecode((string) $response->getBody()),
+ $e,
+ );
+ } catch (GuzzleException $e) {
+ throw LeadMailConnectionException::from($e);
+ }
+ }
+ }
+
+ protected function isRetryable(int $status): bool
+ {
+ return in_array($status, [429, 500, 502, 503, 504], true);
+ }
+
+ protected function pause(int $attempt): void
+ {
+ if ($this->retryDelayMs <= 0) {
+ return;
+ }
+
+ usleep($this->retryDelayMs * 1000 * (2 ** $attempt));
+ }
+
+ /**
+ * @return array
+ *
+ * @throws LeadMailException when the body is not valid JSON
+ */
+ protected function decode(string $raw): array
+ {
+ $decoded = $this->tryDecode($raw);
+
+ if ($decoded === null) {
+ throw new LeadMailException('Received a malformed response from the LeadMail service.');
+ }
+
+ return $decoded;
+ }
+
+ /**
+ * @return array|null
+ */
+ protected function tryDecode(string $raw): ?array
+ {
+ $decoded = json_decode($raw, true);
+
+ return is_array($decoded) ? $decoded : null;
+ }
+
/**
* @return array
*/
diff --git a/src/LeadMailFacade.php b/src/LeadMailFacade.php
index 96c5846..992550d 100644
--- a/src/LeadMailFacade.php
+++ b/src/LeadMailFacade.php
@@ -8,6 +8,9 @@ use Illuminate\Support\Facades\Facade;
* @method static array sendEmail(array $data)
* @method static string[] getDomains()
* @method static array verifyEmail(string $email, bool $allowDisposable = false)
+ * @method static array registerWebhook(string $url, bool $regenerateSecret = false)
+ * @method static array getWebhook()
+ * @method static array deleteWebhook()
*
* @see \LeadM\LeadMail\LeadMailClient
*/
diff --git a/src/LeadMailServiceProvider.php b/src/LeadMailServiceProvider.php
index 5ae0158..66caf55 100644
--- a/src/LeadMailServiceProvider.php
+++ b/src/LeadMailServiceProvider.php
@@ -3,9 +3,13 @@
namespace LeadM\LeadMail;
use Illuminate\Support\Facades\Mail;
+use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\ServiceProvider;
+use LeadM\LeadMail\Console\InstallCommand;
+use LeadM\LeadMail\Http\Controllers\LeadMailWebhookController;
use LeadM\LeadMail\Rules\LeadMailVerify;
+use LeadM\LeadMail\Webhooks\LeadMailWebhook;
class LeadMailServiceProvider extends ServiceProvider
{
@@ -19,13 +23,19 @@ class LeadMailServiceProvider extends ServiceProvider
$this->app->singleton(LeadMailClient::class, function () {
return new LeadMailClient(
- baseUrl: config('leadmail.url'),
- token: config('leadmail.token', ''),
- timeout: config('leadmail.timeout', 30),
+ baseUrl: (string) config('leadmail.url'),
+ token: (string) config('leadmail.token'),
+ timeout: (int) config('leadmail.timeout', 30),
verifySsl: config('leadmail.verify_ssl', true),
autoTenant: config('leadmail.auto_tenant', true),
+ retries: (int) config('leadmail.retries', 2),
+ retryDelayMs: (int) config('leadmail.retry_delay', 200),
);
});
+
+ $this->app->singleton(LeadMailWebhook::class, function () {
+ return new LeadMailWebhook((string) config('leadmail.webhook_secret', ''));
+ });
}
public function boot(): void
@@ -34,8 +44,12 @@ class LeadMailServiceProvider extends ServiceProvider
$this->publishes([
__DIR__.'/../config/leadmail.php' => config_path('leadmail.php'),
], 'leadmail-config');
+
+ $this->commands([InstallCommand::class]);
}
+ $this->registerWebhookRoute();
+
Mail::extend('leadmail', function () {
return new LeadMailTransport(
$this->app->make(LeadMailClient::class),
@@ -53,4 +67,20 @@ class LeadMailServiceProvider extends ServiceProvider
return $passed;
}, 'The :attribute email address could not be verified.');
}
+
+ /**
+ * Auto-register the webhook receiver route unless disabled in config.
+ */
+ protected function registerWebhookRoute(): void
+ {
+ $path = config('leadmail.webhook_route');
+
+ if (blank($path)) {
+ return;
+ }
+
+ Route::post($path, LeadMailWebhookController::class)
+ ->middleware((array) config('leadmail.webhook_middleware', []))
+ ->name('leadmail.webhook');
+ }
}
diff --git a/src/Webhooks/LeadMailWebhook.php b/src/Webhooks/LeadMailWebhook.php
new file mode 100644
index 0000000..efded30
--- /dev/null
+++ b/src/Webhooks/LeadMailWebhook.php
@@ -0,0 +1,65 @@
+parse($request); // throws on a bad signature
+ *
+ * if ($event->isFailure()) {
+ * // react to the failed send ($event->logId, $event->errorMessage, ...)
+ * }
+ *
+ * return response()->noContent();
+ * }
+ */
+final class LeadMailWebhook
+{
+ public function __construct(private readonly string $secret) {}
+
+ /**
+ * Verify the request signature without throwing.
+ */
+ public function verify(Request $request): bool
+ {
+ return WebhookSignature::isValid(
+ $request->getContent(),
+ $request->header(WebhookSignature::HEADER),
+ $this->secret,
+ );
+ }
+
+ /**
+ * Verify the signature and return the typed event.
+ *
+ * @throws InvalidWebhookSignatureException when the signature is missing,
+ * invalid, or the body cannot be decoded.
+ */
+ public function parse(Request $request): WebhookEvent
+ {
+ if (! $this->verify($request)) {
+ throw new InvalidWebhookSignatureException(
+ 'The LeadMail webhook signature is missing or invalid.',
+ );
+ }
+
+ $payload = json_decode($request->getContent(), true);
+
+ if (! is_array($payload)) {
+ throw new InvalidWebhookSignatureException(
+ 'The LeadMail webhook payload could not be decoded.',
+ );
+ }
+
+ return WebhookEvent::fromArray($payload);
+ }
+}
diff --git a/src/Webhooks/WebhookEvent.php b/src/Webhooks/WebhookEvent.php
new file mode 100644
index 0000000..eebd2ca
--- /dev/null
+++ b/src/Webhooks/WebhookEvent.php
@@ -0,0 +1,66 @@
+ $to
+ * @param array|null $metadata
+ * @param array $payload
+ */
+ public function __construct(
+ public readonly string $event,
+ public readonly ?string $deliveryId,
+ public readonly ?string $occurredAt,
+ public readonly ?int $logId,
+ public readonly ?string $tenantId,
+ public readonly ?string $status,
+ public readonly ?string $errorCode,
+ public readonly ?string $errorMessage,
+ public readonly ?string $from,
+ public readonly array $to,
+ public readonly ?string $subject,
+ public readonly ?array $metadata,
+ public readonly array $payload,
+ ) {}
+
+ /**
+ * @param array $payload
+ */
+ public static function fromArray(array $payload): self
+ {
+ $error = is_array($payload['error'] ?? null) ? $payload['error'] : [];
+
+ return new self(
+ event: (string) ($payload['event'] ?? 'unknown'),
+ deliveryId: isset($payload['delivery_id']) ? (string) $payload['delivery_id'] : null,
+ occurredAt: isset($payload['occurred_at']) ? (string) $payload['occurred_at'] : null,
+ logId: isset($payload['log_id']) ? (int) $payload['log_id'] : null,
+ tenantId: isset($payload['tenant_id']) ? (string) $payload['tenant_id'] : null,
+ status: isset($payload['status']) ? (string) $payload['status'] : null,
+ errorCode: isset($error['code']) ? (string) $error['code'] : null,
+ errorMessage: isset($error['message']) ? (string) $error['message'] : null,
+ from: isset($payload['from']) ? (string) $payload['from'] : null,
+ to: is_array($payload['to'] ?? null) ? array_values($payload['to']) : [],
+ subject: isset($payload['subject']) ? (string) $payload['subject'] : null,
+ metadata: is_array($payload['metadata'] ?? null) ? $payload['metadata'] : null,
+ payload: $payload,
+ );
+ }
+
+ /**
+ * Whether this event reports a failed send (event "email.failed").
+ */
+ public function isFailure(): bool
+ {
+ return $this->event === 'email.failed';
+ }
+}
diff --git a/src/Webhooks/WebhookSignature.php b/src/Webhooks/WebhookSignature.php
new file mode 100644
index 0000000..9c2e422
--- /dev/null
+++ b/src/Webhooks/WebhookSignature.php
@@ -0,0 +1,35 @@
+, ).
+ * Always verify against the raw, unparsed request body.
+ */
+final class WebhookSignature
+{
+ /**
+ * The header the service sends the signature in.
+ */
+ public const HEADER = 'X-LeadMail-Signature';
+
+ public static function compute(string $payload, string $secret): string
+ {
+ return 'sha256='.hash_hmac('sha256', $payload, $secret);
+ }
+
+ /**
+ * Constant-time comparison of the expected signature against the received one.
+ */
+ public static function isValid(string $payload, ?string $signature, string $secret): bool
+ {
+ if ($secret === '' || $signature === null || $signature === '') {
+ return false;
+ }
+
+ return hash_equals(self::compute($payload, $secret), $signature);
+ }
+}
diff --git a/tests/Feature/InstallCommandTest.php b/tests/Feature/InstallCommandTest.php
new file mode 100644
index 0000000..cbe8dc4
--- /dev/null
+++ b/tests/Feature/InstallCommandTest.php
@@ -0,0 +1,118 @@
+ $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();
+});
diff --git a/tests/Feature/LeadMailClientTest.php b/tests/Feature/LeadMailClientTest.php
new file mode 100644
index 0000000..ebec26c
--- /dev/null
+++ b/tests/Feature/LeadMailClientTest.php
@@ -0,0 +1,166 @@
+ $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, [], 'not json'),
+ ]);
+
+ 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');
+});
diff --git a/tests/Feature/LeadMailWebhookTest.php b/tests/Feature/LeadMailWebhookTest.php
new file mode 100644
index 0000000..68b27b8
--- /dev/null
+++ b/tests/Feature/LeadMailWebhookTest.php
@@ -0,0 +1,128 @@
+ $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
+ */
+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();
+});
diff --git a/tests/Feature/RegisterWebhookTest.php b/tests/Feature/RegisterWebhookTest.php
new file mode 100644
index 0000000..f76184c
--- /dev/null
+++ b/tests/Feature/RegisterWebhookTest.php
@@ -0,0 +1,68 @@
+ $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);
+});
diff --git a/tests/Feature/WebhookRouteTest.php b/tests/Feature/WebhookRouteTest.php
new file mode 100644
index 0000000..5ab7958
--- /dev/null
+++ b/tests/Feature/WebhookRouteTest.php
@@ -0,0 +1,69 @@
+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();
+});
diff --git a/tests/Pest.php b/tests/Pest.php
new file mode 100644
index 0000000..29e0649
--- /dev/null
+++ b/tests/Pest.php
@@ -0,0 +1,3 @@
+in(__DIR__);
diff --git a/tests/TestCase.php b/tests/TestCase.php
new file mode 100644
index 0000000..d8d42d3
--- /dev/null
+++ b/tests/TestCase.php
@@ -0,0 +1,17 @@
+
+ */
+ protected function getPackageProviders($app): array
+ {
+ return [LeadMailServiceProvider::class];
+ }
+}