Files
Presentation-system/server/mailer.js
T
2026-09-13 20:23:05 +01:00

119 lines
4.1 KiB
JavaScript

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 };
}