Fresh repository history for elektrine/elektrine-haraka hosted at https://git.elektrine.com/elektrine/elektrine-haraka.
522 lines
18 KiB
JavaScript
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
|
|
};
|