Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 64 additions & 1 deletion apps/public-api/src/__tests__/mail.controller.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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' };
Expand Down
15 changes: 11 additions & 4 deletions apps/public-api/src/controllers/mail.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ module.exports.sendMail = async (req, res, next) => {

const {
to,
replyTo,
subject,
html,
text,
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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 = [];
Expand All @@ -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 } : {})
Expand Down
4 changes: 4 additions & 0 deletions packages/common/src/utils/input.validation.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading