Skip to content

Commit c8bd3a0

Browse files
Merge pull request #3375 from pierreb-devkit/fix/mailer-normalize-resend-response
fix(mailer): normalize provider response to nodemailer format
2 parents ad23017 + 82228c8 commit c8bd3a0

12 files changed

Lines changed: 372 additions & 14 deletions

File tree

lib/app.js

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,10 @@ const bootstrap = async () => {
8787
};
8888

8989
/**
90-
* log server configuration
90+
* @desc Log server configuration and SaaS readiness summary to console.
91+
* @returns {Promise<void>}
9192
*/
92-
const logConfiguration = () => {
93+
const logConfiguration = async () => {
9394
// Create server URL
9495
const server = `${(config.secure && config.secure.credentials ? 'https://' : 'http://') + config.api.host}:${config.api.port}`;
9596
// Logging initialization
@@ -99,6 +100,22 @@ const logConfiguration = () => {
99100
console.log(chalk.green(`Server: ${server}`));
100101
console.log(chalk.green(`Database: ${config.db.uri}`));
101102
if (config.cors.origin.length > 0) console.log(chalk.green(`Cors: ${config.cors.origin}`));
103+
104+
// SaaS readiness summary (skip in test to keep output clean)
105+
if (process.env.NODE_ENV !== 'test') {
106+
try {
107+
const { default: HomeService } = await import('../modules/home/services/home.service.js');
108+
const checks = HomeService.getReadinessStatus();
109+
console.log();
110+
console.log(chalk.green('SaaS Readiness:'));
111+
checks.forEach((c) => {
112+
const icon = c.status === 'ok' ? chalk.green('OK') : chalk.yellow('WARN');
113+
console.log(` ${icon} ${c.category.padEnd(12)} ${c.message}`);
114+
});
115+
} catch (err) {
116+
console.log(chalk.yellow(` SaaS readiness check failed: ${err.message}`));
117+
}
118+
}
102119
};
103120

104121
// Boot up the server
@@ -118,7 +135,7 @@ const start = async () => {
118135
if (config.secure && config.secure.credentials)
119136
http = await nodeHttps.createServer(config.secure.credentials, app).setTimeout(config.api.timeout).listen(config.api.port, config.api.host);
120137
else http = await nodeHttp.createServer(app).setTimeout(config.api.timeout).listen(config.api.port, config.api.host);
121-
logConfiguration();
138+
await logConfiguration();
122139
return {
123140
db,
124141
orm,

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

lib/middlewares/policy.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ const deriveSubjectType = (routePath) => {
201201
if (routePath.startsWith('/api/tasks')) return 'Task';
202202
if (routePath.startsWith('/api/uploads')) return 'Upload';
203203
if (routePath.startsWith('/api/home')) return 'Home';
204+
if (routePath === '/api/admin/readiness') return 'Readiness';
204205
if (routePath.startsWith('/api/admin/organizations')) return 'Organization';
205206
if (routePath.includes('/requests')) return 'Membership';
206207
if (routePath.includes('/members')) return 'Membership';

modules/home/controllers/home.controller.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,11 +96,27 @@ const health = (req, res) => {
9696
responses.success(res, 'health check')(payload);
9797
};
9898

99+
/**
100+
* @desc Endpoint to return SaaS readiness checks (admin only).
101+
* @param {Object} req - Express request object
102+
* @param {Object} res - Express response object
103+
* @returns {void}
104+
*/
105+
const readiness = (req, res) => {
106+
try {
107+
const data = HomeService.getReadinessStatus();
108+
responses.success(res, 'readiness check')(data);
109+
} catch (err) {
110+
responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err);
111+
}
112+
};
113+
99114
export default {
100115
releases,
101116
changelogs,
102117
team,
103118
page,
104119
pageByName,
105120
health,
121+
readiness,
106122
};

modules/home/policies/home.policy.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
*/
1313
export function homeAbilities(user, membership, { can }) {
1414
can('read', 'Home');
15+
if (Array.isArray(user?.roles) && user.roles.includes('admin')) {
16+
can('manage', 'Readiness');
17+
}
1518
}
1619

1720
/**

modules/home/routes/home.route.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ export default (app) => {
3535
app.route('/api/home/team').all(policy.isAllowed).get(home.team);
3636
// markdown files
3737
app.route('/api/home/pages/:name').all(policy.isAllowed).get(home.page);
38+
// readiness check — admin only (JWT + CASL)
39+
app.route('/api/admin/readiness').all(passport.authenticate('jwt', { session: false }), policy.isAllowed).get(home.readiness);
3840

3941
// Finish by binding the task middleware
4042
app.param('name', home.pageByName);

0 commit comments

Comments
 (0)