elektrine-haraka/lib/email-builder.js
Maxfield Luke 67c1d41a0c
Some checks are pending
CI and deploy / Test (push) Waiting to run
CI and deploy / Build, push, and deploy (push) Blocked by required conditions
Initial commit on Forgejo
Fresh repository history for elektrine/elektrine-haraka hosted at
https://git.elektrine.com/elektrine/elektrine-haraka.
2026-07-29 04:43:42 -04:00

522 lines
18 KiB
JavaScript

/**
* 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 <email>" 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
};