Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/providers/src/lib/email/braze/braze.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
29 changes: 29 additions & 0 deletions packages/providers/src/lib/email/mailgun/mailgun.provider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': '<original-message-id@example.com>',
References: '<original-message-id@example.com>',
},
});

expect(api.isDone()).toBeTruthy();
api.done();
});

test('should trigger mailgun correctly with custom baseUrl', async () => {
const provider = new MailgunEmailProvider({
...mockConfig,
Expand Down
10 changes: 10 additions & 0 deletions packages/providers/src/lib/email/mailgun/mailgun.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Comment thread
ChmaraX marked this conversation as resolved.
}).body;

if (options.replyTo) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ interface IMandrillSendOptionsMessage {
html: string;
to: { email: string; type: 'to' | string }[];
attachments: IMandrillAttachment[];
headers?: Record<string, string>;
}
interface IMandrillTemplateSendOptionsMessage extends IMandrillSendOptionsMessage {
global_merge_vars?: { name: string; content: string }[];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

await provider.sendMessage({
to: ['test2@test.com'],
subject: 'test subject',
html: '<div> Mail Content </div>',
headers: {
'In-Reply-To': '<original-message-id@example.com>',
References: '<original-message-id@example.com>',
},
});

expect(spy).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.objectContaining({
headers: {
'In-Reply-To': '<original-message-id@example.com>',
References: '<original-message-id@example.com>',
},
}),
})
);
});

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: '<div> Mail Content </div>',
});

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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions packages/providers/src/lib/email/netcore/netcore.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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': '<original-message-id@example.com>',
References: '<original-message-id@example.com>',
},
});

expect(spy).toHaveBeenCalledWith(
expect.objectContaining({
headers: {
'In-Reply-To': '<original-message-id@example.com>',
References: '<original-message-id@example.com>',
},
})
);
});

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<string, unknown>;

expect(payload).not.toHaveProperty('headers');
Comment on lines +241 to +249
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Cover the empty headers: {} path as well.

Line 245 verifies omitted headers, but the worker path described in this PR initializes headers as {}. A truthy-object guard would still leak headers: {} to Nodemailer and this test would not catch it.

🧪 Proposed additional coverage
   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<string, unknown>;
 
     expect(payload).not.toHaveProperty('headers');
   });
+
+  test('should not include headers field when custom headers are empty', async () => {
+    const provider = new NodemailerProvider(mockConfig);
+    const spy = vi.spyOn(provider['transports'], 'sendMail').mockResolvedValue({ messageId: 'test-id' } as any);
+
+    await provider.sendMessage({
+      ...mockNovuMessage,
+      headers: {},
+    });
+
+    const payload = spy.mock.calls[0][0] as Record<string, unknown>;
+
+    expect(payload).not.toHaveProperty('headers');
+  });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/providers/src/lib/email/nodemailer/nodemailer.provider.spec.ts`
around lines 241 - 249, Add a test that covers the case where headers is an
empty object so we don't leak headers: {} to Nodemailer: in the existing spec
for NodemailerProvider (nodemailer.provider.spec.ts) call provider.sendMessage
using a message that has headers: {} (e.g., set mockNovuMessage.headers = {})
and assert the captured payload from provider['transports'].sendMail does not
have the 'headers' property; reference the provider.sendMessage call and the spy
on provider['transports'].sendMail to locate where to add this assertion.

});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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': '<original-message-id@example.com>',
References: '<original-message-id@example.com>',
},
});

expect(spy).toHaveBeenCalledWith(
expect.objectContaining({
Headers: [
{ Name: 'In-Reply-To', Value: '<original-message-id@example.com>' },
{ Name: 'References', Value: '<original-message-id@example.com>' },
],
})
);
});

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(),
})
);
});
Comment thread
ChmaraX marked this conversation as resolved.

test('should trigger postmark correctly with _passthrough', async () => {
const provider = new PostmarkEmailProvider(mockConfig);
const spy = vi.spyOn((provider as any).client, 'sendEmail').mockImplementation(async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
24 changes: 24 additions & 0 deletions packages/providers/src/lib/email/ses/ses.provider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,30 @@ test('should trigger ses library correctly', async () => {
expect(response.id).toEqual('<mock-message-id@email.amazonses.com>');
});

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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

const provider = new SESEmailProvider(mockConfig);
await provider.sendMessage({
...mockNovuMessage,
headers: {
'In-Reply-To': '<original-message-id@example.com>',
References: '<original-message-id@example.com>',
},
});

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: <original-message-id@example.com>')).toBe(true);
expect(emailContent.includes('References: <original-message-id@example.com>')).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 () => {
Expand Down
6 changes: 4 additions & 2 deletions packages/providers/src/lib/email/ses/ses.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>> = {}
) {
const transporter = nodemailer.createTransport({
Expand All @@ -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 },
}),
Expand All @@ -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<Record<string, unknown>> = {}
): Promise<ISendMessageSuccessResponse> {
const info = await this.sendMail(
Expand All @@ -84,6 +85,7 @@ export class SESEmailProvider extends BaseProvider implements IEmailProvider {
cc,
bcc,
replyTo,
headers,
},
bridgeProviderData
);
Expand Down
Loading
Loading