import nodemailer from 'nodemailer'; import { ImapFlow } from 'imapflow'; import { CONFIG_FILE } from './config.js'; import { readJson, writeJson } from './store.js'; import { logEvent } from './logger.js'; // Prefilled for PurelyMail. The admin only needs to add the account address and // password in User management → Mail. const DEFAULTS = { enabled: false, fromName: 'QR Presentation', username: '', // full PurelyMail email address, used as SMTP/IMAP login password: '', smtpHost: 'smtp.purelymail.com', smtpPort: 465, smtpSecure: true, // SSL/TLS on 465 imapHost: 'imap.purelymail.com', imapPort: 993, imapSecure: true, // SSL/TLS on 993 saveToSent: true, sentFolder: 'Sent' }; let cfg = { ...DEFAULTS }; export async function initMail() { const saved = await readJson(CONFIG_FILE, null); cfg = { ...DEFAULTS, ...(saved && saved.mail ? saved.mail : {}) }; } export function isAvailable() { return Boolean(cfg.enabled && cfg.username && cfg.password); } // Never leak the stored password. export function getMailConfig() { const { password, ...safe } = cfg; return { ...safe, hasPassword: Boolean(password), available: isAvailable() }; } export async function saveMailConfig(patch = {}) { const next = { ...cfg, ...patch }; // Blank password field means "keep what's stored". if (patch.password === undefined || patch.password === '') next.password = cfg.password; next.smtpPort = Number(next.smtpPort) || DEFAULTS.smtpPort; next.imapPort = Number(next.imapPort) || DEFAULTS.imapPort; next.enabled = Boolean(next.enabled); next.smtpSecure = Boolean(next.smtpSecure); next.imapSecure = Boolean(next.imapSecure); next.saveToSent = Boolean(next.saveToSent); cfg = next; await writeJson(CONFIG_FILE, { mail: cfg }); return getMailConfig(); } function requireConfigured() { if (!cfg.username || !cfg.password) { throw new Error('Mail account is not set up. Add it under User management → Mail.'); } } // Compile a MIME message to a Buffer using nodemailer's stream transport, so we // can both send it over SMTP and APPEND the exact same bytes to the Sent folder. async function buildRaw({ to, subject, text, html, attachments }) { const composer = nodemailer.createTransport({ streamTransport: true, buffer: true, newline: 'crlf' }); const built = await composer.sendMail({ from: `"${cfg.fromName}" <${cfg.username}>`, to, subject, text, html, attachments }); return built.message; // Buffer } export async function sendMail({ to, subject, text, html, attachments, actor = 'system' }) { requireConfigured(); const recipients = (Array.isArray(to) ? to : [to]).map((s) => String(s).trim()).filter(Boolean); if (!recipients.length) throw new Error('No recipient address provided.'); const raw = await buildRaw({ to: recipients.join(', '), subject, text, html, attachments }); const smtp = nodemailer.createTransport({ host: cfg.smtpHost, port: cfg.smtpPort, secure: cfg.smtpSecure, auth: { user: cfg.username, pass: cfg.password } }); try { await smtp.sendMail({ envelope: { from: cfg.username, to: recipients }, raw }); } catch (err) { await logEvent({ level: 'error', actor, action: 'mail_send', message: `Failed sending to ${recipients.join(', ')}: ${err.message}` }); throw new Error(`Could not send the email: ${err.message}`); } // Keep a copy in the account's Sent folder via IMAP APPEND. if (cfg.saveToSent) { try { const client = new ImapFlow({ host: cfg.imapHost, port: cfg.imapPort, secure: cfg.imapSecure, auth: { user: cfg.username, pass: cfg.password }, logger: false }); await client.connect(); await client.append(cfg.sentFolder || 'Sent', raw, ['\\Seen']); await client.logout(); } catch (err) { await logEvent({ level: 'warn', actor, action: 'mail_sent_copy', message: `Sent to ${recipients.join(', ')} but could not save to "${cfg.sentFolder}": ${err.message}` }); } } await logEvent({ actor, action: 'mail_send', message: `Email sent to ${recipients.join(', ')} — "${subject}"` }); return { ok: true }; }