Skip to content

Commit 560690f

Browse files
Merge pull request #402 from geturbackend/feat/mail-reply-to
feat(mail): add replyTo support to sendMail and sendBatchMail
2 parents f91b0c0 + c4e12f0 commit 560690f

3 files changed

Lines changed: 79 additions & 5 deletions

File tree

‎apps/public-api/src/__tests__/mail.controller.test.js‎

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ jest.mock('@urbackend/common', () => {
8888
};
8989
});
9090

91-
const { Project, decrypt, redis, publicEmailQueue, MailTemplate, MailLog, AppError } = require('@urbackend/common');
91+
const { Project, decrypt, redis, publicEmailQueue, MailTemplate, MailLog, AppError, sendMailSchema } = require('@urbackend/common');
9292
const mailController = require('../controllers/mail.controller');
9393
const originalResendApiKey2 = process.env.RESEND_API_KEY_2;
9494

@@ -192,6 +192,23 @@ describe('mail.controller', () => {
192192
}));
193193
});
194194

195+
test('queues replyTo using the Resend payload field', async () => {
196+
const req = makeReq();
197+
req.body.replyTo = 'replies@example.com';
198+
const res = makeRes();
199+
200+
mockProjectConfig({ _id: 'proj_1', resendApiKey: null });
201+
decrypt.mockReturnValue(null);
202+
redis.eval.mockResolvedValue(1);
203+
204+
await mailController.sendMail(req, res, next);
205+
206+
expect(publicEmailQueue.add).toHaveBeenCalledWith('send-public-email', expect.objectContaining({
207+
payload: expect.objectContaining({ replyTo: 'replies@example.com' })
208+
}), expect.any(Object));
209+
expect(publicEmailQueue.add.mock.calls[0][1].payload.reply_to).toBeUndefined();
210+
});
211+
195212
test('enforces monthly limit', async () => {
196213
const req = makeReq();
197214
const res = makeRes();
@@ -502,6 +519,52 @@ describe('mail.controller', () => {
502519
expect(next.mock.calls[0][0].statusCode).toBe(503);
503520
});
504521

522+
test('uses Resend replyTo field for batch messages', async () => {
523+
const req = makeReq();
524+
req.body = [{
525+
to: 'u@example.com',
526+
replyTo: 'replies@example.com',
527+
subject: 'Batch',
528+
text: 'Hello'
529+
}];
530+
const res = makeRes();
531+
532+
mockProjectConfig({ _id: 'proj_1', resendApiKey: null });
533+
decrypt.mockReturnValue(null);
534+
redis.eval.mockResolvedValue(1);
535+
mockResendClient.batch.send.mockResolvedValue({ data: [{ id: 're_123' }], error: null });
536+
537+
await mailController.sendBatchMail(req, res, next);
538+
539+
expect(mockResendClient.batch.send).toHaveBeenCalledWith([
540+
expect.objectContaining({ replyTo: ['replies@example.com'] })
541+
]);
542+
expect(mockResendClient.batch.send.mock.calls[0][0][0].reply_to).toBeUndefined();
543+
});
544+
545+
test.each([
546+
[{ to: 'u@example.com', subject: ' ', text: 'Hello' }],
547+
[{ to: 'u@example.com', subject: 'Batch', html: ' ', text: '\n\t' }],
548+
[{ to: 'u@example.com', replyTo: [], subject: 'Batch', text: 'Hello' }],
549+
])('rejects invalid batch mail payload %o', async (body) => {
550+
const req = makeReq();
551+
req.body = body;
552+
553+
await mailController.sendBatchMail(req, makeRes(), next);
554+
555+
expect(next).toHaveBeenCalledWith(expect.any(AppError));
556+
expect(next.mock.calls[0][0].statusCode).toBe(400);
557+
});
558+
559+
test('rejects an empty replyTo list for single-message mail', () => {
560+
expect(() => sendMailSchema.parse({
561+
to: 'u@example.com',
562+
replyTo: [],
563+
subject: 'Hello',
564+
text: 'Message'
565+
})).toThrow('Reply-to list cannot be empty');
566+
});
567+
505568
test('enforces BYOK gate for audience creation', async () => {
506569
const req = makeReq();
507570
req.body = { name: 'Audience A' };

‎apps/public-api/src/controllers/mail.controller.js‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ module.exports.sendMail = async (req, res, next) => {
137137

138138
const {
139139
to,
140+
replyTo,
140141
subject,
141142
html,
142143
text,
@@ -295,6 +296,7 @@ module.exports.sendMail = async (req, res, next) => {
295296
to,
296297
subject: resolvedSubject,
297298
};
299+
if (replyTo) payload.replyTo = replyTo;
298300
if (typeof resolvedHtml === "string" && resolvedHtml.trim()) payload.html = resolvedHtml;
299301
if (typeof resolvedText === "string" && resolvedText.trim()) payload.text = resolvedText;
300302

@@ -484,12 +486,16 @@ module.exports.handleResendWebhook = async (req, res, next) => {
484486
// POST /api/mail/send-batch
485487
const sendBatchSchema = z.array(
486488
z.object({
487-
to: z.union([z.string(), z.array(z.string())]),
488-
subject: z.string().min(1, "Subject is required"),
489+
to: z.union([z.string().email(), z.array(z.string().email()).nonempty()]),
490+
replyTo: z.union([z.string().email(), z.array(z.string().email()).nonempty()]).optional(),
491+
subject: z.string().refine((value) => value.trim().length > 0, { message: "Subject is required" }),
489492
html: z.string().optional(),
490493
text: z.string().optional()
491-
})
492-
).min(1).max(100);
494+
}).refine(
495+
(data) => data.html?.trim().length > 0 || data.text?.trim().length > 0,
496+
{ message: "Provide html or text" },
497+
)
498+
).min(1, "Batch cannot be empty").max(100, "Max 100 emails per batch");
493499

494500
module.exports.sendBatchMail = async (req, res, next) => {
495501
const reservedKeys = [];
@@ -515,6 +521,7 @@ module.exports.sendBatchMail = async (req, res, next) => {
515521
const resendPayloads = batch.map(item => ({
516522
from: fromAddress,
517523
to: Array.isArray(item.to) ? item.to : [item.to],
524+
...(item.replyTo ? { replyTo: Array.isArray(item.replyTo) ? item.replyTo : [item.replyTo] } : {}),
518525
subject: item.subject,
519526
...(item.html ? { html: item.html } : {}),
520527
...(item.text ? { text: item.text } : {})

‎packages/common/src/utils/input.validation.js‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -654,6 +654,10 @@ module.exports.sendMailSchema = z
654654
z.string().email("Invalid recipient email format"),
655655
z.array(z.string().email("Invalid recipient email format")).nonempty("Recipient list cannot be empty")
656656
]),
657+
replyTo: z.union([
658+
z.string().email("Invalid replyTo email format"),
659+
z.array(z.string().email("Invalid replyTo email format")).nonempty("Reply-to list cannot be empty")
660+
]).optional(),
657661

658662
// Direct-send fields (backward compatible)
659663
subject: z.preprocess(

0 commit comments

Comments
 (0)