'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 '), 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(//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, };