import nodemailer from 'nodemailer';
import { log } from './logger.js';
let transporter;
// Escape HTML special characters to prevent injection in email bodies
function escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
export function initMailer() {
const host = process.env.SMTP_HOST;
const port = parseInt(process.env.SMTP_PORT || '587', 10);
const user = process.env.SMTP_USER;
const pass = process.env.SMTP_PASS;
if (!host || !user || !pass) {
log.mailer.warn('SMTP not configured – email verification disabled');
return false;
}
transporter = nodemailer.createTransport({
host,
port,
secure: port === 465,
auth: { user, pass },
connectionTimeout: 10_000, // 10 s to establish TCP connection
greetingTimeout: 10_000, // 10 s to receive SMTP greeting
socketTimeout: 15_000, // 15 s of inactivity before abort
});
log.mailer.info('SMTP mailer configured');
return true;
}
export function isMailerConfigured() {
return !!transporter;
}
/**
* Send the verification email with a clickable link.
* @param {string} to – recipient email
* @param {string} name – user's display name
* @param {string} verifyUrl – full verification URL
* @param {string} appName – branding app name (default "Redlight")
*/
// S3: sanitize name for use in email From header (strip quotes, newlines, control chars)
function sanitizeHeaderValue(str) {
return String(str).replace(/["\\\r\n\x00-\x1f]/g, '').trim().slice(0, 100);
}
export async function sendVerificationEmail(to, name, verifyUrl, appName = 'Redlight') {
if (!transporter) {
throw new Error('SMTP not configured');
}
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
const headerAppName = sanitizeHeaderValue(appName);
const safeName = escapeHtml(name);
const safeAppName = escapeHtml(appName);
await transporter.sendMail({
from: `"${headerAppName}" <${from}>`,
to,
subject: `${headerAppName} – Verify your email`,
html: `
Hey ${safeName} 👋
Please verify your email address by clicking the button below:
Verify Email
Or copy this link in your browser:
${escapeHtml(verifyUrl)}
This link is valid for 24 hours.
If you didn't register, please ignore this email.
`,
text: `Hey ${name},\n\nPlease verify your email: ${verifyUrl}\n\nThis link is valid for 24 hours.\n\n– ${appName}`,
});
}
/**
* Send a federation meeting invitation email.
* @param {string} to – recipient email
* @param {string} name – recipient display name
* @param {string} fromUser – sender federated address (user@domain)
* @param {string} roomName – name of the invited room
* @param {string} message – optional personal message
* @param {string} inboxUrl – URL to the federation inbox
* @param {string} appName – branding app name (default "Redlight")
*/
export async function sendFederationInviteEmail(to, name, fromUser, roomName, message, inboxUrl, appName = 'Redlight') {
if (!transporter) return; // silently skip if SMTP not configured
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
const headerAppName = sanitizeHeaderValue(appName);
const safeName = escapeHtml(name);
const safeFromUser = escapeHtml(fromUser);
const safeRoomName = escapeHtml(roomName);
const safeMessage = message ? escapeHtml(message) : null;
const safeAppName = escapeHtml(appName);
await transporter.sendMail({
from: `"${headerAppName}" <${from}>`,
to,
subject: `${headerAppName} - Meeting invitation from ${sanitizeHeaderValue(fromUser)}`,
html: `
Hey ${safeName} 👋
You have received a meeting invitation from ${safeFromUser}.
Room:
${safeRoomName}
${safeMessage ? `
"${safeMessage}"
` : ''}
View Invitation
Open the link above to accept or decline the invitation.
`,
text: `Hey ${name},\n\nYou have received a meeting invitation from ${fromUser}.\nRoom: ${roomName}${message ? `\nMessage: "${message}"` : ''}\n\nView invitation: ${inboxUrl}\n\n– ${appName}`,
});
}
/**
* Send a calendar event invitation email (federated).
*/
export async function sendCalendarInviteEmail(to, name, fromUser, title, startTime, endTime, description, inboxUrl, appName = 'Redlight') {
if (!transporter) return;
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
const headerAppName = sanitizeHeaderValue(appName);
const safeName = escapeHtml(name);
const safeFromUser = escapeHtml(fromUser);
const safeTitle = escapeHtml(title);
const safeDesc = description ? escapeHtml(description) : null;
const formatDate = (iso) => {
try { return new Date(iso).toLocaleString('en-GB', { dateStyle: 'full', timeStyle: 'short' }); }
catch { return iso; }
};
await transporter.sendMail({
from: `"${headerAppName}" <${from}>`,
to,
subject: `${headerAppName} - Calendar invitation from ${sanitizeHeaderValue(fromUser)}`,
html: `
Hey ${safeName} 👋
You have received a calendar invitation from ${safeFromUser}.
${safeTitle}
🕐 ${escapeHtml(formatDate(startTime))} – ${escapeHtml(formatDate(endTime))}
${safeDesc ? `
"${safeDesc}"
` : ''}
View Invitation
Open the link above to accept or decline the invitation.
`,
text: `Hey ${name},\n\nYou have received a calendar invitation from ${fromUser}.\nEvent: ${title}\nTime: ${formatDate(startTime)} \u2013 ${formatDate(endTime)}${description ? `\n\n"${description}"` : ''}\n\nView invitation: ${inboxUrl}\n\n\u2013 ${appName}`,
});
}
/**
* Notify a user that a federated calendar event they received was deleted by the organiser.
*/
export async function sendCalendarEventDeletedEmail(to, name, fromUser, title, appName = 'Redlight') {
if (!transporter) return;
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
const headerAppName = sanitizeHeaderValue(appName);
const safeName = escapeHtml(name);
const safeFromUser = escapeHtml(fromUser);
const safeTitle = escapeHtml(title);
await transporter.sendMail({
from: `"${headerAppName}" <${from}>`,
to,
subject: `${headerAppName} – Calendar event cancelled: ${sanitizeHeaderValue(title)}`,
html: `
Hey ${safeName} 👋
The following calendar event was deleted by the organiser (${safeFromUser}) and is no longer available:
The event has been automatically removed from your calendar.
This message was sent automatically by ${escapeHtml(appName)}.
`,
text: `Hey ${name},\n\nThe calendar event "${title}" by ${fromUser} has been deleted and removed from your calendar.\n\n\u2013 ${appName}`,
});
}
/**
* Send a user registration invite email.
* @param {string} to – recipient email
* @param {string} inviteUrl – full invite registration URL
* @param {string} appName – branding app name (default "Redlight")
*/
export async function sendInviteEmail(to, inviteUrl, appName = 'Redlight') {
if (!transporter) {
throw new Error('SMTP not configured');
}
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
const headerAppName = sanitizeHeaderValue(appName);
const safeAppName = escapeHtml(appName);
await transporter.sendMail({
from: `"${headerAppName}" <${from}>`,
to,
subject: `${headerAppName} – You've been invited`,
html: `
You've been invited! 🎉
You have been invited to create an account on ${safeAppName}.
Click the button below to register:
Create Account
Or copy this link in your browser:
${escapeHtml(inviteUrl)}
This link is valid for 7 days.
If you didn't expect this invitation, you can safely ignore this email.
`,
text: `You've been invited to create an account on ${appName}.\n\nRegister here: ${inviteUrl}\n\nThis link is valid for 7 days.\n\n– ${appName}`,
});
}