diff --git a/apps/public-api/src/__tests__/mail.controller.test.js b/apps/public-api/src/__tests__/mail.controller.test.js
index 7666918d6..a1297982b 100644
--- a/apps/public-api/src/__tests__/mail.controller.test.js
+++ b/apps/public-api/src/__tests__/mail.controller.test.js
@@ -3,15 +3,7 @@
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,
- };
-});
+// Removed resend mock because mail.controller no longer imports it
jest.mock('@urbackend/common', () => {
const { sendMailSchema } = require('../../../../packages/common/src/utils/input.validation');
@@ -34,11 +26,13 @@ jest.mock('@urbackend/common', () => {
decrypt: jest.fn(),
redis: redisMock,
getPlanLimits: jest.fn(() => ({ mailPerMonth: 100 })),
+ publicEmailQueue: {
+ add: jest.fn(() => Promise.resolve({ id: 'job-123' }))
+ }
};
});
-const { Resend, __sendMock } = require('resend');
-const { Project, decrypt, redis } = require('@urbackend/common');
+const { Project, decrypt, redis, publicEmailQueue } = require('@urbackend/common');
const mailController = require('../controllers/mail.controller');
const makeReq = () => ({
@@ -156,10 +150,82 @@ describe('mail.controller', () => {
templateUsed: expect.objectContaining({ name: 'welcome', id: 'tpl_1', scope: 'project' }),
}),
}));
- expect(__sendMock).toHaveBeenCalledWith(expect.objectContaining({
- subject: 'Hello Yash',
- text: 'Welcome, Yash!',
- html: '
Welcome, Yash!
',
+ expect(publicEmailQueue.add).toHaveBeenCalledWith("send-public-email", expect.objectContaining({
+ payload: expect.objectContaining({
+ subject: 'Hello Yash',
+ text: 'Welcome, Yash!',
+ html: 'Welcome, Yash!
',
+ })
+ }), expect.objectContaining({ attempts: expect.any(Number) }));
+ });
+
+ test('refunds quota on terminal async worker failure', async () => {
+ let failedHandler;
+ jest.resetModules();
+ jest.doMock('bullmq', () => ({
+ Queue: jest.fn(),
+ Worker: jest.fn(() => ({
+ on: jest.fn((event, handler) => {
+ if (event === 'failed') failedHandler = handler;
+ }),
+ removeAllListeners: jest.fn(),
+ close: jest.fn()
+ }))
}));
+
+ const mockRedis = { eval: jest.fn().mockResolvedValue(0) };
+ jest.doMock('../../../../packages/common/src/config/redis', () => mockRedis);
+
+ const { initPublicEmailWorker } = require('../../../../packages/common/src/queues/publicEmailQueue');
+ const worker = initPublicEmailWorker();
+
+ const mockJob = {
+ id: 'job-999',
+ data: { consumedQuotaKey: 'project:mail:count:proj_1:2026-05' },
+ opts: { attempts: 3 },
+ attemptsMade: 3
+ };
+
+ if (failedHandler) {
+ await failedHandler(mockJob, new Error("Terminal failure"));
+ }
+
+ expect(mockRedis.eval).toHaveBeenCalledWith(
+ expect.any(String), 1, 'project:mail:count:proj_1:2026-05'
+ );
+ });
+
+ test('does not refund quota on non-terminal async worker failure', async () => {
+ let failedHandler;
+ jest.resetModules();
+ jest.doMock('bullmq', () => ({
+ Queue: jest.fn(),
+ Worker: jest.fn(() => ({
+ on: jest.fn((event, handler) => {
+ if (event === 'failed') failedHandler = handler;
+ }),
+ removeAllListeners: jest.fn(),
+ close: jest.fn()
+ }))
+ }));
+
+ const mockRedis = { eval: jest.fn().mockResolvedValue(0) };
+ jest.doMock('../../../../packages/common/src/config/redis', () => mockRedis);
+
+ const { initPublicEmailWorker } = require('../../../../packages/common/src/queues/publicEmailQueue');
+ const worker = initPublicEmailWorker();
+
+ const mockJob = {
+ id: 'job-888',
+ data: { consumedQuotaKey: 'project:mail:count:proj_1:2026-05' },
+ opts: { attempts: 3 },
+ attemptsMade: 1 // Not terminal yet
+ };
+
+ if (failedHandler) {
+ await failedHandler(mockJob, new Error("Temporary failure"));
+ }
+
+ expect(mockRedis.eval).not.toHaveBeenCalled();
});
});
diff --git a/apps/public-api/src/app.js b/apps/public-api/src/app.js
index 9040f6eaa..54b96b894 100644
--- a/apps/public-api/src/app.js
+++ b/apps/public-api/src/app.js
@@ -22,12 +22,13 @@ 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');
+const {initAuthEmailWorker, initPublicEmailWorker} = require('@urbackend/common');
// Initialize webhook worker
if (process.env.NODE_ENV !== 'test') {
initWebhookWorker();
initAuthEmailWorker();
+ initPublicEmailWorker();
}
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 724607d70..1f04563d2 100644
--- a/apps/public-api/src/controllers/mail.controller.js
+++ b/apps/public-api/src/controllers/mail.controller.js
@@ -1,13 +1,11 @@
-const { Resend } = require("resend");
const { z } = require("zod");
-const { Project, MailTemplate, decrypt, redis, sendMailSchema } = require("@urbackend/common");
+const { Project, MailTemplate, decrypt, redis, sendMailSchema, publicEmailQueue } = 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}`;
@@ -300,38 +298,33 @@ module.exports.sendMail = async (req, res) => {
const { count, key } = await reserveMonthlyMailSlot(projectId, limit);
consumedQuotaKey = key;
- 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: resolvedSubject,
};
if (typeof resolvedHtml === "string" && resolvedHtml.trim()) payload.html = resolvedHtml;
if (typeof resolvedText === "string" && resolvedText.trim()) payload.text = resolvedText;
- const { data, error } = await resend.emails.send(payload);
- if (error) {
- throw new Error(error.message || "Failed to send mail.");
- }
+ const job = await publicEmailQueue.add("send-public-email", {
+ projectId,
+ payload,
+ usingByok,
+ consumedQuotaKey
+ }, {
+ attempts: 3,
+ backoff: { type: 'exponential', delay: 5000 }
+ });
return res.status(200).json({
success: true,
data: {
- id: data?.id || null,
+ id: job.id ? String(job.id) : null,
provider: usingByok ? "byok" : "default",
monthlyUsage: count,
monthlyLimit: limit,
...(templateUsed ? { templateUsed } : {}),
},
- message: "Mail sent successfully.",
+ message: "Mail queued successfully.",
});
} catch (err) {
if (consumedQuotaKey) {
diff --git a/packages/common/src/index.js b/packages/common/src/index.js
index c196c951e..8292d58c0 100644
--- a/packages/common/src/index.js
+++ b/packages/common/src/index.js
@@ -27,6 +27,7 @@ const ApiAnalytics = require("./models/ApiAnalytics");
// Queues
const { authEmailQueue, initAuthEmailWorker } = require("./queues/authEmailQueue");
+const { publicEmailQueue, initPublicEmailWorker } = require("./queues/publicEmailQueue");
const { emailQueue } = require("./queues/emailQueue");
const {
webhookQueue,
@@ -170,6 +171,8 @@ module.exports = {
validateUpdateData,
userSignupSchema,
initAuthEmailWorker,
+ publicEmailQueue,
+ initPublicEmailWorker,
...sessionManager,
...planLimits,
AppError,
diff --git a/packages/common/src/queues/publicEmailQueue.js b/packages/common/src/queues/publicEmailQueue.js
new file mode 100644
index 000000000..c028eedf7
--- /dev/null
+++ b/packages/common/src/queues/publicEmailQueue.js
@@ -0,0 +1,106 @@
+const { Queue, Worker } = require('bullmq');
+const connection = require('../config/redis');
+const Project = require('../models/Project');
+const { decrypt } = require('../utils/encryption');
+const { Resend } = require('resend');
+
+// Lua script: only decrement quota key if it exists (prevents phantom negative keys with no TTL)
+const DECR_IF_EXISTS_SCRIPT = `if redis.call('EXISTS', KEYS[1]) == 1 then return redis.call('DECR', KEYS[1]) else return 0 end`;
+
+// Create the email queue for public API
+const publicEmailQueue = new Queue('public-email-queue', { connection });
+
+let worker = null;
+
+const initPublicEmailWorker = () => {
+ if (worker) return worker;
+
+ // Initialize Worker with Rate Limiting (10 per second to respect Resend limits)
+ worker = new Worker('public-email-queue', async (job) => {
+ const { projectId, payload, usingByok, consumedQuotaKey } = job.data;
+
+ try {
+
+ let clientKey = process.env.RESEND_API_KEY_2 || process.env.RESEND_API_KEY;
+ let fromAddress = process.env.EMAIL_FROM || "urBackend ";
+
+ try {
+ if (projectId && usingByok) {
+ 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) {
+ clientKey = decrypted.trim();
+ fromAddress = project.resendFromEmail && project.resendFromEmail.trim()
+ ? project.resendFromEmail.trim()
+ : "onboarding@resend.dev";
+ }
+ }
+ }
+ } catch (err) {
+ console.error(`[Queue] Failed to load BYOK config for project ${projectId}:`, err);
+ // Fallback to global key
+ }
+
+ if (!clientKey) {
+ throw new Error("Resend API key is not configured.");
+ }
+
+ const resend = new Resend(clientKey);
+
+ const finalPayload = {
+ ...payload,
+ from: fromAddress
+ };
+
+ const redact = (e) => {
+ const atIdx = e.indexOf('@');
+ if (atIdx <= 0) return e;
+ const local = e.slice(0, atIdx);
+ const domain = e.slice(atIdx);
+ if (local.length <= 2) return '*'.repeat(local.length) + domain;
+ return local.slice(0, 2) + '*'.repeat(local.length - 2) + domain;
+ };
+ const toList = Array.isArray(finalPayload.to) ? finalPayload.to : [finalPayload.to];
+ const maskedTo = toList.map(redact).join(', ');
+
+ console.log(`[Queue] Processing public email to: ${maskedTo}`);
+
+ const { data, error } = await resend.emails.send(finalPayload);
+
+ if (error) {
+ console.error(`[Queue] Failed to send public email to ${maskedTo}:`, error);
+ throw new Error(error.message || "Failed to send email");
+ }
+
+ return { data };
+ } catch (err) {
+ throw err;
+ }
+ }, {
+ connection,
+ limiter: {
+ max: 10,
+ duration: 1000, // 10 per second
+ }
+ });
+
+ worker.on('completed', (job) => {
+ console.log(`[Queue] Job ${job.id} (public email) completed successfully`);
+ });
+
+ worker.on('failed', async (job, err) => {
+ console.error(`[Queue] Job ${job?.id} (public email) failed:`, err);
+ if (job && job.data && job.data.consumedQuotaKey) {
+ const maxAttempts = job.opts?.attempts || 1;
+ if (job.attemptsMade >= maxAttempts) {
+ const luaScript = DECR_IF_EXISTS_SCRIPT;
+ await connection.eval(luaScript, 1, job.data.consumedQuotaKey).catch(() => {});
+ }
+ }
+ });
+
+ return worker;
+};
+
+module.exports = { publicEmailQueue, initPublicEmailWorker };