UNPKG

postal-mime

Version:

Email parser for Node.js and browser environments

374 lines (312 loc) 12.2 kB
import { getDecoder, decodeParameterValueContinuations, textEncoder } from './decode-strings.js'; import PassThroughDecoder from './pass-through-decoder.js'; import Base64Decoder from './base64-decoder.js'; import QPDecoder from './qp-decoder.js'; const defaultDecoder = getDecoder(); export default class MimeNode { constructor(options) { this.options = options || {}; this.postalMime = this.options.postalMime; this.root = !!this.options.parentNode; this.childNodes = []; if (this.options.parentNode) { this.parentNode = this.options.parentNode; this.depth = this.parentNode.depth + 1; if (this.depth > this.options.maxNestingDepth) { throw new Error(`Maximum MIME nesting depth of ${this.options.maxNestingDepth} levels exceeded`); } this.options.parentNode.childNodes.push(this); } else { this.depth = 0; } this.state = 'header'; this.headerLines = []; this.headerSize = 0; // RFC 2046 Section 5.1.5: multipart/digest defaults to message/rfc822 const parentMultipartType = this.options.parentMultipartType || null; const defaultContentType = parentMultipartType === 'digest' ? 'message/rfc822' : 'text/plain'; this.contentType = { value: defaultContentType, default: true }; this.contentTransferEncoding = { value: '8bit' }; this.contentDisposition = { value: '' }; this.headers = []; this.contentDecoder = false; } setupContentDecoder(transferEncoding) { if (/base64/i.test(transferEncoding)) { this.contentDecoder = new Base64Decoder(); } else if (/quoted-printable/i.test(transferEncoding)) { this.contentDecoder = new QPDecoder({ decoder: getDecoder(this.contentType.parsed.params.charset) }); } else { this.contentDecoder = new PassThroughDecoder(); } } async finalize() { if (this.state === 'finished') { return; } if (this.state === 'header') { this.processHeaders(); } // remove self from boundary listing let boundaries = this.postalMime.boundaries; for (let i = boundaries.length - 1; i >= 0; i--) { let boundary = boundaries[i]; if (boundary.node === this) { boundaries.splice(i, 1); break; } } await this.finalizeChildNodes(); this.content = this.contentDecoder ? await this.contentDecoder.finalize() : null; this.state = 'finished'; } async finalizeChildNodes() { for (let childNode of this.childNodes) { await childNode.finalize(); } } // Strip RFC 822 comments (parenthesized text) from structured header values stripComments(str) { let result = ''; let depth = 0; let escaped = false; let inQuote = false; for (let i = 0; i < str.length; i++) { const chr = str.charAt(i); if (escaped) { if (depth === 0) { result += chr; } escaped = false; continue; } if (chr === '\\') { escaped = true; if (depth === 0) { result += chr; } continue; } if (chr === '"' && depth === 0) { inQuote = !inQuote; result += chr; continue; } if (!inQuote) { if (chr === '(') { depth++; continue; } if (chr === ')' && depth > 0) { depth--; continue; } } if (depth === 0) { result += chr; } } return result; } parseStructuredHeader(str) { // Strip RFC 822 comments before parsing str = this.stripComments(str); let response = { value: false, params: {} }; let key = false; let value = ''; let stage = 'value'; let quote = false; let escaped = false; let chr; for (let i = 0, len = str.length; i < len; i++) { chr = str.charAt(i); switch (stage) { case 'key': if (chr === '=') { key = value.trim().toLowerCase(); stage = 'value'; value = ''; break; } value += chr; break; case 'value': if (escaped) { value += chr; } else if (chr === '\\') { escaped = true; continue; } else if (quote && chr === quote) { quote = false; } else if (!quote && chr === '"') { quote = chr; } else if (!quote && chr === ';') { if (key === false) { response.value = value.trim(); } else { response.params[key] = value.trim(); } stage = 'key'; value = ''; } else { value += chr; } escaped = false; break; } } // finalize remainder value = value.trim(); if (stage === 'value') { if (key === false) { // default value response.value = value; } else { // subkey value response.params[key] = value; } } else if (value) { // treat as key without value, see emptykey: // Header-Key: somevalue; key=value; emptykey response.params[value.toLowerCase()] = ''; } if (response.value) { response.value = response.value.toLowerCase(); } // convert Parameter Value Continuations into single strings decodeParameterValueContinuations(response); return response; } decodeFlowedText(str, delSp) { return ( str .split(/\r?\n/) // remove soft linebreaks // soft linebreaks are added after space symbols .reduce((previousValue, currentValue) => { if (previousValue.endsWith(' ') && previousValue !== '-- ' && !previousValue.endsWith('\n-- ')) { if (delSp) { // delsp adds space to text to be able to fold it // these spaces can be removed once the text is unfolded return previousValue.slice(0, -1) + currentValue; } else { return previousValue + currentValue; } } else { return previousValue + '\n' + currentValue; } }) // remove whitespace stuffing // http://tools.ietf.org/html/rfc3676#section-4.4 .replace(/^ /gm, '') ); } getTextContent() { if (!this.content) { return ''; } let str = getDecoder(this.contentType.parsed.params.charset).decode(this.content); if (/^flowed$/i.test(this.contentType.parsed.params.format)) { str = this.decodeFlowedText(str, /^yes$/i.test(this.contentType.parsed.params.delsp)); } return str; } processHeaders() { // First pass: merge folded headers (backward iteration) for (let i = this.headerLines.length - 1; i >= 0; i--) { let line = this.headerLines[i]; if (i && /^\s/.test(line)) { this.headerLines[i - 1] += '\n' + line; this.headerLines.splice(i, 1); } } // Initialize rawHeaderLines to store unmodified lines this.rawHeaderLines = []; // Second pass: process headers (MUST be backward to maintain this.headers order) // The existing code iterates backward and postal-mime.js calls .reverse() // We must preserve this behavior to avoid breaking changes for (let i = this.headerLines.length - 1; i >= 0; i--) { let rawLine = this.headerLines[i]; // Extract key from raw line for rawHeaderLines let sep = rawLine.indexOf(':'); let rawKey = sep < 0 ? rawLine.trim() : rawLine.substr(0, sep).trim(); // Store raw line with lowercase key this.rawHeaderLines.push({ key: rawKey.toLowerCase(), line: rawLine }); // Normalize for this.headers (existing behavior - order preserved) let normalizedLine = rawLine.replace(/\s+/g, ' '); sep = normalizedLine.indexOf(':'); let key = sep < 0 ? normalizedLine.trim() : normalizedLine.substr(0, sep).trim(); let value = sep < 0 ? '' : normalizedLine.substr(sep + 1).trim(); this.headers.push({ key: key.toLowerCase(), originalKey: key, value }); switch (key.toLowerCase()) { case 'content-type': if (this.contentType.default) { this.contentType = { value, parsed: {} }; } break; case 'content-transfer-encoding': this.contentTransferEncoding = { value, parsed: {} }; break; case 'content-disposition': this.contentDisposition = { value, parsed: {} }; break; case 'content-id': this.contentId = value; break; case 'content-description': this.contentDescription = value; break; } } this.contentType.parsed = this.parseStructuredHeader(this.contentType.value); this.contentType.multipart = /^multipart\//i.test(this.contentType.parsed.value) ? this.contentType.parsed.value.substr(this.contentType.parsed.value.indexOf('/') + 1) : false; if (this.contentType.multipart && this.contentType.parsed.params.boundary) { // add self to boundary terminator listing this.postalMime.boundaries.push({ value: textEncoder.encode(this.contentType.parsed.params.boundary), node: this }); } this.contentDisposition.parsed = this.parseStructuredHeader(this.contentDisposition.value); this.contentTransferEncoding.encoding = this.contentTransferEncoding.value .toLowerCase() .split(/[^\w-]/) .shift(); this.setupContentDecoder(this.contentTransferEncoding.encoding); } feed(line) { switch (this.state) { case 'header': if (!line.length) { this.state = 'body'; return this.processHeaders(); } this.headerSize += line.length; if (this.headerSize > this.options.maxHeadersSize) { let error = new Error(`Maximum header size of ${this.options.maxHeadersSize} bytes exceeded`); throw error; } this.headerLines.push(defaultDecoder.decode(line)); break; case 'body': { // add line to body this.contentDecoder.update(line); } } } }