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(//gi, ' ') .replace(//gi, ' ') .replace(/<[^>]+>/g, ' ') .replace(/ /gi, ' ') .replace(/&/gi, '&') .replace(/</gi, '<') .replace(/>/gi, '>') .replace(/"/gi, '"') .replace(/'/g, "'"); }