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(//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}`); });