diff --git a/packages/providers/src/lib/email/braze/braze.provider.ts b/packages/providers/src/lib/email/braze/braze.provider.ts index 6eed5e63d89..8f4c19814d2 100644 --- a/packages/providers/src/lib/email/braze/braze.provider.ts +++ b/packages/providers/src/lib/email/braze/braze.provider.ts @@ -69,7 +69,7 @@ export class BrazeEmailProvider extends BaseProvider implements IEmailProvider { bcc: options.bcc?.join(','), plaintext_body: options.text || null, extras: options.payloadDetails || {}, - headers: {}, + headers: options.headers && Object.keys(options.headers).length > 0 ? options.headers : {}, should_inline_css: true, attachments: [], }, diff --git a/packages/providers/src/lib/email/emailjs/emailjs.provider.ts b/packages/providers/src/lib/email/emailjs/emailjs.provider.ts index a71a33334b9..21db1179ea6 100644 --- a/packages/providers/src/lib/email/emailjs/emailjs.provider.ts +++ b/packages/providers/src/lib/email/emailjs/emailjs.provider.ts @@ -29,6 +29,7 @@ export class EmailJsProvider extends BaseProvider implements IEmailProvider { await this.ensureClientInitialized(); const headers: Message['header'] = { + ...emailOptions.headers, from: emailOptions.from || this.config.from, to: emailOptions.to, subject: emailOptions.subject, diff --git a/packages/providers/src/lib/email/mailersend/mailersend.provider.ts b/packages/providers/src/lib/email/mailersend/mailersend.provider.ts index 24e8771031c..4f32f50f18f 100644 --- a/packages/providers/src/lib/email/mailersend/mailersend.provider.ts +++ b/packages/providers/src/lib/email/mailersend/mailersend.provider.ts @@ -76,6 +76,14 @@ export class MailersendEmailProvider extends BaseProvider implements IEmailProvi emailParams.setReplyTo(replyTo); } + const inReplyTo = Object.entries(options.headers ?? {}).find( + ([headerName]) => headerName.toLowerCase() === 'in-reply-to' + )?.[1]; + + if (inReplyTo) { + emailParams.setInReplyTo(inReplyTo); + } + return emailParams; } diff --git a/packages/providers/src/lib/email/mailgun/mailgun.provider.spec.ts b/packages/providers/src/lib/email/mailgun/mailgun.provider.spec.ts index 771bd330372..2084d19def0 100644 --- a/packages/providers/src/lib/email/mailgun/mailgun.provider.spec.ts +++ b/packages/providers/src/lib/email/mailgun/mailgun.provider.spec.ts @@ -33,6 +33,35 @@ test('should trigger mailgun correctly', async () => { api.done(); }); +test('should forward custom headers as h: prefixed fields', async () => { + const provider = new MailgunEmailProvider(mockConfig); + + const api = nock('https://api.mailgun.net'); + + api + .post('/v3/test.com/messages', (body) => { + expect(body.includes('name="h:In-Reply-To"')).toBeTruthy(); + expect(body.includes('name="h:References"')).toBeTruthy(); + + return true; + }) + .reply(200, { + message: 'Queued. Thank you.', + id: '<20111114174239.25659.5817@samples.mailgun.org>', + }); + + await provider.sendMessage({ + ...mockNovuMessage, + headers: { + 'In-Reply-To': '', + References: '', + }, + }); + + expect(api.isDone()).toBeTruthy(); + api.done(); +}); + test('should trigger mailgun correctly with custom baseUrl', async () => { const provider = new MailgunEmailProvider({ ...mockConfig, diff --git a/packages/providers/src/lib/email/mailgun/mailgun.provider.ts b/packages/providers/src/lib/email/mailgun/mailgun.provider.ts index 58ab474f4da..173e35323d9 100644 --- a/packages/providers/src/lib/email/mailgun/mailgun.provider.ts +++ b/packages/providers/src/lib/email/mailgun/mailgun.provider.ts @@ -106,6 +106,16 @@ export class MailgunEmailProvider extends BaseProvider implements IEmailProvider }), }; + if (emailOptions.headers) { + for (const [key, value] of Object.entries(emailOptions.headers)) { + if (emailOptions.replyTo && key.toLowerCase() === 'reply-to') { + continue; + } + + data[`h:${key}`] = value; + } + } + if (emailOptions.replyTo) { data['h:Reply-To'] = emailOptions.replyTo; } diff --git a/packages/providers/src/lib/email/mailjet/mailjet.provider.ts b/packages/providers/src/lib/email/mailjet/mailjet.provider.ts index 36895482b2d..be56f27e2be 100644 --- a/packages/providers/src/lib/email/mailjet/mailjet.provider.ts +++ b/packages/providers/src/lib/email/mailjet/mailjet.provider.ts @@ -118,6 +118,7 @@ export class MailjetEmailProvider extends BaseProvider implements IEmailProvider Base64Content: attachment.file.toString('base64'), ContentID: attachment.cid, })), + ...(options.headers && Object.keys(options.headers).length > 0 && { Headers: options.headers }), }).body; if (options.replyTo) { diff --git a/packages/providers/src/lib/email/mandrill/mandril.interface.ts b/packages/providers/src/lib/email/mandrill/mandril.interface.ts index 571a82ff4c3..205df998253 100644 --- a/packages/providers/src/lib/email/mandrill/mandril.interface.ts +++ b/packages/providers/src/lib/email/mandrill/mandril.interface.ts @@ -15,6 +15,7 @@ interface IMandrillSendOptionsMessage { html: string; to: { email: string; type: 'to' | string }[]; attachments: IMandrillAttachment[]; + headers?: Record; } interface IMandrillTemplateSendOptionsMessage extends IMandrillSendOptionsMessage { global_merge_vars?: { name: string; content: string }[]; diff --git a/packages/providers/src/lib/email/mandrill/mandrill.provider.spec.ts b/packages/providers/src/lib/email/mandrill/mandrill.provider.spec.ts index 2a5f978d067..b8c7cd6cdd6 100644 --- a/packages/providers/src/lib/email/mandrill/mandrill.provider.spec.ts +++ b/packages/providers/src/lib/email/mandrill/mandrill.provider.spec.ts @@ -47,6 +47,53 @@ test('should send a standard email through Mandrill', async () => { }); }); +test('should forward custom headers in message.headers', async () => { + const provider = new MandrillProvider(mockConfig); + const spy = vi.spyOn(provider['transporter'].messages, 'send').mockImplementation(async () => { + + return [{}] as any; + }); + + await provider.sendMessage({ + to: ['test2@test.com'], + subject: 'test subject', + html: '
Mail Content
', + headers: { + 'In-Reply-To': '', + References: '', + }, + }); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.objectContaining({ + headers: { + 'In-Reply-To': '', + References: '', + }, + }), + }) + ); +}); + +test('should not add headers to message when no custom headers provided', async () => { + const provider = new MandrillProvider(mockConfig); + const spy = vi.spyOn(provider['transporter'].messages, 'send').mockImplementation(async () => { + + return [{}] as any; + }); + + await provider.sendMessage({ + to: ['test2@test.com'], + subject: 'test subject', + html: '
Mail Content
', + }); + + const payload = spy.mock.calls[0][0]; + + expect(payload.message).not.toHaveProperty('headers'); +}); + test('should send an email using a Mandrill template', async () => { const provider = new MandrillProvider(mockConfig); const spy = vi.spyOn(provider['transporter'].messages, 'sendTemplate').mockImplementation(async () => { diff --git a/packages/providers/src/lib/email/mandrill/mandrill.provider.ts b/packages/providers/src/lib/email/mandrill/mandrill.provider.ts index f0fc325f2d3..4b1326d4297 100644 --- a/packages/providers/src/lib/email/mandrill/mandrill.provider.ts +++ b/packages/providers/src/lib/email/mandrill/mandrill.provider.ts @@ -86,6 +86,8 @@ export class MandrillProvider extends BaseProvider implements IEmailProvider { type: attachment.mime, name: attachment?.name, })), + ...(emailOptions.headers && + Object.keys(emailOptions.headers).length > 0 && { headers: emailOptions.headers }), }; const { customData } = emailOptions; diff --git a/packages/providers/src/lib/email/netcore/netcore.provider.ts b/packages/providers/src/lib/email/netcore/netcore.provider.ts index ec3a27b01c3..b2d80afc558 100644 --- a/packages/providers/src/lib/email/netcore/netcore.provider.ts +++ b/packages/providers/src/lib/email/netcore/netcore.provider.ts @@ -93,6 +93,10 @@ export class NetCoreProvider extends BaseProvider implements IEmailProvider { }); } + if (options.headers && Object.keys(options.headers).length > 0) { + data.personalizations[0].headers = options.headers; + } + const emailOptions = { method: 'POST', url: '/mail/send', 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 95cf6f3321b..d6fbe9e068a 100644 --- a/packages/providers/src/lib/email/nodemailer/nodemailer.provider.spec.ts +++ b/packages/providers/src/lib/email/nodemailer/nodemailer.provider.spec.ts @@ -204,3 +204,48 @@ describe.skip('NodemailerProvider', () => { }); }); }); + +describe('NodemailerProvider header forwarding', () => { + const mockConfig = { + host: 'test.test.email', + port: 587, + secure: false, + from: 'test@test.com', + senderName: 'John Doe', + user: 'test@test.com', + password: 'test123', + }; + + test('should forward custom headers to sendMail', async () => { + const provider = new NodemailerProvider(mockConfig); + const spy = vi.spyOn(provider['transports'], 'sendMail').mockResolvedValue({ messageId: 'test-id' } as any); + + await provider.sendMessage({ + ...mockNovuMessage, + headers: { + 'In-Reply-To': '', + References: '', + }, + }); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + headers: { + 'In-Reply-To': '', + References: '', + }, + }) + ); + }); + + 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); + + await provider.sendMessage(mockNovuMessage); + + const payload = spy.mock.calls[0][0] as Record; + + expect(payload).not.toHaveProperty('headers'); + }); +}); diff --git a/packages/providers/src/lib/email/nodemailer/nodemailer.provider.ts b/packages/providers/src/lib/email/nodemailer/nodemailer.provider.ts index b36c4af05d1..d84866b2679 100644 --- a/packages/providers/src/lib/email/nodemailer/nodemailer.provider.ts +++ b/packages/providers/src/lib/email/nodemailer/nodemailer.provider.ts @@ -153,6 +153,10 @@ export class NodemailerProvider extends BaseProvider implements IEmailProvider { sendMailOptions.replyTo = options.replyTo; } + if (options.headers && Object.keys(options.headers).length > 0) { + sendMailOptions.headers = options.headers; + } + return sendMailOptions; } } diff --git a/packages/providers/src/lib/email/outlook365/outlook365.provider.ts b/packages/providers/src/lib/email/outlook365/outlook365.provider.ts index d34d0536f54..dff04d10148 100644 --- a/packages/providers/src/lib/email/outlook365/outlook365.provider.ts +++ b/packages/providers/src/lib/email/outlook365/outlook365.provider.ts @@ -97,6 +97,10 @@ export class Outlook365Provider extends BaseProvider implements IEmailProvider { sendMailOptions.replyTo = options.replyTo; } + if (options.headers && Object.keys(options.headers).length > 0) { + sendMailOptions.headers = options.headers; + } + return sendMailOptions; } } diff --git a/packages/providers/src/lib/email/postmark/postmark.provider.spec.ts b/packages/providers/src/lib/email/postmark/postmark.provider.spec.ts index 46efedd71b8..fedacaf1cff 100644 --- a/packages/providers/src/lib/email/postmark/postmark.provider.spec.ts +++ b/packages/providers/src/lib/email/postmark/postmark.provider.spec.ts @@ -62,6 +62,45 @@ test('should trigger postmark correctly', async () => { }); }); +test('should forward custom headers in Postmark Headers format', async () => { + const provider = new PostmarkEmailProvider(mockConfig); + const spy = vi.spyOn((provider as any).client, 'sendEmail').mockImplementation(async () => { + return {}; + }); + + await provider.sendMessage({ + ...mockNovuMessage, + headers: { + 'In-Reply-To': '', + References: '', + }, + }); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + Headers: [ + { Name: 'In-Reply-To', Value: '' }, + { Name: 'References', Value: '' }, + ], + }) + ); +}); + +test('should not add Headers field when no custom headers provided', async () => { + const provider = new PostmarkEmailProvider(mockConfig); + const spy = vi.spyOn((provider as any).client, 'sendEmail').mockImplementation(async () => { + return {}; + }); + + await provider.sendMessage(mockNovuMessage); + + expect(spy).toHaveBeenCalledWith( + expect.not.objectContaining({ + Headers: expect.anything(), + }) + ); +}); + test('should trigger postmark correctly with _passthrough', async () => { const provider = new PostmarkEmailProvider(mockConfig); const spy = vi.spyOn((provider as any).client, 'sendEmail').mockImplementation(async () => { diff --git a/packages/providers/src/lib/email/postmark/postmark.provider.ts b/packages/providers/src/lib/email/postmark/postmark.provider.ts index 07cbfa99f1d..e470a21af84 100644 --- a/packages/providers/src/lib/email/postmark/postmark.provider.ts +++ b/packages/providers/src/lib/email/postmark/postmark.provider.ts @@ -90,6 +90,10 @@ export class PostmarkEmailProvider extends BaseProvider implements IEmailProvide mailData.ReplyTo = options.replyTo; } + if (options.headers && Object.keys(options.headers).length > 0) { + mailData.Headers = Object.entries(options.headers).map(([Name, Value]) => ({ Name, Value })); + } + return mailData; } 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 dbff95a512a..8167ced1aeb 100644 --- a/packages/providers/src/lib/email/ses/ses.provider.spec.ts +++ b/packages/providers/src/lib/email/ses/ses.provider.spec.ts @@ -107,6 +107,30 @@ test('should trigger ses library correctly', async () => { expect(response.id).toEqual(''); }); +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; + }); + + const provider = new SESEmailProvider(mockConfig); + await provider.sendMessage({ + ...mockNovuMessage, + headers: { + 'In-Reply-To': '', + References: '', + }, + }); + + const bufferArray = spy.mock.calls[0][0].input['Content']['Raw']['Data']; + const emailContent = Buffer.from(bufferArray).toString(); + + expect(spy).toHaveBeenCalled(); + expect(emailContent.includes('In-Reply-To: ')).toBe(true); + expect(emailContent.includes('References: ')).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 b970f8860a8..d757b91027f 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 }, + { html, text, to, from, senderName, subject, attachments, cc, bcc, replyTo, headers = {} }, bridgeProviderData: WithPassthrough> = {} ) { const transporter = nodemailer.createTransport({ @@ -54,6 +54,7 @@ export class SESEmailProvider extends BaseProvider implements IEmailProvider { cc, bcc, replyTo, + ...(headers && Object.keys(headers).length > 0 && { headers }), ...(this.config.configurationSetName && { ses: { ConfigurationSetName: this.config.configurationSetName }, }), @@ -63,7 +64,7 @@ export class SESEmailProvider extends BaseProvider implements IEmailProvider { } async sendMessage( - { html, text, to, from, subject, attachments, cc, bcc, replyTo, senderName }: IEmailOptions, + { html, text, to, from, subject, attachments, cc, bcc, replyTo, senderName, headers }: IEmailOptions, bridgeProviderData: WithPassthrough> = {} ): Promise { const info = await this.sendMail( @@ -84,6 +85,7 @@ export class SESEmailProvider extends BaseProvider implements IEmailProvider { cc, bcc, replyTo, + headers, }, bridgeProviderData ); diff --git a/packages/providers/src/lib/email/sparkpost/sparkpost.provider.spec.ts b/packages/providers/src/lib/email/sparkpost/sparkpost.provider.spec.ts index 6d4eb9d4781..392a24d3db4 100644 --- a/packages/providers/src/lib/email/sparkpost/sparkpost.provider.spec.ts +++ b/packages/providers/src/lib/email/sparkpost/sparkpost.provider.spec.ts @@ -54,6 +54,38 @@ test('should trigger sparkpost library correctly', async () => { ); }); +test('should forward custom headers inside content.headers', async () => { + const { mockPost: spy } = axiosSpy({ + data: { + results: { + id: 'id', + }, + }, + }); + const provider = new SparkPostEmailProvider(mockConfig); + + await provider.sendMessage({ + ...mockNovuMessage, + headers: { + 'In-Reply-To': '', + References: '', + }, + }); + + expect(spy).toHaveBeenCalledWith( + '/transmissions', + expect.objectContaining({ + content: expect.objectContaining({ + headers: { + 'In-Reply-To': '', + References: '', + }, + }), + }), + expect.anything() + ); +}); + test('should trigger sparkpost library correctly with _passthrough', async () => { const { mockPost: spy } = axiosSpy({ data: { diff --git a/packages/providers/src/lib/email/sparkpost/sparkpost.provider.ts b/packages/providers/src/lib/email/sparkpost/sparkpost.provider.ts index cee86459f63..8cd883ffcf9 100644 --- a/packages/providers/src/lib/email/sparkpost/sparkpost.provider.ts +++ b/packages/providers/src/lib/email/sparkpost/sparkpost.provider.ts @@ -40,7 +40,7 @@ export class SparkPostEmailProvider extends BaseProvider implements IEmailProvid } async sendMessage( - { from, to, subject, text, html, attachments }: IEmailOptions, + { from, to, subject, text, html, attachments, headers }: IEmailOptions, bridgeProviderData: WithPassthrough> = {} ): Promise { const recipients: { address: string }[] = to.map((recipient) => { @@ -65,6 +65,7 @@ export class SparkPostEmailProvider extends BaseProvider implements IEmailProvid text, html, attachments: files, + ...(headers && Object.keys(headers).length > 0 && { headers }), }, });