From d099c018524e22b16b97395eaad4af654bf23536 Mon Sep 17 00:00:00 2001 From: sanja <52755494+sanjacornelius@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:02:38 -0700 Subject: [PATCH 1/5] Add Microsoft Graph token provider and tests Introduce MicrosoftGraphTokenProvider to obtain and cache OAuth2 client-credentials tokens from Microsoft Graph. The provider validates required credentials, posts to the tenant token endpoint via Guzzle, throws a RuntimeException on missing creds or HTTP errors (propagating server error messages), and caches the access token for ~50 minutes using a server-specific cache key. Includes unit tests that assert exception behavior for missing credentials and mock Guzzle to verify token request parameters and caching behavior. --- .../Mail/MicrosoftGraphTokenProvider.php | 61 ++++++++++++++++ .../Mail/MicrosoftGraphTokenProviderTest.php | 73 +++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 ProcessMaker/Mail/MicrosoftGraphTokenProvider.php create mode 100644 tests/unit/ProcessMaker/Mail/MicrosoftGraphTokenProviderTest.php diff --git a/ProcessMaker/Mail/MicrosoftGraphTokenProvider.php b/ProcessMaker/Mail/MicrosoftGraphTokenProvider.php new file mode 100644 index 0000000000..1926107806 --- /dev/null +++ b/ProcessMaker/Mail/MicrosoftGraphTokenProvider.php @@ -0,0 +1,61 @@ +serverIndex ?: 'default'); + + return Cache::remember($cacheKey, now()->addMinutes(50), function () { + return $this->requestAccessToken(); + }); + } + + private function requestAccessToken(): string + { + $tenantId = $this->config['tenant_id'] ?? null; + $clientId = $this->config['key'] ?? null; + $clientSecret = $this->config['secret'] ?? null; + + if (!$tenantId || !$clientId || !$clientSecret) { + throw new RuntimeException('Microsoft Graph credentials are not configured.'); + } + + $client = new Client(); + $response = $client->post(sprintf(self::TOKEN_URL, $tenantId), [ + 'form_params' => [ + 'client_id' => $clientId, + 'client_secret' => $clientSecret, + 'scope' => self::DEFAULT_SCOPE, + 'grant_type' => 'client_credentials', + ], + 'http_errors' => false, + ]); + + $body = json_decode($response->getBody()->getContents(), true); + + if ($response->getStatusCode() >= 400) { + $message = $body['error_description'] ?? $body['error']['message'] ?? 'Unknown error'; + + throw new RuntimeException('Failed to get Microsoft Graph access token: ' . $message); + } + + return $body['access_token']; + } +} diff --git a/tests/unit/ProcessMaker/Mail/MicrosoftGraphTokenProviderTest.php b/tests/unit/ProcessMaker/Mail/MicrosoftGraphTokenProviderTest.php new file mode 100644 index 0000000000..51f434b75e --- /dev/null +++ b/tests/unit/ProcessMaker/Mail/MicrosoftGraphTokenProviderTest.php @@ -0,0 +1,73 @@ + '', + 'key' => 'client-id', + 'secret' => 'client-secret', + ]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Microsoft Graph credentials are not configured.'); + + $provider->getAccessToken(); + } + + public function testGetAccessTokenRequestsTokenFromMicrosoft() + { + Cache::flush(); + + $tokenResponseBody = Mockery::mock(); + $tokenResponseBody->shouldReceive('getContents') + ->andReturn(json_encode(['access_token' => 'token-from-azure'])); + + $tokenResponse = Mockery::mock(); + $tokenResponse->shouldReceive('getStatusCode')->andReturn(200); + $tokenResponse->shouldReceive('getBody')->andReturn($tokenResponseBody); + + $guzzle = Mockery::mock('overload:GuzzleHttp\Client'); + $guzzle->shouldReceive('post') + ->once() + ->with( + 'https://login.microsoftonline.com/tenant-id-123/oauth2/v2.0/token', + Mockery::on(function ($options) { + return $options['form_params']['client_id'] === 'client-id-123' + && $options['form_params']['client_secret'] === 'client-secret-123' + && $options['form_params']['scope'] === 'https://graph.microsoft.com/.default' + && $options['form_params']['grant_type'] === 'client_credentials' + && $options['http_errors'] === false; + }) + ) + ->andReturn($tokenResponse); + + $provider = new MicrosoftGraphTokenProvider([ + 'tenant_id' => 'tenant-id-123', + 'key' => 'client-id-123', + 'secret' => 'client-secret-123', + ], 1); + + $this->assertSame('token-from-azure', $provider->getAccessToken()); + $this->assertSame('token-from-azure', $provider->getAccessToken()); + } +} From 727173822d1bef83180ce54ed754d4127c07ba87 Mon Sep 17 00:00:00 2001 From: sanja <52755494+sanjacornelius@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:37:21 -0700 Subject: [PATCH 2/5] Inject HTTP client into token provider Add an optional Guzzle Client dependency to MicrosoftGraphTokenProvider so an HTTP client can be injected for testing. The constructor now accepts ?Client $httpClient and the provider uses $this->httpClient ?? new Client() when posting for tokens. Update the unit test to mock GuzzleHttp\Client (remove overload-based mock and separate-process annotations), import Client, and pass the mocked client into the provider. This enables dependency injection and simpler, more reliable tests. --- ProcessMaker/Mail/MicrosoftGraphTokenProvider.php | 5 +++-- .../Mail/MicrosoftGraphTokenProviderTest.php | 9 +++------ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/ProcessMaker/Mail/MicrosoftGraphTokenProvider.php b/ProcessMaker/Mail/MicrosoftGraphTokenProvider.php index 1926107806..913274bea6 100644 --- a/ProcessMaker/Mail/MicrosoftGraphTokenProvider.php +++ b/ProcessMaker/Mail/MicrosoftGraphTokenProvider.php @@ -14,7 +14,8 @@ class MicrosoftGraphTokenProvider public function __construct( private array $config, - private int|string $serverIndex = 0 + private int|string $serverIndex = 0, + private ?Client $httpClient = null, ) { } @@ -37,7 +38,7 @@ private function requestAccessToken(): string throw new RuntimeException('Microsoft Graph credentials are not configured.'); } - $client = new Client(); + $client = $this->httpClient ?? new Client(); $response = $client->post(sprintf(self::TOKEN_URL, $tenantId), [ 'form_params' => [ 'client_id' => $clientId, diff --git a/tests/unit/ProcessMaker/Mail/MicrosoftGraphTokenProviderTest.php b/tests/unit/ProcessMaker/Mail/MicrosoftGraphTokenProviderTest.php index 51f434b75e..4c5870934b 100644 --- a/tests/unit/ProcessMaker/Mail/MicrosoftGraphTokenProviderTest.php +++ b/tests/unit/ProcessMaker/Mail/MicrosoftGraphTokenProviderTest.php @@ -2,16 +2,13 @@ namespace Tests\Unit\ProcessMaker\Mail; +use GuzzleHttp\Client; use Illuminate\Support\Facades\Cache; use Mockery; use ProcessMaker\Mail\MicrosoftGraphTokenProvider; use RuntimeException; use Tests\TestCase; -/** - * @runTestsInSeparateProcesses - * @preserveGlobalState disabled - */ class MicrosoftGraphTokenProviderTest extends TestCase { protected function tearDown(): void @@ -46,7 +43,7 @@ public function testGetAccessTokenRequestsTokenFromMicrosoft() $tokenResponse->shouldReceive('getStatusCode')->andReturn(200); $tokenResponse->shouldReceive('getBody')->andReturn($tokenResponseBody); - $guzzle = Mockery::mock('overload:GuzzleHttp\Client'); + $guzzle = Mockery::mock(Client::class); $guzzle->shouldReceive('post') ->once() ->with( @@ -65,7 +62,7 @@ public function testGetAccessTokenRequestsTokenFromMicrosoft() 'tenant_id' => 'tenant-id-123', 'key' => 'client-id-123', 'secret' => 'client-secret-123', - ], 1); + ], 1, $guzzle); $this->assertSame('token-from-azure', $provider->getAccessToken()); $this->assertSame('token-from-azure', $provider->getAccessToken()); From b0d14ca501733e5af85d67b9b7f925ebdf62e8b3 Mon Sep 17 00:00:00 2001 From: sanja <52755494+sanjacornelius@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:40:11 -0700 Subject: [PATCH 3/5] Use real PSR-7 Response in test Replace Mockery-based response mocks in MicrosoftGraphTokenProviderTest with an actual GuzzleHttp\Psr7\Response instance. Simplifies the test by providing a real PSR-7 response (200) with a JSON body and removes the need to mock getBody()/getContents() and getStatusCode(). Added the Response use statement. --- .../Mail/MicrosoftGraphTokenProviderTest.php | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/tests/unit/ProcessMaker/Mail/MicrosoftGraphTokenProviderTest.php b/tests/unit/ProcessMaker/Mail/MicrosoftGraphTokenProviderTest.php index 4c5870934b..a4f62d67d5 100644 --- a/tests/unit/ProcessMaker/Mail/MicrosoftGraphTokenProviderTest.php +++ b/tests/unit/ProcessMaker/Mail/MicrosoftGraphTokenProviderTest.php @@ -3,6 +3,7 @@ namespace Tests\Unit\ProcessMaker\Mail; use GuzzleHttp\Client; +use GuzzleHttp\Psr7\Response; use Illuminate\Support\Facades\Cache; use Mockery; use ProcessMaker\Mail\MicrosoftGraphTokenProvider; @@ -35,13 +36,7 @@ public function testGetAccessTokenRequestsTokenFromMicrosoft() { Cache::flush(); - $tokenResponseBody = Mockery::mock(); - $tokenResponseBody->shouldReceive('getContents') - ->andReturn(json_encode(['access_token' => 'token-from-azure'])); - - $tokenResponse = Mockery::mock(); - $tokenResponse->shouldReceive('getStatusCode')->andReturn(200); - $tokenResponse->shouldReceive('getBody')->andReturn($tokenResponseBody); + $tokenResponse = new Response(200, [], json_encode(['access_token' => 'token-from-azure'])); $guzzle = Mockery::mock(Client::class); $guzzle->shouldReceive('post') From 79347af0eac2e72941ce58c0067da34c44ae75eb Mon Sep 17 00:00:00 2001 From: sanja <52755494+sanjacornelius@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:46:27 -0700 Subject: [PATCH 4/5] Add Microsoft Graph mail transport and config Introduce Microsoft Graph mail support: add MicrosoftGraphTransport and MicrosoftGraphMessageConverter to send emails via the Graph API and a MicrosoftGraphTokenProvider to obtain access tokens. Integrate the transport into OauthMailManager (createTransport/createMicrosoftGraphTransport) and register a 'microsoft_graph' mailer in config/mail.php. Update the admin settings UI to show the authorization button when the mail driver is set to 'microsoft_graph'. The transport posts a sendMail payload to the Graph endpoint and throws a TransportException on HTTP errors. --- .../Mail/MicrosoftGraphMessageConverter.php | 55 +++++++++++++++++++ .../Mail/MicrosoftGraphTokenProvider.php | 2 +- .../Transports/MicrosoftGraphTransport.php | 53 ++++++++++++++++++ ProcessMaker/Managers/OauthMailManager.php | 22 ++++++++ config/mail.php | 4 ++ .../settings/components/SettingsListing.vue | 4 +- 6 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 ProcessMaker/Mail/MicrosoftGraphMessageConverter.php create mode 100644 ProcessMaker/Mail/Transports/MicrosoftGraphTransport.php diff --git a/ProcessMaker/Mail/MicrosoftGraphMessageConverter.php b/ProcessMaker/Mail/MicrosoftGraphMessageConverter.php new file mode 100644 index 0000000000..e95a3ea281 --- /dev/null +++ b/ProcessMaker/Mail/MicrosoftGraphMessageConverter.php @@ -0,0 +1,55 @@ +getHtmlBody(); + $textBody = $email->getTextBody(); + + $message = [ + 'subject' => $email->getSubject() ?? '', + 'body' => [ + 'contentType' => $htmlBody !== null ? 'HTML' : 'Text', + 'content' => $htmlBody ?? $textBody ?? '', + ], + 'toRecipients' => self::convertAddresses($email->getTo()), + ]; + + $ccRecipients = self::convertAddresses($email->getCc()); + if ($ccRecipients) { + $message['ccRecipients'] = $ccRecipients; + } + + $bccRecipients = self::convertAddresses($email->getBcc()); + if ($bccRecipients) { + $message['bccRecipients'] = $bccRecipients; + } + + return [ + 'message' => $message, + 'saveToSentItems' => true, + ]; + } + + /** + * @param Address[] $addresses + */ + private static function convertAddresses(array $addresses): array + { + return array_map(function (Address $address) { + $emailAddress = ['address' => $address->getAddress()]; + + if ($address->getName()) { + $emailAddress['name'] = $address->getName(); + } + + return ['emailAddress' => $emailAddress]; + }, $addresses); + } +} diff --git a/ProcessMaker/Mail/MicrosoftGraphTokenProvider.php b/ProcessMaker/Mail/MicrosoftGraphTokenProvider.php index 913274bea6..1b97e5af05 100644 --- a/ProcessMaker/Mail/MicrosoftGraphTokenProvider.php +++ b/ProcessMaker/Mail/MicrosoftGraphTokenProvider.php @@ -15,7 +15,7 @@ class MicrosoftGraphTokenProvider public function __construct( private array $config, private int|string $serverIndex = 0, - private ?Client $httpClient = null, + private ?Client $httpClient = null ) { } diff --git a/ProcessMaker/Mail/Transports/MicrosoftGraphTransport.php b/ProcessMaker/Mail/Transports/MicrosoftGraphTransport.php new file mode 100644 index 0000000000..59ff0d1b96 --- /dev/null +++ b/ProcessMaker/Mail/Transports/MicrosoftGraphTransport.php @@ -0,0 +1,53 @@ +client = $client ?? new Client([ + 'base_uri' => 'https://graph.microsoft.com/v1.0/', + ]); + } + + protected function doSend(SentMessage $message): void + { + $email = MessageConverter::toEmail($message->getOriginalMessage()); + $payload = MicrosoftGraphMessageConverter::toSendMailPayload($email); + $token = $this->tokenProvider->getAccessToken(); + + $response = $this->client->post('users/' . rawurlencode($this->senderEmail) . '/sendMail', [ + 'headers' => [ + 'Authorization' => 'Bearer ' . $token, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + 'http_errors' => false, + ]); + + if ($response->getStatusCode() >= 400) { + throw new TransportException( + 'Microsoft Graph send failed: ' . $response->getBody()->getContents() + ); + } + } + + public function __toString(): string + { + return 'microsoft-graph'; + } +} diff --git a/ProcessMaker/Managers/OauthMailManager.php b/ProcessMaker/Managers/OauthMailManager.php index 603b174970..58a7d08b12 100644 --- a/ProcessMaker/Managers/OauthMailManager.php +++ b/ProcessMaker/Managers/OauthMailManager.php @@ -7,6 +7,8 @@ use Google\Client as GoogleClient; use GuzzleHttp\Client; use Illuminate\Mail\MailManager; +use ProcessMaker\Mail\MicrosoftGraphTokenProvider; +use ProcessMaker\Mail\Transports\MicrosoftGraphTransport; use ProcessMaker\Models\EnvironmentVariable; use ProcessMaker\Models\Setting; use ProcessMaker\Packages\Connectors\Email\EmailConfig; @@ -77,6 +79,26 @@ protected function createSmtpTransport($config) return $transport; } + public function createTransport(array $config) + { + if ($this->app->config->get('mail.driver') === 'microsoft_graph') { + return $this->createMicrosoftGraphTransport($config); + } + + return parent::createTransport($config); + } + + protected function createMicrosoftGraphTransport(array $config) + { + return new MicrosoftGraphTransport( + new MicrosoftGraphTokenProvider( + $this->app->config->get('services.microsoft_graph', []), + $this->emailServerIndex ?? 0 + ), + $this->fromAddress + ); + } + public function checkForExpiredAccessToken() { switch ($this->authMethod) { diff --git a/config/mail.php b/config/mail.php index d78ea38d62..1b524c46c7 100644 --- a/config/mail.php +++ b/config/mail.php @@ -86,6 +86,10 @@ 'transport' => 'array', ], + 'microsoft_graph' => [ + 'transport' => 'microsoft_graph', + ], + 'failover' => [ 'transport' => 'failover', 'mailers' => [ diff --git a/resources/js/admin/settings/components/SettingsListing.vue b/resources/js/admin/settings/components/SettingsListing.vue index ecba515d51..01a3ae7e30 100644 --- a/resources/js/admin/settings/components/SettingsListing.vue +++ b/resources/js/admin/settings/components/SettingsListing.vue @@ -607,7 +607,9 @@ export default { filterEmailServerButtons(groupName, groupData, btn) { const authMethod = groupData.find(data => data.key.includes("EMAIL_CONNECTOR_MAIL_AUTH_METHOD")); const selectedAuthMethod = authMethod ? authMethod.ui.options[authMethod.config] : null; - const showAuthAccBtn = selectedAuthMethod && selectedAuthMethod !== 'standard' ? true : false; + const mailDriver = groupData.find(data => data.key.includes("EMAIL_CONNECTOR_MAIL_DRIVER")); + const selectedDriver = mailDriver ? mailDriver.ui.options[mailDriver.config] : null; + const showAuthAccBtn = (selectedAuthMethod && selectedAuthMethod !== 'standard') || selectedDriver === 'microsoft_graph'; if (groupName.includes('Email Server') && !showAuthAccBtn) { // Returns all 'top' position buttons except the '+ Mail Server' and 'Authorize Account' button for email server tabs From 91b80b6032cb45b106932fe3698e5733cdc95471 Mon Sep 17 00:00:00 2001 From: sanja <52755494+sanjacornelius@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:02:33 -0700 Subject: [PATCH 5/5] Remove authorization badge from microsoft_graph --- resources/js/admin/settings/components/SettingsListing.vue | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/resources/js/admin/settings/components/SettingsListing.vue b/resources/js/admin/settings/components/SettingsListing.vue index 01a3ae7e30..0d489764b3 100644 --- a/resources/js/admin/settings/components/SettingsListing.vue +++ b/resources/js/admin/settings/components/SettingsListing.vue @@ -607,9 +607,7 @@ export default { filterEmailServerButtons(groupName, groupData, btn) { const authMethod = groupData.find(data => data.key.includes("EMAIL_CONNECTOR_MAIL_AUTH_METHOD")); const selectedAuthMethod = authMethod ? authMethod.ui.options[authMethod.config] : null; - const mailDriver = groupData.find(data => data.key.includes("EMAIL_CONNECTOR_MAIL_DRIVER")); - const selectedDriver = mailDriver ? mailDriver.ui.options[mailDriver.config] : null; - const showAuthAccBtn = (selectedAuthMethod && selectedAuthMethod !== 'standard') || selectedDriver === 'microsoft_graph'; + const showAuthAccBtn = selectedAuthMethod && selectedAuthMethod !== 'standard'; if (groupName.includes('Email Server') && !showAuthAccBtn) { // Returns all 'top' position buttons except the '+ Mail Server' and 'Authorize Account' button for email server tabs