Add email notifications, SMTP settings and customer archive lifecycle
- Optional email field on users and customers - SMTP config in admin settings with test-mail button and an opt-in "notify admins on upload" toggle - Debounced upload notifier sends one summary email per customer session to the customer, assigned staff and (optionally) admins - Two-step customer lifecycle: "Deaktivieren" archives the link and keeps data, "Dateien löschen" purges files and the DB entry after a name-typed confirmation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -62,4 +62,15 @@ db.exec(`
|
||||
);
|
||||
`);
|
||||
|
||||
// Additive migrations for existing DBs
|
||||
function addColumnIfMissing(table, column, ddl) {
|
||||
const cols = db.prepare(`PRAGMA table_info(${table})`).all();
|
||||
if (!cols.some(c => c.name === column)) {
|
||||
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${ddl}`);
|
||||
}
|
||||
}
|
||||
addColumnIfMissing('users', 'email', 'TEXT');
|
||||
addColumnIfMissing('customers', 'email', 'TEXT');
|
||||
addColumnIfMissing('customers', 'archived_at', 'INTEGER');
|
||||
|
||||
module.exports = db;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
const nodemailer = require('nodemailer');
|
||||
const settings = require('./settings');
|
||||
|
||||
function getConfig() {
|
||||
return {
|
||||
host: settings.get('smtp_host', ''),
|
||||
port: parseInt(settings.get('smtp_port', '587'), 10),
|
||||
secure: settings.get('smtp_secure', 'false') === 'true',
|
||||
user: settings.get('smtp_user', ''),
|
||||
pass: settings.get('smtp_pass', ''),
|
||||
from: settings.get('smtp_from', ''),
|
||||
notify_admin: settings.get('smtp_notify_admin', 'false') === 'true',
|
||||
};
|
||||
}
|
||||
|
||||
function isConfigured() {
|
||||
const c = getConfig();
|
||||
return !!(c.host && c.from);
|
||||
}
|
||||
|
||||
function buildTransport() {
|
||||
const c = getConfig();
|
||||
const opts = {
|
||||
host: c.host,
|
||||
port: c.port,
|
||||
secure: c.secure,
|
||||
};
|
||||
if (c.user) opts.auth = { user: c.user, pass: c.pass };
|
||||
return nodemailer.createTransport(opts);
|
||||
}
|
||||
|
||||
async function send({ to, subject, text, html }) {
|
||||
if (!isConfigured()) throw new Error('SMTP nicht konfiguriert');
|
||||
const c = getConfig();
|
||||
const t = buildTransport();
|
||||
return t.sendMail({ from: c.from, to, subject, text, html });
|
||||
}
|
||||
|
||||
async function verify() {
|
||||
if (!isConfigured()) throw new Error('SMTP nicht konfiguriert');
|
||||
const t = buildTransport();
|
||||
return t.verify();
|
||||
}
|
||||
|
||||
module.exports = { send, verify, isConfigured, getConfig };
|
||||
+102
-27
@@ -9,6 +9,13 @@ const auth = require('./auth');
|
||||
const webdavConfig = require('./webdav-config');
|
||||
const settings = require('./settings');
|
||||
const janitor = require('./janitor');
|
||||
const mailer = require('./mailer');
|
||||
const uploadNotifier = require('./upload-notifier');
|
||||
|
||||
function validEmail(v) {
|
||||
if (!v) return true; // optional
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(v).trim());
|
||||
}
|
||||
|
||||
const PORT = parseInt(process.env.PORT || '3000', 10);
|
||||
const UPLOAD_ROOT = process.env.UPLOAD_ROOT || '/data/uploads';
|
||||
@@ -72,6 +79,8 @@ function isExpired(customer) {
|
||||
return customer.expires_at && customer.expires_at < Date.now();
|
||||
}
|
||||
|
||||
function isArchived(customer) { return !!customer.archived_at; }
|
||||
|
||||
function canAccessCustomer(user, customer, needWrite = false) {
|
||||
if (user.role === 'admin') return true;
|
||||
const row = db.prepare(
|
||||
@@ -144,14 +153,16 @@ const api = express.Router();
|
||||
|
||||
// --- Users (admin only) ---
|
||||
api.get('/users', auth.requireAdmin, (req, res) => {
|
||||
const rows = db.prepare('SELECT id, username, role, created_at FROM users ORDER BY username').all();
|
||||
const rows = db.prepare('SELECT id, username, email, role, created_at FROM users ORDER BY username').all();
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
api.post('/users', auth.requireAdmin, async (req, res) => {
|
||||
const { username, password, role } = req.body || {};
|
||||
const { username, password, role, email } = req.body || {};
|
||||
if (!validEmail(email)) return res.status(400).json({ error: 'invalid email' });
|
||||
try {
|
||||
const id = await auth.createUser(username, password, role || 'staff');
|
||||
if (email) db.prepare('UPDATE users SET email = ? WHERE id = ?').run(String(email).trim(), id);
|
||||
webdavConfig.sync();
|
||||
res.json({ id });
|
||||
} catch (e) {
|
||||
@@ -164,8 +175,9 @@ api.patch('/users/:id', auth.requireAdmin, async (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
const u = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
|
||||
if (!u) return res.status(404).json({ error: 'not found' });
|
||||
const { password, role } = req.body || {};
|
||||
const { password, role, email } = req.body || {};
|
||||
if (role && role !== 'admin' && role !== 'staff') return res.status(400).json({ error: 'invalid role' });
|
||||
if (email !== undefined && !validEmail(email)) return res.status(400).json({ error: 'invalid email' });
|
||||
// Don't let admin demote themselves to the last non-admin
|
||||
if (role && role !== 'admin' && u.id === req.user.id) {
|
||||
const otherAdmins = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'admin' AND id != ?").get(id).n;
|
||||
@@ -174,6 +186,7 @@ api.patch('/users/:id', auth.requireAdmin, async (req, res) => {
|
||||
try {
|
||||
if (password) await auth.setUserPassword(id, password);
|
||||
if (role) db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, id);
|
||||
if (email !== undefined) db.prepare('UPDATE users SET email = ? WHERE id = ?').run(email ? String(email).trim() : null, id);
|
||||
webdavConfig.sync();
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
@@ -202,31 +215,62 @@ api.get('/settings', auth.requireAdmin, (req, res) => {
|
||||
logo_filename: settings.get('logo_filename', ''),
|
||||
logo_width_px: parseInt(settings.get('logo_width_px', '0'), 10),
|
||||
logo_height_px: parseInt(settings.get('logo_height_px', '0'), 10),
|
||||
smtp_host: settings.get('smtp_host', ''),
|
||||
smtp_port: parseInt(settings.get('smtp_port', '587'), 10),
|
||||
smtp_secure: settings.get('smtp_secure', 'false') === 'true',
|
||||
smtp_user: settings.get('smtp_user', ''),
|
||||
smtp_pass_set: !!settings.get('smtp_pass', ''),
|
||||
smtp_from: settings.get('smtp_from', ''),
|
||||
smtp_notify_admin: settings.get('smtp_notify_admin', 'false') === 'true',
|
||||
});
|
||||
});
|
||||
|
||||
api.put('/settings', auth.requireAdmin, (req, res) => {
|
||||
const { public_base_url, janitor_interval_minutes, logo_width_px, logo_height_px } = req.body || {};
|
||||
if (public_base_url !== undefined) {
|
||||
const v = String(public_base_url || '').trim().replace(/\/+$/, '');
|
||||
settings.set('public_base_url', v);
|
||||
const b = req.body || {};
|
||||
if (b.public_base_url !== undefined) {
|
||||
settings.set('public_base_url', String(b.public_base_url || '').trim().replace(/\/+$/, ''));
|
||||
}
|
||||
if (janitor_interval_minutes !== undefined) {
|
||||
const n = Math.max(1, parseInt(janitor_interval_minutes, 10) || 30);
|
||||
if (b.janitor_interval_minutes !== undefined) {
|
||||
const n = Math.max(1, parseInt(b.janitor_interval_minutes, 10) || 30);
|
||||
settings.set('janitor_interval_minutes', String(n));
|
||||
janitor.restart(n * 60 * 1000);
|
||||
}
|
||||
if (logo_width_px !== undefined) {
|
||||
const n = Math.max(0, Math.min(800, parseInt(logo_width_px, 10) || 0));
|
||||
settings.set('logo_width_px', String(n));
|
||||
if (b.logo_width_px !== undefined) {
|
||||
settings.set('logo_width_px', String(Math.max(0, Math.min(800, parseInt(b.logo_width_px, 10) || 0))));
|
||||
}
|
||||
if (logo_height_px !== undefined) {
|
||||
const n = Math.max(0, Math.min(600, parseInt(logo_height_px, 10) || 0));
|
||||
settings.set('logo_height_px', String(n));
|
||||
if (b.logo_height_px !== undefined) {
|
||||
settings.set('logo_height_px', String(Math.max(0, Math.min(600, parseInt(b.logo_height_px, 10) || 0))));
|
||||
}
|
||||
if (b.smtp_host !== undefined) settings.set('smtp_host', String(b.smtp_host || '').trim());
|
||||
if (b.smtp_port !== undefined) settings.set('smtp_port', String(parseInt(b.smtp_port, 10) || 587));
|
||||
if (b.smtp_secure !== undefined) settings.set('smtp_secure', b.smtp_secure ? 'true' : 'false');
|
||||
if (b.smtp_user !== undefined) settings.set('smtp_user', String(b.smtp_user || '').trim());
|
||||
if (b.smtp_pass !== undefined && b.smtp_pass !== '')
|
||||
settings.set('smtp_pass', String(b.smtp_pass));
|
||||
if (b.smtp_pass_clear) settings.set('smtp_pass', '');
|
||||
if (b.smtp_from !== undefined) settings.set('smtp_from', String(b.smtp_from || '').trim());
|
||||
if (b.smtp_notify_admin !== undefined)
|
||||
settings.set('smtp_notify_admin', b.smtp_notify_admin ? 'true' : 'false');
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
api.post('/settings/test-mail', auth.requireAdmin, async (req, res) => {
|
||||
const to = (req.body && req.body.to) || req.user.email || '';
|
||||
if (!to || !validEmail(to)) return res.status(400).json({ error: 'gültige Empfänger-Adresse erforderlich' });
|
||||
try {
|
||||
await mailer.verify();
|
||||
await mailer.send({
|
||||
to,
|
||||
subject: 'Test-Mail vom Upload-Portal',
|
||||
text: 'Wenn du diese Mail liest, funktioniert die SMTP-Konfiguration.',
|
||||
html: '<p>Wenn du diese Mail liest, funktioniert die SMTP-Konfiguration. ✅</p>',
|
||||
});
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Logo (admin manages, public serves) ---
|
||||
const logoUpload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
@@ -295,20 +339,24 @@ api.get('/customers', auth.requireAuth, (req, res) => {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
slug: r.slug,
|
||||
email: r.email,
|
||||
token: isAdmin ? r.token : undefined,
|
||||
has_password: !!r.password_hash,
|
||||
expires_at: r.expires_at,
|
||||
archived_at: r.archived_at,
|
||||
archived: !!r.archived_at,
|
||||
created_at: r.created_at,
|
||||
upload_count: r.upload_count,
|
||||
total_size: r.total_size,
|
||||
my_access: isAdmin ? 'admin' : r.my_access,
|
||||
upload_url: isAdmin ? `${baseUrl}/u/${r.token}` : undefined,
|
||||
upload_url: isAdmin && !r.archived_at ? `${baseUrl}/u/${r.token}` : undefined,
|
||||
})));
|
||||
});
|
||||
|
||||
api.post('/customers', auth.requireAdmin, async (req, res) => {
|
||||
const { name, password, expires_at } = req.body || {};
|
||||
const { name, password, expires_at, email } = req.body || {};
|
||||
if (!name || !String(name).trim()) return res.status(400).json({ error: 'name required' });
|
||||
if (!validEmail(email)) return res.status(400).json({ error: 'invalid email' });
|
||||
const base = slugify(name);
|
||||
const slug = ensureUniqueSlug(base);
|
||||
const token = nanoid(24);
|
||||
@@ -316,9 +364,9 @@ api.post('/customers', auth.requireAdmin, async (req, res) => {
|
||||
const exp = expires_at ? parseInt(expires_at, 10) : null;
|
||||
const created_at = Date.now();
|
||||
const info = db.prepare(`
|
||||
INSERT INTO customers (name, slug, token, password_hash, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(String(name).trim(), slug, token, password_hash, exp, created_at);
|
||||
INSERT INTO customers (name, slug, token, password_hash, expires_at, created_at, email)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(String(name).trim(), slug, token, password_hash, exp, created_at, email ? String(email).trim() : null);
|
||||
customerDir(slug);
|
||||
webdavConfig.sync();
|
||||
res.json({
|
||||
@@ -333,14 +381,17 @@ api.patch('/customers/:id', auth.requireAdmin, async (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
const c = db.prepare('SELECT * FROM customers WHERE id = ?').get(id);
|
||||
if (!c) return res.status(404).json({ error: 'not found' });
|
||||
const { password, expires_at, clear_password } = req.body || {};
|
||||
const { password, expires_at, clear_password, email } = req.body || {};
|
||||
if (email !== undefined && !validEmail(email)) return res.status(400).json({ error: 'invalid email' });
|
||||
let pwHash = c.password_hash;
|
||||
if (clear_password) pwHash = null;
|
||||
else if (password) pwHash = await bcrypt.hash(password, 10);
|
||||
let exp = c.expires_at;
|
||||
if (expires_at === null) exp = null;
|
||||
else if (expires_at) exp = parseInt(expires_at, 10);
|
||||
db.prepare('UPDATE customers SET password_hash = ?, expires_at = ? WHERE id = ?').run(pwHash, exp, id);
|
||||
let mail = c.email;
|
||||
if (email !== undefined) mail = email ? String(email).trim() : null;
|
||||
db.prepare('UPDATE customers SET password_hash = ?, expires_at = ?, email = ? WHERE id = ?').run(pwHash, exp, mail, id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -352,12 +403,35 @@ api.post('/customers/:id/regenerate-token', auth.requireAdmin, (req, res) => {
|
||||
res.json({ token, upload_url: `${settings.getPublicBaseUrl(req)}/u/${token}` });
|
||||
});
|
||||
|
||||
// Archive = disable link, keep entry + files. Reversible.
|
||||
api.post('/customers/:id/archive', auth.requireAdmin, (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
const c = db.prepare('SELECT * FROM customers WHERE id = ?').get(id);
|
||||
if (!c) return res.status(404).json({ error: 'not found' });
|
||||
db.prepare('UPDATE customers SET archived_at = ? WHERE id = ?').run(Date.now(), id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
api.post('/customers/:id/unarchive', auth.requireAdmin, (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
const c = db.prepare('SELECT * FROM customers WHERE id = ?').get(id);
|
||||
if (!c) return res.status(404).json({ error: 'not found' });
|
||||
db.prepare('UPDATE customers SET archived_at = NULL WHERE id = ?').run(id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Purge = remove files + DB row. Irreversible.
|
||||
api.delete('/customers/:id', auth.requireAdmin, (req, res) => {
|
||||
const c = db.prepare('SELECT * FROM customers WHERE id = ?').get(req.params.id);
|
||||
if (!c) return res.status(404).json({ error: 'not found' });
|
||||
try {
|
||||
const dir = path.join(UPLOAD_ROOT, c.slug);
|
||||
if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch (e) {
|
||||
console.error('[purge] failed to remove files for', c.slug, e.message);
|
||||
}
|
||||
db.prepare('DELETE FROM customers WHERE id = ?').run(c.id);
|
||||
webdavConfig.sync();
|
||||
// Files are kept on disk; admin can remove via WebDAV.
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -400,14 +474,14 @@ app.use('/admin/api', api);
|
||||
// ---------- Customer Upload Portal ----------
|
||||
app.get('/u/:token', (req, res) => {
|
||||
const c = getCustomerByToken(req.params.token);
|
||||
if (!c) return res.status(404).send('Link nicht gefunden.');
|
||||
if (!c || isArchived(c)) return res.status(404).send('Link nicht gefunden.');
|
||||
if (isExpired(c)) return res.status(410).send('Link ist abgelaufen.');
|
||||
res.sendFile(path.join(__dirname, '..', 'public', 'upload.html'));
|
||||
});
|
||||
|
||||
app.post('/u/:token/auth', async (req, res) => {
|
||||
const c = getCustomerByToken(req.params.token);
|
||||
if (!c || isExpired(c)) return res.status(404).json({ error: 'invalid' });
|
||||
if (!c || isExpired(c) || isArchived(c)) return res.status(404).json({ error: 'invalid' });
|
||||
if (!c.password_hash) return res.json({ ok: true });
|
||||
const ok = await bcrypt.compare(req.body.password || '', c.password_hash);
|
||||
res.json({ ok });
|
||||
@@ -415,7 +489,7 @@ app.post('/u/:token/auth', async (req, res) => {
|
||||
|
||||
app.get('/u/:token/info', (req, res) => {
|
||||
const c = getCustomerByToken(req.params.token);
|
||||
if (!c || isExpired(c)) return res.status(404).json({ error: 'invalid' });
|
||||
if (!c || isExpired(c) || isArchived(c)) return res.status(404).json({ error: 'invalid' });
|
||||
res.json({
|
||||
name: c.name,
|
||||
has_password: !!c.password_hash,
|
||||
@@ -448,7 +522,7 @@ const upload = multer({
|
||||
|
||||
function uploadAuth(req, res, next) {
|
||||
const c = getCustomerByToken(req.params.token);
|
||||
if (!c || isExpired(c)) return res.status(404).json({ error: 'invalid' });
|
||||
if (!c || isExpired(c) || isArchived(c)) return res.status(404).json({ error: 'invalid' });
|
||||
if (c.password_hash) {
|
||||
const provided = req.headers['x-upload-password'] || '';
|
||||
bcrypt.compare(provided, c.password_hash).then(ok => {
|
||||
@@ -472,6 +546,7 @@ app.post('/u/:token/upload', uploadAuth, upload.single('file'), (req, res) => {
|
||||
INSERT INTO uploads (customer_id, filename, relative_path, size, uploaded_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(c.id, f.filename, rel, f.size, Date.now());
|
||||
uploadNotifier.queue(c.id, { name: f.filename, path: rel, size: f.size });
|
||||
res.json({ ok: true, file: { name: f.filename, path: rel, size: f.size } });
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
const db = require('./db');
|
||||
const mailer = require('./mailer');
|
||||
const settings = require('./settings');
|
||||
|
||||
const DEBOUNCE_MS = 30_000; // collect uploads then send one summary email
|
||||
const pending = new Map(); // customer_id → { timer, items: [{name, size}] }
|
||||
|
||||
function fmtSize(n) {
|
||||
if (!n) return '0 B';
|
||||
if (n < 1024) return n + ' B';
|
||||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
|
||||
if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MB';
|
||||
return (n / 1024 / 1024 / 1024).toFixed(2) + ' GB';
|
||||
}
|
||||
|
||||
function recipientsFor(customer) {
|
||||
const out = [];
|
||||
if (customer.email) out.push({ kind: 'customer', to: customer.email });
|
||||
|
||||
// Staff assigned to this customer (with email)
|
||||
const staff = db.prepare(`
|
||||
SELECT u.email FROM customer_access ca
|
||||
JOIN users u ON u.id = ca.user_id
|
||||
WHERE ca.customer_id = ? AND u.email IS NOT NULL AND u.email != ''
|
||||
`).all(customer.id);
|
||||
for (const s of staff) out.push({ kind: 'staff', to: s.email });
|
||||
|
||||
// All admins with email, if global setting enabled
|
||||
if (settings.get('smtp_notify_admin', 'false') === 'true') {
|
||||
const admins = db.prepare(`
|
||||
SELECT email FROM users WHERE role = 'admin' AND email IS NOT NULL AND email != ''
|
||||
`).all();
|
||||
for (const a of admins) out.push({ kind: 'admin', to: a.email });
|
||||
}
|
||||
|
||||
// Dedupe by email
|
||||
const seen = new Set();
|
||||
return out.filter(r => (seen.has(r.to) ? false : (seen.add(r.to), true)));
|
||||
}
|
||||
|
||||
function buildBodies(customer, items, kind) {
|
||||
const total = items.reduce((a, b) => a + b.size, 0);
|
||||
const list = items.map(i => `- ${i.name} (${fmtSize(i.size)})`).join('\n');
|
||||
const listHtml = items.map(i =>
|
||||
`<tr><td style="padding:2px 10px 2px 0">${escapeHtml(i.name)}</td>` +
|
||||
`<td style="padding:2px 0;color:#666;text-align:right">${fmtSize(i.size)}</td></tr>`
|
||||
).join('');
|
||||
const subject = kind === 'customer'
|
||||
? `Upload-Bestätigung – ${items.length} Datei(en)`
|
||||
: `Neuer Upload von ${customer.name} – ${items.length} Datei(en)`;
|
||||
const intro = kind === 'customer'
|
||||
? `Vielen Dank! Ihr Upload war erfolgreich. Es wurden ${items.length} Datei(en) (${fmtSize(total)}) übertragen.`
|
||||
: `Für Kunde "${customer.name}" wurden soeben ${items.length} Datei(en) (${fmtSize(total)}) hochgeladen.`;
|
||||
const text = `${intro}\n\n${list}\n`;
|
||||
const html = `
|
||||
<div style="font-family:system-ui,sans-serif;max-width:560px;color:#111">
|
||||
<h2 style="margin:0 0 .5rem">${kind === 'customer' ? 'Upload-Bestätigung' : 'Neuer Upload'}</h2>
|
||||
<p>${escapeHtml(intro)}</p>
|
||||
<table style="border-collapse:collapse;font-size:.9rem">${listHtml}</table>
|
||||
</div>`;
|
||||
return { subject, text, html };
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s || '').replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||
}
|
||||
|
||||
async function flush(customerId) {
|
||||
const entry = pending.get(customerId);
|
||||
if (!entry) return;
|
||||
pending.delete(customerId);
|
||||
if (!entry.items.length) return;
|
||||
if (!mailer.isConfigured()) return;
|
||||
|
||||
const customer = db.prepare('SELECT * FROM customers WHERE id = ?').get(customerId);
|
||||
if (!customer) return;
|
||||
|
||||
const recipients = recipientsFor(customer);
|
||||
for (const r of recipients) {
|
||||
try {
|
||||
const { subject, text, html } = buildBodies(customer, entry.items, r.kind);
|
||||
await mailer.send({ to: r.to, subject, text, html });
|
||||
} catch (e) {
|
||||
console.error(`[notifier] send to ${r.to} failed:`, e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function queue(customerId, file) {
|
||||
let entry = pending.get(customerId);
|
||||
if (!entry) {
|
||||
entry = { timer: null, items: [] };
|
||||
pending.set(customerId, entry);
|
||||
}
|
||||
entry.items.push({ name: file.path || file.name, size: file.size });
|
||||
if (entry.timer) clearTimeout(entry.timer);
|
||||
entry.timer = setTimeout(() => flush(customerId), DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
module.exports = { queue, flush };
|
||||
Reference in New Issue
Block a user