/** * Email Builder Module * * Constructs RFC 5322 compliant email messages with proper MIME structure. * Supports plain text, HTML, multipart/alternative, and attachments. */ 'use strict'; const crypto = require('crypto'); const domains = require('./domains'); const HEADER_TOKEN_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; // Headers a client may not set via email_data.headers: identity/routing // fields the builder owns, and MIME structure fields whose first occurrence // would shadow the real ones emitted with the body. const FORBIDDEN_CUSTOM_HEADERS = new Set([ 'from', 'to', 'cc', 'bcc', 'subject', 'date', 'message-id', 'mime-version', 'reply-to', 'sender', 'return-path', 'received', 'content-type', 'content-transfer-encoding' ]); function sanitize_header_value(value) { if (value === undefined || value === null) return ''; return String(value).replace(/[\r\n]+/g, ' ').trim(); } function sanitize_header_name(name) { const normalized = sanitize_header_value(name); if (!normalized || !HEADER_TOKEN_RE.test(normalized)) return ''; return normalized; } function sanitize_message_id(value) { const normalized = sanitize_header_value(value).replace(/[^A-Za-z0-9._-]/g, ''); return normalized || crypto.randomUUID(); } function normalize_address(value) { const extracted = sanitize_header_value(domains.extract_email(sanitize_header_value(value))); if (!extracted) return ''; if (!/^[^\s@<>]+@[A-Za-z0-9.-]+$/.test(extracted)) return ''; return extracted; } function normalize_address_list(value) { if (value === undefined || value === null) return []; const list = Array.isArray(value) ? value : [value]; return list.map((entry) => normalize_address(entry)).filter(Boolean); } // RFC 2047 "B" encoded-word for header text containing non-ASCII characters. // Chunks on code-point boundaries so a multi-byte character is never split // across two encoded-words (each word's base64 payload stays well under 75). function encode_mime_word(value) { const str = sanitize_header_value(value); if (str === '') return ''; // Pure ASCII (printable) needs no encoding. if (/^[\x20-\x7E]*$/.test(str)) return str; // Already a single MIME encoded-word: leave it alone (don't double-encode). if (/^=\?[^?\s]+\?[BbQq]\?[^?\s]*\?=$/.test(str)) return str; const words = []; let chunk = []; let chunk_bytes = 0; const flush = () => { if (chunk.length === 0) return; const encoded = Buffer.from(chunk.join(''), 'utf8').toString('base64'); words.push(`=?UTF-8?B?${encoded}?=`); chunk = []; chunk_bytes = 0; }; for (const ch of str) { const ch_bytes = Buffer.byteLength(ch, 'utf8'); if (chunk_bytes + ch_bytes > 45) flush(); chunk.push(ch); chunk_bytes += ch_bytes; } flush(); // Fold multiple encoded-words with CRLF + space per RFC 2047/5322. return words.join('\r\n '); } // Render an address header value preserving a sanitized display name. // normalize_address() strips the name down to the bare address, which is right // for envelope/routing but wrong for the From/Reply-To/To/Cc headers shown to // the recipient. This keeps "Display Name " while validating the // address, MIME-encoding non-ASCII names, and guarding against header injection. function format_address_header(value) { const email = normalize_address(value); if (!email) return ''; const raw = sanitize_header_value(value); const match = raw.match(/^(.*?)\s*<[^<>]*>\s*$/); let name = match ? match[1].trim() : ''; if (!name) return email; // A pre-formed MIME encoded-word must not be quoted or re-encoded. if (/^=\?[^?\s]+\?[BbQq]\?[^?\s]*\?=$/.test(name)) { return `${name} <${email}>`; } // Unwrap an existing quoted-string to recover the raw display name. let raw_name = name; if (name.length >= 2 && name.startsWith('"') && name.endsWith('"')) { raw_name = name.slice(1, -1).replace(/\\(.)/g, '$1'); } if (/[^\x20-\x7E]/.test(raw_name)) { // Non-ASCII display name: RFC 2047 encode (encoded-words are not quoted). name = encode_mime_word(raw_name); } else if (/[(),:;<>@[\]".\\]/.test(raw_name)) { // Quote names containing RFC 5322 special characters. name = '"' + raw_name.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"'; } else { name = raw_name; } return `${name} <${email}>`; } function format_address_header_list(value) { if (value === undefined || value === null) return []; const list = Array.isArray(value) ? value : [value]; return list.map((entry) => format_address_header(entry)).filter(Boolean); } // RFC 5322 date, e.g. "Fri, 27 Jun 2026 09:59:05 +0000". // Date.toUTCString() emits the obsolete "GMT" zone; this uses the numeric zone. function rfc5322_date(date) { const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; const pad = (n) => String(n).padStart(2, '0'); return `${days[date.getUTCDay()]}, ${pad(date.getUTCDate())} ` + `${months[date.getUTCMonth()]} ${date.getUTCFullYear()} ` + `${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())} +0000`; } function first_string(...values) { for (const value of values) { if (typeof value === 'string') return value; } return undefined; } function get_text_body(email_data) { return first_string(email_data.text_body, email_data.text, email_data.body); } function get_html_body(email_data) { return first_string(email_data.html_body, email_data.html); } function encode_quoted_printable(value) { const lines = String(value || '').replace(/\r\n|\r|\n/g, '\n').split('\n'); return lines.map((line) => { const tokens = []; for (const byte of Buffer.from(line, 'utf8')) { if ((byte >= 33 && byte <= 60) || (byte >= 62 && byte <= 126)) { tokens.push(String.fromCharCode(byte)); } else { tokens.push(`=${byte.toString(16).toUpperCase().padStart(2, '0')}`); } } const wrapped = []; let current = ''; for (const token of tokens) { if (current && current.length + token.length > 75) { wrapped.push(`${current}=`); current = ''; } current += token; } wrapped.push(current); return wrapped.join('\r\n'); }).join('\r\n'); } // Attachments arrive as base64 strings (see README). Validate the payload and // re-wrap it at 76 characters so a large blob never exceeds the SMTP // 998-octet line limit. Returns null for missing or non-base64 data. function encode_attachment_data(data) { if (typeof data !== 'string') return null; const compact = data.replace(/\s+/g, ''); if (compact === '' || compact.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(compact)) { return null; } // Re-encode to normalize non-canonical trailing bits. const normalized = Buffer.from(compact, 'base64').toString('base64'); return normalized.match(/.{1,76}/g).join('\r\n'); } // Content-Disposition filename parameter(s). ASCII names use the plain quoted // form; non-ASCII names get an RFC 2231 filename* value with an ASCII // fallback filename for parsers that don't support the extended syntax. function format_attachment_filename(filename) { const raw = sanitize_header_value(filename) || 'attachment'; const safe = raw.replace(/["\\/]/g, '_'); if (/^[\x20-\x7E]*$/.test(safe)) { return `filename="${safe}"`; } const fallback = safe.replace(/[^\x20-\x7E]/g, '_'); const encoded = Array.from(Buffer.from(safe, 'utf8')) .map((byte) => { const ch = String.fromCharCode(byte); return /[A-Za-z0-9._-]/.test(ch) ? ch : `%${byte.toString(16).toUpperCase().padStart(2, '0')}`; }) .join(''); return `filename="${fallback}"; filename*=UTF-8''${encoded}`; } /** * Build an RFC 5322 compliant email message * @param {Object} email_data - Email data object * @param {string} email_data.from - Sender email address * @param {string|string[]} email_data.to - Recipient(s) * @param {string} [email_data.cc] - CC recipients * @param {string} [email_data.subject] - Email subject * @param {string} [email_data.text_body] - Plain text body * @param {string} [email_data.html_body] - HTML body * @param {string} [email_data.reply_to] - Reply-To address * @param {Object} [email_data.headers] - Custom headers * @param {Array} [email_data.attachments] - Attachments array * @param {string} [message_id] - Optional message ID (generated if not provided) * @returns {Object} Object with email_content and message_id */ function build(email_data, message_id = null) { message_id = sanitize_message_id(message_id || crypto.randomUUID()); const safe_from = normalize_address(email_data.from); const from_header = format_address_header(email_data.from); const reply_to_header = format_address_header(email_data.reply_to || email_data.from) || from_header; const safe_to_recipients = normalize_address_list(email_data.to); const safe_subject = sanitize_header_value(email_data.subject || ''); const text_body = get_text_body(email_data); const html_body = get_html_body(email_data); if (!safe_from) { throw new Error('Invalid from address'); } if (safe_to_recipients.length === 0) { throw new Error('Invalid to recipient list'); } // Extract sender domain for Message-ID const sender_email = domains.extract_email(safe_from); const sender_domain = domains.extract_domain(safe_from) || 'haraka.local'; // Header rendering keeps display names; envelope/routing uses the bare // lists above (and collect_recipients()), so this is header-only. const to_header_recipients = format_address_header_list(email_data.to); const cc_header_recipients = format_address_header_list(email_data.cc); // Start building headers const headers = [ `Message-ID: <${message_id}@${sender_domain}>`, `Date: ${rfc5322_date(new Date())}`, `From: ${from_header}`, `Reply-To: ${reply_to_header}`, // Fold after each address so long recipient lists never exceed the // RFC 5322 998-octet line limit. `To: ${to_header_recipients.join(',\r\n ')}` ]; // Add CC if present if (cc_header_recipients.length > 0) { headers.push(`Cc: ${cc_header_recipients.join(',\r\n ')}`); } // Add subject headers.push(`Subject: ${encode_mime_word(safe_subject)}`); headers.push('MIME-Version: 1.0'); // Add custom headers (with injection prevention) if (email_data.headers) { for (const [key, value] of Object.entries(email_data.headers)) { const safe_key = sanitize_header_name(key); if (!safe_key) continue; const safe_value = sanitize_header_value(value); // Skip dangerous headers that could override critical fields. // Content-Type/Content-Transfer-Encoding matter because custom // headers are emitted before the body's real Content-Type, and // many parsers honor the first occurrence. const lower_key = safe_key.toLowerCase(); if (FORBIDDEN_CUSTOM_HEADERS.has(lower_key)) { continue; } headers.push(`${safe_key}: ${encode_mime_word(safe_value)}`); } } // Determine email structure and build body const has_attachments = email_data.attachments && Array.isArray(email_data.attachments) && email_data.attachments.length > 0; let body_parts; if (has_attachments) { body_parts = build_multipart_mixed(email_data); } else if (text_body && html_body) { body_parts = build_multipart_alternative(text_body, html_body); } else if (html_body) { body_parts = build_html_body(html_body); } else { body_parts = build_text_body(text_body || ''); } // Combine headers and body const email_content = [...headers, ...body_parts].join('\r\n'); return { email_content, message_id, sender_email }; } /** * Build a plain text body * @param {string} text - Plain text content * @returns {string[]} Body lines */ function build_text_body(text) { return [ 'Content-Type: text/plain; charset=utf-8', 'Content-Transfer-Encoding: quoted-printable', '', encode_quoted_printable(text) ]; } /** * Build an HTML body * @param {string} html - HTML content * @returns {string[]} Body lines */ function build_html_body(html) { return [ 'Content-Type: text/html; charset=utf-8', 'Content-Transfer-Encoding: quoted-printable', '', encode_quoted_printable(html) ]; } /** * Build a multipart/alternative body (text + HTML) * @param {string} text - Plain text content * @param {string} html - HTML content * @returns {string[]} Body lines */ function build_multipart_alternative(text, html) { const boundary = `boundary-${crypto.randomUUID()}`; return [ `Content-Type: multipart/alternative; boundary="${boundary}"`, '', `--${boundary}`, 'Content-Type: text/plain; charset=utf-8', 'Content-Transfer-Encoding: quoted-printable', '', encode_quoted_printable(text), '', `--${boundary}`, 'Content-Type: text/html; charset=utf-8', 'Content-Transfer-Encoding: quoted-printable', '', encode_quoted_printable(html), '', `--${boundary}--` ]; } /** * Build a multipart/mixed body (content + attachments) * @param {Object} email_data - Email data with attachments * @returns {string[]} Body lines */ function build_multipart_mixed(email_data) { const mixed_boundary = `boundary-mixed-${crypto.randomUUID()}`; const text_body = get_text_body(email_data); const html_body = get_html_body(email_data); const parts = [ `Content-Type: multipart/mixed; boundary="${mixed_boundary}"`, '', `--${mixed_boundary}` ]; // Add message body as first part if (text_body && html_body) { // Nested multipart/alternative for text + HTML const alt_boundary = `boundary-alt-${crypto.randomUUID()}`; parts.push( `Content-Type: multipart/alternative; boundary="${alt_boundary}"`, '', `--${alt_boundary}`, 'Content-Type: text/plain; charset=utf-8', 'Content-Transfer-Encoding: quoted-printable', '', encode_quoted_printable(text_body), '', `--${alt_boundary}`, 'Content-Type: text/html; charset=utf-8', 'Content-Transfer-Encoding: quoted-printable', '', encode_quoted_printable(html_body), '', `--${alt_boundary}--` ); } else if (html_body) { parts.push( 'Content-Type: text/html; charset=utf-8', 'Content-Transfer-Encoding: quoted-printable', '', encode_quoted_printable(html_body) ); } else { parts.push( 'Content-Type: text/plain; charset=utf-8', 'Content-Transfer-Encoding: quoted-printable', '', encode_quoted_printable(text_body || '') ); } // Add attachment parts for (const attachment of email_data.attachments) { const safe_content_type = sanitize_header_value(attachment.content_type || 'application/octet-stream') || 'application/octet-stream'; const encoded_data = encode_attachment_data(attachment.data); if (encoded_data === null) { throw new Error(`Invalid attachment data for "${sanitize_header_value(attachment.filename) || 'attachment'}": expected a base64 string`); } parts.push( '', `--${mixed_boundary}`, `Content-Type: ${safe_content_type}`, 'Content-Transfer-Encoding: base64', `Content-Disposition: attachment; ${format_attachment_filename(attachment.filename)}`, '', encoded_data ); } // Close boundary parts.push('', `--${mixed_boundary}--`); return parts; } /** * Convert structured email data to webhook format for local delivery * @param {Object} email_data - Structured email data * @param {string} message_id - Message ID * @returns {Object} Webhook-formatted data */ function to_webhook_format(email_data, message_id) { return { message_id: message_id, from: email_data.from, to: Array.isArray(email_data.to) ? email_data.to[0] : email_data.to, cc: email_data.cc, bcc: email_data.bcc, subject: email_data.subject, text_body: get_text_body(email_data), html_body: get_html_body(email_data), attachments: email_data.attachments || [], timestamp: new Date().toISOString(), id: message_id }; } /** * Collect all recipients from email data (to, cc, bcc) * @param {Object} email_data - Email data * @returns {string[]} Array of all recipient email addresses */ function collect_recipients(email_data) { const recipients = []; // Add TO recipients if (email_data.to) { const to_list = normalize_address_list(email_data.to); recipients.push(...to_list); } // Add CC recipients if (email_data.cc) { const cc_list = normalize_address_list(email_data.cc); recipients.push(...cc_list); } // Add BCC recipients if (email_data.bcc) { const bcc_list = normalize_address_list(email_data.bcc); recipients.push(...bcc_list); } return recipients; } module.exports = { build, build_text_body, build_html_body, build_multipart_alternative, build_multipart_mixed, encode_quoted_printable, to_webhook_format, collect_recipients };