119 lines
4.1 KiB
JavaScript
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 };
|
|
}
|