gmass / server.js
amanydv2112's picture
Add rich text editor with toolbar and link insertion
d00e151
Raw
History Blame Contribute Delete
5.61 kB
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}`);
});