From 1b5fd82894db5aeaff6dba34cb49617367be9617 Mon Sep 17 00:00:00 2001 From: netlas Date: Wed, 4 Mar 2026 02:02:04 +0200 Subject: [PATCH] Initial release --- .gitignore | 5 ++ README.md | 106 +++++++++++++++++++++++++ composer.json | 49 ++++++++++++ config/leadmail.php | 55 +++++++++++++ src/LeadMailClient.php | 133 ++++++++++++++++++++++++++++++++ src/LeadMailFacade.php | 20 +++++ src/LeadMailServiceProvider.php | 52 +++++++++++++ src/LeadMailTransport.php | 105 +++++++++++++++++++++++++ src/Rules/LeadMailVerify.php | 35 +++++++++ 9 files changed, 560 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 composer.json create mode 100644 config/leadmail.php create mode 100644 src/LeadMailClient.php create mode 100644 src/LeadMailFacade.php create mode 100644 src/LeadMailServiceProvider.php create mode 100644 src/LeadMailTransport.php create mode 100644 src/Rules/LeadMailVerify.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..08b448f --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/vendor/ +/node_modules/ +.phpunit.cache/ +.phpunit.result.cache +composer.lock diff --git a/README.md b/README.md new file mode 100644 index 0000000..ff1c817 --- /dev/null +++ b/README.md @@ -0,0 +1,106 @@ +# LeadMail SDK for Laravel + +Laravel package for sending emails and verifying email addresses through the [leadMail](https://mail.leadmagnet.dev) service. + +## Requirements + +- PHP 8.2+ +- Laravel 11 or 12 + +## Installation + +```bash +composer require leadm/leadmail +``` + +Publish the config file: + +```bash +php artisan vendor:publish --provider="LeadM\LeadMail\LeadMailServiceProvider" +``` + +## Configuration + +Add these to your `.env`: + +```env +LEADMAIL_URL=https://mail.leadmagnet.dev +LEADMAIL_TOKEN=lm_your_api_token_here +``` + +Optional settings: + +```env +LEADMAIL_TIMEOUT=30 +LEADMAIL_VERIFY_SSL=true +LEADMAIL_AUTO_TENANT=true +``` + +## Usage + +### Send Emails via Mail Driver + +Set `leadmail` as your mail driver in `.env`: + +```env +MAIL_MAILER=leadmail +``` + +Then use Laravel's `Mail` facade as usual: + +```php +Mail::to('user@example.com')->send(new WelcomeMail()); +``` + +### Send Emails via API + +```php +LeadMail::sendEmail([ + 'from' => ['email' => 'hello@yourdomain.com', 'name' => 'Your App'], + 'to' => [['email' => 'user@example.com', 'name' => 'User']], + 'subject' => 'Welcome!', + 'html_body' => '

Welcome to our app

', +]); +``` + +### Verify Email Addresses + +```php +$result = LeadMail::verifyEmail('user@example.com'); + +if ($result['data']['valid']) { + // Email is deliverable +} +``` + +### Validation Rule + +Use the `leadmail_verify` rule in your form requests: + +```php +public function rules(): array +{ + return [ + 'email' => ['required', 'email', 'leadmail_verify'], + ]; +} +``` + +### Get Allowed Sender Domains + +```php +$domains = LeadMail::getDomains(); +// ['yourdomain.com', 'anotherdomain.com'] +``` + +## 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: + +```env +LEADMAIL_AUTO_TENANT=false +``` + +## License + +MIT diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..37d9cd1 --- /dev/null +++ b/composer.json @@ -0,0 +1,49 @@ +{ + "name": "leadm/leadmail", + "description": "Laravel SDK for leadMail email sending and verification service", + "type": "library", + "license": "MIT", + "homepage": "https://github.com/leadmnik/leadmail-sdk", + "authors": [ + { + "name": "Lead Magnet Oy", + "homepage": "https://leadmagnet.fi" + } + ], + "support": { + "issues": "https://github.com/leadmnik/leadmail-sdk/issues", + "source": "https://github.com/leadmnik/leadmail-sdk" + }, + "require": { + "php": "^8.2", + "guzzlehttp/guzzle": "^7.0", + "illuminate/mail": "^11.0|^12.0", + "illuminate/support": "^11.0|^12.0" + }, + "require-dev": { + "orchestra/testbench": "^9.0|^10.0", + "pestphp/pest": "^3.0|^4.0" + }, + "autoload": { + "psr-4": { + "LeadM\\LeadMail\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "LeadM\\LeadMail\\Tests\\": "tests/" + } + }, + "extra": { + "laravel": { + "providers": [ + "LeadM\\LeadMail\\LeadMailServiceProvider" + ], + "aliases": { + "LeadMail": "LeadM\\LeadMail\\LeadMailFacade" + } + } + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/config/leadmail.php b/config/leadmail.php new file mode 100644 index 0000000..44368b8 --- /dev/null +++ b/config/leadmail.php @@ -0,0 +1,55 @@ + env('LEADMAIL_URL', 'https://mail.leadmagnet.dev'), + + /* + |-------------------------------------------------------------------------- + | API Token + |-------------------------------------------------------------------------- + | + | The bearer token for authenticating with the leadMail API. + | Generate this in the leadMail admin dashboard. + | + */ + 'token' => env('LEADMAIL_TOKEN'), + + /* + |-------------------------------------------------------------------------- + | Request Timeout + |-------------------------------------------------------------------------- + | + | Maximum number of seconds to wait for API responses. + | + */ + 'timeout' => env('LEADMAIL_TIMEOUT', 30), + + /* + |-------------------------------------------------------------------------- + | SSL Verification + |-------------------------------------------------------------------------- + | + | Whether to verify SSL certificates. Disable only for local development. + | + */ + 'verify_ssl' => env('LEADMAIL_VERIFY_SSL', true), + + /* + |-------------------------------------------------------------------------- + | Auto Tenant Detection + |-------------------------------------------------------------------------- + | + | When enabled and stancl/tenancy is installed, automatically sends the + | current tenant's key as the X-Tenant-Id header on every request. + | + */ + 'auto_tenant' => env('LEADMAIL_AUTO_TENANT', true), +]; diff --git a/src/LeadMailClient.php b/src/LeadMailClient.php new file mode 100644 index 0000000..ae0e9ad --- /dev/null +++ b/src/LeadMailClient.php @@ -0,0 +1,133 @@ +http = new Client([ + 'base_uri' => rtrim($this->baseUrl, '/').'/api/v1/', + 'timeout' => $this->timeout, + 'verify' => $this->verifySsl, + ]); + } + + /** + * Send an email through the leadMail service. + * + * @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 + */ + public function sendEmail(array $data): array + { + $response = $this->http->post('emails/send', [ + 'headers' => $this->headers(), + 'json' => $data, + ]); + + return json_decode($response->getBody()->getContents(), true); + } + + /** + * Get the allowed sender domains for this client app. + * + * @return string[] + * + * @throws GuzzleException + */ + public function getDomains(): array + { + $response = $this->http->get('domains', [ + 'headers' => $this->headers(), + ]); + + $data = json_decode($response->getBody()->getContents(), true); + + return $data['data']['domains'] ?? []; + } + + /** + * Verify an email address through the leadMail service. + * + * @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', [ + 'headers' => $this->headers(), + 'json' => [ + 'email' => $email, + 'allow_disposable' => $allowDisposable, + ], + ]); + + return json_decode($response->getBody()->getContents(), true); + } catch (GuzzleException $e) { + Log::warning('LeadMail verification failed, failing open', [ + 'email' => $email, + 'error' => $e->getMessage(), + ]); + + return [ + 'success' => true, + 'data' => [ + 'email' => $email, + 'valid' => true, + 'status' => 'unknown', + 'reason' => null, + 'cached' => false, + ], + ]; + } + } + + /** + * @return array + */ + protected function headers(): array + { + $headers = [ + 'Authorization' => 'Bearer '.$this->token, + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ]; + + if ($this->autoTenant && $this->hasTenancy()) { + $tenantId = $this->resolveTenantId(); + if ($tenantId !== null) { + $headers['X-Tenant-Id'] = $tenantId; + } + } + + return $headers; + } + + protected function hasTenancy(): bool + { + return class_exists(\Stancl\Tenancy\Tenancy::class); + } + + protected function resolveTenantId(): ?string + { + if (! function_exists('tenancy') || ! tenancy()->initialized) { + return null; + } + + return (string) tenant()->getTenantKey(); + } +} diff --git a/src/LeadMailFacade.php b/src/LeadMailFacade.php new file mode 100644 index 0000000..96c5846 --- /dev/null +++ b/src/LeadMailFacade.php @@ -0,0 +1,20 @@ +mergeConfigFrom(__DIR__.'/../config/leadmail.php', 'leadmail'); + + $this->app->singleton(LeadMailClient::class, function () { + return new LeadMailClient( + baseUrl: config('leadmail.url'), + token: config('leadmail.token', ''), + timeout: config('leadmail.timeout', 30), + verifySsl: config('leadmail.verify_ssl', true), + autoTenant: config('leadmail.auto_tenant', true), + ); + }); + } + + public function boot(): void + { + if ($this->app->runningInConsole()) { + $this->publishes([ + __DIR__.'/../config/leadmail.php' => config_path('leadmail.php'), + ], 'leadmail-config'); + } + + Mail::extend('leadmail', function () { + return new LeadMailTransport( + $this->app->make(LeadMailClient::class), + ); + }); + + Validator::extend('leadmail_verify', function (string $attribute, mixed $value) { + $rule = $this->app->make(LeadMailVerify::class); + $passed = true; + + $rule->validate($attribute, $value, function () use (&$passed) { + $passed = false; + }); + + return $passed; + }, 'The :attribute email address could not be verified.'); + } +} diff --git a/src/LeadMailTransport.php b/src/LeadMailTransport.php new file mode 100644 index 0000000..4574897 --- /dev/null +++ b/src/LeadMailTransport.php @@ -0,0 +1,105 @@ +getOriginalMessage()); + + $payload = $this->buildPayload($email); + + $result = $this->client->sendEmail($payload); + + if (isset($result['data']['log_id'])) { + $message->getOriginalMessage()->getHeaders()->addTextHeader( + 'X-LeadMail-Log-Id', + (string) $result['data']['log_id'], + ); + } + } + + /** + * @return array + */ + protected function buildPayload(Email $email): array + { + $payload = [ + 'from' => $this->formatAddress($email->getFrom()[0] ?? null), + 'to' => array_map(fn (Address $a) => $this->formatAddress($a), $email->getTo()), + 'subject' => $email->getSubject() ?? '', + ]; + + if ($email->getCc()) { + $payload['cc'] = array_map(fn (Address $a) => $this->formatAddress($a), $email->getCc()); + } + + if ($email->getBcc()) { + $payload['bcc'] = array_map(fn (Address $a) => $this->formatAddress($a), $email->getBcc()); + } + + if ($email->getReplyTo()) { + $replyTo = $email->getReplyTo()[0]; + $payload['reply_to'] = $this->formatAddress($replyTo); + } + + if ($email->getHtmlBody()) { + $payload['html_body'] = $email->getHtmlBody(); + } + + if ($email->getTextBody()) { + $payload['text_body'] = $email->getTextBody(); + } + + $attachments = []; + foreach ($email->getAttachments() as $attachment) { + $attachments[] = [ + 'content' => base64_encode($attachment->getBody()), + 'filename' => $attachment->getFilename() ?? 'attachment', + 'mime_type' => $attachment->getMediaType().'/'.$attachment->getMediaSubtype(), + ]; + } + + if ($attachments) { + $payload['attachments'] = $attachments; + } + + return $payload; + } + + /** + * @return array{email: string, name?: string} + */ + protected function formatAddress(?Address $address): array + { + if ($address === null) { + return ['email' => '']; + } + + $result = ['email' => $address->getAddress()]; + + if ($address->getName()) { + $result['name'] = $address->getName(); + } + + return $result; + } + + public function __toString(): string + { + return 'leadmail'; + } +} diff --git a/src/Rules/LeadMailVerify.php b/src/Rules/LeadMailVerify.php new file mode 100644 index 0000000..6e38a72 --- /dev/null +++ b/src/Rules/LeadMailVerify.php @@ -0,0 +1,35 @@ +client->verifyEmail($value, $this->allowDisposable); + + if (! ($result['data']['valid'] ?? true)) { + $reason = $result['data']['reason'] ?? 'verification failed'; + $fail("The :attribute email address is not deliverable ({$reason})."); + } + } catch (\Throwable) { + // Fail open — if the verification service is down, allow the email through + } + } +}