4bnode 4.1.5 → 4.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/add-api.js +5 -3
  2. package/add-email.js +118 -28
  3. package/add-login.js +35 -16
  4. package/add-mongo-schema.js +13 -5
  5. package/lib/codegen.js +191 -57
  6. package/lib/codegen.test.js +87 -15
  7. package/lib/names.js +1 -1
  8. package/package.json +1 -1
  9. package/skeleton/.4bnode/assets/js/pages/ApiTesterPage.js +54 -9
  10. package/skeleton/.4bnode/assets/js/pages/LogsPage.js +49 -16
  11. package/skeleton/.4bnode/assets/js/pages/MailPage.js +110 -50
  12. package/skeleton/.4bnode/codegen.js +191 -57
  13. package/skeleton/.4bnode/dev-api.js +284 -56
  14. package/skeleton/.env.example +17 -3
  15. package/skeleton/_gitignore +1 -0
  16. package/skeleton/index.js +27 -4
  17. package/skeleton/src/middleware/apiKey.js +14 -1
  18. package/skeleton/src/middleware/errorHandler.js +7 -2
  19. package/app-1/.4bnode/.passkey +0 -1
  20. package/app-1/.4bnode/assets/fonts/NotoSans-Bold.woff2 +0 -0
  21. package/app-1/.4bnode/assets/fonts/NotoSans-Light.woff2 +0 -0
  22. package/app-1/.4bnode/assets/fonts/NotoSans-Medium.woff2 +0 -0
  23. package/app-1/.4bnode/assets/fonts/NotoSans-Regular.woff2 +0 -0
  24. package/app-1/.4bnode/assets/fonts/NotoSans-SemiBold.woff2 +0 -0
  25. package/app-1/.4bnode/assets/images/mongo.png +0 -0
  26. package/app-1/.4bnode/assets/js/App.js +0 -139
  27. package/app-1/.4bnode/assets/js/Root.js +0 -40
  28. package/app-1/.4bnode/assets/js/components/Modal.js +0 -52
  29. package/app-1/.4bnode/assets/js/components/Sidebar.js +0 -73
  30. package/app-1/.4bnode/assets/js/components/ToastContainer.js +0 -15
  31. package/app-1/.4bnode/assets/js/context.js +0 -7
  32. package/app-1/.4bnode/assets/js/lib.js +0 -113
  33. package/app-1/.4bnode/assets/js/main.js +0 -5
  34. package/app-1/.4bnode/assets/js/pages/AiBuilderPage.js +0 -268
  35. package/app-1/.4bnode/assets/js/pages/ApiTesterPage.js +0 -1879
  36. package/app-1/.4bnode/assets/js/pages/DashboardPage.js +0 -435
  37. package/app-1/.4bnode/assets/js/pages/DatabasePage.js +0 -300
  38. package/app-1/.4bnode/assets/js/pages/DocsPage.js +0 -150
  39. package/app-1/.4bnode/assets/js/pages/EnvironmentPage.js +0 -103
  40. package/app-1/.4bnode/assets/js/pages/LogsPage.js +0 -205
  41. package/app-1/.4bnode/assets/js/pages/MailPage.js +0 -224
  42. package/app-1/.4bnode/assets/js/pages/RealtimePage.js +0 -186
  43. package/app-1/.4bnode/assets/js/pages/RoutesPage.js +0 -1485
  44. package/app-1/.4bnode/assets/js/pages/SchemasPage.js +0 -838
  45. package/app-1/.4bnode/assets/js/pages/SecurityPage.js +0 -74
  46. package/app-1/.4bnode/assets/js/pages/SerialPortPage.js +0 -170
  47. package/app-1/.4bnode/assets/js/screens/PasskeyScreen.js +0 -105
  48. package/app-1/.4bnode/codegen.js +0 -1440
  49. package/app-1/.4bnode/dev-api.js +0 -3747
  50. package/app-1/.4bnode/ui.html +0 -1578
  51. package/app-1/.env.example +0 -11
  52. package/app-1/README.md +0 -47
  53. package/app-1/ecosystem.config.cjs +0 -25
  54. package/app-1/index.js +0 -188
  55. package/app-1/package.json +0 -37
  56. package/app-1/public/index.html +0 -292
  57. package/app-1/src/db.js +0 -13
  58. package/app-1/src/docs.js +0 -84
  59. package/app-1/src/middleware/apiKey.js +0 -36
  60. package/app-1/src/middleware/auth.js +0 -17
  61. package/app-1/src/middleware/errorHandler.js +0 -15
  62. package/app-1/src/middleware/roles.js +0 -14
  63. package/app-1/src/middleware/validate.js +0 -20
  64. package/app-1/src/models/user.js +0 -11
  65. package/app-1/src/openapi.json +0 -80
  66. package/app-1/src/routes/upload-resume.js +0 -21
  67. package/app-1/src/services/mailer.js +0 -52
  68. package/app-1/src/validators/upload-resume.js +0 -8
@@ -40,6 +40,25 @@ export function sanitizeName(input) {
40
40
  return trimmed;
41
41
  }
42
42
 
43
+ // Validate a model/route FIELD name before it is interpolated into generated code.
44
+ // A field name becomes a JS identifier (destructuring, object keys), so anything
45
+ // but a plain identifier could break out of the template or pollute a prototype.
46
+ export function sanitizeFieldName(input) {
47
+ if (!input || typeof input !== "string") {
48
+ throw new Error("Field name is required.");
49
+ }
50
+ const trimmed = input.trim();
51
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(trimmed)) {
52
+ throw new Error(
53
+ `Invalid field name "${trimmed}". Use letters, numbers, and underscores; start with a letter or underscore.`
54
+ );
55
+ }
56
+ if (trimmed === "__proto__" || trimmed === "constructor" || trimmed === "prototype") {
57
+ throw new Error(`Field name "${trimmed}" is reserved.`);
58
+ }
59
+ return trimmed;
60
+ }
61
+
43
62
  // ── Pure string helpers for route files ─────────────────────────────────────
44
63
  // These operate on file CONTENT (a string) and return new content. The
45
64
  // file-reading/writing wrappers live in lib/routeFile.js (CLI) and inline in
@@ -76,7 +95,20 @@ export function insertCodeIntoContent(content, codeBlock) {
76
95
 
77
96
  // ── CRUD route builders ─────────────────────────────────────────────────────
78
97
 
98
+ // Field names that must never be mass-assigned from a request body — privilege
99
+ // flags and prototype-pollution keys. Excluded from generated create/update DTOs
100
+ // by default; set these server-side (e.g. in an admin route) instead.
101
+ export const MASS_ASSIGN_BLOCKED = new Set([
102
+ "role", "roles", "isadmin", "admin", "isadministrator",
103
+ "verified", "isverified", "permissions", "scopes",
104
+ "__proto__", "constructor", "prototype",
105
+ ]);
106
+ function writableFields(fields) {
107
+ return fields.filter((f) => !MASS_ASSIGN_BLOCKED.has(String(f).toLowerCase()));
108
+ }
109
+
79
110
  export function buildInsertCode(pascalName, fields, dataSource) {
111
+ fields = writableFields(fields);
80
112
  const assignments = fields
81
113
  .map((f) => ` ${f}: ${dataSource}.${f}`)
82
114
  .join(",\n");
@@ -111,8 +143,15 @@ export function buildReadCode(pascalName) {
111
143
  return `
112
144
  router.get('/', async (req, res) => {
113
145
  try {
114
- const documents = await ${pascalName}.find();
115
- res.json({ data: documents });
146
+ // Paginate to avoid returning an unbounded collection. ?page & ?limit (max 100).
147
+ const page = Math.max(1, parseInt(req.query.page, 10) || 1);
148
+ const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 20));
149
+ const skip = (page - 1) * limit;
150
+ const [documents, total] = await Promise.all([
151
+ ${pascalName}.find().select('-password').skip(skip).limit(limit),
152
+ ${pascalName}.countDocuments(),
153
+ ]);
154
+ res.json({ data: documents, page, limit, total, totalPages: Math.ceil(total / limit) });
116
155
  } catch (err) {
117
156
  console.error(err);
118
157
  res.status(500).json({ message: 'Server error' });
@@ -121,7 +160,8 @@ router.get('/', async (req, res) => {
121
160
 
122
161
  router.get('/:id', async (req, res) => {
123
162
  try {
124
- const document = await ${pascalName}.findById(req.params.id);
163
+ // .select('-password') never leaks credential hashes even if the model has one.
164
+ const document = await ${pascalName}.findById(req.params.id).select('-password');
125
165
  if (!document) {
126
166
  return res.status(404).json({ message: 'Document not found' });
127
167
  }
@@ -134,6 +174,7 @@ router.get('/:id', async (req, res) => {
134
174
  }
135
175
 
136
176
  export function buildUpdateCode(pascalName, fields, dataSource) {
177
+ fields = writableFields(fields);
137
178
  const assignments = fields
138
179
  .map((f) => ` ${f}: ${dataSource}.${f}`)
139
180
  .join(",\n");
@@ -201,6 +242,10 @@ export function generateMongooseModel(pascalName, fields, indexes = []) {
201
242
  if (f.index) parts.push("index: true");
202
243
  if (f.default !== undefined && f.default !== "")
203
244
  parts.push(`default: ${f.default}`);
245
+ // Never return credential fields by default — read queries must opt in with
246
+ // .select('+password'). Prevents accidental hash leaks through find()/findById.
247
+ if (/^password$/i.test(f.name) && f.select === undefined)
248
+ parts.push("select: false");
204
249
  return ` ${f.name}: { ${parts.join(", ")} }`;
205
250
  });
206
251
 
@@ -247,9 +292,14 @@ export function notFound(req, res, next) {
247
292
  export function errorHandler(err, req, res, next) {
248
293
  const status = err.status || err.statusCode || 500;
249
294
  console.error(err);
295
+ const isProd = process.env.NODE_ENV === 'production';
296
+ // In production, only surface messages for client (4xx) errors or errors
297
+ // explicitly marked safe (err.expose). Never leak raw 5xx internals — a Mongo
298
+ // E11000 or driver message discloses schema/indexes and enables enumeration.
299
+ const showMessage = !isProd || err.expose === true || (status >= 400 && status < 500);
250
300
  res.status(status).json({
251
- message: err.message || 'Server error',
252
- ...(process.env.NODE_ENV === 'production' ? {} : { stack: err.stack }),
301
+ message: showMessage ? (err.message || 'Server error') : 'Server error',
302
+ ...(isProd ? {} : { stack: err.stack }),
253
303
  });
254
304
  }
255
305
  `;
@@ -285,7 +335,12 @@ const auth = (req, res, next) => {
285
335
  return res.status(401).json({ message: 'No token, authorization denied' });
286
336
  }
287
337
  try {
288
- const decoded = jwt.verify(token, process.env.JWT_SECRET);
338
+ // Pin the algorithm so a token can't be forged via alg-confusion (e.g. an
339
+ // attacker signing HS256 with a public key if RS256 is ever introduced).
340
+ const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
341
+ if (decoded.type && decoded.type !== 'access') {
342
+ return res.status(401).json({ message: 'Token is not valid' });
343
+ }
289
344
  req.user = decoded;
290
345
  next();
291
346
  } catch (err) {
@@ -514,8 +569,10 @@ const authLimiter = rateLimit({
514
569
  standardHeaders: true,
515
570
  legacyHeaders: false,
516
571
  });
517
- // Only throttle in production — in dev, repeated/hot-reload requests shouldn't hit 429.
518
- if (process.env.NODE_ENV === 'production') {
572
+ // Throttle everywhere EXCEPT explicit local development. Defaulting to on means a
573
+ // deploy that forgets to set NODE_ENV=production is still protected (an unset
574
+ // NODE_ENV must never mean "no rate limiting" — that was the /_dev breach posture).
575
+ if (process.env.NODE_ENV !== 'development') {
519
576
  app.use('/api/auth', authLimiter); // strict limiter first, on auth routes
520
577
  app.use(apiLimiter); // generous global limiter for everything else
521
578
  }`
@@ -796,6 +853,7 @@ export const PACKAGE_VERSIONS = {
796
853
  mongoose: "^8.8.0",
797
854
  multer: "^1.4.5-lts.1",
798
855
  nodemailer: "^6.9.0",
856
+ resend: "^4.0.0",
799
857
  };
800
858
 
801
859
  // Add packages to package.json (dependencies, or devDependencies when dev:true).
@@ -1293,37 +1351,70 @@ const DIR = path.dirname(fileURLToPath(import.meta.url));
1293
1351
  const SPEC = path.join(DIR, 'openapi.json');
1294
1352
  const AUTH = path.join(DIR, 'docs-auth.json');
1295
1353
 
1296
- // Optional password protection. If src/docs-auth.json holds a password hash, the
1354
+ // Optional password protection. If src/docs-auth.json holds { hash, secret }, the
1297
1355
  // whole /docs surface requires a single password (NO username) via a small login
1298
- // page + cookie. Set/remove the password from the dashboard (API Docs). Read live
1299
- // (mtime-cached), so changes apply without a restart.
1300
- let _authCache = { mtimeMs: -1, hash: null };
1301
- function docsHash() {
1356
+ // page + a signed session cookie. Set/remove the password from the dashboard
1357
+ // (API Docs). Read live (mtime-cached), so changes apply without a restart.
1358
+ let _authCache = { mtimeMs: -1, hash: null, secret: null };
1359
+ function docsAuth() {
1302
1360
  try {
1303
1361
  const { mtimeMs } = fs.statSync(AUTH);
1304
1362
  if (mtimeMs !== _authCache.mtimeMs) {
1305
1363
  const data = JSON.parse(fs.readFileSync(AUTH, 'utf8'));
1306
- _authCache = { mtimeMs, hash: (data && data.hash) || null };
1364
+ _authCache = { mtimeMs, hash: (data && data.hash) || null, secret: (data && data.secret) || null };
1307
1365
  }
1308
- } catch { _authCache = { mtimeMs: -1, hash: null }; }
1309
- return _authCache.hash;
1366
+ } catch { _authCache = { mtimeMs: -1, hash: null, secret: null }; }
1367
+ return _authCache;
1310
1368
  }
1369
+ // Stable server secret for signing session cookies. Prefers the persisted secret;
1370
+ // falls back to an ephemeral random one (legacy files with no secret) so a leaked
1371
+ // docs-auth.json can never be forged into a valid session.
1372
+ const EPHEMERAL_SECRET = crypto.randomBytes(32).toString('hex');
1373
+ function sessionSecret() { return docsAuth().secret || EPHEMERAL_SECRET; }
1311
1374
  function safeEq(a, b) {
1312
1375
  try { const x = Buffer.from(String(a)), y = Buffer.from(String(b)); return x.length === y.length && crypto.timingSafeEqual(x, y); } catch { return false; }
1313
1376
  }
1377
+ // Verify a submitted password against the stored hash: salted scrypt
1378
+ // (scrypt$salt$hash) or legacy unsalted sha256 (back-compat).
1379
+ function verifyPassword(candidate, hash) {
1380
+ if (!candidate || !hash) return false;
1381
+ try {
1382
+ if (hash.startsWith('scrypt$')) {
1383
+ const [, saltHex, hashHex] = hash.split('$');
1384
+ const expected = Buffer.from(hashHex, 'hex');
1385
+ const actual = crypto.scryptSync(String(candidate), Buffer.from(saltHex, 'hex'), expected.length);
1386
+ return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
1387
+ }
1388
+ return safeEq(crypto.createHash('sha256').update(String(candidate)).digest('hex'), hash);
1389
+ } catch { return false; }
1390
+ }
1391
+ // Session cookie = base64url(payload).hmac. The stored hash is NEVER sent to the
1392
+ // client, so a leaked docs-auth.json can't be replayed as a session.
1393
+ function signSession() {
1394
+ const payload = Buffer.from(JSON.stringify({ exp: Date.now() + 86400000 })).toString('base64url');
1395
+ const sig = crypto.createHmac('sha256', sessionSecret()).update(payload).digest('base64url');
1396
+ return payload + '.' + sig;
1397
+ }
1398
+ function validSession(token) {
1399
+ if (!token || typeof token !== 'string' || token.indexOf('.') === -1) return false;
1400
+ const [payload, sig] = token.split('.');
1401
+ const expect = crypto.createHmac('sha256', sessionSecret()).update(payload).digest('base64url');
1402
+ if (!safeEq(sig, expect)) return false;
1403
+ try { const { exp } = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); return typeof exp === 'number' && exp > Date.now(); } catch { return false; }
1404
+ }
1314
1405
  function cookieToken(req) {
1315
1406
  const m = (req.headers.cookie || '').match(/(?:^|; )docs_auth=([^;]+)/);
1316
- return m ? m[1] : null;
1407
+ return m ? decodeURIComponent(m[1]) : null;
1317
1408
  }
1318
1409
  const LOGIN_HTML = ${JSON.stringify(buildDocsLoginHtml())};
1319
1410
 
1320
1411
  // Password submit — reachable without auth.
1321
1412
  router.post('/login', (req, res) => {
1322
- const expected = docsHash();
1323
- if (!expected) return res.redirect('/docs');
1324
- const got = crypto.createHash('sha256').update(String((req.body && req.body.password) || '')).digest('hex');
1325
- if (safeEq(got, expected)) {
1326
- res.setHeader('Set-Cookie', 'docs_auth=' + expected + '; Path=/docs; HttpOnly; SameSite=Lax; Max-Age=86400');
1413
+ const { hash } = docsAuth();
1414
+ if (!hash) return res.redirect('/docs');
1415
+ if (verifyPassword(String((req.body && req.body.password) || ''), hash)) {
1416
+ const secure = process.env.NODE_ENV === 'production' ? '; Secure' : '';
1417
+ res.setHeader('Set-Cookie', 'docs_auth=' + signSession() + '; Path=/docs; HttpOnly; SameSite=Lax; Max-Age=86400' + secure);
1327
1418
  return res.redirect('/docs');
1328
1419
  }
1329
1420
  return res.status(401).type('html').send(LOGIN_HTML.replace('<!--ERR-->', 'Incorrect password'));
@@ -1331,9 +1422,9 @@ router.post('/login', (req, res) => {
1331
1422
 
1332
1423
  // Gate the rest of /docs behind the password (when one is set).
1333
1424
  router.use((req, res, next) => {
1334
- const expected = docsHash();
1335
- if (!expected) return next(); // no password → open
1336
- if (safeEq(cookieToken(req), expected)) return next();
1425
+ const { hash } = docsAuth();
1426
+ if (!hash) return next(); // no password → open
1427
+ if (validSession(cookieToken(req))) return next();
1337
1428
  return res.status(401).type('html').send(LOGIN_HTML);
1338
1429
  });
1339
1430
 
@@ -1370,29 +1461,57 @@ export default router;
1370
1461
  }
1371
1462
 
1372
1463
  // ─────────────────────────────────────────────────────────────────────────────
1373
- // Email (nodemailer) — SMTP with presets for popular providers (Brevo, etc.).
1464
+ // Email — two backends: IntraApp(Postmaster) via the Resend API, or any SMTP
1465
+ // server via nodemailer. `type` selects the code path; `apiKeyOnly` tells the
1466
+ // dashboard to show a single API-key field (no host/port/user).
1374
1467
  // ─────────────────────────────────────────────────────────────────────────────
1375
1468
  export const MAIL_PROVIDERS = {
1376
- mailgun: { label: "Mailgun", host: "smtp.mailgun.org", port: 587, secure: false, hint: "Postmaster user + SMTP password" },
1377
- brevo: { label: "Brevo", host: "smtp-relay.brevo.com", port: 587, secure: false, hint: "Login email + SMTP key (SMTP & API → SMTP)" },
1378
- sendgrid: { label: "SendGrid", host: "smtp.sendgrid.net", port: 587, secure: false, user: "apikey", hint: "User is literally 'apikey'; pass is your API key" },
1379
- gmail: { label: "Gmail", host: "smtp.gmail.com", port: 465, secure: true, hint: "Use an App Password, not your login password" },
1380
- mailtrap: { label: "Mailtrap (test)", host: "sandbox.smtp.mailtrap.io", port: 587, secure: false, hint: "Sandbox inbox for testing" },
1381
- smtp: { label: "Custom SMTP", host: "", port: 587, secure: false, hint: "Any SMTP server" },
1469
+ resend: { label: "IntraApp(Postmaster)", type: "resend", apiKeyOnly: true, hint: "Paste your API key (re_...). Sends via the Resend API — no SMTP host or port needed." },
1470
+ smtp: { label: "Custom SMTP", type: "smtp", host: "", port: 587, secure: false, hint: "Any SMTP server. Tick TLS for port 465, otherwise 587." },
1382
1471
  };
1383
1472
 
1384
- // src/services/mailer.js — a configured nodemailer transport + sendMail helper + template.
1473
+ // The 4brains.in email domain is official and gated: it may only be configured as
1474
+ // a sender with the shared authorization password, and every configuration is
1475
+ // reported to the address below. These are shipped into generated apps on purpose.
1476
+ export const OFFICIAL_MAIL_DOMAIN = "4brains.in";
1477
+ export const OFFICIAL_MAIL_DOMAIN_PASSWORD = "99321239";
1478
+ export const OFFICIAL_MAIL_NOTIFY = "pawan@4brains.in";
1479
+ export function isOfficialMailDomain(email) {
1480
+ return /@4brains\.in$/i.test(String(email || "").trim());
1481
+ }
1482
+
1483
+ // src/services/mailer.js — unified mailer with two backends selected by
1484
+ // MAIL_PROVIDER in .env. The public API (sendMail / emailTemplate / verifyMailer)
1485
+ // is identical regardless of backend, so calling code never changes.
1385
1486
  export function buildMailerService() {
1386
- return `import nodemailer from 'nodemailer';
1387
-
1388
- // Transport is configured from SMTP_* env vars (see .env). Works with Brevo,
1389
- // SendGrid, Mailgun, Gmail, Mailtrap, or any SMTP server.
1390
- const transporter = nodemailer.createTransport({
1391
- host: process.env.SMTP_HOST,
1392
- port: Number(process.env.SMTP_PORT) || 587,
1393
- secure: process.env.SMTP_SECURE === 'true', // true for port 465
1394
- auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
1395
- });
1487
+ return `// Unified mailer. Backend is chosen by MAIL_PROVIDER in .env:
1488
+ // - "resend": IntraApp(Postmaster), via the Resend API (RESEND_API_KEY, MAIL_FROM)
1489
+ // - "smtp": any SMTP server, via nodemailer (SMTP_* vars)
1490
+ // Only the selected backend's package is imported (lazily), so you don't need
1491
+ // both installed. sendMail({ to, subject, html }) works the same either way.
1492
+ const PROVIDER = (process.env.MAIL_PROVIDER || 'smtp').toLowerCase();
1493
+
1494
+ let _clientPromise = null;
1495
+ function getClient() {
1496
+ if (_clientPromise) return _clientPromise;
1497
+ _clientPromise = (async () => {
1498
+ if (PROVIDER === 'resend') {
1499
+ const { Resend } = await import('resend');
1500
+ return { type: 'resend', resend: new Resend(process.env.RESEND_API_KEY) };
1501
+ }
1502
+ const nodemailer = (await import('nodemailer')).default;
1503
+ return {
1504
+ type: 'smtp',
1505
+ transporter: nodemailer.createTransport({
1506
+ host: process.env.SMTP_HOST,
1507
+ port: Number(process.env.SMTP_PORT) || 587,
1508
+ secure: process.env.SMTP_SECURE === 'true', // true for port 465 (TLS)
1509
+ auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
1510
+ }),
1511
+ };
1512
+ })();
1513
+ return _clientPromise;
1514
+ }
1396
1515
 
1397
1516
  // Wrap content in a simple, responsive HTML email.
1398
1517
  export function emailTemplate({ title = '', body = '' } = {}) {
@@ -1413,28 +1532,43 @@ export function emailTemplate({ title = '', body = '' } = {}) {
1413
1532
  }
1414
1533
 
1415
1534
  // Send an email. Pass html or text (or use emailTemplate for html).
1416
- // attachments: [{ filename, path }] or [{ filename, content }] (nodemailer format).
1535
+ // attachments: [{ filename, path }] or [{ filename, content }].
1417
1536
  export async function sendMail({ to, subject, html, text, from, cc, bcc, replyTo, attachments } = {}) {
1418
1537
  if (!to || !subject) throw new Error('sendMail requires { to, subject }');
1419
- return transporter.sendMail({
1538
+ // 4brains.in is an official domain. Sending from it is only allowed once it has
1539
+ // been configured through the 4bnode dashboard/CLI with the authorization
1540
+ // password (which sets OFFICIAL_MAIL_AUTHORIZED). Direct/code use is blocked.
1541
+ const effectiveFrom = from || (PROVIDER === 'resend' ? process.env.MAIL_FROM : (process.env.SMTP_FROM || process.env.SMTP_USER));
1542
+ const touchesOfficial = /4brains\\.in/i.test(String(effectiveFrom || '')) || (PROVIDER === 'smtp' && /4brains\\.in/i.test(String(process.env.SMTP_HOST || '')));
1543
+ if (touchesOfficial && !process.env.OFFICIAL_MAIL_AUTHORIZED) {
1544
+ throw new Error('The 4brains.in domain is official and cannot be used directly — configure it via the 4bnode dashboard (Email) with the authorization password.');
1545
+ }
1546
+ const client = await getClient();
1547
+ if (client.type === 'resend') {
1548
+ const { data, error } = await client.resend.emails.send({
1549
+ from: from || process.env.MAIL_FROM,
1550
+ to, cc, bcc, replyTo, subject, html, text, attachments,
1551
+ });
1552
+ if (error) throw new Error(error.message || 'Resend send failed');
1553
+ return data;
1554
+ }
1555
+ return client.transporter.sendMail({
1420
1556
  from: from || process.env.SMTP_FROM || process.env.SMTP_USER,
1421
- to,
1422
- cc,
1423
- bcc,
1424
- replyTo,
1425
- subject,
1426
- text,
1427
- html,
1428
- attachments,
1557
+ to, cc, bcc, replyTo, subject, text, html, attachments,
1429
1558
  });
1430
1559
  }
1431
1560
 
1432
- // Verify the SMTP connection/credentials.
1433
- export function verifyMailer() {
1434
- return transporter.verify();
1561
+ // Verify the mailer is usable (SMTP handshake, or that the Resend key is set).
1562
+ export async function verifyMailer() {
1563
+ const client = await getClient();
1564
+ if (client.type === 'resend') {
1565
+ if (!process.env.RESEND_API_KEY) throw new Error('RESEND_API_KEY is not set.');
1566
+ return true;
1567
+ }
1568
+ return client.transporter.verify();
1435
1569
  }
1436
1570
 
1437
- export default { sendMail, emailTemplate, verifyMailer, transporter };
1571
+ export default { sendMail, emailTemplate, verifyMailer };
1438
1572
  `;
1439
1573
  }
1440
1574