Skip to content

Commit 75480ba

Browse files
Merge pull request #84 from geturbackend/feat/v0.9.0-byok-mail
Feat/v0.9.0 byok mail
2 parents 304529b + 72ba39d commit 75480ba

14 files changed

Lines changed: 565 additions & 19 deletions

File tree

‎AGENTS.md‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,4 +165,12 @@ Social auth (GitHub + Google) shipped. Next: v0.9.0 — Webhooks + BYOK Resend m
165165
- Webhook system: per-project config, HMAC-SHA256, retry, delivery logs
166166
- BYOK Resend mail key: project-level Resend API key, custom domain mail
167167
- Follow same encryption pattern as authProviders for storing Resend key
168-
- Webhook model: separate MongoDB collection (not embedded in Project)
168+
- Webhook model: separate MongoDB collection (not embedded in Project)
169+
170+
## Webhook system (v0.9.0) - Already done
171+
- Model: packages/common/src/models/Webhook.js
172+
- Delivery log: packages/common/src/models/WebhookDelivery.js
173+
- Dispatcher: apps/public-api/src/utils/webhookDispatcher.js
174+
- Queue: BullMQ + existing Redis connection
175+
- Retry: exponential backoff, max 5 attempts, stop on 4xx
176+
- Signature: HMAC-SHA256 in X-urBackend-Signature header

‎apps/dashboard-api/src/controllers/project.controller.js‎

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,12 @@ const sanitizeProjectResponse = (projectObj) => {
156156
delete projectObj.publishableKey;
157157
delete projectObj.secretKey;
158158
delete projectObj.jwtSecret;
159+
const resendConfig = projectObj.resendApiKey;
160+
projectObj.hasResendApiKey =
161+
resendConfig != null &&
162+
typeof resendConfig === "object" &&
163+
Object.keys(resendConfig).length > 0;
164+
delete projectObj.resendApiKey;
159165

160166
projectObj.authProviders = sanitizeAuthProviders(projectObj.authProviders);
161167

@@ -264,7 +270,10 @@ module.exports.getSingleProject = async (req, res) => {
264270
"+authProviders.github.clientSecret.tag " +
265271
"+authProviders.google.clientSecret.encrypted " +
266272
"+authProviders.google.clientSecret.iv " +
267-
"+authProviders.google.clientSecret.tag",
273+
"+authProviders.google.clientSecret.tag " +
274+
"+resendApiKey.encrypted " +
275+
"+resendApiKey.iv " +
276+
"+resendApiKey.tag",
268277
);
269278
if (!project)
270279
return res.status(404).json({ error: "Project not found." });
@@ -1088,9 +1097,30 @@ module.exports.deleteAllFiles = async (req, res) => {
10881097

10891098
module.exports.updateProject = async (req, res) => {
10901099
try {
1091-
const { name, siteUrl } = req.body;
1100+
const { name, siteUrl, resendApiKey, resendFromEmail } = req.body;
10921101
const updateFields = {};
10931102
if (name !== undefined) updateFields.name = name;
1103+
if (resendFromEmail !== undefined) {
1104+
if (typeof resendFromEmail !== "string") {
1105+
return res.status(400).json({ error: "resendFromEmail must be a string." });
1106+
}
1107+
const trimmedFrom = resendFromEmail.trim();
1108+
if (trimmedFrom !== "") {
1109+
if (trimmedFrom.length > 255) {
1110+
return res.status(400).json({ error: "resendFromEmail is too long." });
1111+
}
1112+
let addressToValidate = trimmedFrom;
1113+
const bracketMatch = trimmedFrom.match(/<([^>]+)>$/);
1114+
if (bracketMatch) {
1115+
addressToValidate = bracketMatch[1].trim();
1116+
}
1117+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
1118+
if (!emailRegex.test(addressToValidate)) {
1119+
return res.status(400).json({ error: "resendFromEmail must be a valid format (e.g., 'me@domain.com' or 'App <me@domain.com>')." });
1120+
}
1121+
}
1122+
updateFields.resendFromEmail = trimmedFrom;
1123+
}
10941124
if (siteUrl !== undefined) {
10951125
if (siteUrl !== "" && typeof siteUrl !== "string") {
10961126
return res.status(400).json({ error: "siteUrl must be a string." });
@@ -1116,11 +1146,23 @@ module.exports.updateProject = async (req, res) => {
11161146
}
11171147
updateFields.siteUrl = siteUrl || "";
11181148
}
1149+
if (resendApiKey !== undefined) {
1150+
if (typeof resendApiKey !== "string" || !resendApiKey.trim()) {
1151+
return res
1152+
.status(400)
1153+
.json({ error: "resendApiKey must be a non-empty string." });
1154+
}
1155+
updateFields.resendApiKey = encrypt(resendApiKey.trim());
1156+
}
11191157

11201158
const project = await Project.findOneAndUpdate(
11211159
{ _id: req.params.projectId, owner: req.user._id },
11221160
{ $set: updateFields },
1123-
{ new: true },
1161+
{
1162+
new: true,
1163+
projection:
1164+
"+resendApiKey.encrypted +resendApiKey.iv +resendApiKey.tag",
1165+
},
11241166
);
11251167
if (!project) return res.status(404).json({ error: "Project not found." });
11261168

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
'use strict';
2+
3+
process.env.REDIS_URL = process.env.REDIS_URL || "redis://localhost:6379/0";
4+
process.env.ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || "0123456789012345678901234567890a";
5+
6+
jest.mock('resend', () => {
7+
const sendMock = jest.fn(() => Promise.resolve({ data: { id: 'mail-123' }, error: null }));
8+
return {
9+
Resend: jest.fn(() => ({
10+
emails: { send: sendMock },
11+
})),
12+
__sendMock: sendMock,
13+
};
14+
});
15+
16+
jest.mock('@urbackend/common', () => {
17+
const { sendMailSchema } = require('../../../../packages/common/src/utils/input.validation');
18+
const redisMock = {
19+
status: 'ready',
20+
incr: jest.fn(),
21+
expire: jest.fn(),
22+
decr: jest.fn(),
23+
};
24+
25+
return {
26+
sendMailSchema,
27+
Project: { findById: jest.fn() },
28+
decrypt: jest.fn(),
29+
redis: redisMock,
30+
};
31+
});
32+
33+
const { Resend } = require('resend');
34+
const { Project, decrypt, redis } = require('@urbackend/common');
35+
const mailController = require('../controllers/mail.controller');
36+
37+
const makeReq = () => ({
38+
keyRole: 'secret',
39+
project: { _id: 'proj_1' },
40+
body: { to: 'user@example.com', subject: 'Hello', text: 'This is a message.' },
41+
});
42+
43+
const makeRes = () => {
44+
const res = { status: jest.fn(), json: jest.fn() };
45+
res.status.mockReturnValue(res);
46+
res.json.mockReturnValue(res);
47+
return res;
48+
};
49+
50+
const mockProjectConfig = (payload) => {
51+
Project.findById.mockReturnValue({
52+
select: jest.fn(() => ({
53+
lean: jest.fn(() => Promise.resolve(payload)),
54+
})),
55+
});
56+
};
57+
58+
describe('mail.controller', () => {
59+
beforeEach(() => {
60+
jest.clearAllMocks();
61+
process.env.RESEND_API_KEY = 'default-key';
62+
process.env.EMAIL_FROM = 'mail@urbackend.app';
63+
});
64+
65+
test('sends mail using BYOK key when configured', async () => {
66+
const req = makeReq();
67+
const res = makeRes();
68+
69+
mockProjectConfig({ _id: 'proj_1', resendApiKey: {} });
70+
decrypt.mockReturnValue('byok-key');
71+
redis.incr.mockResolvedValue(1);
72+
73+
await mailController.sendMail(req, res);
74+
75+
expect(redis.incr).toHaveBeenCalledTimes(1);
76+
expect(redis.expire).toHaveBeenCalledTimes(1);
77+
expect(res.status).toHaveBeenCalledWith(200);
78+
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
79+
success: true,
80+
data: expect.objectContaining({ provider: 'byok', monthlyUsage: 1 }),
81+
}));
82+
});
83+
84+
test('falls back to default key when BYOK missing', async () => {
85+
const req = makeReq();
86+
const res = makeRes();
87+
88+
mockProjectConfig({ _id: 'proj_1', resendApiKey: null });
89+
decrypt.mockReturnValue(null);
90+
redis.incr.mockResolvedValue(2);
91+
92+
await mailController.sendMail(req, res);
93+
94+
expect(res.status).toHaveBeenCalledWith(200);
95+
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
96+
data: expect.objectContaining({ provider: 'default', monthlyUsage: 2 }),
97+
}));
98+
});
99+
100+
test('enforces monthly limit', async () => {
101+
const req = makeReq();
102+
const res = makeRes();
103+
104+
mockProjectConfig({ _id: 'proj_1', resendApiKey: null });
105+
decrypt.mockReturnValue(null);
106+
redis.incr.mockResolvedValue(101);
107+
108+
await mailController.sendMail(req, res);
109+
110+
expect(redis.decr).toHaveBeenCalledTimes(1);
111+
expect(res.status).toHaveBeenCalledWith(429);
112+
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
113+
error: 'Monthly mail limit exceeded.',
114+
}));
115+
});
116+
});

‎apps/public-api/src/app.js‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,12 @@ const { capture } = require('@kiroo/sdk');
2222
const {emailQueue} = require('@urbackend/common');
2323
const {authEmailQueue} = require('@urbackend/common');
2424
const {initWebhookWorker} = require('@urbackend/common');
25+
const {initAuthEmailWorker} = require('@urbackend/common');
2526

2627
// Initialize webhook worker
2728
if (process.env.NODE_ENV !== 'test') {
2829
initWebhookWorker();
30+
initAuthEmailWorker();
2931
}
3032

3133
app.use(express.json());
@@ -57,6 +59,7 @@ const dataRoute = require('./routes/data');
5759
const userAuthRoute = require('./routes/userAuth');
5860
const storageRoute = require('./routes/storage');
5961
const schemaRoute = require('./routes/schemas');
62+
const mailRoute = require('./routes/mail');
6063

6164
// ROUTES SETUP
6265
app.use('/api/userAuth', limiter, logger, userAuthRoute);
@@ -74,6 +77,7 @@ const projectCorsPreflight = (req, res, next) => {
7477
app.use('/api/data', projectCorsPreflight, limiter, logger, dataRoute);
7578
app.use('/api/schemas', projectCorsPreflight, limiter, logger, schemaRoute);
7679
app.use('/api/storage', projectCorsPreflight, limiter, logger, storageRoute);
80+
app.use('/api/mail', projectCorsPreflight, limiter, logger, mailRoute);
7781

7882
app.get('/api/server-ip', async (req, res) => {
7983
const ip = await getPublicIp();
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
const { Resend } = require("resend");
2+
const { z } = require("zod");
3+
const { Project, decrypt, redis, sendMailSchema } = require("@urbackend/common");
4+
const {
5+
getMonthKey,
6+
getEndOfMonthTtlSeconds,
7+
getMonthlyMailLimit,
8+
} = require("../utils/mailLimit");
9+
10+
const DEFAULT_FROM = process.env.EMAIL_FROM || "urBackend <urbackend@apps.bitbros.in>";
11+
12+
const getMailCountKey = (projectId, monthKey) =>
13+
`project:mail:count:${projectId}:${monthKey}`;
14+
15+
const loadProjectMailConfig = async (projectId) => {
16+
return Project.findById(projectId)
17+
.select("+resendApiKey.encrypted +resendApiKey.iv +resendApiKey.tag resendFromEmail")
18+
.lean();
19+
};
20+
21+
const reserveMonthlyMailSlot = async (projectId, limit) => {
22+
if (redis.status !== "ready") {
23+
const err = new Error("Mail service unavailable. Redis is not ready.");
24+
err.statusCode = 503;
25+
throw err;
26+
}
27+
28+
const now = new Date();
29+
const monthKey = getMonthKey(now);
30+
const ttlSeconds = getEndOfMonthTtlSeconds(now);
31+
const key = getMailCountKey(projectId, monthKey);
32+
33+
const luaScript = `
34+
local current = redis.call("INCR", KEYS[1])
35+
if current == 1 then
36+
redis.call("EXPIRE", KEYS[1], ARGV[1])
37+
end
38+
return current
39+
`;
40+
const count = await redis.eval(luaScript, 1, key, ttlSeconds);
41+
42+
if (count > limit) {
43+
await redis.decr(key);
44+
const err = new Error("Monthly mail limit exceeded.");
45+
err.statusCode = 429;
46+
err.limit = limit;
47+
throw err;
48+
}
49+
50+
return { count, key };
51+
};
52+
53+
module.exports.sendMail = async (req, res) => {
54+
let consumedQuotaKey = null;
55+
try {
56+
if (req.keyRole !== "secret") {
57+
return res.status(403).json({
58+
success: false,
59+
data: {},
60+
message: "Forbidden. This action requires a Secret Key (sk_live_...).",
61+
});
62+
}
63+
64+
const { to, subject, html, text } = sendMailSchema.parse(req.body || {});
65+
const projectId = req.project?._id;
66+
67+
if (!projectId) {
68+
return res.status(401).json({ success: false, data: {}, message: "Project context missing." });
69+
}
70+
71+
const project = await loadProjectMailConfig(projectId);
72+
if (!project) {
73+
return res.status(404).json({ success: false, data: {}, message: "Project not found." });
74+
}
75+
76+
const encryptedByokKey =
77+
project.resendApiKey && typeof project.resendApiKey === "object" && Object.keys(project.resendApiKey).length > 0
78+
? project.resendApiKey
79+
: null;
80+
const decryptedByokKey = encryptedByokKey ? decrypt(encryptedByokKey) : null;
81+
82+
const usingByok = typeof decryptedByokKey === "string" && decryptedByokKey.trim().length > 0;
83+
const clientKey = usingByok
84+
? decryptedByokKey.trim()
85+
: process.env.RESEND_API_KEY_2 || process.env.RESEND_API_KEY;
86+
87+
if (!clientKey) {
88+
return res.status(500).json({ success: false, data: {}, message: "Resend API key is not configured." });
89+
}
90+
91+
const limit = getMonthlyMailLimit(req.project);
92+
const { count, key } = await reserveMonthlyMailSlot(projectId, limit);
93+
consumedQuotaKey = key;
94+
95+
const resend = new Resend(clientKey);
96+
97+
let fromAddress = DEFAULT_FROM;
98+
if (usingByok) {
99+
fromAddress = project.resendFromEmail && project.resendFromEmail.trim()
100+
? project.resendFromEmail.trim()
101+
: "onboarding@resend.dev";
102+
}
103+
104+
const payload = {
105+
from: fromAddress,
106+
to,
107+
subject,
108+
};
109+
if (typeof html === "string" && html.trim()) payload.html = html;
110+
if (typeof text === "string" && text.trim()) payload.text = text;
111+
112+
const { data, error } = await resend.emails.send(payload);
113+
if (error) {
114+
throw new Error(error.message || "Failed to send mail.");
115+
}
116+
117+
return res.status(200).json({
118+
success: true,
119+
data: {
120+
id: data?.id || null,
121+
provider: usingByok ? "byok" : "default",
122+
monthlyUsage: count,
123+
monthlyLimit: limit,
124+
},
125+
message: "Mail sent successfully.",
126+
});
127+
} catch (err) {
128+
if (consumedQuotaKey) {
129+
await redis.decr(consumedQuotaKey).catch(() => {});
130+
}
131+
132+
if (err instanceof z.ZodError) {
133+
return res.status(400).json({
134+
success: false,
135+
data: {},
136+
message: err.issues?.[0]?.message || "Invalid mail payload.",
137+
});
138+
}
139+
140+
return res.status(err.statusCode || 500).json({
141+
success: false,
142+
data: {},
143+
message: err.message || "Failed to send mail.",
144+
...(typeof err.limit === "number" ? { limit: err.limit } : {}),
145+
});
146+
}
147+
};

0 commit comments

Comments
 (0)