diff --git a/.env.example b/.env.example
index a23ab30..940b7d5 100644
--- a/.env.example
+++ b/.env.example
@@ -1,3 +1,18 @@
MONGO_URI=mongodb://mongodb:27017/LibreChat
PORT=3002
API_KEY=change-me-to-a-secure-key
+
+# Webhook security
+WEBHOOK_SECRET=change-me-to-a-random-secret
+WEBHOOK_BASE_URL=https://your-domain.com/balance-manager
+WEBHOOK_TTL_HOURS=72
+
+# SMTP (Mailgun or any SMTP server)
+SMTP_HOST=smtp.mailgun.org
+SMTP_PORT=587
+SMTP_USER=postmaster@mg.yourdomain.com
+SMTP_PASS=your-smtp-password
+SMTP_FROM=noreply@yourdomain.com
+
+# Notification recipients (comma-separated for multiple)
+NOTIFY_TO=admin@yourdomain.com
diff --git a/package-lock.json b/package-lock.json
index 972ef90..fa86b55 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,7 +10,8 @@
"dependencies": {
"dotenv": "^16.4.5",
"express": "^4.21.0",
- "mongodb": "^6.12.0"
+ "mongodb": "^6.12.0",
+ "nodemailer": "^8.0.4"
}
},
"node_modules/@mongodb-js/saslprep": {
@@ -634,6 +635,15 @@
"node": ">= 0.6"
}
},
+ "node_modules/nodemailer": {
+ "version": "8.0.4",
+ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz",
+ "integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==",
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
diff --git a/package.json b/package.json
index 32ea499..2f0f5a5 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,7 @@
"dependencies": {
"dotenv": "^16.4.5",
"express": "^4.21.0",
- "mongodb": "^6.12.0"
+ "mongodb": "^6.12.0",
+ "nodemailer": "^8.0.4"
}
}
diff --git a/routes/requests.js b/routes/requests.js
index 7228d93..72d0468 100644
--- a/routes/requests.js
+++ b/routes/requests.js
@@ -2,6 +2,7 @@ const { Router } = require('express');
const requestService = require('../services/requests');
const balanceService = require('../services/balance');
const history = require('../services/history');
+const { sendApprovalEmail } = require('../services/email');
const { MongoClient } = require('mongodb');
const publicRouter = Router();
@@ -19,6 +20,9 @@ publicRouter.post('/', (req, res) => {
return res.status(400).json({ error: 'Invalid email address' });
}
const request = requestService.createRequest(email, omgJobNumber);
+ sendApprovalEmail(request).catch(err =>
+ console.error('[email] Failed to send approval notification:', err)
+ );
res.json({ success: true, id: request.id });
} catch (err) {
res.status(500).json({ error: err.message });
diff --git a/routes/webhooks.js b/routes/webhooks.js
new file mode 100644
index 0000000..e2ea50d
--- /dev/null
+++ b/routes/webhooks.js
@@ -0,0 +1,240 @@
+const { Router } = require('express');
+const { MongoClient } = require('mongodb');
+const { verifyToken } = require('../services/email');
+const requestService = require('../services/requests');
+const balanceService = require('../services/balance');
+const history = require('../services/history');
+
+const router = Router();
+
+function htmlPage(title, message, success) {
+ const color = success ? '#4ade80' : '#f87171';
+ const icon = success ? '✓' : '✗';
+ return `
+
+${title}
+
+
+${icon}
${title}
${message}
`;
+}
+
+async function getDbAndUser(email) {
+ const MONGO_URI = process.env.MONGO_URI || 'mongodb://localhost:27017/LibreChat';
+ const client = new MongoClient(MONGO_URI);
+ await client.connect();
+ const db = client.db();
+ const user = await db.collection('users').findOne(
+ { email: email.toLowerCase() },
+ { projection: { _id: 1, email: 1 } },
+ );
+ return { client, db, user };
+}
+
+router.get('/approve/:requestId', async (req, res) => {
+ try {
+ const { token, amount, expires } = req.query;
+
+ if (!verifyToken(req.params.requestId, amount, expires, token)) {
+ return res.status(403).send(htmlPage('Link Expired or Invalid', 'This approval link has expired or is invalid. Please use the admin dashboard to process this request.', false));
+ }
+
+ const requests = requestService.getAll();
+ const request = requests.find(r => r.id === req.params.requestId);
+
+ if (!request) {
+ return res.status(404).send(htmlPage('Not Found', 'This request could not be found.', false));
+ }
+ if (request.status !== 'pending') {
+ return res.status(400).send(htmlPage('Already Processed', `This request was already ${request.status}${request.amountApproved ? ' with ' + (request.amountApproved / 1000000).toFixed(1) + 'M tokens' : ''}.`, false));
+ }
+
+ const numAmount = parseInt(amount);
+ const { client, db, user } = await getDbAndUser(request.email);
+
+ if (!user) {
+ await client.close();
+ return res.status(404).send(htmlPage('User Not Found', `No LibreChat user found with email: ${request.email}`, false));
+ }
+
+ const result = await balanceService.addBalance(db, user._id.toString(), numAmount);
+ await client.close();
+
+ history.log({
+ email: request.email,
+ action: 'approve',
+ amount: numAmount,
+ source: 'email',
+ omgJobNumber: request.omgJobNumber,
+ balanceAfter: result.tokenCredits,
+ });
+
+ requestService.updateRequest(req.params.requestId, 'approved', numAmount);
+
+ const formatted = (numAmount / 1000000).toFixed(1) + 'M';
+ res.send(htmlPage('Request Approved', `${formatted} tokens added to ${request.email}. New balance: ${result.tokenCredits.toLocaleString()} tokens.`, true));
+ } catch (err) {
+ console.error('[webhook] Approve error:', err);
+ res.status(500).send(htmlPage('Error', 'Something went wrong processing this approval. Please use the admin dashboard.', false));
+ }
+});
+
+router.get('/reject/:requestId', async (req, res) => {
+ try {
+ const { token, expires } = req.query;
+
+ if (!verifyToken(req.params.requestId, 'reject', expires, token)) {
+ return res.status(403).send(htmlPage('Link Expired or Invalid', 'This rejection link has expired or is invalid.', false));
+ }
+
+ const requests = requestService.getAll();
+ const request = requests.find(r => r.id === req.params.requestId);
+
+ if (!request) {
+ return res.status(404).send(htmlPage('Not Found', 'This request could not be found.', false));
+ }
+ if (request.status !== 'pending') {
+ return res.status(400).send(htmlPage('Already Processed', `This request was already ${request.status}.`, false));
+ }
+
+ requestService.updateRequest(req.params.requestId, 'rejected', null);
+ res.send(htmlPage('Request Rejected', `Credit request from ${request.email} (${request.omgJobNumber}) has been rejected.`, true));
+ } catch (err) {
+ console.error('[webhook] Reject error:', err);
+ res.status(500).send(htmlPage('Error', 'Something went wrong. Please use the admin dashboard.', false));
+ }
+});
+
+router.get('/custom/:requestId', async (req, res) => {
+ try {
+ const { token, expires } = req.query;
+
+ if (!verifyToken(req.params.requestId, 'custom', expires, token)) {
+ return res.status(403).send(htmlPage('Link Expired or Invalid', 'This link has expired or is invalid.', false));
+ }
+
+ const requests = requestService.getAll();
+ const request = requests.find(r => r.id === req.params.requestId);
+
+ if (!request) {
+ return res.status(404).send(htmlPage('Not Found', 'This request could not be found.', false));
+ }
+ if (request.status !== 'pending') {
+ return res.status(400).send(htmlPage('Already Processed', `This request was already ${request.status}.`, false));
+ }
+
+ res.send(`
+
+Custom Approve - ${request.email}
+
+
+
+
+
Custom Approve
+
Choose an amount to approve for this request
+
+
Email
+
${request.email}
+
OMG Job #
+
${request.omgJobNumber}
+
+
+
+`);
+ } catch (err) {
+ console.error('[webhook] Custom page error:', err);
+ res.status(500).send(htmlPage('Error', 'Something went wrong. Please use the admin dashboard.', false));
+ }
+});
+
+router.use('/custom/:requestId', require('express').urlencoded({ extended: false }));
+
+router.post('/custom/:requestId', async (req, res) => {
+ try {
+ const { token, expires, amount } = req.body;
+
+ if (!verifyToken(req.params.requestId, 'custom', expires, token)) {
+ return res.status(403).send(htmlPage('Link Expired or Invalid', 'This link has expired or is invalid.', false));
+ }
+
+ const numAmount = parseInt(amount);
+ if (isNaN(numAmount) || numAmount <= 0) {
+ return res.status(400).send(htmlPage('Invalid Amount', 'Please enter a valid positive number.', false));
+ }
+
+ const requests = requestService.getAll();
+ const request = requests.find(r => r.id === req.params.requestId);
+
+ if (!request) {
+ return res.status(404).send(htmlPage('Not Found', 'This request could not be found.', false));
+ }
+ if (request.status !== 'pending') {
+ return res.status(400).send(htmlPage('Already Processed', `This request was already ${request.status}.`, false));
+ }
+
+ const { client, db, user } = await getDbAndUser(request.email);
+
+ if (!user) {
+ await client.close();
+ return res.status(404).send(htmlPage('User Not Found', `No LibreChat user found with email: ${request.email}`, false));
+ }
+
+ const result = await balanceService.addBalance(db, user._id.toString(), numAmount);
+ await client.close();
+
+ history.log({
+ email: request.email,
+ action: 'approve',
+ amount: numAmount,
+ source: 'email',
+ omgJobNumber: request.omgJobNumber,
+ balanceAfter: result.tokenCredits,
+ });
+
+ requestService.updateRequest(req.params.requestId, 'approved', numAmount);
+
+ const formatted = (numAmount / 1000000).toFixed(1) + 'M';
+ res.send(htmlPage('Request Approved', `${formatted} tokens added to ${request.email}. New balance: ${result.tokenCredits.toLocaleString()} tokens.`, true));
+ } catch (err) {
+ console.error('[webhook] Custom approve error:', err);
+ res.status(500).send(htmlPage('Error', 'Something went wrong. Please use the admin dashboard.', false));
+ }
+});
+
+module.exports = router;
diff --git a/server.js b/server.js
index 7009671..d9a1127 100644
--- a/server.js
+++ b/server.js
@@ -36,10 +36,14 @@ app.use(express.static(path.join(__dirname, 'public')));
const apiRoutes = require('./routes/api');
const requestRoutes = require('./routes/requests');
+const webhookRoutes = require('./routes/webhooks');
// Public: submit a credit request (no auth needed)
app.use('/api/requests', requestRoutes.publicRouter);
+// Public: email webhook approve/reject (secured by HMAC tokens)
+app.use('/api/webhooks', webhookRoutes);
+
// Admin: manage credit requests (auth required)
app.use('/api/admin/requests', authMiddleware, requestRoutes.adminRouter);
diff --git a/services/email.js b/services/email.js
new file mode 100644
index 0000000..72cd3e1
--- /dev/null
+++ b/services/email.js
@@ -0,0 +1,135 @@
+const crypto = require('crypto');
+const nodemailer = require('nodemailer');
+
+const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || 'change-me-webhook-secret';
+const WEBHOOK_BASE_URL = (process.env.WEBHOOK_BASE_URL || 'http://localhost:3002').replace(/\/$/, '');
+const WEBHOOK_TTL_HOURS = parseInt(process.env.WEBHOOK_TTL_HOURS || '72');
+const NOTIFY_TO = process.env.NOTIFY_TO || '';
+
+function getTransporter() {
+ return nodemailer.createTransport({
+ host: process.env.SMTP_HOST || 'smtp.mailgun.org',
+ port: parseInt(process.env.SMTP_PORT || '587'),
+ secure: false,
+ auth: {
+ user: process.env.SMTP_USER,
+ pass: process.env.SMTP_PASS,
+ },
+ });
+}
+
+function generateToken(requestId, amount, expiresAt) {
+ const payload = `${requestId}|${amount}|${expiresAt}`;
+ return crypto.createHmac('sha256', WEBHOOK_SECRET).update(payload).digest('hex');
+}
+
+function verifyToken(requestId, amount, expires, token) {
+ if (Date.now() > parseInt(expires)) return false;
+ const expected = generateToken(requestId, amount, expires);
+ try {
+ return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(token, 'hex'));
+ } catch {
+ return false;
+ }
+}
+
+function buildWebhookUrl(path, requestId, amount, expiresAt) {
+ const token = generateToken(requestId, amount, expiresAt);
+ return `${WEBHOOK_BASE_URL}/api/webhooks/${path}/${requestId}?token=${token}&amount=${amount}&expires=${expiresAt}`;
+}
+
+function buildEmail(request) {
+ const expiresAt = Date.now() + (WEBHOOK_TTL_HOURS * 60 * 60 * 1000);
+ const expiryDate = new Date(expiresAt).toLocaleDateString('en-GB', {
+ day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit',
+ });
+
+ const presets = [
+ { label: 'Approve 5M', amount: 5000000, color: '#4ade80' },
+ { label: 'Approve 10M', amount: 10000000, color: '#4ade80' },
+ { label: 'Approve 20M', amount: 20000000, color: '#4ade80' },
+ ];
+
+ const approveButtons = presets.map(p => {
+ const url = buildWebhookUrl('approve', request.id, p.amount, expiresAt);
+ return `${p.label}`;
+ }).join('\n ');
+
+ const customUrl = buildWebhookUrl('custom', request.id, 'custom', expiresAt);
+ const rejectUrl = buildWebhookUrl('reject', request.id, 'reject', expiresAt);
+
+ return `
+
+
+
+
+
+
+
+
+ |
+ 💳
+ New Credit Request
+ |
+
+
+
+
+
+ |
+ Email
+ ${request.email}
+ OMG Job Number
+ ${request.omgJobNumber}
+ |
+
+
+ |
+
+
+ |
+ Choose an action:
+ ${approveButtons}
+
+ Custom Amount
+ Reject
+ |
+
+
+ |
+ These links expire on ${expiryDate}
+ |
+
+
+ |
+
+
+
+`;
+}
+
+async function sendApprovalEmail(request) {
+ if (!NOTIFY_TO) {
+ console.log('[email] NOTIFY_TO not configured, skipping email');
+ return;
+ }
+ if (!process.env.SMTP_USER || !process.env.SMTP_PASS) {
+ console.log('[email] SMTP credentials not configured, skipping email');
+ return;
+ }
+
+ const transporter = getTransporter();
+ const html = buildEmail(request);
+ const recipients = NOTIFY_TO.split(',').map(e => e.trim()).filter(Boolean);
+
+ await transporter.sendMail({
+ from: process.env.SMTP_FROM || process.env.SMTP_USER,
+ to: recipients.join(', '),
+ subject: `Credit Request: ${request.email} — ${request.omgJobNumber}`,
+ html,
+ });
+
+ console.log(`[email] Approval notification sent to ${recipients.join(', ')} for request ${request.id}`);
+}
+
+module.exports = { generateToken, verifyToken, sendApprovalEmail };