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 new file mode 100644 index 0000000000..1b97e5af05 --- /dev/null +++ b/ProcessMaker/Mail/MicrosoftGraphTokenProvider.php @@ -0,0 +1,62 @@ +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 = $this->httpClient ?? 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/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..0d489764b3 100644 --- a/resources/js/admin/settings/components/SettingsListing.vue +++ b/resources/js/admin/settings/components/SettingsListing.vue @@ -607,7 +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 showAuthAccBtn = selectedAuthMethod && selectedAuthMethod !== 'standard' ? true : false; + 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 diff --git a/tests/unit/ProcessMaker/Mail/MicrosoftGraphTokenProviderTest.php b/tests/unit/ProcessMaker/Mail/MicrosoftGraphTokenProviderTest.php new file mode 100644 index 0000000000..a4f62d67d5 --- /dev/null +++ b/tests/unit/ProcessMaker/Mail/MicrosoftGraphTokenProviderTest.php @@ -0,0 +1,65 @@ + '', + '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(); + + $tokenResponse = new Response(200, [], json_encode(['access_token' => 'token-from-azure'])); + + $guzzle = Mockery::mock(Client::class); + $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, $guzzle); + + $this->assertSame('token-from-azure', $provider->getAccessToken()); + $this->assertSame('token-from-azure', $provider->getAccessToken()); + } +}