inbound-mail.js 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  1. import { Readable, Writable } from 'node:stream';
  2. import { Splitter, Streamer } from '@zone-eu/mailsplit';
  3. import { extractAddress, parseAddressList } from './mailer.js';
  4. export async function parseInboundMessage(rawMessage, envelopeRecipients = []) {
  5. const sourceBuffer = Buffer.isBuffer(rawMessage) ? rawMessage : Buffer.from(String(rawMessage || ''));
  6. const source = sourceBuffer.toString('utf8');
  7. const textParts = await collectTextParts(sourceBuffer);
  8. const textBody = textParts.find((part) => part.contentType === 'text/plain')?.body || '';
  9. const htmlBody = textParts.find((part) => part.contentType === 'text/html')?.body || '';
  10. const recipients = normalizeRecipients(envelopeRecipients);
  11. const headerRecipients = [
  12. ...parseAddressList(extractHeader(source, 'to')),
  13. ...parseAddressList(extractHeader(source, 'cc'))
  14. ];
  15. return {
  16. sender: extractAddress(extractHeader(source, 'from')) || extractAddress(extractHeader(source, 'sender')),
  17. recipients: recipients.length ? recipients : normalizeRecipients(headerRecipients),
  18. subject: decodeMimeHeader(extractHeader(source, 'subject')) || '(no subject)',
  19. messageId: extractHeader(source, 'message-id'),
  20. rawMessage: source,
  21. textBody,
  22. htmlBody,
  23. preview: previewText(textBody || htmlToText(htmlBody))
  24. };
  25. }
  26. async function collectTextParts(rawMessage) {
  27. const sourceBuffer = Buffer.isBuffer(rawMessage) ? rawMessage : Buffer.from(String(rawMessage || ''));
  28. const source = sourceBuffer.toString('utf8');
  29. const parts = [];
  30. const splitter = new Splitter({ ignoreEmbedded: true });
  31. const streamer = new Streamer((node) => (
  32. ['text/plain', 'text/html'].includes(node.contentType) && node.disposition !== 'attachment'
  33. ));
  34. const drain = new Writable({
  35. objectMode: true,
  36. write(_chunk, _encoding, callback) {
  37. callback();
  38. }
  39. });
  40. streamer.on('node', (data) => {
  41. const chunks = [];
  42. data.decoder.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
  43. data.decoder.on('end', () => {
  44. parts.push({
  45. contentType: data.node.contentType,
  46. body: decodeText(Buffer.concat(chunks), data.node.charset)
  47. });
  48. data.done();
  49. });
  50. data.decoder.on('error', () => data.done());
  51. });
  52. await new Promise((resolve, reject) => {
  53. drain.on('finish', resolve);
  54. drain.on('error', reject);
  55. splitter.on('error', reject);
  56. streamer.on('error', reject);
  57. Readable.from([sourceBuffer]).pipe(splitter).pipe(streamer).pipe(drain);
  58. });
  59. const topLevelContentType = extractHeader(source, 'content-type').split(';', 1)[0].trim().toLowerCase();
  60. if (!parts.length && (!topLevelContentType || topLevelContentType === 'text/plain')) {
  61. const body = source.split(/\r?\n\r?\n/).slice(1).join('\n\n').trim();
  62. if (body) parts.push({ contentType: 'text/plain', body });
  63. }
  64. return parts;
  65. }
  66. function extractHeader(rawMessage, name) {
  67. const head = rawMessage.split(/\r?\n\r?\n/, 1)[0] || '';
  68. const lines = head.split(/\r?\n/);
  69. const headers = [];
  70. for (const line of lines) {
  71. if (/^[\t ]/.test(line) && headers.length) {
  72. headers[headers.length - 1].value += ` ${line.trim()}`;
  73. continue;
  74. }
  75. const index = line.indexOf(':');
  76. if (index === -1) continue;
  77. headers.push({
  78. name: line.slice(0, index).toLowerCase(),
  79. value: line.slice(index + 1).trim()
  80. });
  81. }
  82. return headers.find((header) => header.name === name.toLowerCase())?.value || '';
  83. }
  84. export function decodeMimeHeader(value) {
  85. const source = String(value || '');
  86. const expression = /=\?([^?]+)\?([bq])\?([^?]+)\?=/gi;
  87. let output = '';
  88. let cursor = 0;
  89. let previousWasEncoded = false;
  90. for (const match of source.matchAll(expression)) {
  91. const between = source.slice(cursor, match.index);
  92. const decoded = decodeEncodedWord(match[1], match[2], match[3]);
  93. if (decoded === null) {
  94. output += between + match[0];
  95. previousWasEncoded = false;
  96. } else {
  97. output += previousWasEncoded && /^[\t\r\n ]+$/.test(between) ? '' : between;
  98. output += decoded;
  99. previousWasEncoded = true;
  100. }
  101. cursor = match.index + match[0].length;
  102. }
  103. return output + source.slice(cursor);
  104. }
  105. function decodeEncodedWord(charset, encoding, encoded) {
  106. const buffer = encoding.toLowerCase() === 'b'
  107. ? decodeBase64Word(encoded)
  108. : decodeQuotedWord(encoded);
  109. if (!buffer) return null;
  110. const normalizedCharset = String(charset || '').trim().toLowerCase() === 'utf8'
  111. ? 'utf-8'
  112. : String(charset || '').trim().toLowerCase();
  113. try {
  114. return new TextDecoder(normalizedCharset, { fatal: true }).decode(buffer);
  115. } catch {
  116. return null;
  117. }
  118. }
  119. function decodeBase64Word(value) {
  120. const encoded = String(value || '');
  121. if (!/^[a-z0-9+/]+={0,2}$/i.test(encoded) || encoded.length % 4 === 1) return null;
  122. const unpadded = encoded.replace(/=+$/, '');
  123. const padded = unpadded.padEnd(Math.ceil(unpadded.length / 4) * 4, '=');
  124. return Buffer.from(padded, 'base64');
  125. }
  126. function decodeQuotedWord(value) {
  127. const encoded = String(value || '');
  128. const bytes = [];
  129. for (let index = 0; index < encoded.length; index += 1) {
  130. const character = encoded[index];
  131. if (character === '_') {
  132. bytes.push(0x20);
  133. continue;
  134. }
  135. if (character === '=') {
  136. const hex = encoded.slice(index + 1, index + 3);
  137. if (!/^[a-f0-9]{2}$/i.test(hex)) return null;
  138. bytes.push(Number.parseInt(hex, 16));
  139. index += 2;
  140. continue;
  141. }
  142. const code = character.charCodeAt(0);
  143. if (code < 0x21 || code > 0x7e) return null;
  144. bytes.push(code);
  145. }
  146. return Buffer.from(bytes);
  147. }
  148. function decodeText(buffer, charset) {
  149. const normalized = String(charset || 'utf-8').trim().toLowerCase();
  150. const decoded = ['iso-8859-1', 'latin1', 'latin-1'].includes(normalized)
  151. ? buffer.toString('latin1')
  152. : buffer.toString('utf8');
  153. return decoded.trim();
  154. }
  155. function normalizeRecipients(values) {
  156. return [...new Set((Array.isArray(values) ? values : [values]).map(extractAddress).filter(Boolean))];
  157. }
  158. function previewText(value) {
  159. return String(value || '').replace(/\s+/g, ' ').trim().slice(0, 240);
  160. }
  161. function htmlToText(value) {
  162. return String(value || '')
  163. .replace(/<style[\s\S]*?<\/style>/gi, ' ')
  164. .replace(/<script[\s\S]*?<\/script>/gi, ' ')
  165. .replace(/<[^>]+>/g, ' ')
  166. .replace(/&nbsp;/gi, ' ')
  167. .replace(/&amp;/gi, '&')
  168. .replace(/&lt;/gi, '<')
  169. .replace(/&gt;/gi, '>')
  170. .replace(/&quot;/gi, '"')
  171. .replace(/&#39;/g, "'");
  172. }