inbound-mail.js 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
  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 source = String(rawMessage || '');
  6. const textParts = await collectTextParts(source);
  7. const textBody = textParts.find((part) => part.contentType === 'text/plain')?.body || '';
  8. const htmlBody = textParts.find((part) => part.contentType === 'text/html')?.body || '';
  9. const recipients = normalizeRecipients(envelopeRecipients);
  10. const headerRecipients = [
  11. ...parseAddressList(extractHeader(source, 'to')),
  12. ...parseAddressList(extractHeader(source, 'cc'))
  13. ];
  14. return {
  15. sender: extractAddress(extractHeader(source, 'from')) || extractAddress(extractHeader(source, 'sender')),
  16. recipients: recipients.length ? recipients : normalizeRecipients(headerRecipients),
  17. subject: decodeHeader(extractHeader(source, 'subject')) || '(no subject)',
  18. messageId: extractHeader(source, 'message-id'),
  19. rawMessage: source,
  20. textBody,
  21. htmlBody,
  22. preview: previewText(textBody || htmlToText(htmlBody) || source)
  23. };
  24. }
  25. async function collectTextParts(rawMessage) {
  26. const parts = [];
  27. const splitter = new Splitter({ ignoreEmbedded: true });
  28. const streamer = new Streamer((node) => (
  29. ['text/plain', 'text/html'].includes(node.contentType) && node.disposition !== 'attachment'
  30. ));
  31. const drain = new Writable({
  32. objectMode: true,
  33. write(_chunk, _encoding, callback) {
  34. callback();
  35. }
  36. });
  37. streamer.on('node', (data) => {
  38. const chunks = [];
  39. data.decoder.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
  40. data.decoder.on('end', () => {
  41. parts.push({
  42. contentType: data.node.contentType,
  43. body: decodeText(Buffer.concat(chunks), data.node.charset)
  44. });
  45. data.done();
  46. });
  47. data.decoder.on('error', () => data.done());
  48. });
  49. await new Promise((resolve, reject) => {
  50. drain.on('finish', resolve);
  51. drain.on('error', reject);
  52. splitter.on('error', reject);
  53. streamer.on('error', reject);
  54. Readable.from([Buffer.from(rawMessage)]).pipe(splitter).pipe(streamer).pipe(drain);
  55. });
  56. if (!parts.length) {
  57. const body = rawMessage.split(/\r?\n\r?\n/).slice(1).join('\n\n').trim();
  58. if (body) parts.push({ contentType: 'text/plain', body });
  59. }
  60. return parts;
  61. }
  62. function extractHeader(rawMessage, name) {
  63. const head = rawMessage.split(/\r?\n\r?\n/, 1)[0] || '';
  64. const lines = head.split(/\r?\n/);
  65. const headers = [];
  66. for (const line of lines) {
  67. if (/^[\t ]/.test(line) && headers.length) {
  68. headers[headers.length - 1].value += ` ${line.trim()}`;
  69. continue;
  70. }
  71. const index = line.indexOf(':');
  72. if (index === -1) continue;
  73. headers.push({
  74. name: line.slice(0, index).toLowerCase(),
  75. value: line.slice(index + 1).trim()
  76. });
  77. }
  78. return headers.find((header) => header.name === name.toLowerCase())?.value || '';
  79. }
  80. function decodeHeader(value) {
  81. return String(value || '').replace(/=\?([^?]+)\?([bq])\?([^?]+)\?=/gi, (_, charset, encoding, encoded) => {
  82. const buffer = encoding.toLowerCase() === 'b'
  83. ? Buffer.from(encoded, 'base64')
  84. : Buffer.from(encoded.replace(/_/g, ' ').replace(/=([a-f0-9]{2})/gi, (_hex, value) => (
  85. String.fromCharCode(Number.parseInt(value, 16))
  86. )), 'binary');
  87. return decodeText(buffer, charset);
  88. });
  89. }
  90. function decodeText(buffer, charset) {
  91. const normalized = String(charset || 'utf-8').trim().toLowerCase();
  92. if (['iso-8859-1', 'latin1', 'latin-1'].includes(normalized)) return buffer.toString('latin1').trim();
  93. return buffer.toString('utf8').trim();
  94. }
  95. function normalizeRecipients(values) {
  96. return [...new Set((Array.isArray(values) ? values : [values]).map(extractAddress).filter(Boolean))];
  97. }
  98. function previewText(value) {
  99. return String(value || '').replace(/\s+/g, ' ').trim().slice(0, 240);
  100. }
  101. function htmlToText(value) {
  102. return String(value || '')
  103. .replace(/<style[\s\S]*?<\/style>/gi, ' ')
  104. .replace(/<script[\s\S]*?<\/script>/gi, ' ')
  105. .replace(/<[^>]+>/g, ' ')
  106. .replace(/&nbsp;/gi, ' ')
  107. .replace(/&amp;/gi, '&')
  108. .replace(/&lt;/gi, '<')
  109. .replace(/&gt;/gi, '>')
  110. .replace(/&quot;/gi, '"')
  111. .replace(/&#39;/g, "'");
  112. }