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:
Stefan Hacker
2026-04-16 12:00:06 +02:00
parent 4567e93aa2
commit 3f86fca578
6 changed files with 389 additions and 44 deletions
+11
View File
@@ -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;
+45
View File
@@ -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
View File
@@ -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 } });
});
+100
View File
@@ -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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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 };