Skip to content

Commit 5a9a6a7

Browse files
feat(mailer): add Resend provider (#3349)
* feat(mailer): add Resend provider alongside Nodemailer (#3309) Add provider.resend.js wrapping the Resend SDK, register it in the provider map, and document the config option (mailer.provider: 'resend' + mailer.options.apiKey). Default provider stays 'nodemailer' for backward compatibility. Includes unit tests for both the provider and the mailer index with Resend selected. * fix(mailer): default provider to nodemailer and handle DEVKIT_NODE_ placeholders * fix(mailer): add @returns tag to ResendProvider constructor JSDoc * fix(mailer): add defensive fallback for Resend error message
1 parent 4f278ea commit 5a9a6a7

7 files changed

Lines changed: 311 additions & 203 deletions

File tree

config/defaults/development.config.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,14 +109,17 @@ const config = {
109109
sameSite: 'strict',
110110
},
111111
mailer: {
112-
provider: 'DEVKIT_NODE_mailer_provider',
112+
provider: 'nodemailer', // 'nodemailer' (default) or 'resend'
113113
from: 'DEVKIT_NODE_mailer_from',
114114
options: {
115+
// nodemailer options
115116
service: 'DEVKIT_NODE_mailer_options_service',
116117
auth: {
117118
user: 'DEVKIT_NODE_mailer_options_auth_user',
118119
pass: 'DEVKIT_NODE_mailer_options_auth_pass',
119120
},
121+
// resend options
122+
apiKey: 'DEVKIT_NODE_mailer_options_apiKey',
120123
},
121124
},
122125
seedDB: {

lib/helpers/mailer/index.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,17 @@ import handlebars from 'handlebars';
44
import config from '../../../config/index.js';
55
import files from '../files.js';
66
import NodemailerProvider from './provider.nodemailer.js';
7+
import ResendProvider from './provider.resend.js';
78

8-
const providers = { nodemailer: NodemailerProvider };
9+
const providers = { nodemailer: NodemailerProvider, resend: ResendProvider };
910

1011
/**
1112
* @desc Create a mail provider instance based on config
1213
* @returns {Object} A mail provider with a send method
1314
*/
1415
const createProvider = () => {
15-
const providerName = config.mailer?.provider || 'nodemailer';
16+
const raw = config.mailer?.provider;
17+
const providerName = raw && !String(raw).startsWith('DEVKIT_NODE_') ? raw : 'nodemailer';
1618
const Provider = providers[providerName];
1719
if (!Provider) throw new Error(`Unknown mail provider: ${providerName}`);
1820
return new Provider(config.mailer.options);
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { Resend } from 'resend';
2+
3+
export default class ResendProvider {
4+
/**
5+
* Create a Resend mail provider instance.
6+
* @param {Object} options - Resend provider options
7+
* @param {string} options.apiKey - Resend API key
8+
* @returns {void}
9+
*/
10+
constructor(options) {
11+
if (!options?.apiKey) throw new Error('Resend provider requires an apiKey');
12+
this.client = new Resend(options.apiKey);
13+
}
14+
15+
/**
16+
* @desc Send an email via Resend
17+
* @param {Object} mail - Mail envelope
18+
* @param {string} mail.from - Sender address
19+
* @param {string} mail.to - Recipient address
20+
* @param {string} mail.subject - Email subject
21+
* @param {string} mail.html - HTML body
22+
* @returns {Promise<Object>} Resend API response data
23+
*/
24+
async send({ from, to, subject, html }) {
25+
const { data, error } = await this.client.emails.send({ from, to, subject, html });
26+
if (error) throw new Error(error.message || 'Resend API error');
27+
return data;
28+
}
29+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/**
2+
* Module dependencies.
3+
*/
4+
import { jest, describe, test, expect, beforeEach } from '@jest/globals';
5+
6+
// Mock config, files, and providers before importing mailer
7+
jest.unstable_mockModule('../../../../config/index.js', () => ({
8+
default: {
9+
mailer: {
10+
provider: 'resend',
11+
from: 'test@example.com',
12+
options: { apiKey: 're_test_123' },
13+
},
14+
},
15+
}));
16+
17+
jest.unstable_mockModule('../../files.js', () => ({
18+
default: {
19+
readFile: jest.fn().mockResolvedValue('<p>{{name}}</p>'),
20+
},
21+
}));
22+
23+
const mockSend = jest.fn().mockResolvedValue({ id: 'email_123' });
24+
jest.unstable_mockModule('../provider.resend.js', () => ({
25+
default: jest.fn().mockImplementation(() => ({ send: mockSend })),
26+
}));
27+
28+
jest.unstable_mockModule('../provider.nodemailer.js', () => ({
29+
default: jest.fn().mockImplementation(() => ({ send: jest.fn() })),
30+
}));
31+
32+
const { default: mailer } = await import('../index.js');
33+
34+
describe('mailer index with resend provider unit tests:', () => {
35+
beforeEach(() => {
36+
jest.clearAllMocks();
37+
});
38+
39+
test('should report as configured when from is set', () => {
40+
expect(mailer.isConfigured()).toBe(true);
41+
});
42+
43+
test('should send mail using the resend provider', async () => {
44+
mockSend.mockResolvedValue({ id: 'email_456' });
45+
46+
const result = await mailer.sendMail({
47+
to: 'user@example.com',
48+
subject: 'Welcome',
49+
template: 'welcome',
50+
params: { name: 'Alice' },
51+
});
52+
53+
expect(mockSend).toHaveBeenCalledWith(
54+
expect.objectContaining({
55+
from: 'test@example.com',
56+
to: 'user@example.com',
57+
subject: 'Welcome',
58+
html: '<p>Alice</p>',
59+
}),
60+
);
61+
expect(result).toEqual({ id: 'email_456' });
62+
});
63+
64+
test('should return null on send error', async () => {
65+
mockSend.mockRejectedValue(new Error('API failure'));
66+
67+
const result = await mailer.sendMail({
68+
to: 'user@example.com',
69+
subject: 'Test',
70+
template: 'welcome',
71+
params: { name: 'Bob' },
72+
});
73+
74+
expect(result).toBeNull();
75+
});
76+
});
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* Module dependencies.
3+
*/
4+
import { jest, describe, test, expect, beforeEach } from '@jest/globals';
5+
6+
// Mock resend before importing the provider
7+
jest.unstable_mockModule('resend', () => ({
8+
Resend: jest.fn().mockImplementation(() => ({
9+
emails: {
10+
send: jest.fn(),
11+
},
12+
})),
13+
}));
14+
15+
const { Resend } = await import('resend');
16+
const { default: ResendProvider } = await import('../provider.resend.js');
17+
18+
describe('ResendProvider unit tests:', () => {
19+
let provider;
20+
let sendMock;
21+
22+
beforeEach(() => {
23+
jest.clearAllMocks();
24+
sendMock = jest.fn();
25+
Resend.mockImplementation(() => ({
26+
emails: { send: sendMock },
27+
}));
28+
provider = new ResendProvider({ apiKey: 're_test_123' });
29+
});
30+
31+
test('should throw if apiKey is missing', () => {
32+
expect(() => new ResendProvider({})).toThrow('Resend provider requires an apiKey');
33+
expect(() => new ResendProvider()).toThrow('Resend provider requires an apiKey');
34+
});
35+
36+
test('should create a Resend client with the provided apiKey', () => {
37+
expect(Resend).toHaveBeenCalledWith('re_test_123');
38+
});
39+
40+
test('should send an email successfully', async () => {
41+
const mockData = { id: 'email_123' };
42+
sendMock.mockResolvedValue({ data: mockData, error: null });
43+
44+
const result = await provider.send({
45+
from: 'sender@example.com',
46+
to: 'recipient@example.com',
47+
subject: 'Test',
48+
html: '<p>Hello</p>',
49+
});
50+
51+
expect(sendMock).toHaveBeenCalledWith({
52+
from: 'sender@example.com',
53+
to: 'recipient@example.com',
54+
subject: 'Test',
55+
html: '<p>Hello</p>',
56+
});
57+
expect(result).toEqual(mockData);
58+
});
59+
60+
test('should throw on Resend API error', async () => {
61+
sendMock.mockResolvedValue({ data: null, error: { message: 'Invalid API key' } });
62+
63+
await expect(
64+
provider.send({
65+
from: 'sender@example.com',
66+
to: 'recipient@example.com',
67+
subject: 'Test',
68+
html: '<p>Hello</p>',
69+
}),
70+
).rejects.toThrow('Invalid API key');
71+
});
72+
});

0 commit comments

Comments
 (0)