Files
Infosec/routes/mail.js
T
2026-09-13 20:10:17 +01:00

213 lines
8.4 KiB
JavaScript

'use strict';
const express = require('express');
const { db } = require('../db');
const { log } = require('../lib/audit');
const { toDisplayDateTime } = require('../lib/dates');
const { requireAuth, requireAdmin } = require('../middleware/auth');
const {
getMailConfig, saveMailConfig, verifyConfig, verifyImap, sendMail, adminEmails, recordMail,
} = require('../lib/mailer');
const router = express.Router();
router.use(requireAuth, requireAdmin);
const PAGE = 'Data Management';
const MASK = '********';
/** Never send the stored password back to the browser. */
function maskConfig(cfg) {
return {
...cfg,
pass: cfg.pass ? MASK : '',
imap_pass: cfg.imap_pass ? MASK : '',
};
}
// ---- Configuration ----------------------------------------------------
router.get('/config', (req, res) => {
res.json({ config: maskConfig(getMailConfig()) });
});
function readConfig(body, current) {
return {
host: String((body && body.host) || '').trim(),
port: Number(body && body.port) || 587,
secure: !!(body && body.secure),
user: String((body && body.user) || '').trim(),
// An unchanged password field comes back masked — keep what we already have.
pass: (body && body.pass) === MASK ? current.pass : String((body && body.pass) || ''),
from: String((body && body.from) || '').trim(),
base_url: String((body && body.base_url) || '').trim().replace(/\/+$/, ''),
reject_unauthorized: body && body.reject_unauthorized !== undefined
? !!body.reject_unauthorized : true,
// IMAP — used to file a copy of each sent message in the Sent folder.
imap_host: String((body && body.imap_host) || '').trim(),
imap_port: Number(body && body.imap_port) || 993,
imap_secure: body && body.imap_secure !== undefined ? !!body.imap_secure : true,
imap_user: String((body && body.imap_user) || '').trim(),
imap_pass: (body && body.imap_pass) === MASK ? current.imap_pass : String((body && body.imap_pass) || ''),
imap_sent_folder: String((body && body.imap_sent_folder) || '').trim(),
};
}
router.put('/config', (req, res) => {
const current = getMailConfig();
const cfg = readConfig(req.body, current);
if (cfg.host && !cfg.from) {
return res.status(400).json({ error: 'A "from" address is required when a mail server is set.' });
}
if (cfg.port < 1 || cfg.port > 65535) {
return res.status(400).json({ error: 'Port must be between 1 and 65535.' });
}
saveMailConfig(cfg);
log(req, 'MAIL_CONFIG_UPDATED', PAGE,
cfg.host ? `Mail server set to ${cfg.host}:${cfg.port}` : 'Mail server configuration cleared');
res.json({ ok: true, config: maskConfig(getMailConfig()) });
});
// Verify the connection without saving.
router.post('/test', async (req, res) => {
const cfg = readConfig(req.body, getMailConfig());
try {
await verifyConfig(cfg);
log(req, 'MAIL_CONFIG_TESTED', PAGE, `Connection to ${cfg.host}:${cfg.port} succeeded`);
res.json({ ok: true, message: 'Connection successful.' });
} catch (e) {
log(req, 'MAIL_CONFIG_TESTED', PAGE, `Connection to ${cfg.host}:${cfg.port} failed: ${e.message}`);
res.status(400).json({ error: e.message });
}
});
// Verify the IMAP connection used for saving to the Sent folder.
router.post('/test-imap', async (req, res) => {
const cfg = readConfig(req.body, getMailConfig());
try {
const r = await verifyImap(cfg);
log(req, 'MAIL_IMAP_TESTED', PAGE, `IMAP connection to ${cfg.imap_host}:${cfg.imap_port} succeeded`);
res.json({ ok: true, message: `Connected. Sent folder: ${r.sent_folder}` });
} catch (e) {
log(req, 'MAIL_IMAP_TESTED', PAGE, `IMAP connection to ${cfg.imap_host}:${cfg.imap_port} failed: ${e.message}`);
res.status(400).json({ error: e.message });
}
});
// Send a real test message using the SAVED configuration.
router.post('/test-send', async (req, res) => {
const to = String((req.body && req.body.to) || '').trim() || req.session.user.email;
try {
const r = await sendMail({
to,
subject: 'Infosec test message',
html: '<p>This is a test message from Infosec. If you received it, your mail server settings are working.</p>',
context: 'Test message',
actor: req.session.user.username,
});
log(req, 'MAIL_TEST_SENT', PAGE, `Test message to ${to} ${r.delivered ? 'delivered' : 'queued (no mail server)'}`);
res.json({ ok: true, delivered: r.delivered });
} catch (e) {
res.status(400).json({ error: e.message });
}
});
// ---- Mail log ---------------------------------------------------------
function queryLog(q) {
const where = [];
const args = [];
if (q.q) {
where.push('(to_addr LIKE ? OR from_addr LIKE ? OR subject LIKE ? OR body_preview LIKE ?)');
const like = `%${q.q}%`;
args.push(like, like, like, like);
}
if (q.direction) { where.push('direction = ?'); args.push(q.direction); }
if (q.status) { where.push('status = ?'); args.push(q.status); }
if (q.from) { where.push('ts >= ?'); args.push(q.from); }
if (q.to) { where.push('ts <= ?'); args.push(q.to + ' 23:59:59'); }
const sql = where.length ? ' WHERE ' + where.join(' AND ') : '';
const rows = db.prepare(`SELECT * FROM mail_log${sql} ORDER BY ts DESC, id DESC LIMIT 1000`).all(...args);
const total = db.prepare(`SELECT COUNT(*) c FROM mail_log${sql}`).get(...args).c;
return { rows, total };
}
router.get('/log', (req, res) => {
const { rows, total } = queryLog(req.query);
res.json({
entries: rows,
total,
statuses: db.prepare('SELECT DISTINCT status FROM mail_log ORDER BY status').all().map((r) => r.status),
directions: db.prepare('SELECT DISTINCT direction FROM mail_log ORDER BY direction').all().map((r) => r.direction),
});
});
function toCsv(rows) {
const head = ['Time', 'Direction', 'Status', 'From', 'To', 'Subject', 'Attachments', 'Context', 'Actor', 'Error'];
const esc = (v) => {
const s = v === null || v === undefined ? '' : String(v);
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
const lines = [head.join(',')];
for (const r of rows) {
lines.push([toDisplayDateTime(r.ts), r.direction, r.status, r.from_addr, r.to_addr, r.subject,
r.attachments, r.context, r.actor, r.error].map(esc).join(','));
}
return lines.join('\n');
}
router.get('/log/export', (req, res) => {
const { rows } = queryLog(req.query);
const csv = toCsv(rows);
log(req, 'MAIL_LOG_EXPORT', PAGE, `Exported ${rows.length} mail log entries as CSV`);
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="infosec-mail-log-${new Date().toISOString().slice(0, 10)}.csv"`);
res.send(csv);
});
router.post('/log/email', async (req, res) => {
const { rows } = queryLog(req.body || {});
const to = String((req.body && req.body.to) || '').trim() || adminEmails().join(',');
if (!to) return res.status(400).json({ error: 'No recipient available.' });
const csv = toCsv(rows);
const html =
`<p>Attached is the Infosec mail log (${rows.length} entries).</p>` +
`<p style="color:#8a8a8a;font-size:12px">© 2026 Martinhal IT - Joao Vaz - Version 2.3</p>`;
try {
const r = await sendMail({
to,
subject: `Infosec mail log (${rows.length} entries)`,
html,
attachments: [{ filename: 'infosec-mail-log.csv', content: csv }],
context: 'Mail log export',
actor: req.session.user.username,
});
log(req, 'MAIL_LOG_EMAILED', PAGE,
`Mail log (${rows.length} entries) to ${to} ${r.delivered ? 'delivered' : 'queued (no mail server)'}`);
res.json({ ok: true, delivered: r.delivered, count: rows.length });
} catch (e) {
res.status(400).json({ error: e.message });
}
});
// Record an inbound message. The system has no mailbox of its own, so this
// exists for forwarding/integration: anything posted here appears in the log
// alongside outgoing mail.
router.post('/log/inbound', (req, res) => {
const b = req.body || {};
if (!b.from_addr && !b.subject) {
return res.status(400).json({ error: 'At least a sender or a subject is required.' });
}
recordMail({
direction: 'received',
status: 'delivered',
from_addr: b.from_addr || null,
to_addr: b.to_addr || null,
subject: b.subject || null,
body_preview: String(b.body || '').slice(0, 400) || null,
context: b.context || 'Inbound message',
actor: req.session.user.username,
});
log(req, 'MAIL_INBOUND_RECORDED', PAGE, `Recorded inbound message from ${b.from_addr || 'unknown'}`);
res.json({ ok: true });
});
module.exports = router;