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:
88
README.md
88
README.md
@@ -34,6 +34,15 @@ Optional settings:
|
|||||||
LEADMAIL_TIMEOUT=30
|
LEADMAIL_TIMEOUT=30
|
||||||
LEADMAIL_VERIFY_SSL=true
|
LEADMAIL_VERIFY_SSL=true
|
||||||
LEADMAIL_AUTO_TENANT=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
|
## Usage
|
||||||
@@ -93,6 +102,85 @@ $domains = LeadMail::getDomains();
|
|||||||
// ['yourdomain.com', 'anotherdomain.com']
|
// ['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
|
## 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:
|
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:
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
"guzzlehttp/guzzle": "^7.0",
|
"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/mail": "^11.0|^12.0|^13.0",
|
||||||
"illuminate/support": "^11.0|^12.0|^13.0"
|
"illuminate/support": "^11.0|^12.0|^13.0"
|
||||||
},
|
},
|
||||||
@@ -45,5 +47,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"minimum-stability": "dev",
|
"minimum-stability": "dev",
|
||||||
"prefer-stable": true
|
"prefer-stable": true,
|
||||||
|
"config": {
|
||||||
|
"allow-plugins": {
|
||||||
|
"pestphp/pest-plugin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,4 +52,55 @@ return [
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
'auto_tenant' => env('LEADMAIL_AUTO_TENANT', true),
|
'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'],
|
||||||
];
|
];
|
||||||
|
|||||||
16
phpunit.xml
Normal file
16
phpunit.xml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||||
|
bootstrap="vendor/autoload.php"
|
||||||
|
colors="true">
|
||||||
|
<testsuites>
|
||||||
|
<testsuite name="Test Suite">
|
||||||
|
<directory>tests</directory>
|
||||||
|
</testsuite>
|
||||||
|
</testsuites>
|
||||||
|
<source>
|
||||||
|
<include>
|
||||||
|
<directory>src</directory>
|
||||||
|
</include>
|
||||||
|
</source>
|
||||||
|
</phpunit>
|
||||||
96
src/Console/InstallCommand.php
Normal file
96
src/Console/InstallCommand.php
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace LeadM\LeadMail\Console;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use LeadM\LeadMail\Exceptions\LeadMailException;
|
||||||
|
use LeadM\LeadMail\LeadMailClient;
|
||||||
|
|
||||||
|
class InstallCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'leadmail:install
|
||||||
|
{--url= : Override the webhook URL (defaults to APP_URL + the configured webhook route)}
|
||||||
|
{--rotate : Force generation of a new signing secret}
|
||||||
|
{--no-publish : Skip publishing the config file}';
|
||||||
|
|
||||||
|
protected $description = 'Publish config and connect this app to the leadMail webhook service';
|
||||||
|
|
||||||
|
public function handle(LeadMailClient $client): int
|
||||||
|
{
|
||||||
|
if (! $this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/Events/LeadMailWebhookReceived.php
Normal file
20
src/Events/LeadMailWebhookReceived.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace LeadM\LeadMail\Events;
|
||||||
|
|
||||||
|
use LeadM\LeadMail\Webhooks\WebhookEvent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatched after the auto-registered webhook route verifies and parses an
|
||||||
|
* incoming LeadMail webhook. Listen for this to add custom handling:
|
||||||
|
*
|
||||||
|
* Event::listen(LeadMailWebhookReceived::class, function ($received) {
|
||||||
|
* if ($received->event->isFailure()) {
|
||||||
|
* // ...
|
||||||
|
* }
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
class LeadMailWebhookReceived
|
||||||
|
{
|
||||||
|
public function __construct(public readonly WebhookEvent $event) {}
|
||||||
|
}
|
||||||
9
src/Exceptions/InvalidWebhookSignatureException.php
Normal file
9
src/Exceptions/InvalidWebhookSignatureException.php
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace LeadM\LeadMail\Exceptions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when an incoming webhook request fails signature verification,
|
||||||
|
* meaning it cannot be trusted to have originated from the LeadMail service.
|
||||||
|
*/
|
||||||
|
class InvalidWebhookSignatureException extends LeadMailException {}
|
||||||
23
src/Exceptions/LeadMailConnectionException.php
Normal file
23
src/Exceptions/LeadMailConnectionException.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace LeadM\LeadMail\Exceptions;
|
||||||
|
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when the LeadMail service could not be reached at all
|
||||||
|
* (DNS failure, connection refused, timeout) — i.e. no HTTP response.
|
||||||
|
*
|
||||||
|
* These are the failures worth retrying; the client already retries them
|
||||||
|
* automatically up to the configured limit before throwing.
|
||||||
|
*/
|
||||||
|
class LeadMailConnectionException extends LeadMailException
|
||||||
|
{
|
||||||
|
public static function from(Throwable $e): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
'Could not reach the LeadMail service: '.$e->getMessage(),
|
||||||
|
previous: $e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/Exceptions/LeadMailException.php
Normal file
75
src/Exceptions/LeadMailException.php
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace LeadM\LeadMail\Exceptions;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base exception for every error surfaced by the LeadMail SDK.
|
||||||
|
*
|
||||||
|
* Catch this to handle any LeadMail failure, or catch the more specific
|
||||||
|
* {@see LeadMailRequestException} / {@see LeadMailConnectionException} subtypes.
|
||||||
|
*/
|
||||||
|
class LeadMailException extends RuntimeException
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, array<int, string>> $validationErrors
|
||||||
|
* @param array<string, mixed>|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<string, array<int, string>>
|
||||||
|
*/
|
||||||
|
public function validationErrors(): array
|
||||||
|
{
|
||||||
|
return $this->validationErrors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The full decoded response body, when available.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public function response(): ?array
|
||||||
|
{
|
||||||
|
return $this->response;
|
||||||
|
}
|
||||||
|
}
|
||||||
83
src/Exceptions/LeadMailRequestException.php
Normal file
83
src/Exceptions/LeadMailRequestException.php
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace LeadM\LeadMail\Exceptions;
|
||||||
|
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when the LeadMail service returned an HTTP error response (4xx/5xx).
|
||||||
|
*
|
||||||
|
* The structured details from the API envelope are parsed onto the exception so
|
||||||
|
* callers can branch on them without re-parsing the response body themselves.
|
||||||
|
*/
|
||||||
|
class LeadMailRequestException extends LeadMailException
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Build the exception from a decoded error response, tolerating the
|
||||||
|
* several envelope shapes the API can emit:
|
||||||
|
*
|
||||||
|
* - send errors: {"success": false, "error": {"code", "message"}, "data": {"log_id"}}
|
||||||
|
* - auth errors: {"error": "Invalid API token."}
|
||||||
|
* - validation errors: {"message": "...", "errors": {"field": ["..."]}}
|
||||||
|
*
|
||||||
|
* @param array<string, mixed>|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<string, array<int, string>> $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);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/Http/Controllers/LeadMailWebhookController.php
Normal file
42
src/Http/Controllers/LeadMailWebhookController.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace LeadM\LeadMail\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use LeadM\LeadMail\Events\LeadMailWebhookReceived;
|
||||||
|
use LeadM\LeadMail\Exceptions\InvalidWebhookSignatureException;
|
||||||
|
use LeadM\LeadMail\Webhooks\LeadMailWebhook;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default receiver for LeadMail webhooks, auto-registered by the service
|
||||||
|
* provider. Verifies the signature, logs failures, and dispatches a
|
||||||
|
* LeadMailWebhookReceived event for custom handling.
|
||||||
|
*/
|
||||||
|
class LeadMailWebhookController
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request, LeadMailWebhook $webhook): Response
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$event = $webhook->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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,14 @@
|
|||||||
namespace LeadM\LeadMail;
|
namespace LeadM\LeadMail;
|
||||||
|
|
||||||
use GuzzleHttp\Client;
|
use GuzzleHttp\Client;
|
||||||
|
use GuzzleHttp\Exception\ConnectException;
|
||||||
use GuzzleHttp\Exception\GuzzleException;
|
use GuzzleHttp\Exception\GuzzleException;
|
||||||
|
use GuzzleHttp\Exception\RequestException;
|
||||||
|
use GuzzleHttp\HandlerStack;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use LeadM\LeadMail\Exceptions\LeadMailConnectionException;
|
||||||
|
use LeadM\LeadMail\Exceptions\LeadMailException;
|
||||||
|
use LeadM\LeadMail\Exceptions\LeadMailRequestException;
|
||||||
|
|
||||||
class LeadMailClient
|
class LeadMailClient
|
||||||
{
|
{
|
||||||
@@ -16,12 +22,21 @@ class LeadMailClient
|
|||||||
protected readonly int $timeout = 30,
|
protected readonly int $timeout = 30,
|
||||||
protected readonly bool $verifySsl = true,
|
protected readonly bool $verifySsl = true,
|
||||||
protected readonly bool $autoTenant = 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/',
|
'base_uri' => rtrim($this->baseUrl, '/').'/api/v1/',
|
||||||
'timeout' => $this->timeout,
|
'timeout' => $this->timeout,
|
||||||
'verify' => $this->verifySsl,
|
'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
|
* @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}}
|
* @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
|
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(),
|
'headers' => $this->headers(),
|
||||||
'json' => $data,
|
'json' => $data,
|
||||||
]);
|
], retryable: false);
|
||||||
|
|
||||||
return json_decode($response->getBody()->getContents(), true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,37 +63,97 @@ class LeadMailClient
|
|||||||
*
|
*
|
||||||
* @return string[]
|
* @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
|
public function getDomains(): array
|
||||||
{
|
{
|
||||||
$response = $this->http->get('domains', [
|
$data = $this->request('GET', 'domains', [
|
||||||
'headers' => $this->headers(),
|
'headers' => $this->headers(),
|
||||||
]);
|
], retryable: true);
|
||||||
|
|
||||||
$data = json_decode($response->getBody()->getContents(), true);
|
|
||||||
|
|
||||||
return $data['data']['domains'] ?? [];
|
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.
|
* 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}}
|
* @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
|
public function verifyEmail(string $email, bool $allowDisposable = false): array
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$response = $this->http->post('emails/verify', [
|
return $this->request('POST', 'emails/verify', [
|
||||||
'headers' => $this->headers(),
|
'headers' => $this->headers(),
|
||||||
'json' => [
|
'json' => [
|
||||||
'email' => $email,
|
'email' => $email,
|
||||||
'allow_disposable' => $allowDisposable,
|
'allow_disposable' => $allowDisposable,
|
||||||
],
|
],
|
||||||
]);
|
], retryable: true);
|
||||||
|
} catch (LeadMailException $e) {
|
||||||
return json_decode($response->getBody()->getContents(), true);
|
|
||||||
} catch (GuzzleException $e) {
|
|
||||||
Log::warning('LeadMail verification failed, failing open', [
|
Log::warning('LeadMail verification failed, failing open', [
|
||||||
'email' => $email,
|
'email' => $email,
|
||||||
'error' => $e->getMessage(),
|
'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<string, mixed> $options
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*
|
||||||
|
* @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<string, mixed>
|
||||||
|
*
|
||||||
|
* @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<string, mixed>|null
|
||||||
|
*/
|
||||||
|
protected function tryDecode(string $raw): ?array
|
||||||
|
{
|
||||||
|
$decoded = json_decode($raw, true);
|
||||||
|
|
||||||
|
return is_array($decoded) ? $decoded : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ use Illuminate\Support\Facades\Facade;
|
|||||||
* @method static array sendEmail(array $data)
|
* @method static array sendEmail(array $data)
|
||||||
* @method static string[] getDomains()
|
* @method static string[] getDomains()
|
||||||
* @method static array verifyEmail(string $email, bool $allowDisposable = false)
|
* @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
|
* @see \LeadM\LeadMail\LeadMailClient
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -3,9 +3,13 @@
|
|||||||
namespace LeadM\LeadMail;
|
namespace LeadM\LeadMail;
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Mail;
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use LeadM\LeadMail\Console\InstallCommand;
|
||||||
|
use LeadM\LeadMail\Http\Controllers\LeadMailWebhookController;
|
||||||
use LeadM\LeadMail\Rules\LeadMailVerify;
|
use LeadM\LeadMail\Rules\LeadMailVerify;
|
||||||
|
use LeadM\LeadMail\Webhooks\LeadMailWebhook;
|
||||||
|
|
||||||
class LeadMailServiceProvider extends ServiceProvider
|
class LeadMailServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
@@ -19,13 +23,19 @@ class LeadMailServiceProvider extends ServiceProvider
|
|||||||
|
|
||||||
$this->app->singleton(LeadMailClient::class, function () {
|
$this->app->singleton(LeadMailClient::class, function () {
|
||||||
return new LeadMailClient(
|
return new LeadMailClient(
|
||||||
baseUrl: config('leadmail.url'),
|
baseUrl: (string) config('leadmail.url'),
|
||||||
token: config('leadmail.token', ''),
|
token: (string) config('leadmail.token'),
|
||||||
timeout: config('leadmail.timeout', 30),
|
timeout: (int) config('leadmail.timeout', 30),
|
||||||
verifySsl: config('leadmail.verify_ssl', true),
|
verifySsl: config('leadmail.verify_ssl', true),
|
||||||
autoTenant: config('leadmail.auto_tenant', 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
|
public function boot(): void
|
||||||
@@ -34,8 +44,12 @@ class LeadMailServiceProvider extends ServiceProvider
|
|||||||
$this->publishes([
|
$this->publishes([
|
||||||
__DIR__.'/../config/leadmail.php' => config_path('leadmail.php'),
|
__DIR__.'/../config/leadmail.php' => config_path('leadmail.php'),
|
||||||
], 'leadmail-config');
|
], 'leadmail-config');
|
||||||
|
|
||||||
|
$this->commands([InstallCommand::class]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->registerWebhookRoute();
|
||||||
|
|
||||||
Mail::extend('leadmail', function () {
|
Mail::extend('leadmail', function () {
|
||||||
return new LeadMailTransport(
|
return new LeadMailTransport(
|
||||||
$this->app->make(LeadMailClient::class),
|
$this->app->make(LeadMailClient::class),
|
||||||
@@ -53,4 +67,20 @@ class LeadMailServiceProvider extends ServiceProvider
|
|||||||
return $passed;
|
return $passed;
|
||||||
}, 'The :attribute email address could not be verified.');
|
}, '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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
65
src/Webhooks/LeadMailWebhook.php
Normal file
65
src/Webhooks/LeadMailWebhook.php
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace LeadM\LeadMail\Webhooks;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use LeadM\LeadMail\Exceptions\InvalidWebhookSignatureException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entry point for receiving LeadMail webhooks in a client application.
|
||||||
|
*
|
||||||
|
* Resolve it from the container (the secret is wired from config) and call
|
||||||
|
* {@see parse()} inside your webhook route:
|
||||||
|
*
|
||||||
|
* public function __invoke(Request $request, LeadMailWebhook $webhook)
|
||||||
|
* {
|
||||||
|
* $event = $webhook->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/Webhooks/WebhookEvent.php
Normal file
66
src/Webhooks/WebhookEvent.php
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace LeadM\LeadMail\Webhooks;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typed representation of a LeadMail webhook payload.
|
||||||
|
*
|
||||||
|
* Mirrors the payload emitted by the service's SendWebhookJob. Unknown or
|
||||||
|
* missing fields degrade gracefully to null so a payload change never fatals
|
||||||
|
* the receiver; the untouched payload is always available via {@see $payload}.
|
||||||
|
*/
|
||||||
|
final class WebhookEvent
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $to
|
||||||
|
* @param array<string, mixed>|null $metadata
|
||||||
|
* @param array<string, mixed> $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<string, mixed> $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';
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/Webhooks/WebhookSignature.php
Normal file
35
src/Webhooks/WebhookSignature.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace LeadM\LeadMail\Webhooks;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes and verifies the HMAC signature LeadMail attaches to every webhook.
|
||||||
|
*
|
||||||
|
* The scheme mirrors the service exactly: the signature is
|
||||||
|
* "sha256=" . hash_hmac('sha256', <raw request body>, <webhook secret>).
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
118
tests/Feature/InstallCommandTest.php
Normal file
118
tests/Feature/InstallCommandTest.php
Normal 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();
|
||||||
|
});
|
||||||
166
tests/Feature/LeadMailClientTest.php
Normal file
166
tests/Feature/LeadMailClientTest.php
Normal 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');
|
||||||
|
});
|
||||||
128
tests/Feature/LeadMailWebhookTest.php
Normal file
128
tests/Feature/LeadMailWebhookTest.php
Normal 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();
|
||||||
|
});
|
||||||
68
tests/Feature/RegisterWebhookTest.php
Normal file
68
tests/Feature/RegisterWebhookTest.php
Normal 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);
|
||||||
|
});
|
||||||
69
tests/Feature/WebhookRouteTest.php
Normal file
69
tests/Feature/WebhookRouteTest.php
Normal 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
3
tests/Pest.php
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
uses(LeadM\LeadMail\Tests\TestCase::class)->in(__DIR__);
|
||||||
17
tests/TestCase.php
Normal file
17
tests/TestCase.php
Normal 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];
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user