{
+ try {
+ const tokenUrl = `https://login.microsoftonline.com/${this.config.tenantId}/oauth2/v2.0/token`;
+
+ const tokenData = {
+ client_id: this.config.clientId,
+ client_secret: this.config.clientSecret,
+ scope: 'https://graph.microsoft.com/.default',
+ grant_type: 'client_credentials',
+ };
+
+ const response = await axios.post(tokenUrl, new URLSearchParams(tokenData), {
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ });
+
+ return response.data.access_token;
+ } catch (error) {
+ // Create a clean error object without circular references
+ const cleanError = new Error(this.safeGetErrorMessage(error));
+ throw cleanError;
+ }
+ }
+
+ private safeGetErrorMessage(error: any): string {
+ try {
+ // Handle Axios errors specifically
+ if (error?.isAxiosError) {
+ const status = error.response?.status;
+ const statusText = error.response?.statusText;
+ const data = error.response?.data;
+ const url = error.config?.url;
+ const method = error.config?.method;
+
+ return `MSGraph API Error: ${status} ${statusText} - ${data?.error?.message || data?.message || error.message} (${method} ${url})`;
+ }
+
+ // Handle other errors
+ if (error instanceof Error) {
+ return error.message;
+ }
+
+ // Handle plain objects
+ if (typeof error === 'object' && error !== null) {
+ return error.message || error.error || 'Unknown MSGraph error';
+ }
+
+ // Fallback for primitive values
+ return String(error);
+ } catch (stringifyError) {
+ return 'Error processing MSGraph error message';
+ }
+ }
+
+ private createMailData(options: IEmailOptions) {
+ const sendMailOptions = {
+ message: {
+ subject: options.subject,
+ body: {
+ contentType: options.html ? 'HTML' : 'Text',
+ content: options.html || options.text,
+ },
+ toRecipients: this.config.to ? [{
+ emailAddress: {
+ address: this.config.to,
+ },
+ }] : (Array.isArray(options.to) ? options.to : [options.to]).map(email => ({
+ emailAddress: {
+ address: email,
+ },
+ })).filter((recipient, index, self) =>
+ index === self.findIndex((t) => t.emailAddress.address === recipient.emailAddress.address)
+ ),
+ from: {
+ emailAddress: {
+ address: options.from || this.config.from,
+ name: options.senderName || this.config.senderName,
+ },
+ },
+ replyTo: options.replyTo ? [{
+ emailAddress: {
+ address: options.replyTo,
+ },
+ }] : undefined,
+ attachments: options.attachments?.map(attachment => ({
+ '@odata.type': '#microsoft.graph.fileAttachment',
+ name: attachment.name,
+ contentType: attachment.mime,
+ contentBytes: attachment.file.toString('base64'),
+ contentId: attachment.cid,
+ isInline: attachment.disposition === 'inline',
+ })),
+ },
+ saveToSentItems: true,
+ };
+
+ return sendMailOptions;
+ }
+}
diff --git a/packages/providers/src/lib/email/msgraph-mail/msgraph-mail.test.provider.spec.ts b/packages/providers/src/lib/email/msgraph-mail/msgraph-mail.test.provider.spec.ts
new file mode 100644
index 00000000000..b2afd7b8a52
--- /dev/null
+++ b/packages/providers/src/lib/email/msgraph-mail/msgraph-mail.test.provider.spec.ts
@@ -0,0 +1,187 @@
+import { CheckIntegrationResponseEnum } from '@novu/stateless';
+import axios from 'axios';
+import { expect, test, vi } from 'vitest';
+import { MsGraphEmailProvider } from './msgraph-mail.provider';
+
+const mockConfig = {
+ clientId: 'test-client-id',
+ clientSecret: 'test-client-secret',
+ tenantId: 'test-tenant-id',
+ from: 'test@test.com',
+ senderName: 'Test Sender',
+};
+
+const mockNovuMessage = {
+ to: ['test@test2.com'],
+ subject: 'test subject',
+ html: ' Mail Content
',
+ from: 'test@test.com',
+ attachments: [{ mime: 'text/plain', file: Buffer.from('dGVzdA=='), name: 'test.txt' }],
+ id: 'message_id',
+};
+
+const getSpy = () => vi.spyOn(axios, 'post').mockImplementation(async (url) => {
+ if (url.includes('oauth2/v2.0/token')) {
+ return {
+ data: {
+ access_token: 'mock-access-token',
+ },
+ };
+ }
+ if (url.includes('graph.microsoft.com')) {
+ return {
+ headers: {
+ 'x-ms-request-id': 'msgraph-message-id',
+ },
+ };
+ }
+ return {};
+ });
+
+test('should trigger msgraph library correctly', async () => {
+ const provider = new MsGraphEmailProvider(mockConfig);
+
+ const spy = getSpy();
+
+ const response = await provider.sendMessage(mockNovuMessage);
+
+ expect(spy).toHaveBeenCalled();
+ expect(spy).toHaveBeenCalledTimes(2);
+
+ // Verify Graph API request
+ expect(spy).toHaveBeenNthCalledWith(2,
+ `https://graph.microsoft.com/v1.0/users/${mockConfig.from}/sendMail`,
+ expect.objectContaining({
+ message: expect.objectContaining({
+ subject: mockNovuMessage.subject,
+ body: expect.objectContaining({
+ contentType: 'HTML',
+ content: mockNovuMessage.html,
+ }),
+ toRecipients: expect.arrayContaining([
+ expect.objectContaining({
+ emailAddress: expect.objectContaining({
+ address: mockNovuMessage.to[0],
+ }),
+ }),
+ ]),
+ from: expect.objectContaining({
+ emailAddress: expect.objectContaining({
+ address: mockNovuMessage.from,
+ name: mockConfig.senderName,
+ }),
+ }),
+ attachments: expect.arrayContaining([
+ expect.objectContaining({
+ '@odata.type': '#microsoft.graph.fileAttachment',
+ name: mockNovuMessage.attachments[0].name,
+ contentType: mockNovuMessage.attachments[0].mime,
+ contentBytes: mockNovuMessage.attachments[0].file.toString('base64'),
+ }),
+ ]),
+ }),
+ saveToSentItems: true,
+ }),
+ expect.objectContaining({
+ headers: {
+ 'Authorization': 'Bearer mock-access-token',
+ 'Content-Type': 'application/json',
+ },
+ })
+ );
+
+ expect(response).not.toBeNull();
+ expect(response.id).toBe('msgraph-message-id');
+ expect(response.date).toBeDefined();
+});
+
+test('should trigger msgraph library correctly with _passthrough', async () => {
+ const provider = new MsGraphEmailProvider(mockConfig);
+
+ const spy = getSpy();
+
+ await provider.sendMessage(mockNovuMessage, {
+ _passthrough: {
+ body: {
+ html: ' Mail Content _passthrough
',
+ subject: 'test subject _passthrough',
+ },
+ },
+ });
+
+ expect(spy).toHaveBeenCalled();
+ expect(spy).toHaveBeenCalledTimes(2);
+
+ // Verify Graph API request with overridden values
+ expect(spy).toHaveBeenNthCalledWith(2,
+ 'https://graph.microsoft.com/v1.0/users/test@test.com/sendMail',
+ expect.objectContaining({
+ message: expect.objectContaining({
+ subject: 'test subject _passthrough',
+ body: expect.objectContaining({
+ content: ' Mail Content _passthrough
',
+ }),
+ }),
+ }),
+ expect.any(Object)
+ );
+});
+
+test('should check provider integration correctly', async () => {
+ const provider = new MsGraphEmailProvider(mockConfig);
+
+ const spy = getSpy();
+
+ const response = await provider.checkIntegration(mockNovuMessage);
+
+ expect(spy).toHaveBeenCalled();
+ expect(response).not.toBeNull();
+ expect(response.success).toBeTruthy();
+ expect(response.message).toBe('Integrated successfully!');
+ expect(response.code).toBe(CheckIntegrationResponseEnum.SUCCESS);
+});
+
+test('should handle integration check failure', async () => {
+ const provider = new MsGraphEmailProvider(mockConfig);
+
+ const spy = vi.spyOn(axios, 'post').mockImplementation(async (url, data, config) => {
+ if (url.includes('oauth2/v2.0/token')) {
+ return {
+ data: {
+ access_token: 'mock-access-token',
+ },
+ };
+ }
+ if (url.includes('graph.microsoft.com')) {
+ throw new Error('Graph API Error');
+ }
+ return {};
+ });
+
+ const response = await provider.checkIntegration(mockNovuMessage);
+
+ expect(spy).toHaveBeenCalled();
+ expect(response).not.toBeNull();
+ expect(response.success).toBeFalsy();
+ expect(response.message).toBe('Graph API Error');
+ expect(response.code).toBe(CheckIntegrationResponseEnum.FAILED);
+});
+
+test('should handle OAuth token failure', async () => {
+ const provider = new MsGraphEmailProvider(mockConfig);
+
+ const spy = vi.spyOn(axios, 'post').mockImplementation(async (url, data, config) => {
+ if (url.includes('oauth2/v2.0/token')) {
+ throw new Error('OAuth Error');
+ }
+ return {};
+ });
+
+ try {
+ await provider.sendMessage(mockNovuMessage);
+ } catch (error) {
+ expect(error.message).toBe('OAuth Error');
+ }
+
+ expect(spy).toHaveBeenCalled();
+});
diff --git a/packages/shared/src/consts/providers/channels/email.ts b/packages/shared/src/consts/providers/channels/email.ts
index f80479ab564..c40cae02cd5 100644
--- a/packages/shared/src/consts/providers/channels/email.ts
+++ b/packages/shared/src/consts/providers/channels/email.ts
@@ -25,6 +25,7 @@ import {
sendinblueConfig,
sesConfig,
sparkpostConfig,
+ msGraphConfig,
} from '../credentials';
import { IProviderConfig } from '../provider.interface';
@@ -186,4 +187,12 @@ export const emailProviders: IProviderConfig[] = [
docReference: `https://docs.novu.co/channels/email/email-webhook${UTM_CAMPAIGN_QUERY_PARAM}`,
logoFileName: { light: 'email_webhook.svg', dark: 'email_webhook.svg' },
},
+ {
+ id: EmailProviderIdEnum.MsGraph,
+ displayName: 'Microsoft Graph',
+ channel: ChannelTypeEnum.EMAIL,
+ credentials: msGraphConfig,
+ docReference: 'https://docs.microsoft.com/en-us/graph/api/user-sendmail',
+ logoFileName: { light: 'msgraph.svg', dark: 'msgraph.svg' },
+ }
];
diff --git a/packages/shared/src/consts/providers/credentials/provider-credentials.ts b/packages/shared/src/consts/providers/credentials/provider-credentials.ts
index f97ac0dcadc..66298614994 100644
--- a/packages/shared/src/consts/providers/credentials/provider-credentials.ts
+++ b/packages/shared/src/consts/providers/credentials/provider-credentials.ts
@@ -1319,6 +1319,31 @@ export const sinchConfig: IConfigCredential[] = [
...smsConfigBase,
];
+export const msGraphConfig: IConfigCredential[] = [
+ {
+ key: CredentialsKeyEnum.ClientId,
+ displayName: 'Client ID',
+ description: 'Your Microsoft Graph application client ID',
+ type: 'string',
+ required: true,
+ },
+ {
+ key: CredentialsKeyEnum.SecretKey,
+ displayName: 'Client secret',
+ description: 'Your Microsoft Graph application client secret',
+ type: 'string',
+ required: true,
+ },
+ {
+ key: CredentialsKeyEnum.TenantId,
+ displayName: 'Tenant ID',
+ description: 'Your Azure AD tenant ID',
+ type: 'string',
+ required: true,
+ },
+ ...mailConfigBase,
+];
+
export const ISendProProviderConfig: IConfigCredential[] = [
{
key: CredentialsKeyEnum.ApiKey,
@@ -1334,4 +1359,4 @@ export const ISendProProviderConfig: IConfigCredential[] = [
type: 'text',
required: false,
}
- ];
+];
diff --git a/packages/shared/src/entities/integration/credential.interface.ts b/packages/shared/src/entities/integration/credential.interface.ts
index 2994c8d8ec6..d19a1d85d11 100644
--- a/packages/shared/src/entities/integration/credential.interface.ts
+++ b/packages/shared/src/entities/integration/credential.interface.ts
@@ -52,4 +52,5 @@ export interface ICredentials {
AppIOBearerToken?: string;
AppIOOriginalSignature?: string;
servicePlanId?: string;
+ tenantId?: string;
}
diff --git a/packages/shared/src/types/providers.ts b/packages/shared/src/types/providers.ts
index 16b52028367..125d294d882 100644
--- a/packages/shared/src/types/providers.ts
+++ b/packages/shared/src/types/providers.ts
@@ -51,6 +51,7 @@ export enum CredentialsKeyEnum {
SenderId = 'senderId',
AppIOBaseUrl = 'AppIOBaseUrl',
ServicePlanId = 'servicePlanId',
+ TenantId = 'tenantId',
}
export type ConfigurationKey = keyof IConfigurations;
@@ -77,6 +78,7 @@ export enum EmailProviderIdEnum {
SparkPost = 'sparkpost',
EmailWebhook = 'email-webhook',
Braze = 'braze',
+ MsGraph = 'msgraph',
}
export enum SmsProviderIdEnum {