From cafdd53c6b129cb74d7ea68c9b0019fe094d6d57 Mon Sep 17 00:00:00 2001 From: yash-pouranik Date: Wed, 8 Apr 2026 01:38:35 +0530 Subject: [PATCH 1/7] feat: implement per-project BYOK resend mail and auth routing --- AGENTS.md | 10 +- .../src/controllers/project.controller.js | 33 ++++- .../src/__tests__/mail.controller.test.js | 116 +++++++++++++++ apps/public-api/src/app.js | 2 + .../src/controllers/mail.controller.js | 126 +++++++++++++++++ .../src/controllers/userAuth.controller.js | 5 +- apps/public-api/src/routes/mail.js | 10 ++ apps/public-api/src/utils/mailLimit.js | 27 ++++ .../src/pages/ProjectSettings.jsx | 133 +++++++++++++++++- packages/common/src/index.js | 2 + packages/common/src/models/Project.js | 2 + packages/common/src/queues/authEmailQueue.js | 25 +++- packages/common/src/utils/emailService.js | 13 +- packages/common/src/utils/input.validation.js | 19 +++ 14 files changed, 511 insertions(+), 12 deletions(-) create mode 100644 apps/public-api/src/__tests__/mail.controller.test.js create mode 100644 apps/public-api/src/controllers/mail.controller.js create mode 100644 apps/public-api/src/routes/mail.js create mode 100644 apps/public-api/src/utils/mailLimit.js diff --git a/AGENTS.md b/AGENTS.md index 60d50c8c1..ac68c507d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -165,4 +165,12 @@ Social auth (GitHub + Google) shipped. Next: v0.9.0 — Webhooks + BYOK Resend m - Webhook system: per-project config, HMAC-SHA256, retry, delivery logs - BYOK Resend mail key: project-level Resend API key, custom domain mail - Follow same encryption pattern as authProviders for storing Resend key -- Webhook model: separate MongoDB collection (not embedded in Project) \ No newline at end of file +- Webhook model: separate MongoDB collection (not embedded in Project) + +## Webhook system (v0.9.0) - Already done +- Model: packages/common/src/models/Webhook.js +- Delivery log: packages/common/src/models/WebhookDelivery.js +- Dispatcher: apps/public-api/src/utils/webhookDispatcher.js +- Queue: BullMQ + existing Redis connection +- Retry: exponential backoff, max 5 attempts, stop on 4xx +- Signature: HMAC-SHA256 in X-urBackend-Signature header \ No newline at end of file diff --git a/apps/dashboard-api/src/controllers/project.controller.js b/apps/dashboard-api/src/controllers/project.controller.js index a13206f66..8901e3c1c 100644 --- a/apps/dashboard-api/src/controllers/project.controller.js +++ b/apps/dashboard-api/src/controllers/project.controller.js @@ -156,6 +156,12 @@ const sanitizeProjectResponse = (projectObj) => { delete projectObj.publishableKey; delete projectObj.secretKey; delete projectObj.jwtSecret; + const resendConfig = projectObj.resendApiKey; + projectObj.hasResendApiKey = + resendConfig != null && + typeof resendConfig === "object" && + Object.keys(resendConfig).length > 0; + delete projectObj.resendApiKey; projectObj.authProviders = sanitizeAuthProviders(projectObj.authProviders); @@ -264,7 +270,10 @@ module.exports.getSingleProject = async (req, res) => { "+authProviders.github.clientSecret.tag " + "+authProviders.google.clientSecret.encrypted " + "+authProviders.google.clientSecret.iv " + - "+authProviders.google.clientSecret.tag", + "+authProviders.google.clientSecret.tag " + + "+resendApiKey.encrypted " + + "+resendApiKey.iv " + + "+resendApiKey.tag", ); if (!project) return res.status(404).json({ error: "Project not found." }); @@ -1088,9 +1097,15 @@ module.exports.deleteAllFiles = async (req, res) => { module.exports.updateProject = async (req, res) => { try { - const { name, siteUrl } = req.body; + const { name, siteUrl, resendApiKey, resendFromEmail } = req.body; const updateFields = {}; if (name !== undefined) updateFields.name = name; + if (resendFromEmail !== undefined) { + if (typeof resendFromEmail !== "string") { + return res.status(400).json({ error: "resendFromEmail must be a string." }); + } + updateFields.resendFromEmail = resendFromEmail.trim(); + } if (siteUrl !== undefined) { if (siteUrl !== "" && typeof siteUrl !== "string") { return res.status(400).json({ error: "siteUrl must be a string." }); @@ -1116,11 +1131,23 @@ module.exports.updateProject = async (req, res) => { } updateFields.siteUrl = siteUrl || ""; } + if (resendApiKey !== undefined) { + if (typeof resendApiKey !== "string" || !resendApiKey.trim()) { + return res + .status(400) + .json({ error: "resendApiKey must be a non-empty string." }); + } + updateFields.resendApiKey = encrypt(resendApiKey.trim()); + } const project = await Project.findOneAndUpdate( { _id: req.params.projectId, owner: req.user._id }, { $set: updateFields }, - { new: true }, + { + new: true, + select: + "+resendApiKey.encrypted +resendApiKey.iv +resendApiKey.tag", + }, ); if (!project) return res.status(404).json({ error: "Project not found." }); diff --git a/apps/public-api/src/__tests__/mail.controller.test.js b/apps/public-api/src/__tests__/mail.controller.test.js new file mode 100644 index 000000000..a85126066 --- /dev/null +++ b/apps/public-api/src/__tests__/mail.controller.test.js @@ -0,0 +1,116 @@ +'use strict'; + +process.env.REDIS_URL = process.env.REDIS_URL || "redis://localhost:6379/0"; +process.env.ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || "0123456789012345678901234567890a"; + +jest.mock('resend', () => { + const sendMock = jest.fn(() => Promise.resolve({ data: { id: 'mail-123' }, error: null })); + return { + Resend: jest.fn(() => ({ + emails: { send: sendMock }, + })), + __sendMock: sendMock, + }; +}); + +jest.mock('@urbackend/common', () => { + const { sendMailSchema } = require('../../../../packages/common/src/utils/input.validation'); + const redisMock = { + status: 'ready', + incr: jest.fn(), + expire: jest.fn(), + decr: jest.fn(), + }; + + return { + sendMailSchema, + Project: { findById: jest.fn() }, + decrypt: jest.fn(), + redis: redisMock, + }; +}); + +const { Resend } = require('resend'); +const { Project, decrypt, redis } = require('@urbackend/common'); +const mailController = require('../controllers/mail.controller'); + +const makeReq = () => ({ + keyRole: 'secret', + project: { _id: 'proj_1' }, + body: { to: 'user@example.com', subject: 'Hello', text: 'This is a message.' }, +}); + +const makeRes = () => { + const res = { status: jest.fn(), json: jest.fn() }; + res.status.mockReturnValue(res); + res.json.mockReturnValue(res); + return res; +}; + +const mockProjectConfig = (payload) => { + Project.findById.mockReturnValue({ + select: jest.fn(() => ({ + lean: jest.fn(() => Promise.resolve(payload)), + })), + }); +}; + +describe('mail.controller', () => { + beforeEach(() => { + jest.clearAllMocks(); + process.env.RESEND_API_KEY = 'default-key'; + process.env.EMAIL_FROM = 'mail@urbackend.app'; + }); + + test('sends mail using BYOK key when configured', async () => { + const req = makeReq(); + const res = makeRes(); + + mockProjectConfig({ _id: 'proj_1', resendApiKey: {} }); + decrypt.mockReturnValue('byok-key'); + redis.incr.mockResolvedValue(1); + + await mailController.sendMail(req, res); + + expect(redis.incr).toHaveBeenCalledTimes(1); + expect(redis.expire).toHaveBeenCalledTimes(1); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + success: true, + data: expect.objectContaining({ provider: 'byok', monthlyUsage: 1 }), + })); + }); + + test('falls back to default key when BYOK missing', async () => { + const req = makeReq(); + const res = makeRes(); + + mockProjectConfig({ _id: 'proj_1', resendApiKey: null }); + decrypt.mockReturnValue(null); + redis.incr.mockResolvedValue(2); + + await mailController.sendMail(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ provider: 'default', monthlyUsage: 2 }), + })); + }); + + test('enforces monthly limit', async () => { + const req = makeReq(); + const res = makeRes(); + + mockProjectConfig({ _id: 'proj_1', resendApiKey: null }); + decrypt.mockReturnValue(null); + redis.incr.mockResolvedValue(101); + + await mailController.sendMail(req, res); + + expect(redis.decr).toHaveBeenCalledTimes(1); + expect(res.status).toHaveBeenCalledWith(429); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + error: 'Monthly mail limit exceeded.', + })); + }); +}); diff --git a/apps/public-api/src/app.js b/apps/public-api/src/app.js index 1dfb8dc8b..e9888be56 100644 --- a/apps/public-api/src/app.js +++ b/apps/public-api/src/app.js @@ -57,6 +57,7 @@ const dataRoute = require('./routes/data'); const userAuthRoute = require('./routes/userAuth'); const storageRoute = require('./routes/storage'); const schemaRoute = require('./routes/schemas'); +const mailRoute = require('./routes/mail'); // ROUTES SETUP app.use('/api/userAuth', limiter, logger, userAuthRoute); @@ -74,6 +75,7 @@ const projectCorsPreflight = (req, res, next) => { app.use('/api/data', projectCorsPreflight, limiter, logger, dataRoute); app.use('/api/schemas', projectCorsPreflight, limiter, logger, schemaRoute); app.use('/api/storage', projectCorsPreflight, limiter, logger, storageRoute); +app.use('/api/mail', projectCorsPreflight, limiter, logger, mailRoute); app.get('/api/server-ip', async (req, res) => { const ip = await getPublicIp(); diff --git a/apps/public-api/src/controllers/mail.controller.js b/apps/public-api/src/controllers/mail.controller.js new file mode 100644 index 000000000..05d1fb1ca --- /dev/null +++ b/apps/public-api/src/controllers/mail.controller.js @@ -0,0 +1,126 @@ +const { Resend } = require("resend"); +const { z } = require("zod"); +const { Project, decrypt, redis, sendMailSchema } = require("@urbackend/common"); +const { + getMonthKey, + getEndOfMonthTtlSeconds, + getMonthlyMailLimit, +} = require("../utils/mailLimit"); + +const DEFAULT_FROM = process.env.EMAIL_FROM || "urBackend "; + +const getMailCountKey = (projectId, monthKey) => + `project:mail:count:${projectId}:${monthKey}`; + +const loadProjectMailConfig = async (projectId) => { + return Project.findById(projectId) + .select("+resendApiKey.encrypted +resendApiKey.iv +resendApiKey.tag") + .lean(); +}; + +const reserveMonthlyMailSlot = async (projectId, limit) => { + if (redis.status !== "ready") { + const err = new Error("Mail service unavailable. Redis is not ready."); + err.statusCode = 503; + throw err; + } + + const now = new Date(); + const monthKey = getMonthKey(now); + const ttlSeconds = getEndOfMonthTtlSeconds(now); + const key = getMailCountKey(projectId, monthKey); + + const count = await redis.incr(key); + if (count === 1) { + await redis.expire(key, ttlSeconds); + } + + if (count > limit) { + await redis.decr(key); + const err = new Error("Monthly mail limit exceeded."); + err.statusCode = 429; + err.limit = limit; + throw err; + } + + return { count, key }; +}; + +module.exports.sendMail = async (req, res) => { + try { + if (req.keyRole !== "secret") { + return res.status(403).json({ + error: "Forbidden. This action requires a Secret Key (sk_live_...).", + }); + } + + const { to, subject, html, text } = sendMailSchema.parse(req.body || {}); + const projectId = req.project?._id; + + if (!projectId) { + return res.status(401).json({ error: "Project context missing." }); + } + + const limit = getMonthlyMailLimit(req.project); + const { count } = await reserveMonthlyMailSlot(projectId, limit); + + const project = await loadProjectMailConfig(projectId); + if (!project) { + return res.status(404).json({ error: "Project not found." }); + } + + const decryptedByokKey = decrypt(project.resendApiKey); + const usingByok = typeof decryptedByokKey === "string" && decryptedByokKey.trim().length > 0; + const clientKey = usingByok + ? decryptedByokKey.trim() + : process.env.RESEND_API_KEY_2 || process.env.RESEND_API_KEY; + + if (!clientKey) { + return res.status(500).json({ error: "Resend API key is not configured." }); + } + + const resend = new Resend(clientKey); + + let fromAddress = DEFAULT_FROM; + if (usingByok) { + fromAddress = project.resendFromEmail && project.resendFromEmail.trim() + ? project.resendFromEmail.trim() + : "onboarding@resend.dev"; + } + + const payload = { + from: fromAddress, + to, + subject, + }; + if (typeof html === "string" && html.trim()) payload.html = html; + if (typeof text === "string" && text.trim()) payload.text = text; + + const { data, error } = await resend.emails.send(payload); + if (error) { + throw new Error(error.message || "Failed to send mail."); + } + + return res.status(200).json({ + success: true, + data: { + id: data?.id || null, + provider: usingByok ? "byok" : "default", + monthlyUsage: count, + monthlyLimit: limit, + }, + message: "Mail sent successfully.", + }); + } catch (err) { + if (err instanceof z.ZodError) { + return res.status(400).json({ + error: err.issues?.[0]?.message || "Invalid mail payload.", + }); + } + + return res.status(err.statusCode || 500).json({ + error: err.message || "Failed to send mail.", + ...(typeof err.limit === "number" ? { limit: err.limit } : {}), + }); + } +}; diff --git a/apps/public-api/src/controllers/userAuth.controller.js b/apps/public-api/src/controllers/userAuth.controller.js index 27eba223d..c59835109 100644 --- a/apps/public-api/src/controllers/userAuth.controller.js +++ b/apps/public-api/src/controllers/userAuth.controller.js @@ -893,7 +893,8 @@ module.exports.signup = async (req, res) => { email, otp, type: 'verification', - pname: project.name + pname: project.name, + projectId: String(project._id) }); const issuedTokens = await issueAuthTokens({ @@ -1162,7 +1163,7 @@ module.exports.requestPasswordReset = async (req, res) => { const otp = Math.floor(100000 + Math.random() * 900000).toString(); await redis.set(`project:${project._id}:otp:reset:${email}`, otp, 'EX', 300); - await authEmailQueue.add('send-reset-email', { email, otp, type: 'password_reset', pname: project.name }); + await authEmailQueue.add('send-reset-email', { email, otp, type: 'password_reset', pname: project.name, projectId: String(project._id) }); res.json({ message: "If that email exists, a reset code has been sent." }); } catch (err) { diff --git a/apps/public-api/src/routes/mail.js b/apps/public-api/src/routes/mail.js new file mode 100644 index 000000000..ceab92ac8 --- /dev/null +++ b/apps/public-api/src/routes/mail.js @@ -0,0 +1,10 @@ +const express = require("express"); +const router = express.Router(); +const verifyApiKey = require("../middlewares/verifyApiKey"); +const requireSecretKey = require("../middlewares/requireSecretKey"); +const projectRateLimiter = require("../middlewares/projectRateLimiter"); +const { sendMail } = require("../controllers/mail.controller"); + +router.post("/send", verifyApiKey, projectRateLimiter, requireSecretKey, sendMail); + +module.exports = router; diff --git a/apps/public-api/src/utils/mailLimit.js b/apps/public-api/src/utils/mailLimit.js new file mode 100644 index 000000000..c96e2f353 --- /dev/null +++ b/apps/public-api/src/utils/mailLimit.js @@ -0,0 +1,27 @@ +const MONTHLY_FREE_MAIL_LIMIT = 100; + +const padMonth = (month) => String(month).padStart(2, "0"); + +const getMonthKey = (now = new Date()) => { + const year = now.getUTCFullYear(); + const month = now.getUTCMonth() + 1; + return `${year}-${padMonth(month)}`; +}; + +const getEndOfMonthTtlSeconds = (now = new Date()) => { + const year = now.getUTCFullYear(); + const month = now.getUTCMonth(); + const nextMonthStart = new Date(Date.UTC(year, month + 1, 1, 0, 0, 0)); + return Math.max(1, Math.ceil((nextMonthStart.getTime() - now.getTime()) / 1000)); +}; + +const getMonthlyMailLimit = () => { + // v0.9.0 default: free tier limit for all projects. + return MONTHLY_FREE_MAIL_LIMIT; +}; + +module.exports = { + getMonthKey, + getEndOfMonthTtlSeconds, + getMonthlyMailLimit, +}; diff --git a/apps/web-dashboard/src/pages/ProjectSettings.jsx b/apps/web-dashboard/src/pages/ProjectSettings.jsx index 33359dec7..c25f19e7e 100644 --- a/apps/web-dashboard/src/pages/ProjectSettings.jsx +++ b/apps/web-dashboard/src/pages/ProjectSettings.jsx @@ -18,6 +18,10 @@ export default function ProjectSettings() { const [project, setProject] = useState(null); const [loading, setLoading] = useState(true); const [deleteConfirm, setDeleteConfirm] = useState(""); + const [hasResendKey, setHasResendKey] = useState(false); + const [resendKeyValue, setResendKeyValue] = useState(""); + const [resendFromEmailValue, setResendFromEmailValue] = useState(""); + const [resendKeyLoading, setResendKeyLoading] = useState(false); // --- NEW STATE FOR RENAME --- const [newName, setNewName] = useState(""); @@ -29,6 +33,8 @@ export default function ProjectSettings() { try { const res = await api.get(`/api/projects/${projectId}`); setProject(res.data); + setHasResendKey(!!res.data?.hasResendApiKey); + setResendFromEmailValue(res.data.resendFromEmail || ""); // Set initial name for renaming setNewName(res.data.name); setSiteUrl(res.data.siteUrl || ""); @@ -42,7 +48,7 @@ export default function ProjectSettings() { }, [projectId, user]); // --- NEW: HANDLE RENAME --- - const handleRename = async () => { + const handleRename = async () => { if (!newName.trim()) return toast.error("Project name cannot be empty"); setRenaming(true); @@ -61,6 +67,41 @@ export default function ProjectSettings() { } }; + const handleResendKeySave = async () => { + const trimmedKey = resendKeyValue.trim(); + const trimmedEmail = resendFromEmailValue.trim(); + + const payload = {}; + if (trimmedKey) payload.resendApiKey = trimmedKey; + if (trimmedEmail !== project?.resendFromEmail) payload.resendFromEmail = trimmedEmail; + + if (Object.keys(payload).length === 0) { + return toast.error("Nothing to update."); + } + + setResendKeyLoading(true); + try { + await api.patch(`/api/projects/${projectId}`, payload); + toast.success("Mail settings saved."); + + const updates = {}; + if (payload.resendApiKey) { + setResendKeyValue(""); + setHasResendKey(true); + updates.hasResendApiKey = true; + } + if (payload.resendFromEmail !== undefined) { + updates.resendFromEmail = payload.resendFromEmail; + } + + setProject((prev) => (prev ? { ...prev, ...updates } : prev)); + } catch (err) { + toast.error(err.response?.data?.error || "Failed to save mail settings"); + } finally { + setResendKeyLoading(false); + } + }; + const handleDeleteProject = async () => { if (deleteConfirm !== project.name) return toast.error("Project name does not match"); @@ -205,6 +246,96 @@ export default function ProjectSettings() { +
+

+ Custom Mail (Resend BYOK) +

+

+ Upload a per-project Resend API key to send mail from your own account. + The repository never exposes the key after you save it. +

+
+
+ + setResendKeyValue(e.target.value)} + style={{ + width: "100%", + padding: "12px", + background: "var(--color-bg-input)", + border: "1px solid var(--color-border)", + borderRadius: "8px", + color: "#fff", + fontFamily: "monospace", + }} + /> +
+ +
+ + setResendFromEmailValue(e.target.value)} + style={{ + width: "100%", + padding: "12px", + background: "var(--color-bg-input)", + border: "1px solid var(--color-border)", + borderRadius: "8px", + color: "#fff", + }} + /> + + The verified domain associated with your Resend API key. Left blank, default onboarding@resend.dev will be used. + +
+ +
+ +
+
+
+ {/* External Configuration */}
({}) }, google: { type: authProviderSchema, default: () => ({}) }, }, + resendApiKey: { type: resourceConfigSchema, default: null }, + resendFromEmail: { type: String, default: "" }, collections: [collectionSchema], allowedDomains: { diff --git a/packages/common/src/queues/authEmailQueue.js b/packages/common/src/queues/authEmailQueue.js index 3f925ba57..cc0f15882 100644 --- a/packages/common/src/queues/authEmailQueue.js +++ b/packages/common/src/queues/authEmailQueue.js @@ -1,19 +1,40 @@ const { Queue, Worker } = require('bullmq'); const connection = require('../config/redis'); const { sendAuthOtpEmail } = require('../utils/emailService'); +const Project = require('../models/Project'); +const { decrypt } = require('../utils/encryption'); // Create the email queue specifically for fast OTPs const authEmailQueue = new Queue('auth-email-queue', { connection }); // Initialize Worker with Rate Limiting const worker = new Worker('auth-email-queue', async (job) => { - const { email, otp, type, pname } = job.data; + const { email, otp, type, pname, projectId } = job.data; const redact = (e) => e.replace(/(.{2})(.*)(?=@)/, (gp1, gp2, gp3) => gp2 + "*".repeat(gp3.length)); const maskedEmail = redact(email); + let byokKey = null; + let byokFrom = null; + + try { + if (projectId) { + const project = await Project.findById(projectId).select('+resendApiKey.encrypted +resendApiKey.iv +resendApiKey.tag resendFromEmail').lean(); + if (project && project.resendApiKey) { + const decrypted = decrypt(project.resendApiKey); + if (typeof decrypted === 'string' && decrypted.trim().length > 0) { + byokKey = decrypted.trim(); + byokFrom = project.resendFromEmail || null; + } + } + } + } catch (err) { + console.error(`[Queue] Failed to load BYOK config for project ${projectId}:`, err); + // Continue and fallback to global key if BYOK lookup fails + } + try { console.log(`[Queue] Processing ${type} email for: ${maskedEmail}`); - await sendAuthOtpEmail(email, { otp, type, pname}); + await sendAuthOtpEmail(email, { otp, type, pname, byokKey, byokFrom }); } catch (error) { console.error(`[Queue] Failed to send auth email to ${maskedEmail}:`, error); throw error; diff --git a/packages/common/src/utils/emailService.js b/packages/common/src/utils/emailService.js index 4b9871d62..2be7d1431 100644 --- a/packages/common/src/utils/emailService.js +++ b/packages/common/src/utils/emailService.js @@ -134,7 +134,7 @@ async function sendReleaseEmail(email, { version, title, content }) { } // FUNCTION - SEND AUTH OTP EMAIL -async function sendAuthOtpEmail(email, { otp, type, pname }) { +async function sendAuthOtpEmail(email, { otp, type, pname, byokKey, byokFrom }) { const rawPname = pname || "urBackend"; let safeEmailHandle = rawPname.replace(/[^a-zA-Z0-9]/g, '').toLowerCase(); @@ -194,8 +194,15 @@ async function sendAuthOtpEmail(email, { otp, type, pname }) { - const fromAddress = `${finalDisplayName} <${safeEmailHandle}.urbackend@apps.bitbros.in>`; - const { data, error } = await resend.emails.send({ + let mailClient = resend; + let fromAddress = `${finalDisplayName} <${safeEmailHandle}.urbackend@apps.bitbros.in>`; + + if (byokKey) { + mailClient = new Resend(byokKey); + fromAddress = byokFrom || "onboarding@resend.dev"; + } + + const { data, error } = await mailClient.emails.send({ from: fromAddress, to: email, subject: subject, diff --git a/packages/common/src/utils/input.validation.js b/packages/common/src/utils/input.validation.js index a45efbd3f..8424c2ce4 100644 --- a/packages/common/src/utils/input.validation.js +++ b/packages/common/src/utils/input.validation.js @@ -458,3 +458,22 @@ module.exports.updateWebhookSchema = z.object({ (data) => Object.keys(data).length > 0, { message: "At least one field must be provided for update." } ); + +module.exports.sendMailSchema = z + .object({ + to: z.string().email("Invalid recipient email format"), + subject: z + .string() + .min(1, "Subject is required") + .max(200, "Subject is too long"), + html: z.string().optional(), + text: z.string().optional(), + }) + .refine( + (data) => + (typeof data.html === "string" && data.html.trim().length > 0) || + (typeof data.text === "string" && data.text.trim().length > 0), + { + message: "Provide at least one of html or text content.", + }, + ); From a78f289363512d91ead25cc3f0cb35c1f2efcddc Mon Sep 17 00:00:00 2001 From: yash-pouranik Date: Wed, 8 Apr 2026 15:09:11 +0530 Subject: [PATCH 2/7] fix: rollback monthly mail quota on failed BYOK deliveries and validate sender address format --- apps/dashboard-api/src/controllers/project.controller.js | 9 ++++++++- apps/public-api/src/controllers/mail.controller.js | 8 +++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/dashboard-api/src/controllers/project.controller.js b/apps/dashboard-api/src/controllers/project.controller.js index 8901e3c1c..a5feaec38 100644 --- a/apps/dashboard-api/src/controllers/project.controller.js +++ b/apps/dashboard-api/src/controllers/project.controller.js @@ -1104,7 +1104,14 @@ module.exports.updateProject = async (req, res) => { if (typeof resendFromEmail !== "string") { return res.status(400).json({ error: "resendFromEmail must be a string." }); } - updateFields.resendFromEmail = resendFromEmail.trim(); + const trimmedFrom = resendFromEmail.trim(); + if (trimmedFrom !== "") { + const senderRegex = /^(?:.*<)?[^\s@]+@[^>\s@]+\.[^>\s@]+(?:>)?$/; + if (!senderRegex.test(trimmedFrom)) { + return res.status(400).json({ error: "resendFromEmail must be a valid format (e.g., 'me@domain.com' or 'App ')." }); + } + } + updateFields.resendFromEmail = trimmedFrom; } if (siteUrl !== undefined) { if (siteUrl !== "" && typeof siteUrl !== "string") { diff --git a/apps/public-api/src/controllers/mail.controller.js b/apps/public-api/src/controllers/mail.controller.js index 05d1fb1ca..04d6948be 100644 --- a/apps/public-api/src/controllers/mail.controller.js +++ b/apps/public-api/src/controllers/mail.controller.js @@ -47,6 +47,7 @@ const reserveMonthlyMailSlot = async (projectId, limit) => { }; module.exports.sendMail = async (req, res) => { + let consumedQuotaKey = null; try { if (req.keyRole !== "secret") { return res.status(403).json({ @@ -62,7 +63,8 @@ module.exports.sendMail = async (req, res) => { } const limit = getMonthlyMailLimit(req.project); - const { count } = await reserveMonthlyMailSlot(projectId, limit); + const { count, key } = await reserveMonthlyMailSlot(projectId, limit); + consumedQuotaKey = key; const project = await loadProjectMailConfig(projectId); if (!project) { @@ -112,6 +114,10 @@ module.exports.sendMail = async (req, res) => { message: "Mail sent successfully.", }); } catch (err) { + if (consumedQuotaKey) { + await redis.decr(consumedQuotaKey).catch(() => {}); + } + if (err instanceof z.ZodError) { return res.status(400).json({ error: err.issues?.[0]?.message || "Invalid mail payload.", From 2d9b53c8802a1f30d799fcbd4317eb79902dad86 Mon Sep 17 00:00:00 2001 From: yash-pouranik Date: Wed, 8 Apr 2026 15:20:54 +0530 Subject: [PATCH 3/7] fix: resolve ReDoS vulnerability in sender email regex validation --- .../src/controllers/project.controller.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/dashboard-api/src/controllers/project.controller.js b/apps/dashboard-api/src/controllers/project.controller.js index a5feaec38..1e96c2acd 100644 --- a/apps/dashboard-api/src/controllers/project.controller.js +++ b/apps/dashboard-api/src/controllers/project.controller.js @@ -1106,8 +1106,16 @@ module.exports.updateProject = async (req, res) => { } const trimmedFrom = resendFromEmail.trim(); if (trimmedFrom !== "") { - const senderRegex = /^(?:.*<)?[^\s@]+@[^>\s@]+\.[^>\s@]+(?:>)?$/; - if (!senderRegex.test(trimmedFrom)) { + if (trimmedFrom.length > 255) { + return res.status(400).json({ error: "resendFromEmail is too long." }); + } + let addressToValidate = trimmedFrom; + const bracketMatch = trimmedFrom.match(/<([^>]+)>$/); + if (bracketMatch) { + addressToValidate = bracketMatch[1].trim(); + } + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(addressToValidate)) { return res.status(400).json({ error: "resendFromEmail must be a valid format (e.g., 'me@domain.com' or 'App ')." }); } } From 68c62f80b5cb1231d312492358a11992408443d8 Mon Sep 17 00:00:00 2001 From: yash-pouranik Date: Wed, 8 Apr 2026 15:38:48 +0530 Subject: [PATCH 4/7] fix: address copilot review suggestions for byok validation and worker init edge-cases --- .../src/controllers/project.controller.js | 2 +- apps/public-api/src/app.js | 2 ++ .../src/controllers/mail.controller.js | 17 ++++++++++------ .../src/pages/ProjectSettings.jsx | 2 +- packages/common/src/index.js | 3 ++- packages/common/src/queues/authEmailQueue.js | 20 +++++++++++++------ 6 files changed, 31 insertions(+), 15 deletions(-) diff --git a/apps/dashboard-api/src/controllers/project.controller.js b/apps/dashboard-api/src/controllers/project.controller.js index 1e96c2acd..b533d878f 100644 --- a/apps/dashboard-api/src/controllers/project.controller.js +++ b/apps/dashboard-api/src/controllers/project.controller.js @@ -1160,7 +1160,7 @@ module.exports.updateProject = async (req, res) => { { $set: updateFields }, { new: true, - select: + projection: "+resendApiKey.encrypted +resendApiKey.iv +resendApiKey.tag", }, ); diff --git a/apps/public-api/src/app.js b/apps/public-api/src/app.js index e9888be56..b4c5108d9 100644 --- a/apps/public-api/src/app.js +++ b/apps/public-api/src/app.js @@ -22,10 +22,12 @@ const { capture } = require('@kiroo/sdk'); const {emailQueue} = require('@urbackend/common'); const {authEmailQueue} = require('@urbackend/common'); const {initWebhookWorker} = require('@urbackend/common'); +const {initAuthEmailWorker} = require('@urbackend/common'); // Initialize webhook worker if (process.env.NODE_ENV !== 'test') { initWebhookWorker(); + initAuthEmailWorker(); } app.use(express.json()); diff --git a/apps/public-api/src/controllers/mail.controller.js b/apps/public-api/src/controllers/mail.controller.js index 04d6948be..f64f8a378 100644 --- a/apps/public-api/src/controllers/mail.controller.js +++ b/apps/public-api/src/controllers/mail.controller.js @@ -14,7 +14,7 @@ const getMailCountKey = (projectId, monthKey) => const loadProjectMailConfig = async (projectId) => { return Project.findById(projectId) - .select("+resendApiKey.encrypted +resendApiKey.iv +resendApiKey.tag") + .select("+resendApiKey.encrypted +resendApiKey.iv +resendApiKey.tag resendFromEmail") .lean(); }; @@ -62,16 +62,17 @@ module.exports.sendMail = async (req, res) => { return res.status(401).json({ error: "Project context missing." }); } - const limit = getMonthlyMailLimit(req.project); - const { count, key } = await reserveMonthlyMailSlot(projectId, limit); - consumedQuotaKey = key; - const project = await loadProjectMailConfig(projectId); if (!project) { return res.status(404).json({ error: "Project not found." }); } - const decryptedByokKey = decrypt(project.resendApiKey); + const encryptedByokKey = + project.resendApiKey && typeof project.resendApiKey === "object" && Object.keys(project.resendApiKey).length > 0 + ? project.resendApiKey + : null; + const decryptedByokKey = encryptedByokKey ? decrypt(encryptedByokKey) : null; + const usingByok = typeof decryptedByokKey === "string" && decryptedByokKey.trim().length > 0; const clientKey = usingByok ? decryptedByokKey.trim() @@ -81,6 +82,10 @@ module.exports.sendMail = async (req, res) => { return res.status(500).json({ error: "Resend API key is not configured." }); } + const limit = getMonthlyMailLimit(req.project); + const { count, key } = await reserveMonthlyMailSlot(projectId, limit); + consumedQuotaKey = key; + const resend = new Resend(clientKey); let fromAddress = DEFAULT_FROM; diff --git a/apps/web-dashboard/src/pages/ProjectSettings.jsx b/apps/web-dashboard/src/pages/ProjectSettings.jsx index c25f19e7e..764d037c9 100644 --- a/apps/web-dashboard/src/pages/ProjectSettings.jsx +++ b/apps/web-dashboard/src/pages/ProjectSettings.jsx @@ -304,7 +304,7 @@ export default function ProjectSettings() { Default Sent-From Address { +let worker = null; + +const initAuthEmailWorker = () => { + if (worker) return worker; + + // Initialize Worker with Rate Limiting + worker = new Worker('auth-email-queue', async (job) => { const { email, otp, type, pname, projectId } = job.data; const redact = (e) => e.replace(/(.{2})(.*)(?=@)/, (gp1, gp2, gp3) => gp2 + "*".repeat(gp3.length)); const maskedEmail = redact(email); @@ -51,8 +56,11 @@ worker.on('completed', (job) => { console.log(`[Queue] Job ${job.id} completed successfully`); }); -worker.on('failed', (job, err) => { - console.error(`[Queue] Job ${job.id} failed:`, err); -}); + worker.on('failed', (job, err) => { + console.error(`[Queue] Job ${job.id} failed:`, err); + }); + + return worker; +}; -module.exports = { authEmailQueue }; +module.exports = { authEmailQueue, initAuthEmailWorker }; From 4713e2e98270fc517d0a36606824f7e571d37753 Mon Sep 17 00:00:00 2001 From: yash-pouranik Date: Wed, 8 Apr 2026 16:02:52 +0530 Subject: [PATCH 5/7] fix multiple import of initauthworker --- packages/common/src/index.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/common/src/index.js b/packages/common/src/index.js index a5df01db1..02425b5e2 100644 --- a/packages/common/src/index.js +++ b/packages/common/src/index.js @@ -22,8 +22,7 @@ const Webhook = require("./models/Webhook"); const WebhookDelivery = require("./models/WebhookDelivery"); // Queues -const { authEmailQueue, - initAuthEmailWorker, initAuthEmailWorker } = require("./queues/authEmailQueue"); +const { authEmailQueue, initAuthEmailWorker } = require("./queues/authEmailQueue"); const { emailQueue } = require("./queues/emailQueue"); const { webhookQueue, From e7a1fd1551c0dbadbdbc6f54f3e51276fb5b4b8a Mon Sep 17 00:00:00 2001 From: yash-pouranik Date: Wed, 8 Apr 2026 16:04:15 +0530 Subject: [PATCH 6/7] fix multiple import of initauthworker --- packages/common/src/index.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/common/src/index.js b/packages/common/src/index.js index 02425b5e2..d55f95cea 100644 --- a/packages/common/src/index.js +++ b/packages/common/src/index.js @@ -151,5 +151,6 @@ module.exports = { validateData, validateUpdateData, userSignupSchema, + initAuthEmailWorker, ...sessionManager, }; From 72ba39dbc5252ca60f6b3f1ebf1617bbf6bb455b Mon Sep 17 00:00:00 2001 From: yash-pouranik Date: Wed, 8 Apr 2026 16:07:23 +0530 Subject: [PATCH 7/7] fix: make redis quota reservation atomic and standardize error responses --- .../src/controllers/mail.controller.js | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/apps/public-api/src/controllers/mail.controller.js b/apps/public-api/src/controllers/mail.controller.js index f64f8a378..2d7f3e2fa 100644 --- a/apps/public-api/src/controllers/mail.controller.js +++ b/apps/public-api/src/controllers/mail.controller.js @@ -30,10 +30,14 @@ const reserveMonthlyMailSlot = async (projectId, limit) => { const ttlSeconds = getEndOfMonthTtlSeconds(now); const key = getMailCountKey(projectId, monthKey); - const count = await redis.incr(key); - if (count === 1) { - await redis.expire(key, ttlSeconds); - } + const luaScript = ` + local current = redis.call("INCR", KEYS[1]) + if current == 1 then + redis.call("EXPIRE", KEYS[1], ARGV[1]) + end + return current + `; + const count = await redis.eval(luaScript, 1, key, ttlSeconds); if (count > limit) { await redis.decr(key); @@ -51,7 +55,9 @@ module.exports.sendMail = async (req, res) => { try { if (req.keyRole !== "secret") { return res.status(403).json({ - error: "Forbidden. This action requires a Secret Key (sk_live_...).", + success: false, + data: {}, + message: "Forbidden. This action requires a Secret Key (sk_live_...).", }); } @@ -59,12 +65,12 @@ module.exports.sendMail = async (req, res) => { const projectId = req.project?._id; if (!projectId) { - return res.status(401).json({ error: "Project context missing." }); + return res.status(401).json({ success: false, data: {}, message: "Project context missing." }); } const project = await loadProjectMailConfig(projectId); if (!project) { - return res.status(404).json({ error: "Project not found." }); + return res.status(404).json({ success: false, data: {}, message: "Project not found." }); } const encryptedByokKey = @@ -79,7 +85,7 @@ module.exports.sendMail = async (req, res) => { : process.env.RESEND_API_KEY_2 || process.env.RESEND_API_KEY; if (!clientKey) { - return res.status(500).json({ error: "Resend API key is not configured." }); + return res.status(500).json({ success: false, data: {}, message: "Resend API key is not configured." }); } const limit = getMonthlyMailLimit(req.project); @@ -125,12 +131,16 @@ module.exports.sendMail = async (req, res) => { if (err instanceof z.ZodError) { return res.status(400).json({ - error: err.issues?.[0]?.message || "Invalid mail payload.", + success: false, + data: {}, + message: err.issues?.[0]?.message || "Invalid mail payload.", }); } return res.status(err.statusCode || 500).json({ - error: err.message || "Failed to send mail.", + success: false, + data: {}, + message: err.message || "Failed to send mail.", ...(typeof err.limit === "number" ? { limit: err.limit } : {}), }); }