diff --git a/apps/api/src/app/agents/services/chat-sdk.service.spec.ts b/apps/api/src/app/agents/services/chat-sdk.service.spec.ts new file mode 100644 index 00000000000..497ed01bc65 --- /dev/null +++ b/apps/api/src/app/agents/services/chat-sdk.service.spec.ts @@ -0,0 +1,119 @@ +import { ChannelTypeEnum, EmailProviderIdEnum } from '@novu/shared'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import { ChatSdkService } from './chat-sdk.service'; + +describe('ChatSdkService', () => { + describe('buildSendEmailCallback', () => { + it('should skip custom MIME alternatives for unsupported outbound providers', async () => { + const logger = { + warn: sinon.stub(), + error: sinon.stub(), + debug: sinon.stub(), + info: sinon.stub(), + }; + const integrationRepository = { + findOne: sinon.stub().resolves({ + _id: 'outbound-integration-id', + _environmentId: 'env-id', + _organizationId: 'org-id', + providerId: EmailProviderIdEnum.Resend, + channel: ChannelTypeEnum.EMAIL, + credentials: {}, + active: true, + }), + }; + const service = new ChatSdkService(logger as any, {} as any, {} as any, {} as any, integrationRepository as any); + const sendEmail = (service as any).buildSendEmailCallback( + { + environmentId: 'env-id', + organizationId: 'org-id', + credentials: {}, + }, + 'outbound-integration-id' + ); + + const result = await sendEmail({ + from: 'agent@example.com', + to: 'user@gmail.com', + subject: 'Re: Hello', + text: '👀', + html: '

👀

', + alternatives: [ + { + contentType: 'text/vnd.google.email-reaction+json', + content: JSON.stringify({ version: 1, emoji: '👀' }), + }, + ], + messageId: '', + inReplyTo: '', + references: '', + }); + + expect(result).to.deep.equal({ messageId: '' }); + expect(logger.warn.calledOnce).to.equal(true); + expect(logger.warn.firstCall.args[0]).to.deep.equal({ + providerId: EmailProviderIdEnum.Resend, + outboundIntegrationId: 'outbound-integration-id', + }); + expect(logger.warn.firstCall.args[1]).to.include('does not support custom MIME alternatives'); + expect( + integrationRepository.findOne.calledOnceWithMatch({ + _id: 'outbound-integration-id', + channel: ChannelTypeEnum.EMAIL, + }) + ).to.equal(true); + }); + + it('should not claim success when unsupported MIME alternatives omit messageId', async () => { + const logger = { + warn: sinon.stub(), + error: sinon.stub(), + debug: sinon.stub(), + info: sinon.stub(), + }; + const integrationRepository = { + findOne: sinon.stub().resolves({ + _id: 'outbound-integration-id', + _environmentId: 'env-id', + _organizationId: 'org-id', + providerId: EmailProviderIdEnum.Resend, + channel: ChannelTypeEnum.EMAIL, + credentials: {}, + active: true, + }), + }; + const service = new ChatSdkService(logger as any, {} as any, {} as any, {} as any, integrationRepository as any); + const sendEmail = (service as any).buildSendEmailCallback( + { + environmentId: 'env-id', + organizationId: 'org-id', + credentials: {}, + }, + 'outbound-integration-id' + ); + + const result = await sendEmail({ + from: 'agent@example.com', + to: 'user@gmail.com', + subject: 'Re: Hello', + text: '👀', + html: '

👀

', + alternatives: [ + { + contentType: 'text/vnd.google.email-reaction+json', + content: JSON.stringify({ version: 1, emoji: '👀' }), + }, + ], + }); + + expect(result).to.deep.equal({ messageId: undefined }); + expect(logger.warn.calledOnce).to.equal(true); + expect(logger.warn.firstCall.args[0]).to.deep.equal({ + providerId: EmailProviderIdEnum.Resend, + outboundIntegrationId: 'outbound-integration-id', + }); + expect(logger.warn.firstCall.args[1]).to.include('no messageId was supplied'); + }); + }); +}); diff --git a/apps/api/src/app/agents/services/chat-sdk.service.ts b/apps/api/src/app/agents/services/chat-sdk.service.ts index 87e88b33f60..884d3eaeab0 100644 --- a/apps/api/src/app/agents/services/chat-sdk.service.ts +++ b/apps/api/src/app/agents/services/chat-sdk.service.ts @@ -39,6 +39,15 @@ function wrapMsgId(id: string): string { const MAX_CACHED_INSTANCES = 200; const INSTANCE_TTL_MS = 1000 * 60 * 30; +// EMAIL_ALTERNATIVES_SUPPORTED_PROVIDERS is a deliberate allowlist for providers that preserve custom MIME +// alternatives used by Gmail reactions; Braze, Brevo, Mailgun, Mailjet, Mailtrap, Mandrill, Plunk, Postmark, +// Resend, SparkPost, and similar providers are excluded until their SDK paths are verified. +const EMAIL_ALTERNATIVES_SUPPORTED_PROVIDERS = new Set([ + EmailProviderIdEnum.CustomSMTP, + EmailProviderIdEnum.Outlook365, + EmailProviderIdEnum.SendGrid, + EmailProviderIdEnum.SES, +]); /** * Holds a cached Chat instance alongside a mutable pointer to the current @@ -320,10 +329,14 @@ export class ChatSdkService implements OnModuleDestroy { subject: string; html: string; text?: string; + alternatives?: Array<{ + contentType: string; + content: string | Buffer; + }>; inReplyTo?: string; references?: string; messageId?: string; - }) => Promise<{ messageId: string }> { + }) => Promise<{ messageId?: string }> { return async (params) => { if (!outboundIntegrationId) { throw new BadRequestException( @@ -357,6 +370,34 @@ export class ChatSdkService implements OnModuleDestroy { ); } + const hasUnsupportedAlternatives = + params.alternatives?.length && !EMAIL_ALTERNATIVES_SUPPORTED_PROVIDERS.has(integration.providerId); + if (hasUnsupportedAlternatives) { + // NovuEmailAdapterImpl.addReaction supplies a reaction Message-ID; any custom MIME alternative caller must do + // the same so skipped unsupported sends don't claim provider delivery. + if (!params.messageId) { + this.logger.warn( + { + providerId: integration.providerId, + outboundIntegrationId, + }, + 'Skipping email with custom MIME alternatives because the outbound provider is unsupported and no messageId was supplied' + ); + + return { messageId: undefined }; + } + + this.logger.warn( + { + providerId: integration.providerId, + outboundIntegrationId, + }, + 'Skipping email reaction because the outbound provider does not support custom MIME alternatives' + ); + + return { messageId: params.messageId }; + } + const decrypted = decryptCredentials(integration.credentials); const mailFactory = new MailFactory(); const handler = mailFactory.getHandler({ ...integration, credentials: decrypted }, params.from); @@ -366,6 +407,7 @@ export class ChatSdkService implements OnModuleDestroy { subject: params.subject, html: params.html, text: params.text, + alternatives: params.alternatives, from: params.from, senderName: config.credentials.senderName || undefined, headers: { diff --git a/packages/chat-adapter-email/src/adapter.ts b/packages/chat-adapter-email/src/adapter.ts index de8425c43df..941256ecb1f 100644 --- a/packages/chat-adapter-email/src/adapter.ts +++ b/packages/chat-adapter-email/src/adapter.ts @@ -19,6 +19,19 @@ import type { NovuEmailAdapterConfig, NovuEmailRawMessage, NovuEmailThreadId } f import { generateMessageId, hashMessageId, parseEmailAddress } from './utils.js'; import { WebhookHandler } from './webhook-handler.js'; +const GMAIL_REACTION_CONTENT_TYPE = 'text/vnd.google.email-reaction+json'; +const GMAIL_MESSAGE_ID_DOMAINS = new Set(['mail.gmail.com']); +const MAX_REACTION_REFERENCE_IDS = 20; +// The email acknowledgement flow currently emits "eyes"; other named reactions are intentionally supported only when +// they have an explicit Unicode mapping that Gmail can validate as a single emoji. +const EMAIL_REACTION_EMOJI_BY_NAME: Record = { + eyes: '👀', + thumbs_up: '👍', + heart: '❤️', + laugh: '😂', + tada: '🎉', +}; + class NotImplementedError extends Error { constructor(method: string) { super(`${method} is not supported by the email adapter`); @@ -51,7 +64,10 @@ export class NovuEmailAdapterImpl implements Adapter[0], + chatModule.parseMarkdown + ); } // -- Thread ID methods -- @@ -138,9 +154,7 @@ export class NovuEmailAdapterImpl implements Adapter` - : agentAddress; + const fromHeader = this.config.senderName ? `${this.config.senderName} <${agentAddress}>` : agentAddress; const messageId = generateMessageId(agentAddress); const replyHeaders = await this.threadResolver.getReplyHeaders(threadId); @@ -180,6 +194,48 @@ export class NovuEmailAdapterImpl implements Adapter { + const decoded = this.threadResolver.decodeThreadId(threadId); + if (!this.isGmailMessageId(messageId)) { + return; + } + + const agentAddress = await this.threadResolver.getAgentAddress(threadId); + if (!agentAddress) { + throw new Error(`No agent address found for thread ${threadId} — cannot determine From address for reaction`); + } + + let reactionEmoji: string; + try { + reactionEmoji = this.toReactionEmoji(emoji); + } catch { + return; + } + + const reactionMessageId = generateMessageId(agentAddress); + const storedSubject = await this.threadResolver.getSubject(threadId); + const subject = storedSubject ? this.toReplySubject(storedSubject) : 'New message'; + const replyHeaders = await this.threadResolver.getReplyHeaders(threadId); + const references = this.buildReactionReferences(replyHeaders?.References, messageId); + + await this.config.sendEmail({ + from: agentAddress, + to: decoded.recipientAddress, + subject, + text: reactionEmoji, + html: `

${reactionEmoji}

`, + alternatives: [ + { + contentType: GMAIL_REACTION_CONTENT_TYPE, + content: JSON.stringify({ version: 1, emoji: reactionEmoji }), + }, + ], + messageId: reactionMessageId, + inReplyTo: messageId, + references, + }); + } + /** * Normalize AdapterPostableMessage variants into a uniform shape. */ @@ -263,11 +319,84 @@ export class NovuEmailAdapterImpl implements Adapter { - throw new NotImplementedError('addReaction'); + async removeReaction(_threadId: string, _messageId: string, _emoji: string): Promise { + // Gmail's email reaction MIME format only defines adding a reaction, so removeReaction is intentionally a no-op. } - async removeReaction(_threadId: string, _messageId: string, _emoji: string): Promise { - throw new NotImplementedError('removeReaction'); + private isGmailMessageId(messageId: string): boolean { + const domain = messageId.trim().replace(/^<|>$/g, '').split('@').at(-1)?.toLowerCase(); + + return !!domain && GMAIL_MESSAGE_ID_DOMAINS.has(domain); + } + + private toReplySubject(subject: string): string { + return /^re:/i.test(subject) ? subject : `Re: ${subject}`; + } + + private toReactionEmoji(emoji: unknown): string { + const emojiName = this.toEmojiName(emoji); + if (emojiName && EMAIL_REACTION_EMOJI_BY_NAME[emojiName]) { + return EMAIL_REACTION_EMOJI_BY_NAME[emojiName]; + } + + if (typeof emoji === 'string' && this.isSingleEmojiGrapheme(emoji)) { + return emoji; + } + + throw new Error(`Unsupported email reaction emoji: ${emojiName ?? String(emoji)}`); + } + + private isSingleEmojiGrapheme(value: string): boolean { + const trimmed = value.trim(); + if (!trimmed) { + return false; + } + + const graphemes = this.segmentGraphemes(trimmed); + const [firstGrapheme] = graphemes; + + return ( + graphemes.length === 1 && + firstGrapheme !== undefined && + /[\p{Extended_Pictographic}\p{Emoji_Presentation}]/u.test(firstGrapheme) + ); + } + + private segmentGraphemes(value: string): string[] { + const Segmenter = ( + Intl as unknown as { + Segmenter?: new ( + locale: string, + options: { granularity: 'grapheme' } + ) => { segment(input: string): Iterable<{ segment: string }> }; + } + ).Segmenter; + + if (!Segmenter) { + return Array.from(value); + } + + return Array.from(new Segmenter('en', { granularity: 'grapheme' }).segment(value), ({ segment }) => segment); + } + + private toEmojiName(emoji: unknown): string | undefined { + if (typeof emoji === 'string') { + return emoji; + } + + if (emoji && typeof emoji === 'object' && 'name' in emoji && typeof emoji.name === 'string') { + return emoji.name; + } + + return undefined; + } + + private buildReactionReferences(references: string | undefined, messageId: string): string { + const ids = references?.split(/\s+/).filter(Boolean) ?? []; + if (!ids.includes(messageId)) { + ids.push(messageId); + } + + return ids.slice(-MAX_REACTION_REFERENCE_IDS).join(' '); } } diff --git a/packages/chat-adapter-email/src/types.ts b/packages/chat-adapter-email/src/types.ts index 0d9c1f9fa7f..84c16e49223 100644 --- a/packages/chat-adapter-email/src/types.ts +++ b/packages/chat-adapter-email/src/types.ts @@ -1,3 +1,4 @@ +import type { IEmailAlternative } from '@novu/shared'; import type { Adapter } from 'chat'; export type { EmailWebhookPayload, NovuEmailAttachment } from '@novu/shared'; @@ -5,15 +6,18 @@ export type { EmailWebhookPayload, NovuEmailAttachment } from '@novu/shared'; export interface NovuEmailAdapterConfig { senderName?: string; signingSecret: string; - sendEmail: (params: SendEmailParams) => Promise<{ messageId: string }>; + sendEmail: (params: SendEmailParams) => Promise<{ messageId?: string }>; } +export type EmailAlternative = IEmailAlternative; + export interface SendEmailParams { from: string; to: string; subject: string; html: string; text?: string; + alternatives?: EmailAlternative[]; inReplyTo?: string; references?: string; messageId?: string; diff --git a/packages/providers/src/lib/email/nodemailer/nodemailer.provider.spec.ts b/packages/providers/src/lib/email/nodemailer/nodemailer.provider.spec.ts index d6fbe9e068a..17721c56eff 100644 --- a/packages/providers/src/lib/email/nodemailer/nodemailer.provider.spec.ts +++ b/packages/providers/src/lib/email/nodemailer/nodemailer.provider.spec.ts @@ -191,7 +191,7 @@ describe.skip('NodemailerProvider', () => { test('should throw an error if TLS options are not a valid JSON', () => { try { - const provider = new NodemailerProvider({ + new NodemailerProvider({ ...mockConfig, tlsOptions: (() => {}) as unknown as ConnectionOptions, }); @@ -238,6 +238,26 @@ describe('NodemailerProvider header forwarding', () => { ); }); + test('should forward custom MIME alternatives to sendMail', async () => { + const provider = new NodemailerProvider(mockConfig); + const spy = vi.spyOn(provider['transports'], 'sendMail').mockResolvedValue({ messageId: 'test-id' } as any); + const reactionAlternative = { + contentType: 'text/vnd.google.email-reaction+json', + content: JSON.stringify({ version: 1, emoji: '👀' }), + }; + + await provider.sendMessage({ + ...mockNovuMessage, + alternatives: [reactionAlternative], + }); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + alternatives: [reactionAlternative], + }) + ); + }); + test('should not include headers field when no custom headers provided', async () => { const provider = new NodemailerProvider(mockConfig); const spy = vi.spyOn(provider['transports'], 'sendMail').mockResolvedValue({ messageId: 'test-id' } as any); diff --git a/packages/providers/src/lib/email/nodemailer/nodemailer.provider.ts b/packages/providers/src/lib/email/nodemailer/nodemailer.provider.ts index d84866b2679..3b6de1accce 100644 --- a/packages/providers/src/lib/email/nodemailer/nodemailer.provider.ts +++ b/packages/providers/src/lib/email/nodemailer/nodemailer.provider.ts @@ -137,6 +137,7 @@ export class NodemailerProvider extends BaseProvider implements IEmailProvider { subject: options.subject, html: options.html, text: options.text, + ...(options.alternatives?.length ? { alternatives: options.alternatives } : {}), cc: options.cc, attachments: options.attachments?.map((attachment) => ({ filename: attachment?.name, diff --git a/packages/providers/src/lib/email/outlook365/outlook365.provider.spec.ts b/packages/providers/src/lib/email/outlook365/outlook365.provider.spec.ts index 8575a5624f9..94f3c08e153 100644 --- a/packages/providers/src/lib/email/outlook365/outlook365.provider.spec.ts +++ b/packages/providers/src/lib/email/outlook365/outlook365.provider.spec.ts @@ -73,6 +73,26 @@ test('should trigger outlook365 library correctly with _passthrough', async () = }); }); +test('should forward custom MIME alternatives to sendMail', async () => { + const provider = new Outlook365Provider(mockConfig); + const reactionAlternative = { + contentType: 'text/vnd.google.email-reaction+json', + content: JSON.stringify({ version: 1, emoji: '👀' }), + }; + + const response = await provider.sendMessage({ + ...mockNovuMessage, + alternatives: [reactionAlternative], + }); + + expect(response).not.toBeNull(); + expect(sendMailMock).toHaveBeenCalledWith( + expect.objectContaining({ + alternatives: [reactionAlternative], + }) + ); +}); + test('should check provider integration correctly', async () => { const provider = new Outlook365Provider(mockConfig); diff --git a/packages/providers/src/lib/email/outlook365/outlook365.provider.ts b/packages/providers/src/lib/email/outlook365/outlook365.provider.ts index dff04d10148..f537e093c42 100644 --- a/packages/providers/src/lib/email/outlook365/outlook365.provider.ts +++ b/packages/providers/src/lib/email/outlook365/outlook365.provider.ts @@ -83,6 +83,7 @@ export class Outlook365Provider extends BaseProvider implements IEmailProvider { subject: options.subject, html: options.html, text: options.text, + ...(options.alternatives?.length ? { alternatives: options.alternatives } : {}), attachments: options.attachments?.map((attachment) => ({ filename: attachment.name, content: attachment.file, diff --git a/packages/providers/src/lib/email/sendgrid/sendgrid.provider.spec.ts b/packages/providers/src/lib/email/sendgrid/sendgrid.provider.spec.ts index 6b51e3cd86b..a4c8b80c8d9 100644 --- a/packages/providers/src/lib/email/sendgrid/sendgrid.provider.spec.ts +++ b/packages/providers/src/lib/email/sendgrid/sendgrid.provider.spec.ts @@ -131,6 +131,39 @@ test('should trigger sendgrid correctly with _passthrough', async () => { }); }); +test('should send custom MIME alternatives in content array', async () => { + const provider = new SendgridEmailProvider(mockConfig); + const spy = vi.spyOn(MailService.prototype, 'send').mockImplementation(async () => { + return {} as any; + }); + const reactionAlternative = { + contentType: 'text/vnd.google.email-reaction+json', + content: JSON.stringify({ version: 1, emoji: '👀' }), + }; + + await provider.sendMessage({ + ...mockNovuMessage, + text: '👀', + html: '

👀

', + alternatives: [reactionAlternative], + }); + + const payload = spy.mock.calls[0][0] as unknown as Record; + expect(payload).not.toHaveProperty('html'); + expect(payload).toEqual( + expect.objectContaining({ + content: [ + { type: 'text/plain', value: '👀' }, + { type: 'text/html', value: '

👀

' }, + { + type: 'text/vnd.google.email-reaction+json', + value: JSON.stringify({ version: 1, emoji: '👀' }), + }, + ], + }) + ); +}); + test('should check provider integration correctly', async () => { const provider = new SendgridEmailProvider(mockConfig); const spy = vi.spyOn(MailService.prototype, 'send').mockImplementation(async () => { diff --git a/packages/providers/src/lib/email/sendgrid/sendgrid.provider.ts b/packages/providers/src/lib/email/sendgrid/sendgrid.provider.ts index 51ae5347527..60460e3a5bc 100644 --- a/packages/providers/src/lib/email/sendgrid/sendgrid.provider.ts +++ b/packages/providers/src/lib/email/sendgrid/sendgrid.provider.ts @@ -17,6 +17,7 @@ import { BaseProvider, CasingEnum } from '../../../base.provider'; import { WithPassthrough } from '../../../utils/types'; type AttachmentJSON = MailDataRequired['attachments'][0]; +type SendGridContent = NonNullable; export class SendgridEmailProvider extends BaseProvider implements IEmailProvider { id = EmailProviderIdEnum.SendGrid; @@ -114,6 +115,7 @@ export class SendgridEmailProvider extends BaseProvider implements IEmailProvide return attachmentJson; }); + const content = this.buildContent(options); const mailData: Partial = { from: { @@ -124,7 +126,7 @@ export class SendgridEmailProvider extends BaseProvider implements IEmailProvide to: options.to.map((email) => ({ email })), cc: options.cc?.map((ccItem) => ({ email: ccItem })), bcc: options.bcc?.map((ccItem) => ({ email: ccItem })), - html: options.html, + ...(content ? { content } : { html: options.html }), subject: options.subject, substitutions: {}, category: options.notificationDetails?.workflowIdentifier, @@ -156,6 +158,21 @@ export class SendgridEmailProvider extends BaseProvider implements IEmailProvide return mailData as MailDataRequired; } + private buildContent(options: IEmailOptions): SendGridContent | undefined { + if (!options.alternatives?.length) { + return undefined; + } + + return [ + ...(options.text ? [{ type: 'text/plain', value: options.text }] : []), + { type: 'text/html', value: options.html }, + ...options.alternatives.map((alternative) => ({ + type: alternative.contentType, + value: Buffer.isBuffer(alternative.content) ? alternative.content.toString() : alternative.content, + })), + ] as SendGridContent; + } + private getIpPoolObject(options: IEmailOptions) { const ipPoolNameValue = options.ipPoolName || this.config.ipPoolName; diff --git a/packages/providers/src/lib/email/ses/ses.provider.spec.ts b/packages/providers/src/lib/email/ses/ses.provider.spec.ts index 8167ced1aeb..0c2eb8af183 100644 --- a/packages/providers/src/lib/email/ses/ses.provider.spec.ts +++ b/packages/providers/src/lib/email/ses/ses.provider.spec.ts @@ -110,7 +110,6 @@ test('should trigger ses library correctly', async () => { test('should forward custom headers in raw email content', async () => { const mockResponse = { MessageId: 'mock-message-id' }; const spy = vi.spyOn(SESv2Client.prototype, 'send').mockImplementation(async () => { - return mockResponse as any; }); @@ -131,6 +130,30 @@ test('should forward custom headers in raw email content', async () => { expect(emailContent.includes('References: ')).toBe(true); }); +test('should forward custom MIME alternatives in raw email content', async () => { + const mockResponse = { MessageId: 'mock-message-id' }; + const spy = vi.spyOn(SESv2Client.prototype, 'send').mockImplementation(async () => { + return mockResponse as any; + }); + + const provider = new SESEmailProvider(mockConfig); + await provider.sendMessage({ + ...mockNovuMessage, + alternatives: [ + { + contentType: 'text/vnd.google.email-reaction+json', + content: JSON.stringify({ version: 1, emoji: '👀' }), + }, + ], + }); + + const bufferArray = spy.mock.calls[0][0].input['Content']['Raw']['Data']; + const emailContent = Buffer.from(bufferArray).toString(); + + expect(spy).toHaveBeenCalled(); + expect(emailContent.includes('Content-Type: text/vnd.google.email-reaction+json')).toBe(true); +}); + test('should trigger ses library correctly with _passthrough', async () => { const mockResponse = { MessageId: 'mock-message-id' }; const spy = vi.spyOn(SESv2Client.prototype, 'send').mockImplementation(async () => { diff --git a/packages/providers/src/lib/email/ses/ses.provider.ts b/packages/providers/src/lib/email/ses/ses.provider.ts index d757b91027f..4313b77f53a 100644 --- a/packages/providers/src/lib/email/ses/ses.provider.ts +++ b/packages/providers/src/lib/email/ses/ses.provider.ts @@ -34,7 +34,7 @@ export class SESEmailProvider extends BaseProvider implements IEmailProvider { } private async sendMail( - { html, text, to, from, senderName, subject, attachments, cc, bcc, replyTo, headers = {} }, + { html, text, alternatives = [], to, from, senderName, subject, attachments, cc, bcc, replyTo, headers = {} }, bridgeProviderData: WithPassthrough> = {} ) { const transporter = nodemailer.createTransport({ @@ -45,6 +45,7 @@ export class SESEmailProvider extends BaseProvider implements IEmailProvider { to, html, text, + ...(alternatives.length ? { alternatives } : {}), subject, attachments, from: { @@ -64,7 +65,7 @@ export class SESEmailProvider extends BaseProvider implements IEmailProvider { } async sendMessage( - { html, text, to, from, subject, attachments, cc, bcc, replyTo, senderName, headers }: IEmailOptions, + { html, text, alternatives, to, from, subject, attachments, cc, bcc, replyTo, senderName, headers }: IEmailOptions, bridgeProviderData: WithPassthrough> = {} ): Promise { const info = await this.sendMail( @@ -75,6 +76,7 @@ export class SESEmailProvider extends BaseProvider implements IEmailProvider { subject, html, text, + alternatives, attachments: attachments?.map((attachment) => ({ filename: attachment?.name, content: attachment.file, diff --git a/packages/shared/src/types/events.ts b/packages/shared/src/types/events.ts index c337c8071d1..11b3eaa15b1 100644 --- a/packages/shared/src/types/events.ts +++ b/packages/shared/src/types/events.ts @@ -20,12 +20,18 @@ export interface IAttachmentOptions { disposition?: string; } +export interface IEmailAlternative { + contentType: string; + content: string | Buffer; +} + export interface IEmailOptions { to: string[]; subject: string; html: string; from?: string; text?: string; + alternatives?: IEmailAlternative[]; attachments?: IAttachmentOptions[]; id?: string; replyTo?: string; diff --git a/packages/stateless/src/lib/provider/provider.interface.ts b/packages/stateless/src/lib/provider/provider.interface.ts index 5f9f1e32c7d..c2d78ff79bb 100644 --- a/packages/stateless/src/lib/provider/provider.interface.ts +++ b/packages/stateless/src/lib/provider/provider.interface.ts @@ -17,12 +17,18 @@ export interface IProvider { }>; } +export interface IEmailAlternative { + contentType: string; + content: string | Buffer; +} + export interface IEmailOptions { to: string[]; subject: string; html: string; from?: string; text?: string; + alternatives?: IEmailAlternative[]; attachments?: IAttachmentOptions[]; id?: string; replyTo?: string;