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
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@ jest.mock('../controllers/project.controller', () => {
deleteMailTemplate: jest.fn(ok),
requestUpload: jest.fn(ok),
confirmUpload: jest.fn(ok),
getMailLogs: jest.fn(ok),
getResendLiveStatus: jest.fn(ok),
manageAudiences: jest.fn(ok),
deleteAudience: jest.fn(ok),
manageContacts: jest.fn(ok),
deleteContact: jest.fn(ok),
sendMarketingBroadcast: jest.fn(ok),
};
});

Expand Down
266 changes: 261 additions & 5 deletions apps/dashboard-api/src/controllers/project.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,16 @@ const {
} = require("@urbackend/common");
const { generateApiKey, hashApiKey } = require("@urbackend/common");
const { z } = require("zod");
const { encrypt } = require("@urbackend/common");
const { encrypt, decrypt } = require("@urbackend/common");
const { URL } = require("url");
const path = require("path");
const axios = require("axios");
const { getConnection } = require("@urbackend/common");
const { getCompiledModel } = require("@urbackend/common");
const { QueryEngine } = require("@urbackend/common");
const { storageRegistry } = require("@urbackend/common");
const { AppError } = require("@urbackend/common");
const { resolveEffectivePlan } = require("@urbackend/common");
const {
deleteProjectByApiKeyCache,
setProjectById,
Expand All @@ -33,7 +35,7 @@ const { getPresignedUploadUrl } = require("@urbackend/common");
const { verifyUploadedFile } = require("@urbackend/common");
const { getPublicIp } = require("@urbackend/common");
const { clearCompiledModel } = require("@urbackend/common");
const { createUniqueIndexes, ApiAnalytics } = require("@urbackend/common");
const { createUniqueIndexes, ApiAnalytics, MailLog } = require("@urbackend/common");
const { emitEvent } = require('../utils/emitEvent');
const MAX_FILE_SIZE = 10 * 1024 * 1024;
const SAFETY_MAX_BYTES = 100 * 1024 * 1024;
Expand Down Expand Up @@ -1435,12 +1437,19 @@ module.exports.updateProject = async (req, res) => {
updateFields.siteUrl = siteUrl || "";
}
if (resendApiKey !== undefined) {
if (typeof resendApiKey !== "string" || !resendApiKey.trim()) {
const trimmedKey = typeof resendApiKey === "string" ? resendApiKey.trim() : "";
if (!trimmedKey) {
return res
.status(400)
.json({ error: "resendApiKey must be a non-empty string." });
}
updateFields.resendApiKey = encrypt(resendApiKey.trim());

// Sanitize the key: Prevent CRLF (HTTP Header Injection) and invalid characters
if (!/^re_[A-Za-z0-9_]+$/.test(trimmedKey)) {
return res.status(400).json({ error: "Invalid Resend API Key format." });
}

updateFields.resendApiKey = encrypt(trimmedKey);
Comment on lines 1439 to +1452
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Allow resendApiKey to be unset.

After this change, a project that already has BYOK configured cannot revert to the shared-key path via updateProject; sending an empty value just returns 400 and leaves the old encrypted key in place. Treat empty/null as a clear operation instead of forcing the previous key to stick around.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/dashboard-api/src/controllers/project.controller.js` around lines 1439 -
1452, The current validation rejects empty resendApiKey and prevents clearing an
existing BYOK entry; change the logic in the block handling resendApiKey so that
when resendApiKey is provided but empty/null (e.g., after trimming) you treat it
as a clear operation instead of returning 400: set updateFields.resendApiKey to
null (or delete the field) to indicate removal, and only call
encrypt(trimmedKey) and assign updateFields.resendApiKey when trimmedKey is
non-empty and passes the /^re_[A-Za-z0-9_]+$/ check; keep the existing error
response for non-empty values that fail the format regex.

}

const project = await Project.findOneAndUpdate(
Expand Down Expand Up @@ -2366,4 +2375,251 @@ module.exports.updateCollectionRls = async (req, res) => {
} catch (err) {
res.status(500).json({ error: err.message });
}
}
};

// -------------------- EXPANDED MAIL API PLATFORM PROXIES --------------------

const getResolvedResendKey = (project) => {
if (project?.resendApiKey?.encrypted) {
try {
const key = decrypt(project.resendApiKey);
if (key) return { key, isByok: true };
} catch (e) {
console.error("Failed to decrypt project resend key", e);
}
}
return { key: process.env.RESEND_API_KEY_2 || process.env.RESEND_API_KEY, isByok: false };
};
Comment on lines +2382 to +2392
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Do not silently fall back to the shared key when a stored BYOK key can't be decrypted.

When project.resendApiKey exists but decrypt() fails, getResolvedResendKey() returns the global key. getResendLiveStatus() then queries the wrong Resend account and can incorrectly report a project-scoped mail log as missing. Only use the global fallback when no project key is configured at all.

Also applies to: 2426-2449

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/dashboard-api/src/controllers/project.controller.js` around lines 2382 -
2392, getResolvedResendKey currently falls back to the global RESEND_API_KEY
when decrypt(project.resendApiKey) throws, which causes wrong-account queries;
change getResolvedResendKey so that if project.resendApiKey exists but decrypt()
fails it does NOT return the global key but instead returns a null (or explicit
"no usable key") result while marking that a project key was configured (e.g.
return { key: null, isByok: true } or similar) so callers can distinguish
"configured-but-invalid" from "not-configured"; then update callers such as
getResendLiveStatus to check for a null/invalid key and avoid querying the
global Resend account (handle as missing/invalid project key rather than falling
back to shared key).


module.exports.getMailLogs = async (req, res) => {
try {
const { projectId } = req.params;
const project = await Project.findOne({ _id: projectId, owner: req.user._id });
if (!project) return res.status(404).json({ success: false, message: "Project not found" });

const logs = await MailLog.find({ projectId: project._id })
.sort({ sentAt: -1 })
.limit(50)
.lean();

return res.json({ success: true, data: { logs } });
} catch (err) {
return res.status(500).json({ success: false, message: err.message });
}
Comment on lines +2394 to +2408
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Normalize the new mail proxy response envelope and error handling.

These handlers mix { success, message }, { success, data }, and raw err.message passthroughs. That makes the new endpoints inconsistent for clients and bypasses the controller error-handling contract. Normalize every branch to { success, data: {}, message } and funnel failures through AppError instead of serializing arbitrary upstream text directly.

As per coding guidelines, All API endpoints return: { success: bool, data: {}, message: "" }. Use AppError class for errors — never raw throw, never expose MongoDB errors to client.

Also applies to: 2411-2450, 2453-2485, 2488-2511, 2514-2551, 2554-2582, 2585-2625

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/dashboard-api/src/controllers/project.controller.js` around lines 2394 -
2408, Update the getMailLogs controller to always return the standardized
envelope { success, data: {}, message: "" } and replace direct error responses
with AppError instances: when Project is not found, return
res.status(404).json({ success: false, data: {}, message: "Project not found" })
(or throw new AppError("Project not found", 404) and let middleware handle it),
and on any catch block wrap/throw an AppError rather than returning err.message;
ensure successful path returns res.json({ success: true, data: { logs },
message: "" }); apply the same pattern to the other related handlers referenced
(lines ~2411-2625) so all use Project, MailLog, AppError, and the consistent
envelope.

};

module.exports.getResendLiveStatus = async (req, res) => {
try {
const { projectId, resendId } = req.params;
if (!/^[A-Za-z0-9_-]{1,128}$/.test(resendId)) {
return res.status(400).json({ success: false, message: "Invalid resendId format." });
}

const project = await Project.findOne({ _id: projectId, owner: req.user._id }).select("+resendApiKey.encrypted +resendApiKey.iv +resendApiKey.tag");
if (!project) return res.status(404).json({ success: false, message: "Project not found" });

const logEntry = await MailLog.findOne({ resendEmailId: resendId, projectId: project._id }).lean();
if (!logEntry) {
return res.status(404).json({ success: false, message: "Mail log entry not found for this project." });
}

const { key } = getResolvedResendKey(project);
if (!key) return res.status(400).json({ success: false, message: "Resend API Key is missing." });

const safeResendId = encodeURIComponent(resendId);
const response = await axios.get(`https://api.resend.com/emails/${safeResendId}`, {
headers: { Authorization: `Bearer ${key}` }
});
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Comment on lines +2430 to +2432
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Bound the new Resend proxy calls with a timeout.

Every new axios call here uses the default unbounded wait. A slow or hung Resend edge can pin dashboard-api request workers indefinitely and turn one upstream stall into a cascading outage. Add a shared timeout and translate timeout failures into a 502/504.

Also applies to: 2465-2477, 2503-2505, 2530-2543, 2574-2576, 2616-2618

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/dashboard-api/src/controllers/project.controller.js` around lines 2430 -
2432, The axios calls to the Resend API (e.g., the axios.get using safeResendId
and Authorization `key`) must be bounded with a shared timeout option (e.g.,
timeout in ms) and error-handled to map timeout/ETIMEDOUT/ECONNABORTED style
errors to a 502 or 504 HTTP response rather than hanging; update the axios
invocation(s) at the shown call and the other occurrences (around lines
2465-2477, 2503-2505, 2530-2543, 2574-2576, 2616-2618) to pass the timeout
option and catch errors, detecting timeout-specific error codes/messages and
returning res.status(502 or 504). Ensure the same shared timeout value is reused
across all Resend proxy calls for consistency.


return res.json({ success: true, data: response.data });
} catch (err) {
const { resendId } = req.params;
if (err.response?.status === 404) {
return res.status(404).json({
success: false,
data: {
id: resendId,
last_event: "unknown",
providerStatus: "not_found",
},
message: "Email status not found on Resend for this id."
});
}
const errorMsg = err.response?.data?.message || err.message;
return res.status(err.response?.status || 500).json({ success: false, message: errorMsg });
}
};

module.exports.manageAudiences = async (req, res) => {
try {
const { projectId } = req.params;
const project = await Project.findOne({ _id: projectId, owner: req.user._id }).select("+resendApiKey.encrypted +resendApiKey.iv +resendApiKey.tag");
if (!project) return res.status(404).json({ success: false, message: "Project not found" });

const { key, isByok } = getResolvedResendKey(project);
if (!isByok || !key) {
return res.status(403).json({ success: false, message: "Audiences require a custom Resend API Key (BYOK) configured in Project Settings." });
}

if (req.method === "GET") {
const response = await axios.get("https://api.resend.com/audiences", {
headers: { Authorization: `Bearer ${key}` }
});
return res.json({ success: true, data: response.data });
}

if (req.method === "POST") {
const { name } = req.body;
if (!name) return res.status(400).json({ success: false, message: "Audience name required" });

const response = await axios.post("https://api.resend.com/audiences", { name }, {
headers: { Authorization: `Bearer ${key}` }
});
return res.json({ success: true, data: response.data });
}

return res.status(405).json({ success: false, message: "Method not allowed" });
} catch (err) {
const errorMsg = err.response?.data?.message || err.message;
return res.status(err.response?.status || 500).json({ success: false, message: errorMsg });
}
};

module.exports.deleteAudience = async (req, res) => {
try {
const { projectId, audienceId } = req.params;
if (!/^[A-Za-z0-9_-]+$/.test(audienceId)) {
return res.status(400).json({ success: false, message: "Invalid audienceId format" });
}
const safeAudienceId = encodeURIComponent(audienceId);
const project = await Project.findOne({ _id: projectId, owner: req.user._id }).select("+resendApiKey.encrypted +resendApiKey.iv +resendApiKey.tag");
if (!project) return res.status(404).json({ success: false, message: "Project not found" });

const { key, isByok } = getResolvedResendKey(project);
if (!isByok || !key) {
return res.status(403).json({ success: false, message: "Audiences require a custom Resend API Key (BYOK)." });
}

await axios.delete(`https://api.resend.com/audiences/${safeAudienceId}`, {
headers: { Authorization: `Bearer ${key}` }
});
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed

return res.json({ success: true, message: "Audience deleted successfully" });
} catch (err) {
const errorMsg = err.response?.data?.message || err.message;
return res.status(err.response?.status || 500).json({ success: false, message: errorMsg });
}
};

module.exports.manageContacts = async (req, res) => {
try {
const { projectId, audienceId } = req.params;
if (!/^[A-Za-z0-9_-]+$/.test(audienceId)) {
return res.status(400).json({ success: false, message: "Invalid audienceId format" });
}
const safeAudienceId = encodeURIComponent(audienceId);
const project = await Project.findOne({ _id: projectId, owner: req.user._id }).select("+resendApiKey.encrypted +resendApiKey.iv +resendApiKey.tag");
if (!project) return res.status(404).json({ success: false, message: "Project not found" });

const { key, isByok } = getResolvedResendKey(project);
if (!isByok || !key) {
return res.status(403).json({ success: false, message: "Contacts require a custom Resend API Key (BYOK)." });
}

if (req.method === "GET") {
const response = await axios.get(`https://api.resend.com/audiences/${safeAudienceId}/contacts`, {
headers: { Authorization: `Bearer ${key}` }
});
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
return res.json({ success: true, data: response.data });
}

if (req.method === "POST") {
const { email, firstName, lastName, unsubscribed } = req.body;
if (!email) return res.status(400).json({ success: false, message: "Contact email required" });

const payload = { email, first_name: firstName, last_name: lastName, unsubscribed };
const response = await axios.post(`https://api.resend.com/audiences/${safeAudienceId}/contacts`, payload, {
headers: { Authorization: `Bearer ${key}` }
});
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
return res.json({ success: true, data: response.data });
}

return res.status(405).json({ success: false, message: "Method not allowed" });
} catch (err) {
const errorMsg = err.response?.data?.message || err.message;
return res.status(err.response?.status || 500).json({ success: false, message: errorMsg });
}
};

module.exports.deleteContact = async (req, res) => {
try {
const { projectId, audienceId, contactId } = req.params;
const project = await Project.findOne({ _id: projectId, owner: req.user._id }).select("+resendApiKey.encrypted +resendApiKey.iv +resendApiKey.tag");
if (!project) return res.status(404).json({ success: false, message: "Project not found" });

const { key, isByok } = getResolvedResendKey(project);
if (!isByok || !key) {
return res.status(403).json({ success: false, message: "Contacts require a custom Resend API Key (BYOK)." });
}

const resendIdPattern = /^[A-Za-z0-9_-]+$/;
if (!resendIdPattern.test(audienceId) || !resendIdPattern.test(contactId)) {
return res.status(400).json({ success: false, message: "Invalid audienceId or contactId format." });
}

const safeAudienceId = encodeURIComponent(audienceId);
const safeContactId = encodeURIComponent(contactId);

// Resend uses DELETE /audiences/{audience_id}/contacts/{id} or by email
await axios.delete(`https://api.resend.com/audiences/${safeAudienceId}/contacts/${safeContactId}`, {
headers: { Authorization: `Bearer ${key}` }
});
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed

return res.json({ success: true, message: "Contact removed successfully" });
} catch (err) {
const errorMsg = err.response?.data?.message || err.message;
return res.status(err.response?.status || 500).json({ success: false, message: errorMsg });
}
};

module.exports.sendMarketingBroadcast = async (req, res) => {
try {
const { projectId } = req.params;
const { audienceId, subject, html, from } = req.body;

const project = await Project.findOne({ _id: projectId, owner: req.user._id }).select("+resendApiKey.encrypted +resendApiKey.iv +resendApiKey.tag");
if (!project) return res.status(404).json({ success: false, message: "Project not found" });

const { key, isByok } = getResolvedResendKey(project);
if (!isByok || !key) {
return res.status(403).json({ success: false, message: "Marketing Broadcasts require a custom Resend API Key (BYOK)." });
}

const dev = await Developer.findById(req.user._id);
const effectivePlan = resolveEffectivePlan(dev);
if (effectivePlan !== "pro") {
return res.status(403).json({ success: false, message: "Marketing Broadcasts are a premium feature requiring the Pro tier." });
}

if (!audienceId || !subject || !html) {
return res.status(400).json({ success: false, message: "Audience ID, subject, and html content are required." });
}

// Mass marketing broadcasts logic using Resend Broadcasts API
const payload = {
audience_id: audienceId,
subject,
html,
from: from || project.resendFromEmail || "onboarding@resend.dev"
};

const response = await axios.post("https://api.resend.com/broadcasts", payload, {
headers: { Authorization: `Bearer ${key}` }
});

return res.json({ success: true, data: response.data, message: "Broadcast dispatched successfully!" });
} catch (err) {
const errorMsg = err.response?.data?.message || err.message;
return res.status(err.response?.status || 500).json({ success: false, message: errorMsg });
}
};
22 changes: 20 additions & 2 deletions apps/dashboard-api/src/routes/projects.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,15 @@ const {
updateMailTemplate,
deleteMailTemplate,
requestUpload,
confirmUpload
} = require("../controllers/project.controller")
confirmUpload,
getMailLogs,
getResendLiveStatus,
manageAudiences,
deleteAudience,
manageContacts,
deleteContact,
sendMarketingBroadcast
} = require("../controllers/project.controller");

const { createAdminUser, resetPassword, getUserDetails, updateAdminUser, listUserSessions, revokeUserSession } = require('../controllers/userAuth.controller');

Expand Down Expand Up @@ -88,6 +95,17 @@ router.post('/:projectId/mail/templates', authMiddleware, verifyEmail, planEnfor
router.patch('/:projectId/mail/templates/:templateId', authMiddleware, verifyEmail, planEnforcement.attachDeveloper, planEnforcement.checkMailTemplatesGate, updateMailTemplate);
router.delete('/:projectId/mail/templates/:templateId', authMiddleware, verifyEmail, deleteMailTemplate);

// EXPANDED MAIL API PLATFORM PROXIES
router.get('/:projectId/mail/logs', authMiddleware, getMailLogs);
router.get('/:projectId/mail/logs/:resendId/live', authMiddleware, getResendLiveStatus);
router.get('/:projectId/mail/audiences', authMiddleware, manageAudiences);
router.post('/:projectId/mail/audiences', authMiddleware, verifyEmail, manageAudiences);
router.delete('/:projectId/mail/audiences/:audienceId', authMiddleware, verifyEmail, deleteAudience);
router.get('/:projectId/mail/audiences/:audienceId/contacts', authMiddleware, manageContacts);
router.post('/:projectId/mail/audiences/:audienceId/contacts', authMiddleware, verifyEmail, manageContacts);
router.delete('/:projectId/mail/audiences/:audienceId/contacts/:contactId', authMiddleware, verifyEmail, deleteContact);
router.post('/:projectId/mail/broadcasts', authMiddleware, verifyEmail, sendMarketingBroadcast);

// PATCH REQ FOR ALLOWED DOMAINS
router.patch('/:projectId/allowed-domains', authMiddleware, verifyEmail, updateAllowedDomains);

Expand Down
1 change: 1 addition & 0 deletions apps/public-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"mongoose": "^8.19.2",
"multer": "^2.0.2",
"resend": "^6.6.0",
"svix": "^1.93.0",
"uuid": "^9.0.1",
"zod": "^4.1.13"
},
Expand Down
Loading
Loading