diff --git a/packages/azure-mailer b/packages/azure-mailer deleted file mode 160000 index b6d04a3..0000000 --- a/packages/azure-mailer +++ /dev/null @@ -1 +0,0 @@ -Subproject commit b6d04a3e3a01a8439853efe8f52f672ae8a04b75 diff --git a/packages/azure-mailer/.gitignore b/packages/azure-mailer/.gitignore new file mode 100644 index 0000000..e2a3893 --- /dev/null +++ b/packages/azure-mailer/.gitignore @@ -0,0 +1,3 @@ +/vendor/ +composer.lock +*.cache diff --git a/packages/azure-mailer/README.md b/packages/azure-mailer/README.md new file mode 100644 index 0000000..f68de82 --- /dev/null +++ b/packages/azure-mailer/README.md @@ -0,0 +1,82 @@ +# promoseven/azure-mailer + +Laravel mail transport for Microsoft 365 via the Azure AD Graph API. +Replaces SMTP with a Client Credentials OAuth2 flow — drop-in compatible with +Laravel's `Mail` facade, Mailables, Notifications, and queued mail. + +## Requirements + +- PHP 8.2+ +- Laravel 11 or 12 +- An Azure AD App Registration with `Mail.Send` application permission + +## Installation + +```bash +composer require promoseven/azure-mailer +``` + +## Azure AD Setup + +1. Go to **Azure Portal → App Registrations → New registration** +2. Note the **Tenant ID**, **Client ID** +3. Under **Certificates & secrets**, create a new **Client secret** +4. Under **API permissions**, add **Microsoft Graph → Application permissions → Mail.Send** +5. Click **Grant admin consent** + +## Configuration + +Add to `config/mail.php` under `mailers`: + +```php +'azure' => [ + 'transport' => 'azure', + 'tenant_id' => env('AZURE_TENANT_ID'), + 'client_id' => env('AZURE_CLIENT_ID'), + 'client_secret' => env('AZURE_CLIENT_SECRET'), + 'from_address' => env('AZURE_MAIL_FROM_ADDRESS'), +], +``` + +Set `.env`: + +```env +MAIL_MAILER=azure + +AZURE_TENANT_ID=your-tenant-id +AZURE_CLIENT_ID=your-client-id +AZURE_CLIENT_SECRET=your-client-secret +AZURE_MAIL_FROM_ADDRESS=noreply@yourdomain.com +``` + +The `from_address` must be a mailbox in your Microsoft 365 tenant. + +## Advanced config (optional) + +Publish the config file to override defaults: + +```bash +php artisan vendor:publish --tag=azure-mailer-config +``` + +This creates `config/azure-mailer.php`: + +```php +return [ + 'save_to_sent_items' => false, // set true to keep copies in Sent folder + 'timeout' => 30, // HTTP timeout in seconds + 'graph_api_version' => 'v1.0', // or 'beta' +]; +``` + +## Usage + +No changes needed — use Laravel mail exactly as before: + +```php +Mail::to('user@example.com')->send(new OrderConfirmation($order)); +``` + +## License + +MIT diff --git a/packages/azure-mailer/composer.json b/packages/azure-mailer/composer.json new file mode 100644 index 0000000..ed924f7 --- /dev/null +++ b/packages/azure-mailer/composer.json @@ -0,0 +1,40 @@ +{ + "name": "promoseven/azure-mailer", + "description": "Laravel mail transport for Microsoft 365 via Azure AD Graph API", + "type": "library", + "license": "MIT", + "require": { + "php": "^8.2", + "illuminate/support": "^11.0|^12.0", + "illuminate/http": "^11.0|^12.0", + "illuminate/cache": "^11.0|^12.0", + "illuminate/mail": "^11.0|^12.0", + "symfony/mailer": "^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "orchestra/testbench": "^10.0" + }, + "autoload": { + "psr-4": { + "PromoSeven\\AzureMailer\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "PromoSeven\\AzureMailer\\Tests\\": "tests/" + } + }, + "extra": { + "laravel": { + "providers": [ + "PromoSeven\\AzureMailer\\AzureMailerServiceProvider" + ] + } + }, + "scripts": { + "test": "vendor/bin/phpunit" + }, + "minimum-stability": "stable", + "prefer-stable": true +} diff --git a/packages/azure-mailer/config/azure-mailer.php b/packages/azure-mailer/config/azure-mailer.php new file mode 100644 index 0000000..47ace2e --- /dev/null +++ b/packages/azure-mailer/config/azure-mailer.php @@ -0,0 +1,7 @@ + false, + 'timeout' => 30, + 'graph_api_version' => 'v1.0', +]; diff --git a/packages/azure-mailer/phpunit.xml b/packages/azure-mailer/phpunit.xml new file mode 100644 index 0000000..6fb2c52 --- /dev/null +++ b/packages/azure-mailer/phpunit.xml @@ -0,0 +1,11 @@ + + + + + tests + + + diff --git a/packages/azure-mailer/src/AzureMailerServiceProvider.php b/packages/azure-mailer/src/AzureMailerServiceProvider.php new file mode 100644 index 0000000..00d202e --- /dev/null +++ b/packages/azure-mailer/src/AzureMailerServiceProvider.php @@ -0,0 +1,37 @@ +mergeConfigFrom(__DIR__ . '/../config/azure-mailer.php', 'azure-mailer'); + } + + public function boot(): void + { + $this->publishes([ + __DIR__ . '/../config/azure-mailer.php' => config_path('azure-mailer.php'), + ], 'azure-mailer-config'); + + $this->callAfterResolving(MailManager::class, function (MailManager $manager) { + $manager->extend('azure', function (array $config) { + $merged = array_merge(config('azure-mailer', []), $config); + + return new AzureTransport( + new GraphClient(new TokenManager($merged), $merged), + $merged + ); + }); + }); + } +} diff --git a/packages/azure-mailer/src/Exceptions/AuthenticationException.php b/packages/azure-mailer/src/Exceptions/AuthenticationException.php new file mode 100644 index 0000000..e2b1e53 --- /dev/null +++ b/packages/azure-mailer/src/Exceptions/AuthenticationException.php @@ -0,0 +1,13 @@ +endpoint(); + + $response = Http::withToken($this->tokenManager->getToken()) + ->timeout($this->config['timeout'] ?? 30) + ->post($url, $payload); + + if ($response->status() === 401) { + Log::warning('azure-mailer: 401 received, retrying with fresh token'); + $this->tokenManager->invalidate(); + + $response = Http::withToken($this->tokenManager->getToken()) + ->timeout($this->config['timeout'] ?? 30) + ->post($url, $payload); + } + + if (! $response->successful()) { + $error = $response->json('error', []); + throw GraphApiException::fromResponse( + $error['code'] ?? (string) $response->status(), + $error['message'] ?? 'Unknown error' + ); + } + } + + private function endpoint(): string + { + $version = $this->config['graph_api_version'] ?? 'v1.0'; + $from = $this->config['from_address']; + + return "https://graph.microsoft.com/{$version}/users/{$from}/sendMail"; + } +} diff --git a/packages/azure-mailer/src/Graph/TokenManager.php b/packages/azure-mailer/src/Graph/TokenManager.php new file mode 100644 index 0000000..a0e9112 --- /dev/null +++ b/packages/azure-mailer/src/Graph/TokenManager.php @@ -0,0 +1,63 @@ +cacheKey(); + + if ($token = Cache::get($key)) { + return $token; + } + + return $this->fetchAndCache($key); + } + + public function invalidate(): void + { + Cache::forget($this->cacheKey()); + } + + private function fetchAndCache(string $key): string + { + $response = Http::asForm()->post( + "https://login.microsoftonline.com/{$this->config['tenant_id']}/oauth2/v2.0/token", + [ + 'grant_type' => 'client_credentials', + 'client_id' => $this->config['client_id'], + 'client_secret' => $this->config['client_secret'], + 'scope' => 'https://graph.microsoft.com/.default', + ] + ); + + if (! $response->successful()) { + $body = $response->json(); + throw AuthenticationException::fromResponse( + $body['error'] ?? 'unknown_error', + $body['error_description'] ?? 'No description provided' + ); + } + + $body = $response->json(); + $ttl = max(1, ($body['expires_in'] ?? 3600) - 60); + + Cache::put($key, $body['access_token'], $ttl); + + return $body['access_token']; + } + + private function cacheKey(): string + { + return 'azure_mailer_token_' . $this->config['client_id']; + } +} diff --git a/packages/azure-mailer/src/Transport/AzureTransport.php b/packages/azure-mailer/src/Transport/AzureTransport.php new file mode 100644 index 0000000..1dee758 --- /dev/null +++ b/packages/azure-mailer/src/Transport/AzureTransport.php @@ -0,0 +1,86 @@ +getOriginalMessage(); + + if (! $email instanceof Email) { + return; + } + + $this->client->send($this->buildPayload($email)); + } + + public function __toString(): string + { + return 'azure'; + } + + private function buildPayload(Email $email): array + { + return [ + 'message' => [ + 'subject' => $email->getSubject() ?? '', + 'body' => [ + 'contentType' => $email->getHtmlBody() !== null ? 'HTML' : 'Text', + 'content' => $email->getHtmlBody() ?? $email->getTextBody() ?? '', + ], + 'toRecipients' => $this->mapAddresses($email->getTo()), + 'ccRecipients' => $this->mapAddresses($email->getCc()), + 'bccRecipients' => $this->mapAddresses($email->getBcc()), + 'replyTo' => $this->mapAddresses($email->getReplyTo()), + 'attachments' => $this->mapAttachments($email), + ], + 'saveToSentItems' => (bool) ($this->config['save_to_sent_items'] ?? false), + ]; + } + + private function mapAddresses(array $addresses): array + { + return array_map(fn ($addr) => [ + 'emailAddress' => [ + 'address' => $addr->getAddress(), + 'name' => $addr->getName() ?? '', + ], + ], $addresses); + } + + private function mapAttachments(Email $email): array + { + $result = []; + + foreach ($email->getAttachments() as $attachment) { + if (! $attachment instanceof DataPart) { + continue; + } + + $result[] = [ + '@odata.type' => '#microsoft.graph.fileAttachment', + 'name' => $attachment->getFilename() ?? 'attachment', + 'contentType' => $attachment->getMediaType() . '/' . $attachment->getMediaSubtype(), + 'contentBytes' => base64_encode($attachment->getBody()), + ]; + } + + return $result; + } +} diff --git a/packages/azure-mailer/tests/ExceptionsTest.php b/packages/azure-mailer/tests/ExceptionsTest.php new file mode 100644 index 0000000..5411b8a --- /dev/null +++ b/packages/azure-mailer/tests/ExceptionsTest.php @@ -0,0 +1,29 @@ +assertInstanceOf(\RuntimeException::class, $e); + $this->assertStringContainsString('invalid_client', $e->getMessage()); + $this->assertStringContainsString('The client secret is incorrect.', $e->getMessage()); + } + + public function test_graph_api_exception_formats_message(): void + { + $e = GraphApiException::fromResponse('ErrorItemNotFound', 'The specified object was not found.'); + + $this->assertInstanceOf(\RuntimeException::class, $e); + $this->assertStringContainsString('ErrorItemNotFound', $e->getMessage()); + $this->assertStringContainsString('The specified object was not found.', $e->getMessage()); + } +} diff --git a/packages/azure-mailer/tests/Graph/.gitkeep b/packages/azure-mailer/tests/Graph/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/azure-mailer/tests/Graph/GraphClientTest.php b/packages/azure-mailer/tests/Graph/GraphClientTest.php new file mode 100644 index 0000000..8ed7e5c --- /dev/null +++ b/packages/azure-mailer/tests/Graph/GraphClientTest.php @@ -0,0 +1,124 @@ + 'test-tenant', + 'client_id' => 'test-client-id', + 'client_secret' => 'test-secret', + 'from_address' => 'sender@example.com', + 'timeout' => 30, + 'graph_api_version' => 'v1.0', + ]; + + private array $payload = [ + 'message' => [ + 'subject' => 'Test', + 'body' => ['contentType' => 'HTML', 'content' => '

Hello

'], + 'toRecipients' => [['emailAddress' => ['address' => 'to@example.com', 'name' => '']]], + 'ccRecipients' => [], + 'bccRecipients' => [], + 'replyTo' => [], + 'attachments' => [], + ], + 'saveToSentItems' => false, + ]; + + public function test_sends_payload_with_bearer_token(): void + { + Http::fake([ + 'login.microsoftonline.com/*' => Http::response(['access_token' => 'tok', 'expires_in' => 3600], 200), + 'graph.microsoft.com/*' => Http::response('', 202), + ]); + + $client = new GraphClient(new TokenManager($this->config), $this->config); + $client->send($this->payload); + + Http::assertSent(function ($request) { + return str_contains($request->url(), 'graph.microsoft.com/v1.0/users/sender@example.com/sendMail') + && $request->hasHeader('Authorization', 'Bearer tok'); + }); + } + + public function test_retries_with_fresh_token_on_401(): void + { + Log::shouldReceive('warning') + ->once() + ->with('azure-mailer: 401 received, retrying with fresh token'); + + Http::fake([ + 'login.microsoftonline.com/*' => Http::sequence() + ->push(['access_token' => 'stale-token', 'expires_in' => 3600], 200) + ->push(['access_token' => 'fresh-token', 'expires_in' => 3600], 200), + 'graph.microsoft.com/*' => Http::sequence() + ->push('', 401) + ->push('', 202), + ]); + + $client = new GraphClient(new TokenManager($this->config), $this->config); + $client->send($this->payload); + + Http::assertSentCount(4); // 2 token fetches + 2 graph calls + } + + public function test_throws_graph_api_exception_on_second_401(): void + { + Log::shouldReceive('warning')->once(); + + Http::fake([ + 'login.microsoftonline.com/*' => Http::response(['access_token' => 'tok', 'expires_in' => 3600], 200), + 'graph.microsoft.com/*' => Http::response([ + 'error' => ['code' => 'InvalidAuthenticationToken', 'message' => 'Token is expired.'], + ], 401), + ]); + + $this->expectException(GraphApiException::class); + $this->expectExceptionMessage('InvalidAuthenticationToken'); + + $client = new GraphClient(new TokenManager($this->config), $this->config); + $client->send($this->payload); + } + + public function test_throws_graph_api_exception_on_other_error(): void + { + Http::fake([ + 'login.microsoftonline.com/*' => Http::response(['access_token' => 'tok', 'expires_in' => 3600], 200), + 'graph.microsoft.com/*' => Http::response([ + 'error' => ['code' => 'ErrorInvalidRecipients', 'message' => 'Recipient address is invalid.'], + ], 400), + ]); + + $this->expectException(GraphApiException::class); + $this->expectExceptionMessage('ErrorInvalidRecipients'); + + $client = new GraphClient(new TokenManager($this->config), $this->config); + $client->send($this->payload); + } + + public function test_uses_graph_api_version_from_config(): void + { + Http::fake([ + 'login.microsoftonline.com/*' => Http::response(['access_token' => 'tok', 'expires_in' => 3600], 200), + 'graph.microsoft.com/*' => Http::response('', 202), + ]); + + $config = array_merge($this->config, ['graph_api_version' => 'beta']); + $client = new GraphClient(new TokenManager($config), $config); + $client->send($this->payload); + + Http::assertSent(function ($request) { + return str_contains($request->url(), 'graph.microsoft.com/beta/'); + }); + } +} diff --git a/packages/azure-mailer/tests/Graph/TokenManagerTest.php b/packages/azure-mailer/tests/Graph/TokenManagerTest.php new file mode 100644 index 0000000..934c966 --- /dev/null +++ b/packages/azure-mailer/tests/Graph/TokenManagerTest.php @@ -0,0 +1,119 @@ + 'test-tenant', + 'client_id' => 'test-client-id', + 'client_secret' => 'test-secret', + ]; + + protected function setUp(): void + { + parent::setUp(); + Cache::flush(); + } + + public function test_fetches_token_from_azure_on_cache_miss(): void + { + Http::fake([ + 'login.microsoftonline.com/*' => Http::response([ + 'access_token' => 'my-access-token', + 'expires_in' => 3600, + 'token_type' => 'Bearer', + ], 200), + ]); + + $manager = new TokenManager($this->config); + $token = $manager->getToken(); + + $this->assertSame('my-access-token', $token); + Http::assertSent(function ($request) { + return str_contains($request->url(), 'test-tenant/oauth2/v2.0/token') + && $request['grant_type'] === 'client_credentials' + && $request['client_id'] === 'test-client-id' + && $request['client_secret'] === 'test-secret' + && $request['scope'] === 'https://graph.microsoft.com/.default'; + }); + } + + public function test_returns_cached_token_without_hitting_azure(): void + { + Http::fake([ + 'login.microsoftonline.com/*' => Http::response([ + 'access_token' => 'first-token', + 'expires_in' => 3600, + ], 200), + ]); + + $manager = new TokenManager($this->config); + $manager->getToken(); // first call — hits Azure + $manager->getToken(); // second call — should use cache + + Http::assertSentCount(1); + } + + public function test_invalidate_clears_cached_token(): void + { + Http::fake([ + 'login.microsoftonline.com/*' => Http::sequence() + ->push(['access_token' => 'token-one', 'expires_in' => 3600], 200) + ->push(['access_token' => 'token-two', 'expires_in' => 3600], 200), + ]); + + $manager = new TokenManager($this->config); + $manager->getToken(); // fetches token-one + $manager->invalidate(); // clears cache + $second = $manager->getToken(); // fetches token-two + + $this->assertSame('token-two', $second); + Http::assertSentCount(2); + } + + public function test_token_is_cached_with_ttl_of_expires_in_minus_60(): void + { + Http::fake([ + 'login.microsoftonline.com/*' => Http::response([ + 'access_token' => 'ttl-token', + 'expires_in' => 120, // 120 - 60 = 60 second TTL + ], 200), + ]); + + $manager = new TokenManager($this->config); + $manager->getToken(); + + // Token should be in cache immediately after fetch + $this->assertTrue(Cache::has('azure_mailer_token_test-client-id')); + + // Advance time past the TTL (61 seconds) + $this->travel(61)->seconds(); + + // Token should now be expired from cache + $this->assertFalse(Cache::has('azure_mailer_token_test-client-id')); + } + + public function test_throws_authentication_exception_on_azure_error(): void + { + Http::fake([ + 'login.microsoftonline.com/*' => Http::response([ + 'error' => 'invalid_client', + 'error_description' => 'The client secret supplied is incorrect.', + ], 401), + ]); + + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessage('invalid_client'); + + (new TokenManager($this->config))->getToken(); + } +} diff --git a/packages/azure-mailer/tests/ServiceProviderTest.php b/packages/azure-mailer/tests/ServiceProviderTest.php new file mode 100644 index 0000000..efb79d1 --- /dev/null +++ b/packages/azure-mailer/tests/ServiceProviderTest.php @@ -0,0 +1,33 @@ +set('mail.mailers.azure', [ + 'transport' => 'azure', + 'tenant_id' => 'test-tenant', + 'client_id' => 'test-client-id', + 'client_secret' => 'test-secret', + 'from_address' => 'sender@example.com', + ]); + } + + public function test_azure_transport_is_registered_with_mail_manager(): void + { + $manager = $this->app->make(MailManager::class); + $transport = $manager->mailer('azure')->getSymfonyTransport(); + + $this->assertInstanceOf(AzureTransport::class, $transport); + $this->assertSame('azure', (string) $transport); + } +} diff --git a/packages/azure-mailer/tests/TestCase.php b/packages/azure-mailer/tests/TestCase.php new file mode 100644 index 0000000..e7d0449 --- /dev/null +++ b/packages/azure-mailer/tests/TestCase.php @@ -0,0 +1,21 @@ +set('cache.default', 'array'); + } +} diff --git a/packages/azure-mailer/tests/Transport/.gitkeep b/packages/azure-mailer/tests/Transport/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/azure-mailer/tests/Transport/AzureTransportTest.php b/packages/azure-mailer/tests/Transport/AzureTransportTest.php new file mode 100644 index 0000000..ceb2b87 --- /dev/null +++ b/packages/azure-mailer/tests/Transport/AzureTransportTest.php @@ -0,0 +1,131 @@ +createMock(GraphClient::class); + $client->expects($this->once()) + ->method('send') + ->willReturnCallback(function (array $payload) use (&$captured) { + $captured = $payload; + }); + + $email = (new Email()) + ->from('from@example.com') + ->to('to@example.com') + ->subject('Hello') + ->html('

World

'); + + $transport = new AzureTransport($client, ['save_to_sent_items' => false]); + $transport->send($email, Envelope::create($email)); + + $this->assertSame('Hello', $captured['message']['subject']); + $this->assertSame('HTML', $captured['message']['body']['contentType']); + $this->assertSame('

World

', $captured['message']['body']['content']); + $this->assertSame('to@example.com', $captured['message']['toRecipients'][0]['emailAddress']['address']); + $this->assertFalse($captured['saveToSentItems']); + } + + public function test_falls_back_to_text_body_when_no_html(): void + { + $captured = []; + $client = $this->createMock(GraphClient::class); + $client->method('send')->willReturnCallback(function ($p) use (&$captured) { $captured = $p; }); + + $email = (new Email()) + ->from('from@example.com') + ->to('to@example.com') + ->subject('Text only') + ->text('Plain text content'); + + $transport = new AzureTransport($client, ['save_to_sent_items' => false]); + $transport->send($email, Envelope::create($email)); + + $this->assertSame('Text', $captured['message']['body']['contentType']); + $this->assertSame('Plain text content', $captured['message']['body']['content']); + } + + public function test_maps_cc_bcc_and_reply_to(): void + { + $captured = []; + $client = $this->createMock(GraphClient::class); + $client->method('send')->willReturnCallback(function ($p) use (&$captured) { $captured = $p; }); + + $email = (new Email()) + ->from('from@example.com') + ->to('to@example.com') + ->cc('cc@example.com') + ->bcc('bcc@example.com') + ->replyTo('reply@example.com') + ->subject('Recipients test') + ->html('

hi

'); + + $transport = new AzureTransport($client, ['save_to_sent_items' => false]); + $transport->send($email, Envelope::create($email)); + + $this->assertSame('cc@example.com', $captured['message']['ccRecipients'][0]['emailAddress']['address']); + $this->assertSame('bcc@example.com', $captured['message']['bccRecipients'][0]['emailAddress']['address']); + $this->assertSame('reply@example.com', $captured['message']['replyTo'][0]['emailAddress']['address']); + } + + public function test_encodes_attachments_as_base64(): void + { + $captured = []; + $client = $this->createMock(GraphClient::class); + $client->method('send')->willReturnCallback(function ($p) use (&$captured) { $captured = $p; }); + + $email = (new Email()) + ->from('from@example.com') + ->to('to@example.com') + ->subject('With attachment') + ->html('

see attachment

') + ->attach('PDF content here', 'report.pdf', 'application/pdf'); + + $transport = new AzureTransport($client, ['save_to_sent_items' => false]); + $transport->send($email, Envelope::create($email)); + + $attachment = $captured['message']['attachments'][0]; + $this->assertSame('#microsoft.graph.fileAttachment', $attachment['@odata.type']); + $this->assertSame('report.pdf', $attachment['name']); + $this->assertSame(base64_encode('PDF content here'), $attachment['contentBytes']); + $this->assertSame('application/pdf', $attachment['contentType']); + } + + public function test_save_to_sent_items_is_configurable(): void + { + $captured = []; + $client = $this->createMock(GraphClient::class); + $client->method('send')->willReturnCallback(function ($p) use (&$captured) { $captured = $p; }); + + $email = (new Email()) + ->from('from@example.com') + ->to('to@example.com') + ->subject('Save it') + ->html('

keep

'); + + $transport = new AzureTransport($client, ['save_to_sent_items' => true]); + $transport->send($email, Envelope::create($email)); + + $this->assertTrue($captured['saveToSentItems']); + } + + public function test_to_string_returns_azure(): void + { + $client = $this->createMock(GraphClient::class); + $transport = new AzureTransport($client, []); + + $this->assertSame('azure', (string) $transport); + } +} diff --git a/packages/ultra-message b/packages/ultra-message deleted file mode 160000 index 5616023..0000000 --- a/packages/ultra-message +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 56160235808f7d9d9dd3ec8d0eefa20a17807647 diff --git a/packages/ultra-message/.gitignore b/packages/ultra-message/.gitignore new file mode 100644 index 0000000..3a9875b --- /dev/null +++ b/packages/ultra-message/.gitignore @@ -0,0 +1,2 @@ +/vendor/ +composer.lock diff --git a/packages/ultra-message/composer.json b/packages/ultra-message/composer.json new file mode 100644 index 0000000..20dd747 --- /dev/null +++ b/packages/ultra-message/composer.json @@ -0,0 +1,42 @@ +{ + "name": "promoseven/ultra-message", + "description": "Laravel WhatsApp integration via UltraMSG API", + "type": "library", + "license": "MIT", + "require": { + "php": "^8.2", + "illuminate/support": "^11.0|^12.0", + "illuminate/http": "^11.0|^12.0", + "illuminate/notifications": "^11.0|^12.0", + "illuminate/routing": "^11.0|^12.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "orchestra/testbench": "^10.0" + }, + "autoload": { + "psr-4": { + "PromoSeven\\UltraMessage\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "PromoSeven\\UltraMessage\\Tests\\": "tests/" + } + }, + "extra": { + "laravel": { + "providers": [ + "PromoSeven\\UltraMessage\\UltraMessageServiceProvider" + ], + "aliases": { + "UltraMessage": "PromoSeven\\UltraMessage\\Facades\\UltraMessage" + } + } + }, + "scripts": { + "test": "vendor/bin/phpunit" + }, + "minimum-stability": "stable", + "prefer-stable": true +} diff --git a/packages/ultra-message/config/ultra-message.php b/packages/ultra-message/config/ultra-message.php new file mode 100644 index 0000000..97db905 --- /dev/null +++ b/packages/ultra-message/config/ultra-message.php @@ -0,0 +1,10 @@ + env('ULTRAMSG_INSTANCE_ID'), + 'token' => env('ULTRAMSG_TOKEN'), + 'webhook_secret' => env('ULTRAMSG_WEBHOOK_SECRET', null), + 'webhook_path' => env('ULTRAMSG_WEBHOOK_PATH', 'ultra-message/webhook'), + 'timeout' => env('ULTRAMSG_TIMEOUT', 30), + 'enabled' => env('ULTRAMSG_ENABLED', true), +]; diff --git a/packages/ultra-message/phpunit.xml.dist b/packages/ultra-message/phpunit.xml.dist new file mode 100644 index 0000000..6fb2c52 --- /dev/null +++ b/packages/ultra-message/phpunit.xml.dist @@ -0,0 +1,11 @@ + + + + + tests + + + diff --git a/packages/ultra-message/routes/.gitkeep b/packages/ultra-message/routes/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/packages/ultra-message/routes/.gitkeep @@ -0,0 +1 @@ + diff --git a/packages/ultra-message/routes/webhook.php b/packages/ultra-message/routes/webhook.php new file mode 100644 index 0000000..5ae9d1f --- /dev/null +++ b/packages/ultra-message/routes/webhook.php @@ -0,0 +1,7 @@ +name('ultra-message.webhook'); diff --git a/packages/ultra-message/src/Events/UltraMessageWebhookReceived.php b/packages/ultra-message/src/Events/UltraMessageWebhookReceived.php new file mode 100644 index 0000000..9e8adc8 --- /dev/null +++ b/packages/ultra-message/src/Events/UltraMessageWebhookReceived.php @@ -0,0 +1,13 @@ +instance('ultra-message.config-resolver', $resolver); + app()->forgetInstance(UltraMessageClient::class); + } +} diff --git a/packages/ultra-message/src/Http/Controllers/.gitkeep b/packages/ultra-message/src/Http/Controllers/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/packages/ultra-message/src/Http/Controllers/.gitkeep @@ -0,0 +1 @@ + diff --git a/packages/ultra-message/src/Http/Controllers/WebhookController.php b/packages/ultra-message/src/Http/Controllers/WebhookController.php new file mode 100644 index 0000000..b1cfcb6 --- /dev/null +++ b/packages/ultra-message/src/Http/Controllers/WebhookController.php @@ -0,0 +1,29 @@ +header('X-Hub-Signature-256', ''); + $expected = 'sha256=' . hash_hmac('sha256', $request->getContent(), $secret); + + if (!hash_equals($expected, $signature)) { + abort(403, 'Invalid webhook signature.'); + } + } + + event(new UltraMessageWebhookReceived($request->all())); + + return response('OK', 200); + } +} diff --git a/packages/ultra-message/src/UltraMessageChannel.php b/packages/ultra-message/src/UltraMessageChannel.php new file mode 100644 index 0000000..523b655 --- /dev/null +++ b/packages/ultra-message/src/UltraMessageChannel.php @@ -0,0 +1,39 @@ +toUltraMessage($notifiable); + + $to = $message->to ?: $notifiable->routeNotificationFor('ultra_message', $notification); + + if (!$to) { + return; + } + + match ($message->type) { + 'text' => $this->client->sendText($to, $message->payload['body'], $message->payload['quoted_id'] ?? null), + 'image' => $this->client->sendImage($to, $message->payload['image'], $message->payload['caption'] ?? ''), + 'document' => $this->client->sendDocument($to, $message->payload['document'], $message->payload['filename'], $message->payload['caption'] ?? ''), + 'audio' => $this->client->sendAudio($to, $message->payload['audio']), + 'voice' => $this->client->sendVoice($to, $message->payload['audio']), + 'video' => $this->client->sendVideo($to, $message->payload['video'], $message->payload['caption'] ?? ''), + 'sticker' => $this->client->sendSticker($to, $message->payload['sticker']), + 'contact' => $this->client->sendContact($to, $message->payload['contact']), + 'location' => $this->client->sendLocation($to, $message->payload['lat'], $message->payload['lng'], $message->payload['address'] ?? ''), + default => throw new UltraMessageException("Unknown message type: {$message->type}"), + }; + } +} diff --git a/packages/ultra-message/src/UltraMessageClient.php b/packages/ultra-message/src/UltraMessageClient.php new file mode 100644 index 0000000..bb201be --- /dev/null +++ b/packages/ultra-message/src/UltraMessageClient.php @@ -0,0 +1,187 @@ +instanceId = $config['instance_id'] ?? ''; + $this->token = $config['token'] ?? ''; + $this->timeout = $config['timeout'] ?? 30; + $this->enabled = $config['enabled'] ?? true; + } + + protected function post(string $endpoint, array $data): array + { + if (!$this->enabled) { + return []; + } + + $response = Http::timeout($this->timeout) + ->asForm() + ->post(self::BASE_URL . "/{$this->instanceId}/{$endpoint}", array_merge($data, [ + 'token' => $this->token, + ])); + + if ($response->failed()) { + throw new UltraMessageException("UltraMSG HTTP error: {$response->status()}"); + } + + $body = $response->json() ?? []; + + if (isset($body['error'])) { + throw new UltraMessageException($body['error']); + } + + return $body; + } + + protected function get(string $endpoint, array $query = []): array + { + if (!$this->enabled) { + return []; + } + + $response = Http::timeout($this->timeout) + ->get(self::BASE_URL . "/{$this->instanceId}/{$endpoint}", array_merge($query, [ + 'token' => $this->token, + ])); + + if ($response->failed()) { + throw new UltraMessageException("UltraMSG HTTP error: {$response->status()}"); + } + + $body = $response->json() ?? []; + + if (isset($body['error'])) { + throw new UltraMessageException($body['error']); + } + + return $body; + } + + public function sendText(string $to, string $message, ?string $replyId = null): array + { + $data = ['to' => $to, 'body' => $message]; + if ($replyId !== null) { + $data['quoted_id'] = $replyId; + } + return $this->post('messages/chat', $data); + } + + public function sendImage(string $to, string $imageUrl, string $caption = ''): array + { + return $this->post('messages/image', [ + 'to' => $to, + 'image' => $imageUrl, + 'caption' => $caption, + ]); + } + + public function sendDocument(string $to, string $fileUrl, string $filename, string $caption = ''): array + { + return $this->post('messages/document', [ + 'to' => $to, + 'document' => $fileUrl, + 'filename' => $filename, + 'caption' => $caption, + ]); + } + + public function sendAudio(string $to, string $audioUrl): array + { + return $this->post('messages/audio', [ + 'to' => $to, + 'audio' => $audioUrl, + ]); + } + + public function sendVoice(string $to, string $audioUrl): array + { + return $this->post('messages/voice', [ + 'to' => $to, + 'audio' => $audioUrl, + ]); + } + + public function sendVideo(string $to, string $videoUrl, string $caption = ''): array + { + return $this->post('messages/video', [ + 'to' => $to, + 'video' => $videoUrl, + 'caption' => $caption, + ]); + } + + public function sendSticker(string $to, string $stickerUrl): array + { + return $this->post('messages/sticker', [ + 'to' => $to, + 'sticker' => $stickerUrl, + ]); + } + + public function sendContact(string $to, string $contactId): array + { + return $this->post('messages/contact', [ + 'to' => $to, + 'contact' => $contactId, + ]); + } + + public function sendLocation(string $to, float $lat, float $lng, string $address = ''): array + { + return $this->post('messages/location', [ + 'to' => $to, + 'lat' => $lat, + 'lng' => $lng, + 'address' => $address, + ]); + } + + public function sendReaction(string $to, string $messageId, string $emoji): array + { + return $this->post('messages/reaction', [ + 'to' => $to, + 'msgId' => $messageId, + 'emoji' => $emoji, + ]); + } + + public function deleteMessage(string $messageId): array + { + return $this->post('messages/delete', [ + 'msgId' => $messageId, + ]); + } + + public function getInstanceStatus(): array + { + return $this->get('instance/status'); + } + + public function getChats(): array + { + return $this->get('chats/'); + } + + public function getContacts(): array + { + return $this->get('contacts/'); + } + + public function getGroups(): array + { + return $this->get('groups/'); + } +} diff --git a/packages/ultra-message/src/UltraMessageException.php b/packages/ultra-message/src/UltraMessageException.php new file mode 100644 index 0000000..0387d2c --- /dev/null +++ b/packages/ultra-message/src/UltraMessageException.php @@ -0,0 +1,7 @@ +sent[] = ['endpoint' => $endpoint, 'data' => $data]; + return ['sent' => 'ok']; + } + + protected function get(string $endpoint, array $query = []): array + { + return []; + } + + public function assertSent(callable $callback): void + { + Assert::assertTrue( + collect($this->sent)->contains($callback), + 'Expected UltraMessage was not sent.' + ); + } + + public function assertNotSent(): void + { + Assert::assertEmpty($this->sent, 'Unexpected UltraMessage messages were sent.'); + } + + public function assertSentCount(int $count): void + { + Assert::assertCount($count, $this->sent, "Expected {$count} messages sent, got " . count($this->sent)); + } + + public function getSent(): array + { + return $this->sent; + } +} diff --git a/packages/ultra-message/src/UltraMessageMessage.php b/packages/ultra-message/src/UltraMessageMessage.php new file mode 100644 index 0000000..9f5a67b --- /dev/null +++ b/packages/ultra-message/src/UltraMessageMessage.php @@ -0,0 +1,67 @@ +type = $type; + $this->payload = $payload; + } + + public static function text(string $message, ?string $replyId = null): self + { + return new self('text', ['body' => $message, 'quoted_id' => $replyId]); + } + + public static function image(string $url, string $caption = ''): self + { + return new self('image', ['image' => $url, 'caption' => $caption]); + } + + public static function document(string $url, string $filename, string $caption = ''): self + { + return new self('document', ['document' => $url, 'filename' => $filename, 'caption' => $caption]); + } + + public static function audio(string $url): self + { + return new self('audio', ['audio' => $url]); + } + + public static function voice(string $url): self + { + return new self('voice', ['audio' => $url]); + } + + public static function video(string $url, string $caption = ''): self + { + return new self('video', ['video' => $url, 'caption' => $caption]); + } + + public static function sticker(string $url): self + { + return new self('sticker', ['sticker' => $url]); + } + + public static function contact(string $contactId): self + { + return new self('contact', ['contact' => $contactId]); + } + + public static function location(float $lat, float $lng, string $address = ''): self + { + return new self('location', ['lat' => $lat, 'lng' => $lng, 'address' => $address]); + } + + public function to(string $number): self + { + $this->to = $number; + return $this; + } +} diff --git a/packages/ultra-message/src/UltraMessageServiceProvider.php b/packages/ultra-message/src/UltraMessageServiceProvider.php new file mode 100644 index 0000000..f4b4750 --- /dev/null +++ b/packages/ultra-message/src/UltraMessageServiceProvider.php @@ -0,0 +1,37 @@ +mergeConfigFrom(__DIR__ . '/../config/ultra-message.php', 'ultra-message'); + + $this->app->singleton('ultra-message.config-resolver', fn() => null); + + $this->app->bind(UltraMessageClient::class, function ($app) { + $resolver = $app->make('ultra-message.config-resolver'); + $config = $resolver ? call_user_func($resolver) : config('ultra-message'); + + return new UltraMessageClient($config); + }); + + $this->app->bind(UltraMessageChannel::class, function ($app) { + return new UltraMessageChannel($app->make(UltraMessageClient::class)); + }); + } + + public function boot(): void + { + if ($this->app->runningInConsole()) { + $this->publishes([ + __DIR__ . '/../config/ultra-message.php' => config_path('ultra-message.php'), + ], 'ultra-message-config'); + } + + $this->loadRoutesFrom(__DIR__ . '/../routes/webhook.php'); + } +} diff --git a/packages/ultra-message/tests/TestCase.php b/packages/ultra-message/tests/TestCase.php new file mode 100644 index 0000000..fa1ab63 --- /dev/null +++ b/packages/ultra-message/tests/TestCase.php @@ -0,0 +1,21 @@ +set('ultra-message.instance_id', 'instance123'); + $app['config']->set('ultra-message.token', 'test-token'); + $app['config']->set('ultra-message.enabled', true); + } +} diff --git a/packages/ultra-message/tests/UltraMessageChannelTest.php b/packages/ultra-message/tests/UltraMessageChannelTest.php new file mode 100644 index 0000000..d296be0 --- /dev/null +++ b/packages/ultra-message/tests/UltraMessageChannelTest.php @@ -0,0 +1,110 @@ + Http::response(['sent' => 'true'], 200)]); + + $client = new UltraMessageClient([ + 'instance_id' => 'instance123', + 'token' => 'test-token', + 'timeout' => 30, + 'enabled' => true, + ]); + $channel = new UltraMessageChannel($client); + + $notifiable = new class { + public string $whatsapp_number = '+971501234567'; + public function routeNotificationFor(string $channel, $notification = null): string + { + return $this->whatsapp_number; + } + }; + + $notification = new class extends Notification { + public function toUltraMessage($notifiable): UltraMessageMessage + { + return UltraMessageMessage::text('Test message'); + } + }; + + $channel->send($notifiable, $notification); + + Http::assertSent(function ($request) { + return str_contains($request->url(), 'messages/chat') + && $request['body'] === 'Test message' + && $request['to'] === '+971501234567'; + }); + } + + public function test_channel_uses_message_to_over_notifiable_route(): void + { + Http::fake(['api.ultramsg.com/*' => Http::response(['sent' => 'true'], 200)]); + + $client = new UltraMessageClient([ + 'instance_id' => 'instance123', + 'token' => 'test-token', + 'timeout' => 30, + 'enabled' => true, + ]); + $channel = new UltraMessageChannel($client); + + $notifiable = new class { + public function routeNotificationFor(string $channel, $notification = null): string + { + return '+9710000000'; + } + }; + + $notification = new class extends Notification { + public function toUltraMessage($notifiable): UltraMessageMessage + { + return UltraMessageMessage::text('Override test')->to('+971999999'); + } + }; + + $channel->send($notifiable, $notification); + + Http::assertSent(fn($r) => $r['to'] === '+971999999'); + } + + public function test_channel_skips_when_no_recipient(): void + { + Http::fake(); + + $client = new UltraMessageClient([ + 'instance_id' => 'instance123', + 'token' => 'test-token', + 'timeout' => 30, + 'enabled' => true, + ]); + $channel = new UltraMessageChannel($client); + + $notifiable = new class { + public function routeNotificationFor(string $channel, $notification = null): ?string + { + return null; + } + }; + + $notification = new class extends Notification { + public function toUltraMessage($notifiable): UltraMessageMessage + { + return UltraMessageMessage::text('No recipient'); + } + }; + + $channel->send($notifiable, $notification); + + Http::assertNothingSent(); + } +} diff --git a/packages/ultra-message/tests/UltraMessageClientTest.php b/packages/ultra-message/tests/UltraMessageClientTest.php new file mode 100644 index 0000000..9c196a8 --- /dev/null +++ b/packages/ultra-message/tests/UltraMessageClientTest.php @@ -0,0 +1,128 @@ +client = new UltraMessageClient([ + 'instance_id' => 'instance123', + 'token' => 'test-token', + 'timeout' => 30, + 'enabled' => true, + ]); + } + + public function test_send_text_posts_correct_payload(): void + { + Http::fake([ + 'api.ultramsg.com/*' => Http::response(['sent' => 'true', 'id' => 'msg1'], 200), + ]); + + $result = $this->client->sendText('+971501234567', 'Hello World'); + + Http::assertSent(function ($request) { + return str_contains($request->url(), 'instance123/messages/chat') + && $request['to'] === '+971501234567' + && $request['body'] === 'Hello World' + && $request['token'] === 'test-token'; + }); + + $this->assertEquals(['sent' => 'true', 'id' => 'msg1'], $result); + } + + public function test_send_text_throws_on_api_error(): void + { + Http::fake([ + 'api.ultramsg.com/*' => Http::response(['error' => 'invalid token'], 200), + ]); + + $this->expectException(UltraMessageException::class); + $this->expectExceptionMessage('invalid token'); + + $this->client->sendText('+971501234567', 'Hello'); + } + + public function test_send_text_throws_on_http_failure(): void + { + Http::fake([ + 'api.ultramsg.com/*' => Http::response([], 500), + ]); + + $this->expectException(UltraMessageException::class); + + $this->client->sendText('+971501234567', 'Hello'); + } + + public function test_send_returns_early_when_disabled(): void + { + Http::fake(); + + $client = new UltraMessageClient([ + 'instance_id' => 'instance123', + 'token' => 'test-token', + 'timeout' => 30, + 'enabled' => false, + ]); + + $result = $client->sendText('+971501234567', 'Hello'); + + Http::assertNothingSent(); + $this->assertEquals([], $result); + } + + public function test_send_image_posts_correct_payload(): void + { + Http::fake([ + 'api.ultramsg.com/*' => Http::response(['sent' => 'true'], 200), + ]); + + $this->client->sendImage('+971501234567', 'https://example.com/img.jpg', 'Caption'); + + Http::assertSent(function ($request) { + return str_contains($request->url(), 'instance123/messages/image') + && $request['image'] === 'https://example.com/img.jpg' + && $request['caption'] === 'Caption'; + }); + } + + public function test_send_document_posts_correct_payload(): void + { + Http::fake([ + 'api.ultramsg.com/*' => Http::response(['sent' => 'true'], 200), + ]); + + $this->client->sendDocument('+971501234567', 'https://example.com/file.pdf', 'invoice.pdf', 'Your invoice'); + + Http::assertSent(function ($request) { + return str_contains($request->url(), 'instance123/messages/document') + && $request['document'] === 'https://example.com/file.pdf' + && $request['filename'] === 'invoice.pdf' + && $request['caption'] === 'Your invoice'; + }); + } + + public function test_send_location_posts_correct_payload(): void + { + Http::fake([ + 'api.ultramsg.com/*' => Http::response(['sent' => 'true'], 200), + ]); + + $this->client->sendLocation('+971501234567', 25.197197, 55.2721877, 'Dubai, UAE'); + + Http::assertSent(function ($request) { + return str_contains($request->url(), 'instance123/messages/location') + && $request['lat'] == 25.197197 + && $request['lng'] == 55.2721877 + && $request['address'] === 'Dubai, UAE'; + }); + } +}