diff --git a/apps/public-api/src/__tests__/mail.controller.test.js b/apps/public-api/src/__tests__/mail.controller.test.js index ca75c92b..2dcab564 100644 --- a/apps/public-api/src/__tests__/mail.controller.test.js +++ b/apps/public-api/src/__tests__/mail.controller.test.js @@ -88,7 +88,7 @@ jest.mock('@urbackend/common', () => { }; }); -const { Project, decrypt, redis, publicEmailQueue, MailTemplate, MailLog, AppError } = require('@urbackend/common'); +const { Project, decrypt, redis, publicEmailQueue, MailTemplate, MailLog, AppError, sendMailSchema } = require('@urbackend/common'); const mailController = require('../controllers/mail.controller'); const originalResendApiKey2 = process.env.RESEND_API_KEY_2; @@ -192,6 +192,23 @@ describe('mail.controller', () => { })); }); + test('queues replyTo using the Resend payload field', async () => { + const req = makeReq(); + req.body.replyTo = 'replies@example.com'; + const res = makeRes(); + + mockProjectConfig({ _id: 'proj_1', resendApiKey: null }); + decrypt.mockReturnValue(null); + redis.eval.mockResolvedValue(1); + + await mailController.sendMail(req, res, next); + + expect(publicEmailQueue.add).toHaveBeenCalledWith('send-public-email', expect.objectContaining({ + payload: expect.objectContaining({ replyTo: 'replies@example.com' }) + }), expect.any(Object)); + expect(publicEmailQueue.add.mock.calls[0][1].payload.reply_to).toBeUndefined(); + }); + test('enforces monthly limit', async () => { const req = makeReq(); const res = makeRes(); @@ -502,6 +519,52 @@ describe('mail.controller', () => { expect(next.mock.calls[0][0].statusCode).toBe(503); }); + test('uses Resend replyTo field for batch messages', async () => { + const req = makeReq(); + req.body = [{ + to: 'u@example.com', + replyTo: 'replies@example.com', + subject: 'Batch', + text: 'Hello' + }]; + const res = makeRes(); + + mockProjectConfig({ _id: 'proj_1', resendApiKey: null }); + decrypt.mockReturnValue(null); + redis.eval.mockResolvedValue(1); + mockResendClient.batch.send.mockResolvedValue({ data: [{ id: 're_123' }], error: null }); + + await mailController.sendBatchMail(req, res, next); + + expect(mockResendClient.batch.send).toHaveBeenCalledWith([ + expect.objectContaining({ replyTo: ['replies@example.com'] }) + ]); + expect(mockResendClient.batch.send.mock.calls[0][0][0].reply_to).toBeUndefined(); + }); + + test.each([ + [{ to: 'u@example.com', subject: ' ', text: 'Hello' }], + [{ to: 'u@example.com', subject: 'Batch', html: ' ', text: '\n\t' }], + [{ to: 'u@example.com', replyTo: [], subject: 'Batch', text: 'Hello' }], + ])('rejects invalid batch mail payload %o', async (body) => { + const req = makeReq(); + req.body = body; + + await mailController.sendBatchMail(req, makeRes(), next); + + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); + }); + + test('rejects an empty replyTo list for single-message mail', () => { + expect(() => sendMailSchema.parse({ + to: 'u@example.com', + replyTo: [], + subject: 'Hello', + text: 'Message' + })).toThrow('Reply-to list cannot be empty'); + }); + test('enforces BYOK gate for audience creation', async () => { const req = makeReq(); req.body = { name: 'Audience A' }; diff --git a/apps/public-api/src/controllers/mail.controller.js b/apps/public-api/src/controllers/mail.controller.js index 7161fe25..6fae46f0 100644 --- a/apps/public-api/src/controllers/mail.controller.js +++ b/apps/public-api/src/controllers/mail.controller.js @@ -137,6 +137,7 @@ module.exports.sendMail = async (req, res, next) => { const { to, + replyTo, subject, html, text, @@ -295,6 +296,7 @@ module.exports.sendMail = async (req, res, next) => { to, subject: resolvedSubject, }; + if (replyTo) payload.replyTo = replyTo; if (typeof resolvedHtml === "string" && resolvedHtml.trim()) payload.html = resolvedHtml; if (typeof resolvedText === "string" && resolvedText.trim()) payload.text = resolvedText; @@ -484,12 +486,16 @@ module.exports.handleResendWebhook = async (req, res, next) => { // POST /api/mail/send-batch const sendBatchSchema = z.array( z.object({ - to: z.union([z.string(), z.array(z.string())]), - subject: z.string().min(1, "Subject is required"), + to: z.union([z.string().email(), z.array(z.string().email()).nonempty()]), + replyTo: z.union([z.string().email(), z.array(z.string().email()).nonempty()]).optional(), + subject: z.string().refine((value) => value.trim().length > 0, { message: "Subject is required" }), html: z.string().optional(), text: z.string().optional() - }) -).min(1).max(100); + }).refine( + (data) => data.html?.trim().length > 0 || data.text?.trim().length > 0, + { message: "Provide html or text" }, + ) +).min(1, "Batch cannot be empty").max(100, "Max 100 emails per batch"); module.exports.sendBatchMail = async (req, res, next) => { const reservedKeys = []; @@ -515,6 +521,7 @@ module.exports.sendBatchMail = async (req, res, next) => { const resendPayloads = batch.map(item => ({ from: fromAddress, to: Array.isArray(item.to) ? item.to : [item.to], + ...(item.replyTo ? { replyTo: Array.isArray(item.replyTo) ? item.replyTo : [item.replyTo] } : {}), subject: item.subject, ...(item.html ? { html: item.html } : {}), ...(item.text ? { text: item.text } : {}) diff --git a/packages/common/src/utils/input.validation.js b/packages/common/src/utils/input.validation.js index 22140290..f10281fc 100755 --- a/packages/common/src/utils/input.validation.js +++ b/packages/common/src/utils/input.validation.js @@ -654,6 +654,10 @@ module.exports.sendMailSchema = z z.string().email("Invalid recipient email format"), z.array(z.string().email("Invalid recipient email format")).nonempty("Recipient list cannot be empty") ]), + replyTo: z.union([ + z.string().email("Invalid replyTo email format"), + z.array(z.string().email("Invalid replyTo email format")).nonempty("Reply-to list cannot be empty") + ]).optional(), // Direct-send fields (backward compatible) subject: z.preprocess(