Full project: Svelte 5 frontend, Vite 7 backend API, Pug email templates (email-gen), Docker deployment. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
198 lines
6.5 KiB
JavaScript
198 lines
6.5 KiB
JavaScript
const http = require('http')
|
|
const fs = require('fs')
|
|
const path = require('path')
|
|
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, reject) => {
|
|
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 reject(new Error('payload_too_large'))
|
|
try {
|
|
resolve(data ? JSON.parse(data) : {})
|
|
} catch {
|
|
reject(new Error('invalid_json'))
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
function sanitizeProjectSlug(value) {
|
|
return String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, '')
|
|
}
|
|
|
|
function rewriteHtmlPug(projectDir, preheader = '', gender = 'female', genderPaths = {}) {
|
|
const htmlPugPath = path.resolve(projectDir, 'html.pug')
|
|
const safePreheader = preheader.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
|
function sanitizePartPath(p, fallback) {
|
|
if (!p || typeof p !== 'string') return fallback
|
|
const clean = p.replace(/\\/g, '/').replace(/\/+/g, '/')
|
|
if (/\.\./.test(clean) || /[\0\r\n]/.test(clean)) return fallback
|
|
if (!clean.startsWith('./parts/') && !clean.startsWith('parts/')) return fallback
|
|
return clean
|
|
}
|
|
const headerPath = gender === 'male'
|
|
? sanitizePartPath(genderPaths.headerMale, './parts/header/header-man')
|
|
: sanitizePartPath(genderPaths.headerFemale, './parts/header/header-woman')
|
|
const footerPath = gender === 'male'
|
|
? sanitizePartPath(genderPaths.footerMale, './parts/footer/footer-man')
|
|
: sanitizePartPath(genderPaths.footerFemale, './parts/footer/footer-woman')
|
|
const body = [
|
|
'extends layout/layout.pug',
|
|
'',
|
|
'block header',
|
|
` include ${headerPath}`,
|
|
'block preheader',
|
|
` +preheader("${safePreheader}")`,
|
|
'block content',
|
|
' include ./letters/let.pug',
|
|
'block footer',
|
|
` include ${footerPath}`,
|
|
'',
|
|
].join('\n')
|
|
fs.writeFileSync(htmlPugPath, body, 'utf-8')
|
|
}
|
|
|
|
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 script = `
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const Email = require('email-templates');
|
|
|
|
async function run() {
|
|
// For node -e, first extra arg starts at argv[1]
|
|
const project = process.argv[1];
|
|
const renderTemplate = process.argv[2];
|
|
const root = process.argv[3];
|
|
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, 'index.html');
|
|
const normalized = String(html).replace('#MAILRU_PREHEADER_TAG#', '<vk-snippet-end/>');
|
|
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], {
|
|
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())
|
|
}
|
|
}
|
|
|
|
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') {
|
|
let body
|
|
try { body = await readBody(req) } catch (e) { return send(res, 400, { error: e.message }) }
|
|
const projectSlug = sanitizeProjectSlug(body.projectSlug)
|
|
const pug = String(body.pug || '')
|
|
const preheader = String(body.preheader || '')
|
|
const gender = String(body.gender || 'female')
|
|
const genderPaths = body.genderPaths || {}
|
|
|
|
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` })
|
|
}
|
|
|
|
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')
|
|
rewriteHtmlPug(projectDir, preheader, gender, genderPaths)
|
|
renderWithNode(projectSlug, 'html')
|
|
|
|
const htmlPath = path.resolve(EMAIL_GEN_ROOT, 'public', 'index.html')
|
|
if (!fs.existsSync(htmlPath)) {
|
|
return send(res, 500, { error: 'preview_not_found', details: 'public/index.html not found' })
|
|
}
|
|
|
|
const html = fs.readFileSync(htmlPath, 'utf-8')
|
|
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}`)
|
|
})
|