From fc5fce8dbb67bc4f377a9695f74241e6c1423e5c Mon Sep 17 00:00:00 2001 From: yash-pouranik Date: Tue, 25 Aug 2026 19:08:15 +0530 Subject: [PATCH 1/2] feat(mail): add replyTo support to sendMail and sendBatchMail --- apps/public-api/src/controllers/mail.controller.js | 12 ++++++++---- packages/common/src/utils/input.validation.js | 4 ++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/public-api/src/controllers/mail.controller.js b/apps/public-api/src/controllers/mail.controller.js index 7161fe25..8a30b273 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.reply_to = replyTo; if (typeof resolvedHtml === "string" && resolvedHtml.trim()) payload.html = resolvedHtml; if (typeof resolvedText === "string" && resolvedText.trim()) payload.text = resolvedText; @@ -484,12 +486,13 @@ 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())]).optional(), + subject: z.string().min(1), html: z.string().optional(), text: z.string().optional() - }) -).min(1).max(100); + }).refine(data => data.html || data.text, { 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 +518,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 ? { reply_to: 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..ddfaefd0 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")) + ]).optional(), // Direct-send fields (backward compatible) subject: z.preprocess( From c4e12f0c7c56969766fb3c7f4e8e17d08118cfd6 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:26:47 +0000 Subject: [PATCH 2/2] Fix Resend reply-to fields and reject empty mail content --- .../src/__tests__/mail.controller.test.js | 65 ++++++++++++++++++- .../src/controllers/mail.controller.js | 13 ++-- packages/common/src/utils/input.validation.js | 2 +- 3 files changed, 73 insertions(+), 7 deletions(-) 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 8a30b273..6fae46f0 100644 --- a/apps/public-api/src/controllers/mail.controller.js +++ b/apps/public-api/src/controllers/mail.controller.js @@ -296,7 +296,7 @@ module.exports.sendMail = async (req, res, next) => { to, subject: resolvedSubject, }; - if (replyTo) payload.reply_to = replyTo; + if (replyTo) payload.replyTo = replyTo; if (typeof resolvedHtml === "string" && resolvedHtml.trim()) payload.html = resolvedHtml; if (typeof resolvedText === "string" && resolvedText.trim()) payload.text = resolvedText; @@ -487,11 +487,14 @@ module.exports.handleResendWebhook = async (req, res, next) => { const sendBatchSchema = z.array( z.object({ to: z.union([z.string().email(), z.array(z.string().email()).nonempty()]), - replyTo: z.union([z.string().email(), z.array(z.string().email())]).optional(), - subject: z.string().min(1), + 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() - }).refine(data => data.html || data.text, { message: "Provide html or text" }) + }).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) => { @@ -518,7 +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 ? { reply_to: Array.isArray(item.replyTo) ? item.replyTo : [item.replyTo] } : {}), + ...(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 ddfaefd0..f10281fc 100755 --- a/packages/common/src/utils/input.validation.js +++ b/packages/common/src/utils/input.validation.js @@ -656,7 +656,7 @@ module.exports.sendMailSchema = z ]), replyTo: z.union([ z.string().email("Invalid replyTo email format"), - z.array(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)