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]; + } +}