From 5fc3cd1ec559d60ca6976de28038dac3d82f28c1 Mon Sep 17 00:00:00 2001 From: mAjjikuttira Date: Tue, 14 Oct 2025 09:29:21 -0600 Subject: [PATCH 1/2] feat(email): integrate Microsoft Graph email provider with configuration and handler support --- .../app/integrations/dtos/credentials.dto.ts | 5 + .../images/providers/light/square/msgraph.svg | 32 +++ .../static/images/providers/dark/msgraph.svg | 32 +++ .../static/images/providers/light/msgraph.svg | 32 +++ .../src/factories/mail/handlers/index.ts | 1 + .../mail/handlers/msgraph.handler.ts | 21 ++ .../src/factories/mail/mail.factory.ts | 2 + .../integration/integration.schema.ts | 3 +- .../src/schemas/providers/email/index.ts | 1 + packages/framework/src/shared.ts | 1 + packages/providers/src/lib/email/index.ts | 1 + .../msgraph-mail/msgraph-mail.provider.ts | 190 ++++++++++++++++++ .../msgraph-mail.test.provider.spec.ts | 187 +++++++++++++++++ .../src/consts/providers/channels/email.ts | 9 + .../credentials/provider-credentials.ts | 24 +++ .../integration/credential.interface.ts | 1 + packages/shared/src/types/providers.ts | 2 + 17 files changed, 543 insertions(+), 1 deletion(-) create mode 100644 apps/dashboard/public/images/providers/light/square/msgraph.svg create mode 100644 apps/web/public/static/images/providers/dark/msgraph.svg create mode 100644 apps/web/public/static/images/providers/light/msgraph.svg create mode 100644 libs/application-generic/src/factories/mail/handlers/msgraph.handler.ts create mode 100644 packages/providers/src/lib/email/msgraph-mail/msgraph-mail.provider.ts create mode 100644 packages/providers/src/lib/email/msgraph-mail/msgraph-mail.test.provider.spec.ts diff --git a/apps/api/src/app/integrations/dtos/credentials.dto.ts b/apps/api/src/app/integrations/dtos/credentials.dto.ts index b4b3560449c..523d6d1fd75 100644 --- a/apps/api/src/app/integrations/dtos/credentials.dto.ts +++ b/apps/api/src/app/integrations/dtos/credentials.dto.ts @@ -226,4 +226,9 @@ export class CredentialsDto implements ICredentials { @IsString() @IsOptional() senderId?: string; + + @ApiPropertyOptional() + @IsString() + @IsOptional() + tenantId?: string; } diff --git a/apps/dashboard/public/images/providers/light/square/msgraph.svg b/apps/dashboard/public/images/providers/light/square/msgraph.svg new file mode 100644 index 00000000000..2fc4f163579 --- /dev/null +++ b/apps/dashboard/public/images/providers/light/square/msgraph.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/public/static/images/providers/dark/msgraph.svg b/apps/web/public/static/images/providers/dark/msgraph.svg new file mode 100644 index 00000000000..2fc4f163579 --- /dev/null +++ b/apps/web/public/static/images/providers/dark/msgraph.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/public/static/images/providers/light/msgraph.svg b/apps/web/public/static/images/providers/light/msgraph.svg new file mode 100644 index 00000000000..2fc4f163579 --- /dev/null +++ b/apps/web/public/static/images/providers/light/msgraph.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/libs/application-generic/src/factories/mail/handlers/index.ts b/libs/application-generic/src/factories/mail/handlers/index.ts index 24d5cf6966a..c889a95c75f 100644 --- a/libs/application-generic/src/factories/mail/handlers/index.ts +++ b/libs/application-generic/src/factories/mail/handlers/index.ts @@ -18,3 +18,4 @@ export * from './sendgrid.handler'; export * from './sendinblue.handler'; export * from './ses.handler'; export * from './sparkpost.handler'; +export * from './msgraph.handler'; diff --git a/libs/application-generic/src/factories/mail/handlers/msgraph.handler.ts b/libs/application-generic/src/factories/mail/handlers/msgraph.handler.ts new file mode 100644 index 00000000000..f785f831c07 --- /dev/null +++ b/libs/application-generic/src/factories/mail/handlers/msgraph.handler.ts @@ -0,0 +1,21 @@ +import { MsGraphEmailProvider } from '@novu/providers'; +import { ChannelTypeEnum, EmailProviderIdEnum, ICredentials } from '@novu/shared'; +import { BaseEmailHandler } from './base.handler'; + +export class MsGraphHandler extends BaseEmailHandler { + constructor() { + super(EmailProviderIdEnum.MsGraph, ChannelTypeEnum.EMAIL); + } + + buildProvider(credentials: ICredentials) { + const config = { + clientId: credentials.clientId as string, + clientSecret: credentials.secretKey as string, + tenantId: credentials.tenantId as string, + from: credentials.from as string, + senderName: credentials.senderName as string, + }; + + this.provider = new MsGraphEmailProvider(config); + } +} diff --git a/libs/application-generic/src/factories/mail/mail.factory.ts b/libs/application-generic/src/factories/mail/mail.factory.ts index b094849ad7d..fe548e19321 100644 --- a/libs/application-generic/src/factories/mail/mail.factory.ts +++ b/libs/application-generic/src/factories/mail/mail.factory.ts @@ -20,6 +20,7 @@ import { SendgridHandler, SendinblueHandler, SparkPostHandler, + MsGraphHandler, } from './handlers'; import { IMailFactory, IMailHandler } from './interfaces'; @@ -45,6 +46,7 @@ export class MailFactory implements IMailFactory { new EmailWebhookHandler(), new NovuEmailHandler(), new BrazeEmailHandler(), + new MsGraphHandler(), ]; getHandler( diff --git a/libs/dal/src/repositories/integration/integration.schema.ts b/libs/dal/src/repositories/integration/integration.schema.ts index 67d9c687518..653b8183f85 100644 --- a/libs/dal/src/repositories/integration/integration.schema.ts +++ b/libs/dal/src/repositories/integration/integration.schema.ts @@ -64,7 +64,8 @@ const integrationSchema = new Schema( accessKey: Schema.Types.String, appSid: Schema.Types.String, senderId: Schema.Types.String, - servicePlanId: Schema.Types.String, + servicePlanId: Schema.Types.String, + tenantId: Schema.Types.String, }, configurations: { inboundWebhookEnabled: Schema.Types.Boolean, diff --git a/packages/framework/src/schemas/providers/email/index.ts b/packages/framework/src/schemas/providers/email/index.ts index 2415e496ec5..9b389eb6db5 100644 --- a/packages/framework/src/schemas/providers/email/index.ts +++ b/packages/framework/src/schemas/providers/email/index.ts @@ -29,4 +29,5 @@ export const emailProviderSchemas = { sendinblue: genericProviderSchemas, ses: genericProviderSchemas, sparkpost: genericProviderSchemas, + msgraph: genericProviderSchemas, } as const satisfies Record; diff --git a/packages/framework/src/shared.ts b/packages/framework/src/shared.ts index 13420eff91a..1948a4f77cc 100644 --- a/packages/framework/src/shared.ts +++ b/packages/framework/src/shared.ts @@ -121,6 +121,7 @@ export enum EmailProviderIdEnum { SparkPost = 'sparkpost', EmailWebhook = 'email-webhook', Braze = 'braze', + MsGraph = 'msgraph', } export enum SmsProviderIdEnum { diff --git a/packages/providers/src/lib/email/index.ts b/packages/providers/src/lib/email/index.ts index 367d63882a9..c68472f45b2 100644 --- a/packages/providers/src/lib/email/index.ts +++ b/packages/providers/src/lib/email/index.ts @@ -21,3 +21,4 @@ export * from './ses/ses.config'; export * from './ses/ses.provider'; export * from './sparkpost/sparkpost.error'; export * from './sparkpost/sparkpost.provider'; +export * from './msgraph-mail/msgraph-mail.provider'; diff --git a/packages/providers/src/lib/email/msgraph-mail/msgraph-mail.provider.ts b/packages/providers/src/lib/email/msgraph-mail/msgraph-mail.provider.ts new file mode 100644 index 00000000000..ff0773942d5 --- /dev/null +++ b/packages/providers/src/lib/email/msgraph-mail/msgraph-mail.provider.ts @@ -0,0 +1,190 @@ +import { EmailProviderIdEnum } from '@novu/shared'; +import { + ChannelTypeEnum, + CheckIntegrationResponseEnum, + ICheckIntegrationResponse, + IEmailOptions, + IEmailProvider, + ISendMessageSuccessResponse, +} from '@novu/stateless'; +import { BaseProvider, CasingEnum } from '../../../base.provider'; +import { WithPassthrough } from '../../../utils/types'; +import axios from 'axios'; + +export class MsGraphEmailProvider extends BaseProvider implements IEmailProvider { + id = EmailProviderIdEnum.MsGraph; + protected casing: CasingEnum = CasingEnum.CAMEL_CASE; + channelType = ChannelTypeEnum.EMAIL as ChannelTypeEnum.EMAIL; + + constructor(private config: { + clientId: string; + clientSecret: string; + tenantId: string; + from: string; + senderName: string; + to?: string; // BWALK CUSTOM CODE + }) { + super(); + } + + async sendMessage( + options: IEmailOptions, + bridgeProviderData: WithPassthrough> = {} + ): Promise { + const transformedOptions = this.transform(bridgeProviderData, options).body as unknown as IEmailOptions; + const mailData = this.createMailData(transformedOptions); + + const info = await this.sendEmailViaMsGraph(mailData); + + return { + id: info?.messageId, + date: new Date().toISOString(), + }; + } + + async checkIntegration(options: IEmailOptions): Promise { + try { + const mailData = this.createMailData(options); + await this.sendEmailViaMsGraph(mailData); + + return { + success: true, + message: 'Integrated successfully!', + code: CheckIntegrationResponseEnum.SUCCESS, + }; + } catch (error) { + return { + success: false, + message: this.safeGetErrorMessage(error), + code: CheckIntegrationResponseEnum.FAILED, + }; + } + } + + private async sendEmailViaMsGraph(emailData: any) { + try { + // Get OAuth2 access token + const accessToken = await this.getAccessToken(); + + // Send email via Microsoft Graph API + const response = await axios.post( + `https://graph.microsoft.com/v1.0/users/${this.config.from}/sendMail`, + emailData, + { + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + } + ); + + return { + messageId: response.headers['x-ms-request-id'] || 'msgraph-sent', + }; + } catch (error) { + // Create a clean error object without circular references + const cleanError = new Error(this.safeGetErrorMessage(error)); + throw cleanError; + } + } + + private async getAccessToken(): Promise { + 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 2d9fa378212..fc6c21173a5 100644 --- a/packages/shared/src/consts/providers/credentials/provider-credentials.ts +++ b/packages/shared/src/consts/providers/credentials/provider-credentials.ts @@ -1306,3 +1306,27 @@ 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, +]; diff --git a/packages/shared/src/entities/integration/credential.interface.ts b/packages/shared/src/entities/integration/credential.interface.ts index ef05147de8c..80d3259d5e0 100644 --- a/packages/shared/src/entities/integration/credential.interface.ts +++ b/packages/shared/src/entities/integration/credential.interface.ts @@ -48,4 +48,5 @@ export interface ICredentials { appSid?: string; senderId?: string; servicePlanId?: string; + tenantId?: string; } diff --git a/packages/shared/src/types/providers.ts b/packages/shared/src/types/providers.ts index 8b3ff9bb811..df587d4650f 100644 --- a/packages/shared/src/types/providers.ts +++ b/packages/shared/src/types/providers.ts @@ -50,6 +50,7 @@ export enum CredentialsKeyEnum { AppSid = 'appSid', SenderId = 'senderId', ServicePlanId = 'servicePlanId', + TenantId = 'tenantId', } export type ConfigurationKey = keyof IConfigurations; @@ -76,6 +77,7 @@ export enum EmailProviderIdEnum { SparkPost = 'sparkpost', EmailWebhook = 'email-webhook', Braze = 'braze', + MsGraph = 'msgraph', } export enum SmsProviderIdEnum { From 2fbb5d582cc42075ce60a900da243c1a79a5349b Mon Sep 17 00:00:00 2001 From: mAjjikuttira Date: Thu, 16 Oct 2025 15:09:03 -0600 Subject: [PATCH 2/2] feat(icons): add Microsoft Graph SVG icons for light and dark themes under square folder - needed to show workflow icons in web --- .../images/providers/dark/square/msgraph.svg | 32 +++++++++++++++++++ .../images/providers/light/square/msgraph.svg | 32 +++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 apps/web/public/static/images/providers/dark/square/msgraph.svg create mode 100644 apps/web/public/static/images/providers/light/square/msgraph.svg diff --git a/apps/web/public/static/images/providers/dark/square/msgraph.svg b/apps/web/public/static/images/providers/dark/square/msgraph.svg new file mode 100644 index 00000000000..2fc4f163579 --- /dev/null +++ b/apps/web/public/static/images/providers/dark/square/msgraph.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/public/static/images/providers/light/square/msgraph.svg b/apps/web/public/static/images/providers/light/square/msgraph.svg new file mode 100644 index 00000000000..2fc4f163579 --- /dev/null +++ b/apps/web/public/static/images/providers/light/square/msgraph.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + +