// Minimal RFC 5322/MIME parser for what neomutt hands to `sendmail`: // RFC 2047 headers, (nested) multipart, quoted-printable/base64/8bit, charsets, // RFC 2231 filenames and text/plain format=flowed (RFC 3676). const decoderFor = (charset) => { try { return new TextDecoder((charset || 'utf-8').toLowerCase().replace(/^us-ascii$/, 'utf-8')); } catch { return new TextDecoder('utf-8'); } }; export function decodeWords(s) { // Whitespace between adjacent encoded-words does not count. return s.replace(/(=\?[^?]+\?[BbQq]\?[^?]*\?=)\s+(?==\?)/g, '$1').replace( /=\?([^?*]+)(?:\*[^?]*)?\?([BbQq])\?([^?]*)\?=/g, (_, charset, enc, text) => { const bytes = enc.toUpperCase() === 'B' ? Buffer.from(text, 'base64') : Buffer.from(text.replace(/_/g, ' ').replace(/=([0-9A-Fa-f]{2})/g, (m, h) => String.fromCharCode(parseInt(h, 16))), 'latin1'); return decoderFor(charset).decode(bytes); }, ); } function splitHeaderBody(buf) { const text = buf.toString('latin1'); const m = /\r?\n\r?\n/.exec(text); if (!m) return [text, Buffer.alloc(0)]; return [text.slice(0, m.index), buf.subarray(m.index + m[0].length)]; } export function parseHeaders(raw) { const headers = new Map(); for (const line of raw.replace(/\r?\n[ \t]+/g, ' ').split(/\r?\n/)) { const i = line.indexOf(':'); if (i <= 0) continue; const name = line.slice(0, i).trim().toLowerCase(); // Raw headers arrive as latin1 (bytes); if they are 8-bit UTF-8, decode again. const value = Buffer.from(line.slice(i + 1).trim(), 'latin1').toString('utf8'); if (!headers.has(name)) headers.set(name, []); headers.get(name).push(value); } return headers; } // "text/plain; charset=utf-8; name*=utf-8''a%20b" -> { value, params } export function parseParams(header = '') { const [value, ...rest] = header.split(';'); const params = {}; const continuations = {}; for (const part of rest) { const i = part.indexOf('='); if (i < 0) continue; let key = part.slice(0, i).trim().toLowerCase(); let val = part.slice(i + 1).trim().replace(/^"(.*)"$/s, '$1'); const cont = /^(.+?)\*(\d+)(\*)?$/.exec(key); if (cont) { (continuations[cont[1]] ||= []).push([Number(cont[2]), val, Boolean(cont[3])]); continue; } if (key.endsWith('*')) { key = key.slice(0, -1); val = decode2231(val); } params[key] = decodeWords(val); } for (const [key, parts] of Object.entries(continuations)) { parts.sort((a, b) => a[0] - b[0]); const encoded = parts[0][2]; const joined = parts.map((p) => p[1]).join(''); params[key] = encoded ? decode2231(joined) : decodeWords(joined); } return { value: value.trim().toLowerCase(), params }; } function decode2231(val) { const m = /^([^']*)'[^']*'(.*)$/.exec(val); if (!m) return val; const bytes = Buffer.from(m[2].replace(/%([0-9A-Fa-f]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16))), 'latin1'); return decoderFor(m[1]).decode(bytes); } function decodeBody(buf, encoding = '') { switch (encoding.toLowerCase()) { case 'base64': return Buffer.from(buf.toString('latin1').replace(/[^A-Za-z0-9+/=]/g, ''), 'base64'); case 'quoted-printable': { const s = buf.toString('latin1').replace(/=\r?\n/g, ''); return Buffer.from(s.replace(/=([0-9A-Fa-f]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16))), 'latin1'); } default: return buf; } } // RFC 3676: lines ending in a space continue on the next one. export function unflow(text, delsp = false) { const out = []; let cur = null; for (let line of text.split(/\r?\n/)) { if (line !== '-- ' && line.startsWith(' ')) line = line.slice(1); // space-stuffing const soft = line.endsWith(' ') && line !== '-- '; const piece = soft && delsp ? line.slice(0, -1) : line; cur = cur === null ? piece : cur + piece; if (!soft) { out.push(cur); cur = null; } } if (cur !== null) out.push(cur); return out.join('\n'); } function parsePart(buf, parts) { const [rawHeaders, body] = splitHeaderBody(buf); const headers = parseHeaders(rawHeaders); const get = (n) => headers.get(n)?.[0]; const ctype = parseParams(get('content-type') || 'text/plain; charset=us-ascii'); const disposition = parseParams(get('content-disposition') || ''); if (ctype.value.startsWith('multipart/') && ctype.params.boundary) { const text = body.toString('latin1'); const delim = `--${ctype.params.boundary}`; const chunks = text.split(new RegExp(`(?:^|\\r?\\n)${delim.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:--)?[ \\t]*(?:\\r?\\n|$)`)); for (const chunk of chunks.slice(1, -1)) parsePart(Buffer.from(chunk, 'latin1'), parts); // with no closing "--boundary--", the last chunk is a part too const last = chunks[chunks.length - 1]; if (chunks.length > 1 && !new RegExp(`${delim.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}--`).test(text) && last.trim()) { parsePart(Buffer.from(last, 'latin1'), parts); } return headers; } const data = decodeBody(body, get('content-transfer-encoding')); const filename = disposition.params.filename || ctype.params.name || null; const isAttachment = disposition.value === 'attachment' || (filename && !ctype.value.startsWith('text/')) || !ctype.value.startsWith('text/'); if (!isAttachment) { let text = decoderFor(ctype.params.charset).decode(data); if ((ctype.params.format || '').toLowerCase() === 'flowed') { text = unflow(text, (ctype.params.delsp || '').toLowerCase() === 'yes'); } parts.push({ kind: 'text', contentType: ctype.value, text }); } else { parts.push({ kind: 'attachment', contentType: ctype.value, filename, data }); } return headers; } // Devuelve { headers: Map, text, html, attachments: [{filename, contentType, data}] } export function parseMessage(input) { const buf = Buffer.isBuffer(input) ? input : Buffer.from(input); const parts = []; const headers = parsePart(buf, parts); const texts = parts.filter((p) => p.kind === 'text'); return { headers, header: (name) => { const v = headers.get(name.toLowerCase())?.[0]; return v === undefined ? undefined : decodeWords(v); }, text: texts.find((p) => p.contentType === 'text/plain')?.text ?? null, html: texts.find((p) => p.contentType === 'text/html')?.text ?? null, attachments: parts.filter((p) => p.kind === 'attachment'), }; } // "Juan <5491@c.us>, 5492@c.us" -> ['5491@c.us', '5492@c.us'] export function addresses(value = '') { const out = []; // drop comments and quoted names before splitting on commas const cleaned = value.replace(/"(?:[^"\\]|\\.)*"/g, '""').replace(/\([^)]*\)/g, ''); for (const part of cleaned.split(',')) { const angle = /<([^>]+)>/.exec(part); const addr = (angle ? angle[1] : part).trim(); if (addr) out.push(addr); } return out; }