import net from 'node:net'; import tls from 'node:tls'; import crypto from 'node:crypto'; import { signDkim } from './dkim.js'; export function parseAddressList(value) { return String(value || '') .split(',') .map((item) => item.trim()) .filter(Boolean) .map(extractAddress) .filter(Boolean); } export function extractAddress(value) { const match = String(value || '').match(/<([^<>@\s]+@[^<>@\s]+)>/); const address = match ? match[1] : String(value || '').trim(); if (!/^[^@\s<>]+@[^@\s<>]+\.[^@\s<>]+$/.test(address)) return ''; return address.toLowerCase(); } export function domainFromAddress(value) { const address = extractAddress(value); return address.split('@')[1] || ''; } export function buildMessage({ from, to, subject, text, html, baseUrl }) { const recipients = Array.isArray(to) ? to : parseAddressList(to); if (!recipients.length) throw new Error('At least one recipient is required.'); const messageIdHost = domainFromAddress(from) || 'localhost'; const messageId = `<${crypto.randomUUID()}@${messageIdHost}>`; const commonHeaders = [ ['From', sanitizeHeader(from)], ['To', recipients.join(', ')], ['Subject', encodeHeader(subject || '(no subject)')], ['Date', new Date().toUTCString()], ['Message-ID', messageId], ['MIME-Version', '1.0'], ['X-MailHub', baseUrl || 'mailhub'] ]; if (html) { const boundary = `mailhub-${crypto.randomBytes(12).toString('hex')}`; const headers = [ ...commonHeaders, ['Content-Type', `multipart/alternative; boundary="${boundary}"`] ]; const body = [ `--${boundary}`, 'Content-Type: text/plain; charset=UTF-8', 'Content-Transfer-Encoding: base64', '', encodeBase64Body(text || stripHtml(html)), `--${boundary}`, 'Content-Type: text/html; charset=UTF-8', 'Content-Transfer-Encoding: base64', '', encodeBase64Body(html), `--${boundary}--`, '' ].join('\r\n'); return `${formatHeaders(headers)}\r\n\r\n${body}`; } const headers = [ ...commonHeaders, ['Content-Type', 'text/plain; charset=UTF-8'], ['Content-Transfer-Encoding', 'base64'] ]; return `${formatHeaders(headers)}\r\n\r\n${encodeBase64Body(text || '')}\r\n`; } export function signMessageForDomain(rawMessage, domain) { if (!domain?.dkimPrivate || !domain?.selector) return rawMessage; return signDkim(rawMessage, { domain: domain.domain, selector: domain.selector, privateKey: domain.dkimPrivate }); } export async function sendViaSmtp({ host, port, secure, username, password, helo, mailFrom, recipients, rawMessage }) { if (!host) throw new Error('SMTP_HOST is not configured.'); const client = await SmtpClient.connect({ host, port, secure }); try { await client.expect([220]); let response = await client.command(`EHLO ${helo || 'mailhub.local'}`, [250, 502, 500]); if (![250].includes(response.code)) { await client.command(`HELO ${helo || 'mailhub.local'}`, [250]); } if (username || password) { const auth = Buffer.from(`\u0000${username || ''}\u0000${password || ''}`).toString('base64'); await client.command(`AUTH PLAIN ${auth}`, [235]); } await client.command(`MAIL FROM:<${extractAddress(mailFrom)}>`, [250]); for (const recipient of recipients) { await client.command(`RCPT TO:<${recipient}>`, [250, 251]); } await client.command('DATA', [354]); await client.writeData(dotStuff(rawMessage)); const dataResponse = await client.expect([250]); await client.command('QUIT', [221]).catch(() => null); return dataResponse; } finally { client.close(); } } function sanitizeHeader(value) { return String(value || '').replace(/[\r\n]+/g, ' ').trim(); } function encodeHeader(value) { const clean = sanitizeHeader(value); if (/^[\x20-\x7e]*$/.test(clean)) return clean; return `=?UTF-8?B?${Buffer.from(clean).toString('base64')}?=`; } function formatHeaders(headers) { return headers .filter(([, value]) => value !== undefined && value !== null && value !== '') .map(([name, value]) => `${name}: ${value}`) .join('\r\n'); } function normalizeBody(value) { return String(value || '').replace(/\r?\n/g, '\r\n'); } function encodeBase64Body(value) { const encoded = Buffer.from(normalizeBody(value), 'utf8').toString('base64'); return encoded.replace(/.{1,76}/g, '$&\r\n').trimEnd(); } function stripHtml(value) { return String(value || '') .replace(//gi, '') .replace(//gi, '') .replace(/<[^>]+>/g, ' ') .replace(/\s+/g, ' ') .trim(); } function dotStuff(rawMessage) { const normalized = rawMessage.replace(/\r?\n/g, '\r\n'); return `${normalized.replace(/^\./gm, '..')}\r\n.`; } class SmtpClient { static connect({ host, port = 25, secure = false }) { return new Promise((resolve, reject) => { const socket = secure ? tls.connect({ host, port: Number(port), servername: host }) : net.createConnection({ host, port: Number(port) }); const client = new SmtpClient(socket); socket.once('connect', () => resolve(client)); socket.once('secureConnect', () => resolve(client)); socket.once('error', reject); setTimeout(() => reject(new Error('SMTP connection timeout.')), 15000).unref(); }); } constructor(socket) { this.socket = socket; this.buffer = ''; this.pending = []; this.currentLines = []; socket.setEncoding('utf8'); socket.on('data', (chunk) => this.onData(chunk)); socket.on('error', (error) => this.rejectPending(error)); socket.on('close', () => this.rejectPending(new Error('SMTP connection closed.'))); } command(command, expectedCodes) { this.socket.write(`${command}\r\n`); return this.expect(expectedCodes); } writeData(data) { this.socket.write(`${data}\r\n`); return Promise.resolve(); } expect(expectedCodes) { return new Promise((resolve, reject) => { this.pending.push({ expectedCodes, resolve, reject }); this.flushResponses(); }); } close() { this.socket.destroy(); } onData(chunk) { this.buffer += chunk; let index; while ((index = this.buffer.indexOf('\n')) !== -1) { const rawLine = this.buffer.slice(0, index).replace(/\r$/, ''); this.buffer = this.buffer.slice(index + 1); this.currentLines.push(rawLine); if (/^\d{3} /.test(rawLine)) { this.flushResponses(); } } } flushResponses() { while (this.pending.length && this.currentLines.length) { const lastLine = this.currentLines[this.currentLines.length - 1]; if (!/^\d{3} /.test(lastLine)) return; const responseLines = this.currentLines.splice(0); const code = Number(lastLine.slice(0, 3)); const response = { code, message: responseLines.join('\n') }; const pending = this.pending.shift(); if (pending.expectedCodes.includes(code)) { pending.resolve(response); } else { pending.reject(new Error(`Unexpected SMTP response ${response.message}`)); } } } rejectPending(error) { while (this.pending.length) { this.pending.shift().reject(error); } } }