Spaces:
Build error
Build error
| const express = require('express'); | |
| const multer = require('multer'); | |
| const nodemailer = require('nodemailer'); | |
| const path = require('path'); | |
| const fs = require('fs'); | |
| const app = express(); | |
| const PORT = process.env.PORT || 3939; | |
| const uploadDir = path.join(__dirname, 'uploads'); | |
| const draftDir = path.join(__dirname, 'draft'); | |
| const draftJson = path.join(draftDir, 'draft.json'); | |
| const draftPdf = path.join(draftDir, 'attachment.pdf'); | |
| const draftMeta = path.join(draftDir, 'attachment.meta.json'); | |
| for (const d of [uploadDir, draftDir]) if (!fs.existsSync(d)) fs.mkdirSync(d); | |
| const upload = multer({ dest: uploadDir, limits: { fileSize: 50 * 1024 * 1024 } }); | |
| function uploadSingleSafe(field) { | |
| return (req, res, next) => upload.single(field)(req, res, err => { | |
| if (err) return res.status(400).json({ error: 'Upload failed: ' + err.message }); | |
| next(); | |
| }); | |
| } | |
| app.use(express.json({ limit: '5mb' })); | |
| app.use(express.static(path.join(__dirname, 'public'))); | |
| const EMAIL_RE = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g; | |
| function deriveName(email) { | |
| const local = email.split('@')[0]; | |
| const first = local.split(/[._\-+0-9]+/).filter(Boolean)[0] || local; | |
| return first.charAt(0).toUpperCase() + first.slice(1).toLowerCase(); | |
| } | |
| function renderTemplate(tpl, vars) { | |
| return tpl.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, k) => vars[k] ?? ''); | |
| } | |
| function readDraft() { | |
| try { return JSON.parse(fs.readFileSync(draftJson, 'utf8')); } catch { return {}; } | |
| } | |
| function readPdfMeta() { | |
| try { | |
| if (!fs.existsSync(draftPdf)) return null; | |
| const m = JSON.parse(fs.readFileSync(draftMeta, 'utf8')); | |
| const s = fs.statSync(draftPdf); | |
| return { name: m.name || 'attachment.pdf', size: s.size }; | |
| } catch { return null; } | |
| } | |
| app.post('/api/parse', (req, res) => { | |
| const text = (req.body.text || '').toString(); | |
| const found = text.match(EMAIL_RE) || []; | |
| const seen = new Set(); | |
| const emails = []; | |
| for (const e of found) { | |
| const lower = e.toLowerCase(); | |
| if (!seen.has(lower)) { | |
| seen.add(lower); | |
| emails.push({ email: lower, name: deriveName(lower) }); | |
| } | |
| } | |
| res.json({ emails }); | |
| }); | |
| app.get('/api/draft', (req, res) => { | |
| res.json({ fields: readDraft(), attachment: readPdfMeta() }); | |
| }); | |
| app.post('/api/draft', (req, res) => { | |
| fs.writeFileSync(draftJson, JSON.stringify(req.body || {}, null, 2)); | |
| res.json({ ok: true }); | |
| }); | |
| app.post('/api/draft/pdf', uploadSingleSafe('attachment'), (req, res) => { | |
| if (!req.file) return res.status(400).json({ error: 'no file' }); | |
| fs.renameSync(req.file.path, draftPdf); | |
| fs.writeFileSync(draftMeta, JSON.stringify({ name: req.file.originalname })); | |
| res.json({ attachment: readPdfMeta() }); | |
| }); | |
| app.delete('/api/draft/pdf', (req, res) => { | |
| for (const f of [draftPdf, draftMeta]) if (fs.existsSync(f)) fs.unlinkSync(f); | |
| res.json({ ok: true }); | |
| }); | |
| let activeJob = null; | |
| app.post('/api/send', uploadSingleSafe('attachment'), async (req, res) => { | |
| const { gmailUser, gmailPass, subject, body, recipients, fromName } = req.body; | |
| let parsed; | |
| try { parsed = JSON.parse(recipients); } catch { return res.status(400).json({ error: 'bad recipients' }); } | |
| if (!gmailUser || !gmailPass || !subject || !body || !Array.isArray(parsed) || parsed.length === 0) { | |
| return res.status(400).json({ error: 'missing fields' }); | |
| } | |
| const transporter = nodemailer.createTransport({ | |
| service: 'gmail', | |
| auth: { user: gmailUser, pass: gmailPass.replace(/\s+/g, '') }, | |
| connectionTimeout: 10000, | |
| greetingTimeout: 10000, | |
| socketTimeout: 10000, | |
| }); | |
| try { | |
| await transporter.verify(); | |
| } catch (e) { | |
| if (req.file) fs.unlink(req.file.path, () => {}); | |
| const msg = e.code === 'ETIMEDOUT' || e.code === 'ESOCKET' | |
| ? 'Cannot reach Gmail SMTP — this host likely blocks outbound SMTP (port 465/587). Run gmass locally instead.' | |
| : 'Gmail auth failed: ' + e.message; | |
| return res.status(401).json({ error: msg }); | |
| } | |
| let attachment = []; | |
| if (req.file) { | |
| fs.renameSync(req.file.path, draftPdf); | |
| fs.writeFileSync(draftMeta, JSON.stringify({ name: req.file.originalname })); | |
| } | |
| const meta = readPdfMeta(); | |
| if (meta) attachment = [{ filename: meta.name, path: draftPdf }]; | |
| const jobId = Date.now().toString(); | |
| activeJob = { id: jobId, total: parsed.length, sent: 0, failed: 0, log: [], done: false }; | |
| res.json({ jobId, total: parsed.length }); | |
| (async () => { | |
| for (const r of parsed) { | |
| const vars = { name: r.name, email: r.email }; | |
| const subj = renderTemplate(subject, vars); | |
| const html = renderTemplate(body, vars); | |
| const text = html.replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]+>/g, ''); | |
| try { | |
| await transporter.sendMail({ | |
| from: fromName ? `${fromName} <${gmailUser}>` : gmailUser, | |
| to: r.email, | |
| subject: subj, | |
| text, | |
| html, | |
| attachments: attachment, | |
| }); | |
| activeJob.sent++; | |
| activeJob.log.push({ email: r.email, status: 'ok' }); | |
| } catch (e) { | |
| activeJob.failed++; | |
| activeJob.log.push({ email: r.email, status: 'fail', error: e.message }); | |
| } | |
| await new Promise(r => setTimeout(r, 1200)); | |
| } | |
| activeJob.done = true; | |
| })(); | |
| }); | |
| app.get('/api/env', (req, res) => { | |
| res.json({ hosted: !!process.env.SPACE_ID }); | |
| }); | |
| app.get('/api/status/:id', (req, res) => { | |
| if (!activeJob || activeJob.id !== req.params.id) return res.status(404).json({ error: 'no job' }); | |
| res.json(activeJob); | |
| }); | |
| app.listen(PORT, () => { | |
| console.log(`gmass running → http://localhost:${PORT}`); | |
| }); | |