| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190 |
- import { Readable, Writable } from 'node:stream';
- import { Splitter, Streamer } from '@zone-eu/mailsplit';
- import { extractAddress, parseAddressList } from './mailer.js';
- export async function parseInboundMessage(rawMessage, envelopeRecipients = []) {
- const sourceBuffer = Buffer.isBuffer(rawMessage) ? rawMessage : Buffer.from(String(rawMessage || ''));
- const source = sourceBuffer.toString('utf8');
- const textParts = await collectTextParts(sourceBuffer);
- const textBody = textParts.find((part) => part.contentType === 'text/plain')?.body || '';
- const htmlBody = textParts.find((part) => part.contentType === 'text/html')?.body || '';
- const recipients = normalizeRecipients(envelopeRecipients);
- const headerRecipients = [
- ...parseAddressList(extractHeader(source, 'to')),
- ...parseAddressList(extractHeader(source, 'cc'))
- ];
- return {
- sender: extractAddress(extractHeader(source, 'from')) || extractAddress(extractHeader(source, 'sender')),
- recipients: recipients.length ? recipients : normalizeRecipients(headerRecipients),
- subject: decodeMimeHeader(extractHeader(source, 'subject')) || '(no subject)',
- messageId: extractHeader(source, 'message-id'),
- rawMessage: source,
- textBody,
- htmlBody,
- preview: previewText(textBody || htmlToText(htmlBody))
- };
- }
- async function collectTextParts(rawMessage) {
- const sourceBuffer = Buffer.isBuffer(rawMessage) ? rawMessage : Buffer.from(String(rawMessage || ''));
- const source = sourceBuffer.toString('utf8');
- const parts = [];
- const splitter = new Splitter({ ignoreEmbedded: true });
- const streamer = new Streamer((node) => (
- ['text/plain', 'text/html'].includes(node.contentType) && node.disposition !== 'attachment'
- ));
- const drain = new Writable({
- objectMode: true,
- write(_chunk, _encoding, callback) {
- callback();
- }
- });
- streamer.on('node', (data) => {
- const chunks = [];
- data.decoder.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
- data.decoder.on('end', () => {
- parts.push({
- contentType: data.node.contentType,
- body: decodeText(Buffer.concat(chunks), data.node.charset)
- });
- data.done();
- });
- data.decoder.on('error', () => data.done());
- });
- await new Promise((resolve, reject) => {
- drain.on('finish', resolve);
- drain.on('error', reject);
- splitter.on('error', reject);
- streamer.on('error', reject);
- Readable.from([sourceBuffer]).pipe(splitter).pipe(streamer).pipe(drain);
- });
- const topLevelContentType = extractHeader(source, 'content-type').split(';', 1)[0].trim().toLowerCase();
- if (!parts.length && (!topLevelContentType || topLevelContentType === 'text/plain')) {
- const body = source.split(/\r?\n\r?\n/).slice(1).join('\n\n').trim();
- if (body) parts.push({ contentType: 'text/plain', body });
- }
- return parts;
- }
- function extractHeader(rawMessage, name) {
- const head = rawMessage.split(/\r?\n\r?\n/, 1)[0] || '';
- const lines = head.split(/\r?\n/);
- const headers = [];
- for (const line of lines) {
- if (/^[\t ]/.test(line) && headers.length) {
- headers[headers.length - 1].value += ` ${line.trim()}`;
- continue;
- }
- const index = line.indexOf(':');
- if (index === -1) continue;
- headers.push({
- name: line.slice(0, index).toLowerCase(),
- value: line.slice(index + 1).trim()
- });
- }
- return headers.find((header) => header.name === name.toLowerCase())?.value || '';
- }
- export function decodeMimeHeader(value) {
- const source = String(value || '');
- const expression = /=\?([^?]+)\?([bq])\?([^?]+)\?=/gi;
- let output = '';
- let cursor = 0;
- let previousWasEncoded = false;
- for (const match of source.matchAll(expression)) {
- const between = source.slice(cursor, match.index);
- const decoded = decodeEncodedWord(match[1], match[2], match[3]);
- if (decoded === null) {
- output += between + match[0];
- previousWasEncoded = false;
- } else {
- output += previousWasEncoded && /^[\t\r\n ]+$/.test(between) ? '' : between;
- output += decoded;
- previousWasEncoded = true;
- }
- cursor = match.index + match[0].length;
- }
- return output + source.slice(cursor);
- }
- function decodeEncodedWord(charset, encoding, encoded) {
- const buffer = encoding.toLowerCase() === 'b'
- ? decodeBase64Word(encoded)
- : decodeQuotedWord(encoded);
- if (!buffer) return null;
- const normalizedCharset = String(charset || '').trim().toLowerCase() === 'utf8'
- ? 'utf-8'
- : String(charset || '').trim().toLowerCase();
- try {
- return new TextDecoder(normalizedCharset, { fatal: true }).decode(buffer);
- } catch {
- return null;
- }
- }
- function decodeBase64Word(value) {
- const encoded = String(value || '');
- if (!/^[a-z0-9+/]+={0,2}$/i.test(encoded) || encoded.length % 4 === 1) return null;
- const unpadded = encoded.replace(/=+$/, '');
- const padded = unpadded.padEnd(Math.ceil(unpadded.length / 4) * 4, '=');
- return Buffer.from(padded, 'base64');
- }
- function decodeQuotedWord(value) {
- const encoded = String(value || '');
- const bytes = [];
- for (let index = 0; index < encoded.length; index += 1) {
- const character = encoded[index];
- if (character === '_') {
- bytes.push(0x20);
- continue;
- }
- if (character === '=') {
- const hex = encoded.slice(index + 1, index + 3);
- if (!/^[a-f0-9]{2}$/i.test(hex)) return null;
- bytes.push(Number.parseInt(hex, 16));
- index += 2;
- continue;
- }
- const code = character.charCodeAt(0);
- if (code < 0x21 || code > 0x7e) return null;
- bytes.push(code);
- }
- return Buffer.from(bytes);
- }
- function decodeText(buffer, charset) {
- const normalized = String(charset || 'utf-8').trim().toLowerCase();
- const decoded = ['iso-8859-1', 'latin1', 'latin-1'].includes(normalized)
- ? buffer.toString('latin1')
- : buffer.toString('utf8');
- return decoded.trim();
- }
- function normalizeRecipients(values) {
- return [...new Set((Array.isArray(values) ? values : [values]).map(extractAddress).filter(Boolean))];
- }
- function previewText(value) {
- return String(value || '').replace(/\s+/g, ' ').trim().slice(0, 240);
- }
- function htmlToText(value) {
- return String(value || '')
- .replace(/<style[\s\S]*?<\/style>/gi, ' ')
- .replace(/<script[\s\S]*?<\/script>/gi, ' ')
- .replace(/<[^>]+>/g, ' ')
- .replace(/ /gi, ' ')
- .replace(/&/gi, '&')
- .replace(/</gi, '<')
- .replace(/>/gi, '>')
- .replace(/"/gi, '"')
- .replace(/'/g, "'");
- }
|