const http = require('http') const fs = require('fs') const path = require('path') const crypto = require('crypto') const { spawnSync } = require('child_process') const PORT = Number(process.env.PORT || 8787) const EMAIL_GEN_ROOT = path.resolve(process.env.EMAIL_GEN_ROOT || '/workspace/email-gen') function send(res, status, payload) { res.statusCode = status res.setHeader('Content-Type', 'application/json') res.end(JSON.stringify(payload)) } const MAX_BODY_SIZE = 5 * 1024 * 1024 function readBody(req) { return new Promise((resolve) => { let size = 0 let data = '' req.on('data', (chunk) => { size += chunk.length if (size <= MAX_BODY_SIZE) data += chunk }) req.on('end', () => { if (size > MAX_BODY_SIZE) return resolve({}) try { resolve(data ? JSON.parse(data) : {}) } catch { resolve({}) } }) req.on('error', () => resolve({})) }) } function sanitizeProjectSlug(value) { return String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, '') } function validatePugSafety(pug) { const dangerous = [ /\brequire\s*\(/i, /\bprocess\b/, /\bchild_process\b/, /\bexecSync\b/, /\bexec\s*\(/, /\bspawn\s*\(/, /\beval\s*\(/, /\bFunction\s*\(/, /\bnew\s+Function\b/, /\bglobal\b/, /\b__dirname\b/, /\b__filename\b/, /\bfs\s*\./, /\bBuffer\b/, ] for (const re of dangerous) { if (re.test(pug)) { return { safe: false, pattern: re.source } } } return { safe: true } } function ensureBuilderPreviewTemplate(projectDir) { const templateName = '__builder_preview__.pug' const templatePath = path.resolve(projectDir, templateName) const templateBody = [ 'extends ./html.pug', '', 'block content', ' include ./letters/let.pug', '', ].join('\n') fs.writeFileSync(templatePath, templateBody, 'utf-8') return { templateName, templatePath } } function ensureNpmDeps() { const marker = path.resolve(EMAIL_GEN_ROOT, 'node_modules') if (fs.existsSync(marker)) return const install = spawnSync('npm', ['install'], { cwd: EMAIL_GEN_ROOT, encoding: 'utf-8', shell: false, }) if (install.status !== 0) { throw new Error((install.stderr || install.stdout || 'npm install failed').trim()) } } function renderWithNode(projectSlug, renderTemplate = 'html') { const outFileName = `render_${crypto.randomBytes(16).toString('hex')}.html` const script = ` const path = require('path'); const fs = require('fs'); const Email = require('email-templates'); async function run() { const project = process.argv[1]; const renderTemplate = process.argv[2]; const root = process.argv[3]; const outFile = process.argv[4]; const email = new Email(); const html = await email.render({ path: project + '/' + renderTemplate, juiceResources: { preserveImportant: true, applyStyleTags: true, removeStyleTags: true, preserveMediaQueries: true, webResources: { relativeTo: path.resolve(root, 'emails', project) } }, }, { pretty: true }); const outDir = path.resolve(root, 'public'); if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true }); const outPath = path.resolve(outDir, outFile); const normalized = String(html).replace('#MAILRU_PREHEADER_TAG#', ''); fs.writeFileSync(outPath, normalized, 'utf-8'); } run().catch((error) => { console.error(error && error.stack ? error.stack : String(error)); process.exit(1); }); ` const run = spawnSync(process.execPath, ['-e', script, projectSlug, renderTemplate, EMAIL_GEN_ROOT, outFileName], { cwd: EMAIL_GEN_ROOT, encoding: 'utf-8', shell: false, timeout: 120000, }) if (run.error || run.status !== 0) { throw new Error((run.stderr || run.stdout || run.error?.message || 'render failed').trim()) } return outFileName } function applyNowrap(html) { const templatePlaceholders = [] let protected_ = html.replace(/@\{[^}]*\}/g, (m) => { templatePlaceholders.push(m) return `\u200BTPL${templatePlaceholders.length - 1}\u200B` }) function wrapShort(text) { return text.replace( /(?]*class="[^"]*\bh3\b[^"]*"[^>]*>)([\s\S]*?)(<\/span><\/td>)/gi, (_match, open, content, close) => { const processed = content.replace(/>([^<]+) '>' + wrapShort(t) + '<') const firstText = processed.replace(/^([^<]+)/, (m) => wrapShort(m)) return open + firstText + close } ) return result .replace(/\u200Bspan\u200Bnwr\u200B/g, '') .replace(/\u200B\/span\u200Bnwr\u200B/g, '') .replace(/\u200BTPL(\d+)\u200B/g, (_, i) => templatePlaceholders[parseInt(i)]) } const server = http.createServer(async (req, res) => { if (req.method === 'GET' && req.url === '/health') { return send(res, 200, { ok: true }) } if (req.method === 'POST' && req.url === '/render') { const body = await readBody(req) const projectSlug = sanitizeProjectSlug(body.projectSlug) const pug = String(body.pug || '') if (!projectSlug) return send(res, 400, { error: 'missing_project_slug', details: 'Project slug required' }) if (!pug.trim()) return send(res, 400, { error: 'missing_pug', details: 'PUG required' }) if (!fs.existsSync(EMAIL_GEN_ROOT)) { return send(res, 500, { error: 'email_gen_not_found', details: 'email-gen root missing' }) } const emailsRoot = path.resolve(EMAIL_GEN_ROOT, 'emails') const projectDir = path.resolve(emailsRoot, projectSlug) if (!projectDir.startsWith(emailsRoot)) { return send(res, 400, { error: 'invalid_project_slug', details: 'Invalid project slug' }) } if (!fs.existsSync(projectDir)) { return send(res, 404, { error: 'email_project_not_found', details: `Project ${projectSlug} not found` }) } const pugCheck = validatePugSafety(pug) if (!pugCheck.safe) { return send(res, 400, { error: 'unsafe_pug', details: `Dangerous pattern detected: ${pugCheck.pattern}` }) } try { ensureNpmDeps() const lettersDir = path.resolve(projectDir, 'letters') if (!fs.existsSync(lettersDir)) fs.mkdirSync(lettersDir, { recursive: true }) fs.writeFileSync(path.resolve(lettersDir, 'let.pug'), pug, 'utf-8') const { templateName, templatePath } = ensureBuilderPreviewTemplate(projectDir) let outFileName try { outFileName = renderWithNode(projectSlug, templateName.replace(/\.pug$/, '')) } finally { if (fs.existsSync(templatePath)) fs.unlinkSync(templatePath) } const htmlPath = path.resolve(EMAIL_GEN_ROOT, 'public', outFileName) if (!fs.existsSync(htmlPath)) { return send(res, 500, { error: 'preview_not_found', details: 'Rendered file not found' }) } const rawHtml = fs.readFileSync(htmlPath, 'utf-8') try { fs.unlinkSync(htmlPath) } catch (_) {} const html = applyNowrap(rawHtml) return send(res, 200, { html, generatedAt: new Date().toISOString(), }) } catch (error) { return send(res, 500, { error: 'render_failed', details: error?.message || 'Unknown render error', }) } } return send(res, 404, { error: 'not_found' }) }) server.listen(PORT, () => { console.log(`email-gen-api listening on :${PORT}`) console.log(`email-gen root: ${EMAIL_GEN_ROOT}`) })