225 lines
8.1 KiB
JavaScript
225 lines
8.1 KiB
JavaScript
'use strict';
|
|
const nodemailer = require('nodemailer');
|
|
const { ImapFlow } = require('imapflow');
|
|
const { db } = require('../db');
|
|
const { log } = require('./audit');
|
|
const { getJSON, setJSON } = require('./settings');
|
|
|
|
const SETTINGS_KEY = 'mail_config';
|
|
|
|
/**
|
|
* Effective mail configuration.
|
|
* Settings saved in Data Management win; anything not set there falls back to
|
|
* the environment, so an existing .env-based install keeps working untouched.
|
|
*/
|
|
function getMailConfig() {
|
|
const s = getJSON(SETTINGS_KEY, {});
|
|
const pick = (a, b) => (a === undefined || a === null || a === '' ? b : a);
|
|
return {
|
|
host: pick(s.host, process.env.SMTP_HOST || ''),
|
|
port: Number(pick(s.port, process.env.SMTP_PORT || 587)),
|
|
secure: String(pick(s.secure, process.env.SMTP_SECURE || 'false')) === 'true',
|
|
user: pick(s.user, process.env.SMTP_USER || ''),
|
|
pass: pick(s.pass, process.env.SMTP_PASS || ''),
|
|
from: pick(s.from, process.env.MAIL_FROM || 'Infosec <no-reply@martinhal.local>'),
|
|
reject_unauthorized: s.reject_unauthorized === undefined ? true : !!s.reject_unauthorized,
|
|
// Used to build clickable links inside notification emails.
|
|
base_url: String(pick(s.base_url, process.env.BASE_URL || '')).replace(/\/+$/, ''),
|
|
// IMAP, used only to place a copy of each sent message in the Sent folder.
|
|
// When the host is blank this feature is simply off.
|
|
imap_host: pick(s.imap_host, process.env.IMAP_HOST || ''),
|
|
imap_port: Number(pick(s.imap_port, process.env.IMAP_PORT || 993)),
|
|
imap_secure: s.imap_secure === undefined
|
|
? String(process.env.IMAP_SECURE || 'true') !== 'false'
|
|
: !!s.imap_secure,
|
|
// Falls back to the SMTP credentials, which is the common case.
|
|
imap_user: pick(s.imap_user, pick(s.user, process.env.IMAP_USER || process.env.SMTP_USER || '')),
|
|
imap_pass: pick(s.imap_pass, pick(s.pass, process.env.IMAP_PASS || process.env.SMTP_PASS || '')),
|
|
imap_sent_folder: pick(s.imap_sent_folder, process.env.IMAP_SENT_FOLDER || ''),
|
|
source: s.host ? 'settings' : (process.env.SMTP_HOST ? 'environment' : 'none'),
|
|
};
|
|
}
|
|
|
|
function saveMailConfig(cfg) {
|
|
setJSON(SETTINGS_KEY, cfg);
|
|
}
|
|
|
|
/** Build a transport from the given configuration (null when unconfigured). */
|
|
function buildTransport(cfg) {
|
|
const c = cfg || getMailConfig();
|
|
if (!c.host) return null;
|
|
return nodemailer.createTransport({
|
|
host: c.host,
|
|
port: c.port,
|
|
secure: c.secure,
|
|
auth: c.user ? { user: c.user, pass: c.pass } : undefined,
|
|
tls: { rejectUnauthorized: c.reject_unauthorized !== false },
|
|
});
|
|
}
|
|
|
|
// ---- Mail log ---------------------------------------------------------
|
|
const insertMail = db.prepare(
|
|
`INSERT INTO mail_log (direction, status, from_addr, to_addr, subject, body_preview, attachments, error, context, actor)
|
|
VALUES (@direction, @status, @from_addr, @to_addr, @subject, @body_preview, @attachments, @error, @context, @actor)`
|
|
);
|
|
|
|
function stripHtml(html) {
|
|
return String(html || '')
|
|
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
|
.replace(/<[^>]+>/g, ' ')
|
|
.replace(/ /g, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
.slice(0, 400);
|
|
}
|
|
|
|
function recordMail(entry) {
|
|
insertMail.run({
|
|
direction: entry.direction || 'sent',
|
|
status: entry.status,
|
|
from_addr: entry.from_addr || null,
|
|
to_addr: entry.to_addr || null,
|
|
subject: entry.subject || null,
|
|
body_preview: entry.body_preview || null,
|
|
attachments: entry.attachments || null,
|
|
error: entry.error || null,
|
|
context: entry.context || null,
|
|
actor: entry.actor || null,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Send an email and record it in the mail log.
|
|
* When SMTP is not configured the message is queued (status 'queued') rather
|
|
* than lost, so the system stays usable without a mail server.
|
|
*/
|
|
async function sendMail({ to, subject, html, attachments, context, actor }) {
|
|
const cfg = getMailConfig();
|
|
const transporter = buildTransport(cfg);
|
|
const base = {
|
|
direction: 'sent',
|
|
from_addr: cfg.from,
|
|
to_addr: to,
|
|
subject,
|
|
body_preview: stripHtml(html),
|
|
attachments: (attachments || []).map((a) => a.filename).join(', ') || null,
|
|
context: context || null,
|
|
actor: actor || null,
|
|
};
|
|
|
|
if (!transporter) {
|
|
recordMail({ ...base, status: 'queued', error: 'No mail server configured' });
|
|
log(null, 'MAIL_OUTBOX', 'System', `SMTP not configured. Queued mail -> ${to} | ${subject}`);
|
|
return { delivered: false };
|
|
}
|
|
|
|
try {
|
|
// Build the message once so the exact bytes we send can also be filed in
|
|
// the Sent folder.
|
|
const info = await transporter.sendMail({ from: cfg.from, to, subject, html, attachments });
|
|
recordMail({ ...base, status: 'delivered' });
|
|
|
|
// Place a copy in the mail server's Sent folder, best-effort: a failure
|
|
// here must never turn a successfully-sent message into an error.
|
|
let sentCopy = null;
|
|
if (cfg.imap_host && info && info.message) {
|
|
try {
|
|
const folder = await appendToSent(cfg, info.message);
|
|
sentCopy = { ok: true, folder };
|
|
} catch (e) {
|
|
sentCopy = { ok: false, error: e.message };
|
|
log(null, 'MAIL_SENT_COPY_FAILED', 'System',
|
|
`Could not copy message to the Sent folder: ${e.message}`);
|
|
}
|
|
}
|
|
return { delivered: true, sentCopy };
|
|
} catch (e) {
|
|
recordMail({ ...base, status: 'failed', error: e.message });
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Append a raw RFC822 message to the mail server's Sent folder over IMAP.
|
|
* Tries the configured folder name, then the server's special-use \\Sent
|
|
* mailbox, then common names. Marks the copy as \\Seen.
|
|
*/
|
|
async function appendToSent(cfg, raw) {
|
|
const client = new ImapFlow({
|
|
host: cfg.imap_host,
|
|
port: cfg.imap_port,
|
|
secure: cfg.imap_secure,
|
|
auth: { user: cfg.imap_user, pass: cfg.imap_pass },
|
|
tls: { rejectUnauthorized: cfg.reject_unauthorized !== false },
|
|
logger: false,
|
|
});
|
|
await client.connect();
|
|
try {
|
|
const candidates = [];
|
|
if (cfg.imap_sent_folder) candidates.push(cfg.imap_sent_folder);
|
|
// Prefer whatever the server marks as its Sent mailbox.
|
|
try {
|
|
for await (const box of client.list()) {
|
|
const flags = box.flags || new Set();
|
|
if ((box.specialUse === '\\Sent') || flags.has('\\Sent')) candidates.push(box.path);
|
|
}
|
|
} catch (_) { /* listing not supported; fall through to common names */ }
|
|
candidates.push('Sent', 'Sent Items', 'INBOX.Sent', '[Gmail]/Sent Mail');
|
|
|
|
let lastErr = null;
|
|
for (const path of candidates) {
|
|
if (!path) continue;
|
|
try {
|
|
await client.append(path, raw, ['\\Seen']);
|
|
return path;
|
|
} catch (e) { lastErr = e; }
|
|
}
|
|
throw lastErr || new Error('No Sent folder could be found on the mail server.');
|
|
} finally {
|
|
try { await client.logout(); } catch (_) { /* ignore */ }
|
|
}
|
|
}
|
|
|
|
/** Verify IMAP credentials and confirm a Sent folder can be found. */
|
|
async function verifyImap(cfg) {
|
|
if (!cfg.imap_host) throw new Error('No IMAP host configured.');
|
|
const client = new ImapFlow({
|
|
host: cfg.imap_host,
|
|
port: cfg.imap_port,
|
|
secure: cfg.imap_secure,
|
|
auth: { user: cfg.imap_user, pass: cfg.imap_pass },
|
|
tls: { rejectUnauthorized: cfg.reject_unauthorized !== false },
|
|
logger: false,
|
|
});
|
|
await client.connect();
|
|
try {
|
|
let sent = cfg.imap_sent_folder || null;
|
|
if (!sent) {
|
|
for await (const box of client.list()) {
|
|
const flags = box.flags || new Set();
|
|
if (box.specialUse === '\\Sent' || flags.has('\\Sent')) { sent = box.path; break; }
|
|
}
|
|
}
|
|
return { ok: true, sent_folder: sent || '(will try common names)' };
|
|
} finally {
|
|
try { await client.logout(); } catch (_) { /* ignore */ }
|
|
}
|
|
}
|
|
|
|
/** Verify a configuration by opening a connection (used by "Test connection"). */
|
|
async function verifyConfig(cfg) {
|
|
const transporter = buildTransport(cfg);
|
|
if (!transporter) throw new Error('No mail server host configured.');
|
|
await transporter.verify();
|
|
return true;
|
|
}
|
|
|
|
function adminEmails() {
|
|
const rows = db.prepare(`SELECT email FROM users WHERE role = 'admin'`).all();
|
|
return rows.map((r) => r.email);
|
|
}
|
|
|
|
module.exports = {
|
|
sendMail, adminEmails, getMailConfig, saveMailConfig, verifyConfig, verifyImap, recordMail, stripHtml,
|
|
};
|