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;