Skip to content

Commit 82228c8

Browse files
fix(mailer): normalize provider response to nodemailer format
Resend returns { id } while Nodemailer returns { accepted, rejected }. Normalize in sendMail() so controllers checking mail?.accepted.length work correctly with any provider. Also add attachment support to both providers and mailer index. Closes #3373
1 parent 0e0bade commit 82228c8

5 files changed

Lines changed: 86 additions & 9 deletions

File tree

lib/helpers/mailer/index.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ const isConfigured = () => !!(config.mailer && config.mailer.from && !String(con
4444
* @param {string} mail.subject - Email subject
4545
* @param {string} mail.template - Template name (without .html)
4646
* @param {Object} mail.params - Template parameters
47+
* @param {Array} [mail.attachments] - Optional attachments array
48+
* @param {string} mail.attachments[].filename - Attachment filename
49+
* @param {string} mail.attachments[].content - Attachment content (string or Buffer)
4750
* @returns {Promise<Object|null>} The send result or null if not configured
4851
*/
4952
const sendMail = async (mail) => {
@@ -54,12 +57,15 @@ const sendMail = async (mail) => {
5457
const template = handlebars.compile(file);
5558
const html = template(mail.params);
5659
try {
57-
return await getProvider().send({
60+
const result = await getProvider().send({
5861
from: config.mailer.from,
5962
to: mail.to,
6063
subject: mail.subject,
6164
html,
65+
attachments: mail.attachments,
6266
});
67+
if (!Array.isArray(result?.accepted)) return { ...result, accepted: [mail.to], rejected: [] };
68+
return result;
6369
} catch (err) {
6470
console.error(`Mail send error: ${err.message}`);
6571
return null;

lib/helpers/mailer/provider.nodemailer.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ export default class NodemailerProvider {
55
this.transport = nodemailer.createTransport(options);
66
}
77

8-
async send({ from, to, subject, html }) {
9-
return this.transport.sendMail({ from, to, subject, html });
8+
async send({ from, to, subject, html, attachments }) {
9+
const payload = { from, to, subject, html };
10+
if (attachments?.length) payload.attachments = attachments;
11+
return this.transport.sendMail(payload);
1012
}
1113
}

lib/helpers/mailer/provider.resend.js

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,20 @@ export default class ResendProvider {
1919
* @param {string} mail.to - Recipient address
2020
* @param {string} mail.subject - Email subject
2121
* @param {string} mail.html - HTML body
22+
* @param {Array} [mail.attachments] - Optional attachments array
23+
* @param {string} mail.attachments[].filename - Attachment filename
24+
* @param {string} mail.attachments[].content - Attachment content (string or Buffer)
2225
* @returns {Promise<Object>} Resend API response data
2326
*/
24-
async send({ from, to, subject, html }) {
25-
const { data, error } = await this.client.emails.send({ from, to, subject, html });
27+
async send({ from, to, subject, html, attachments }) {
28+
const payload = { from, to, subject, html };
29+
if (attachments?.length) {
30+
payload.attachments = attachments.map((a) => ({
31+
filename: a.filename,
32+
content: Buffer.from(a.content).toString('base64'),
33+
}));
34+
}
35+
const { data, error } = await this.client.emails.send(payload);
2636
if (error) throw new Error(error.message || 'Resend API error');
2737
return data;
2838
}

lib/helpers/mailer/tests/mailer.unit.tests.js

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ jest.unstable_mockModule('../../files.js', () => ({
2020
},
2121
}));
2222

23-
const mockSend = jest.fn().mockResolvedValue({ id: 'email_123' });
23+
const mockSend = jest.fn().mockResolvedValue({ id: 'email_123', accepted: ['user@example.com'], rejected: [] });
2424
jest.unstable_mockModule('../provider.resend.js', () => ({
2525
default: jest.fn().mockImplementation(() => ({ send: mockSend })),
2626
}));
@@ -40,7 +40,7 @@ describe('mailer index with resend provider unit tests:', () => {
4040
expect(mailer.isConfigured()).toBe(true);
4141
});
4242

43-
test('should send mail using the resend provider', async () => {
43+
test('should send mail using the resend provider and normalize response', async () => {
4444
mockSend.mockResolvedValue({ id: 'email_456' });
4545

4646
const result = await mailer.sendMail({
@@ -58,10 +58,42 @@ describe('mailer index with resend provider unit tests:', () => {
5858
html: '<p>Alice</p>',
5959
}),
6060
);
61-
expect(result).toEqual({ id: 'email_456' });
61+
expect(result).toEqual({ id: 'email_456', accepted: ['user@example.com'], rejected: [] });
6262
});
6363

64-
test('should return null on send error', async () => {
64+
test('should pass through response unchanged when accepted is already an array', async () => {
65+
mockSend.mockResolvedValue({ id: 'email_789', accepted: ['user@example.com'], rejected: [] });
66+
67+
const result = await mailer.sendMail({
68+
to: 'user@example.com',
69+
subject: 'Welcome',
70+
template: 'welcome',
71+
params: { name: 'Charlie' },
72+
});
73+
74+
expect(result).toEqual({ id: 'email_789', accepted: ['user@example.com'], rejected: [] });
75+
});
76+
77+
test('should forward attachments to the provider', async () => {
78+
mockSend.mockResolvedValue({ id: 'email_attach', accepted: ['user@example.com'], rejected: [] });
79+
80+
const attachments = [{ filename: 'report.csv', content: 'a,b\n1,2' }];
81+
await mailer.sendMail({
82+
to: 'user@example.com',
83+
subject: 'Report',
84+
template: 'welcome',
85+
params: { name: 'Carol' },
86+
attachments,
87+
});
88+
89+
expect(mockSend).toHaveBeenCalledWith(
90+
expect.objectContaining({
91+
attachments,
92+
}),
93+
);
94+
});
95+
96+
test('should return null on send error', async () => {
6597
mockSend.mockRejectedValue(new Error('API failure'));
6698

6799
const result = await mailer.sendMail({

lib/helpers/mailer/tests/provider.resend.unit.tests.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,33 @@ describe('ResendProvider unit tests:', () => {
5757
expect(result).toEqual(mockData);
5858
});
5959

60+
test('should send an email with attachments', async () => {
61+
const mockData = { id: 'email_456' };
62+
sendMock.mockResolvedValue({ data: mockData, error: null });
63+
64+
const result = await provider.send({
65+
from: 'sender@example.com',
66+
to: 'recipient@example.com',
67+
subject: 'Test with attachment',
68+
html: '<p>See attached</p>',
69+
attachments: [{ filename: 'data.csv', content: 'col1,col2\nval1,val2' }],
70+
});
71+
72+
expect(sendMock).toHaveBeenCalledWith({
73+
from: 'sender@example.com',
74+
to: 'recipient@example.com',
75+
subject: 'Test with attachment',
76+
html: '<p>See attached</p>',
77+
attachments: [
78+
{
79+
filename: 'data.csv',
80+
content: Buffer.from('col1,col2\nval1,val2').toString('base64'),
81+
},
82+
],
83+
});
84+
expect(result).toEqual(mockData);
85+
});
86+
6087
test('should throw on Resend API error', async () => {
6188
sendMock.mockResolvedValue({ data: null, error: { message: 'Invalid API key' } });
6289

0 commit comments

Comments
 (0)