'use strict'; /** * MIME 解析与组装(RFC 5322 / 2047 / 2231 / 2045-2049)。 * * 这一层决定了「中文对不对」。生产环境的教训: * - 头部必须能解 RFC 2047 编码字(否则主题显示成 =?utf-8?B?...?=) * - 头部若被写成裸 UTF-8,必须按 UTF-8 优先解(按 Latin-1 解会得到 [æµè¯]) * - 正文要按声明的 charset 解,且要能回退到 GBK/Big5(老邮件) * - 组装时要主动用 RFC 2047 编码非 ASCII 头部,不能把裸中文写进 Subject */ const { TextDecoder } = require('node:util'); const crypto = require('node:crypto'); const CRLF = '\r\n'; // ─────────────────────────────────────────────────────────── 字符集 const DECODERS = new Map(); function decoderFor(charset) { const key = String(charset || 'utf-8').trim().toLowerCase().replace(/^["']|["']$/g, ''); if (DECODERS.has(key)) return DECODERS.get(key); const aliases = { 'utf8': 'utf-8', 'utf-8': 'utf-8', 'gb2312': 'gbk', 'gb18030': 'gbk', 'gbk': 'gbk', 'big5': 'big5', 'big5-hkscs': 'big5', 'shift_jis': 'shift_jis', 'shift-jis': 'shift_jis', 'sjis': 'shift_jis', 'euc-kr': 'euc-kr', 'ks_c_5601-1987': 'euc-kr', 'latin1': 'windows-1252', 'iso-8859-1': 'windows-1252', 'us-ascii': 'utf-8', 'ascii': 'utf-8', 'windows-1252': 'windows-1252', }; const label = aliases[key] || key; let dec = null; try { dec = new TextDecoder(label, { fatal: false }); } catch { dec = null; } DECODERS.set(key, dec); return dec; } /** 按 charset 解码字节;失败则依次尝试 utf-8 / gbk */ function decodeCharset(buffer, charset) { if (!buffer || !buffer.length) return ''; const dec = decoderFor(charset); if (dec) { try { return dec.decode(buffer); } catch { /* 落到下面的回退 */ } } try { return new TextDecoder('utf-8', { fatal: false }).decode(buffer); } catch { return buffer.toString('latin1'); } } // ─────────────────────────────────────────────────────────── 传输编码 /** quoted-printable → 原始字节(含软换行合并) */ function decodeQuotedPrintable(buffer) { const text = buffer.toString('latin1'); const out = []; for (let i = 0; i < text.length; i++) { const c = text[i]; if (c === '=') { const n1 = text[i + 1]; const n2 = text[i + 2]; if (n1 === '\r' && n2 === '\n') { i += 2; continue; } if (n1 === '\n') { i += 1; continue; } if (/[0-9A-Fa-f]{2}/.test((n1 || '') + (n2 || ''))) { out.push(parseInt(n1 + n2, 16)); i += 2; continue; } } out.push(c.charCodeAt(0) & 0xff); } return Buffer.from(out); } function decodeTransfer(buffer, encoding) { const enc = String(encoding || '').trim().toLowerCase(); if (enc === 'base64') { const clean = buffer.toString('latin1').replace(/[^A-Za-z0-9+/=]/g, ''); try { return Buffer.from(clean, 'base64'); } catch { return buffer; } } if (enc === 'quoted-printable') return decodeQuotedPrintable(buffer); return buffer; } // ─────────────────────────────────────────────────────────── RFC 2047 function decodeOneWord(charset, encoding, text) { let bytes; if (encoding.toUpperCase() === 'B') { try { bytes = Buffer.from(text.replace(/\s+/g, ''), 'base64'); } catch { return text; } } else { // Q:下划线代表空格,=XX 代表字节 const bytesArr = []; for (let i = 0; i < text.length; i++) { const c = text[i]; if (c === '_') { bytesArr.push(0x20); continue; } if (c === '=' && /[0-9A-Fa-f]{2}/.test(text.substr(i + 1, 2))) { bytesArr.push(parseInt(text.substr(i + 1, 2), 16)); i += 2; continue; } bytesArr.push(c.charCodeAt(0) & 0xff); } bytes = Buffer.from(bytesArr); } return decodeCharset(bytes, charset); } /** 解码头部里的 RFC 2047 编码字;相邻编码字之间的空白会被丢弃(RFC 2047 规定) */ function decodeWords(input) { const s = String(input == null ? '' : input); if (!s.includes('=?')) return s; const re = /=\?([^?\s]+)\?([BbQq])\?([^?]*)\?=/g; let out = ''; let last = 0; let m; let prevWasWord = false; while ((m = re.exec(s)) !== null) { let between = s.slice(last, m.index); if (prevWasWord && /^\s*$/.test(between)) between = ''; out += between + decodeOneWord(m[1], m[2], m[3]); prevWasWord = true; last = m.index + m[0].length; } out += s.slice(last); return out.replace(/[\r\n]+/g, ' ').trim(); } /** 把可能是裸 UTF-8 的头部值还原成可读文本 */ function smartDecodeHeader(rawValue) { const s = String(rawValue == null ? '' : rawValue); if (s.includes('=?')) return decodeWords(s); // 全是 ASCII 直接返回 if (!/[\u0080-\u00ff]/.test(s)) return s.trim(); // 含高位字节:优先按 UTF-8 解,失败再按 Latin-1 const bytes = Buffer.from(s, 'latin1'); const utf8 = decodeCharset(bytes, 'utf-8'); if (!utf8.includes('\ufffd')) return utf8.trim(); return s.trim(); } /** RFC 2231(filename*=UTF-8''...)与普通 filename 参数 */ function decodeParameter(value) { if (value == null) return ''; const v = String(value).trim(); const m = /^([^']*)'([^']*)'(.*)$/s.exec(v); if (m) { const charset = m[1] || 'utf-8'; const encoded = m[3]; // 关键:直接把 %XX 还原成**字节**,不要先 decodeURIComponent 成字符串。 // 先转字符串再用 Buffer.from(str,'binary'/'latin1') 会把多字节字符按单字节截断, // 中文文件名会变成一串 U+FFFD(这是在真机上踩到的坑)。 const bytes = []; for (let i = 0; i < encoded.length; i++) { const ch = encoded[i]; if (ch === '%' && /^[0-9A-Fa-f]{2}$/.test(encoded.substr(i + 1, 2))) { bytes.push(parseInt(encoded.substr(i + 1, 2), 16)); i += 2; } else { bytes.push(ch.charCodeAt(0) & 0xff); } } return decodeCharset(Buffer.from(bytes), charset); } return smartDecodeHeader(v.replace(/^"|"$/g, '')); } // ─────────────────────────────────────────────────────────── 头部 /** 拆出头部与正文(按字节,保持忠实) */ function splitHeadBody(buffer) { const idx = buffer.indexOf('\r\n\r\n'); if (idx >= 0) return { head: buffer.subarray(0, idx), body: buffer.subarray(idx + 4) }; const idx2 = buffer.indexOf('\n\n'); if (idx2 >= 0) return { head: buffer.subarray(0, idx2), body: buffer.subarray(idx2 + 2) }; return { head: buffer, body: Buffer.alloc(0) }; } /** 解析头部为 [{name, value}](折行拼接) */ function parseHeaders(headBuffer) { const text = headBuffer.toString('latin1'); const lines = text.split(/\r?\n/); const headers = []; let cur = null; for (const line of lines) { if (/^[ \t]/.test(line) && cur) { cur.value += CRLF + line; continue; } const i = line.indexOf(':'); if (i <= 0) { cur = null; continue; } cur = { name: line.slice(0, i).trim(), value: line.slice(i + 1) }; headers.push(cur); } return headers; } /** Content-Type 解析:mime 类型 + 参数表 */ function parseContentType(value) { const s = String(value || 'text/plain'); const semi = s.indexOf(';'); const mime = (semi < 0 ? s : s.slice(0, semi)).trim().toLowerCase() || 'text/plain'; const params = {}; if (semi >= 0) { const rest = s.slice(semi + 1); // 参数可能带引号并含分号,用状态机切分 const chunks = []; let buf = ''; let inQuote = false; for (const ch of rest) { if (ch === '"') { inQuote = !inQuote; buf += ch; continue; } if (ch === ';' && !inQuote) { chunks.push(buf); buf = ''; continue; } buf += ch; } if (buf) chunks.push(buf); for (const chunk of chunks) { const eq = chunk.indexOf('='); if (eq < 0) continue; const k = chunk.slice(0, eq).trim().toLowerCase(); const v = chunk.slice(eq + 1).trim(); params[k] = v; } } // RFC 2231 的分段参数:filename*0*=utf-8''%E9%AA%8C; filename*1*=... → 合并成 filename* const keys = Object.keys(params); const groups = new Map(); for (const k of keys) { const m = /^([a-z0-9_-]+)\*(\d+)(\*?)$/.exec(k); if (!m) continue; if (!groups.has(m[1])) groups.set(m[1], []); groups.get(m[1]).push({ idx: Number(m[2]), encoded: m[3] === '*', key: k }); } for (const [base, parts] of groups) { if (parts.length < 2 && !String(parts[0].key).endsWith('*')) continue; parts.sort((a, b) => a.idx - b.idx); let joined = ''; let allEncoded = true; for (const p of parts) { const raw = params[p.key].replace(/^"|"$/g, ''); joined += raw; if (!p.encoded) allEncoded = false; } params[base + (allEncoded ? '*' : '')] = joined; for (const p of parts) delete params[p.key]; } return { mime, params }; } /** 从 Content-Disposition 里取文件名 */ function filenameFrom(headers) { const disp = getHeader(headers, 'content-disposition'); if (disp) { const { params } = parseContentType(disp); if (params['filename*']) return decodeParameter(params['filename*']); if (params.filename) return decodeParameter(params.filename); } const ct = parseContentType(getHeader(headers, 'content-type')); if (ct.params['name*']) return decodeParameter(ct.params['name*']); if (ct.params.name) return decodeParameter(ct.params.name); return ''; } function getHeader(headers, name) { const lower = name.toLowerCase(); const hit = headers.find((h) => h.name.toLowerCase() === lower); return hit ? hit.value : null; } /** 地址列表解析:`"张三" , c@d.com` */ function parseAddressList(rawValue) { const s = smartDecodeHeader(rawValue || ''); if (!s.trim()) return []; const parts = []; let buf = ''; let inQuote = false; let inAngle = false; for (const ch of s) { if (ch === '"') inQuote = !inQuote; if (ch === '<' && !inQuote) inAngle = true; if (ch === '>' && !inQuote) inAngle = false; if (ch === ',' && !inQuote && !inAngle) { parts.push(buf); buf = ''; continue; } buf += ch; } if (buf.trim()) parts.push(buf); return parts.map((p) => { const t = p.trim(); const m = /^(.*?)<([^>]+)>\s*$/.exec(t); if (m) { const name = m[1].trim().replace(/^"|"$/g, '').trim(); return { name, address: m[2].trim() }; } return { name: '', address: t.replace(/^"|"$/g, '') }; }).filter((a) => a.address); } // ─────────────────────────────────────────────────────────── 正文解析 /** 粗略地从 HTML 提取纯文本(没有 text/plain 部分时的回退) */ function htmlToText(html) { return String(html) .replace(//gi, '') .replace(//gi, '') .replace(//gi, '\n') .replace(/<\/(p|div|li|tr|h[1-6])>/gi, '\n') .replace(/<[^>]+>/g, '') .replace(/ /gi, ' ') .replace(/</gi, '<').replace(/>/gi, '>') .replace(/"/gi, '"').replace(/'/gi, "'") .replace(/&/gi, '&') .replace(/\n{3,}/g, '\n\n') .trim(); } /** * 递归解析一个 MIME 实体。 * @returns {{headers, mime, params, text, html, attachments, children}} */ function parseEntity(headBuffer, bodyBuffer, depth = 0) { const headers = parseHeaders(headBuffer); const ct = parseContentType(getHeader(headers, 'content-type')); const transfer = (getHeader(headers, 'content-transfer-encoding') || '').trim(); const node = { headers, mime: ct.mime, params: ct.params, text: null, html: null, attachments: [], children: [], }; if (ct.mime.startsWith('multipart/') && (ct.params.boundary || ct.params.boundary === '')) { const boundary = String(ct.params.boundary || '').replace(/^"|"$/g, ''); const parts = splitMultipart(bodyBuffer, boundary); for (const p of parts) { if (depth > 8) break; const child = parseEntity(p.head, p.body, depth + 1); node.children.push(child); if (child.text != null && node.text == null) node.text = child.text; if (child.html != null && node.html == null) node.html = child.html; node.attachments.push(...child.attachments); } // 有些坑爹邮件把文本塞在 multipart 的 preamble 里 return node; } const decoded = decodeTransfer(bodyBuffer, transfer); const charset = ct.params.charset; const disposition = (parseContentType(getHeader(headers, 'content-disposition')).mime || ''); const filename = filenameFrom(headers); const isAttachment = disposition === 'attachment' || (!!filename && !/^text\/(plain|html)$/i.test(ct.mime)) || /^(image|application|audio|video)\//i.test(ct.mime) && !!filename; if (!isAttachment && ct.mime === 'text/plain') { node.text = decodeCharset(decoded, charset).replace(/\r\n/g, '\n'); return node; } if (!isAttachment && ct.mime === 'text/html') { node.html = decodeCharset(decoded, charset).replace(/\r\n/g, '\n'); return node; } if (ct.mime === 'message/rfc822') { const inner = parseEntityFromBuffer(decoded, depth + 1); node.children.push(inner); if (inner.text != null && node.text == null) node.text = inner.text; if (inner.html != null && node.html == null) node.html = inner.html; node.attachments.push(...inner.attachments); return node; } // 其余一律当附件(含无文件名的内联图) node.attachments.push({ filename: filename || '(未命名)', contentType: ct.mime, contentId: (getHeader(headers, 'content-id') || '').replace(/^<|>$/g, '') || null, inline: disposition === 'inline', size: decoded.length, content: decoded, }); return node; } function parseEntityFromBuffer(buffer, depth = 0) { const { head, body } = splitHeadBody(buffer); return parseEntity(head, body, depth); } /** 按 boundary 切分 multipart 正文 */ function splitMultipart(bodyBuffer, boundary) { const delim = Buffer.from('--' + boundary); const parts = []; let start = bodyBuffer.indexOf(delim); if (start < 0) return parts; start += delim.length; // 跳过紧随其后的 CRLF if (bodyBuffer[start] === 0x0d && bodyBuffer[start + 1] === 0x0a) start += 2; else if (bodyBuffer[start] === 0x0a) start += 1; for (;;) { const next = bodyBuffer.indexOf(delim, start); if (next < 0) break; let end = next; // 去掉分隔符前的 CRLF if (end >= 2 && bodyBuffer[end - 2] === 0x0d && bodyBuffer[end - 1] === 0x0a) end -= 2; else if (end >= 1 && bodyBuffer[end - 1] === 0x0a) end -= 1; const chunk = bodyBuffer.subarray(start, end); const { head, body } = splitHeadBody(chunk); parts.push({ head, body }); // 判断是否为结束分隔符 "--boundary--" const afterDelim = next + delim.length; if (bodyBuffer[afterDelim] === 0x2d && bodyBuffer[afterDelim + 1] === 0x2d) break; start = afterDelim; if (bodyBuffer[start] === 0x0d && bodyBuffer[start + 1] === 0x0a) start += 2; else if (bodyBuffer[start] === 0x0a) start += 1; } return parts; } /** 顶级解析:给出客户端 UI 需要的全部字段 */ function parseMessage(rawBuffer) { const buffer = Buffer.isBuffer(rawBuffer) ? rawBuffer : Buffer.from(rawBuffer); const root = parseEntityFromBuffer(buffer, 0); const headers = root.headers; const subject = smartDecodeHeader(getHeader(headers, 'subject') || ''); const from = parseAddressList(getHeader(headers, 'from')); const to = parseAddressList(getHeader(headers, 'to')); const cc = parseAddressList(getHeader(headers, 'cc')); const replyTo = parseAddressList(getHeader(headers, 'reply-to')); const text = root.text != null ? root.text : (root.html ? htmlToText(root.html) : ''); return { headers, headerMap: headers.reduce((m, h) => { m[h.name.toLowerCase()] = h.value; return m; }, {}), subject, from, to, cc, replyTo, date: getHeader(headers, 'date') ? smartDecodeHeader(getHeader(headers, 'date')) : null, messageId: (getHeader(headers, 'message-id') || '').trim() || null, inReplyTo: (getHeader(headers, 'in-reply-to') || '').trim() || null, references: (getHeader(headers, 'references') || '').trim() || null, text, html: root.html || null, attachments: root.attachments, size: buffer.length, }; } // ─────────────────────────────────────────────────────────── 组装(发信) /** 非 ASCII 头部值 → RFC 2047 编码字(按 45 字节分块,保证整词 ≤75 字符) */ function encodeHeaderValue(value) { const s = String(value == null ? '' : value).replace(/[\r\n]+/g, ' '); // eslint-disable-next-line no-control-regex if (/^[\x20-\x7e]*$/.test(s)) return s; const bytes = Buffer.from(s, 'utf8'); const chunks = []; let cur = []; let curLen = 0; for (const ch of s) { const b = Buffer.byteLength(ch, 'utf8'); if (curLen + b > 45) { chunks.push(Buffer.from(cur.join(''), 'utf8')); cur = []; curLen = 0; } cur.push(ch); curLen += b; } if (cur.length) chunks.push(Buffer.from(cur.join(''), 'utf8')); return chunks.map((b) => `=?UTF-8?B?${b.toString('base64')}?=`).join(CRLF + ' '); } /** 组装地址头 */ function formatAddress(addr) { const name = addr.name ? encodeHeaderValue(addr.name) : ''; if (!name) return addr.address; const needsQuote = !/^[\x20-\x7e]*$/.test(name) || /[",;:<>@\\()\[\]]/.test(name); return `${needsQuote && !name.startsWith('=?') ? `"${name.replace(/"/g, '\\"')}"` : name} <${addr.address}>`; } function wrap76(base64) { const lines = []; for (let i = 0; i < base64.length; i += 76) lines.push(base64.slice(i, i + 76)); return lines.join(CRLF); } function generateMessageId(domain) { const rnd = crypto.randomBytes(12).toString('hex'); return `<${Date.now()}.${rnd}@${domain || 'localhost'}>`; } function rfc2822Date(d = new Date()) { const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; const pad = (n) => String(n).padStart(2, '0'); const off = -d.getTimezoneOffset(); const sign = off >= 0 ? '+' : '-'; const abs = Math.abs(off); return `${days[d.getDay()]}, ${pad(d.getDate())} ${months[d.getMonth()]} ${d.getFullYear()} ` + `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())} ` + `${sign}${pad(Math.floor(abs / 60))}${pad(abs % 60)}`; } /** * 组装一封可发送的 MIME 报文(返回 UTF-8 Buffer,CRLF 行尾)。 * @param {object} msg {from:{name,address}, to:[], cc:[], subject, text, html, * attachments:[{filename,contentType,content}], inReplyTo, * references, domain, date} */ function buildMessage(msg) { const domain = msg.domain || (msg.from && msg.from.address ? String(msg.from.address).split('@')[1] : 'localhost'); const headers = []; headers.push(['From', formatAddress(msg.from)]); if (msg.to && msg.to.length) headers.push(['To', msg.to.map(formatAddress).join(', ')]); if (msg.cc && msg.cc.length) headers.push(['Cc', msg.cc.map(formatAddress).join(', ')]); headers.push(['Subject', encodeHeaderValue(msg.subject || '')]); headers.push(['Date', msg.date || rfc2822Date()]); headers.push(['Message-ID', msg.messageId || generateMessageId(domain)]); if (msg.inReplyTo) headers.push(['In-Reply-To', msg.inReplyTo]); if (msg.references) headers.push(['References', msg.references]); headers.push(['MIME-Version', '1.0']); const attachments = msg.attachments || []; const useHtml = !!msg.html; const textPart = (body, mime, charset = 'UTF-8') => { const isAscii = /^[\x09\x0a\x0d\x20-\x7e]*$/.test(body); const enc = isAscii ? '7bit' : 'base64'; const payload = isAscii ? body.replace(/\r?\n/g, CRLF) : wrap76(Buffer.from(body, 'utf8').toString('base64')); return [ `Content-Type: ${mime}; charset=${charset}`, `Content-Transfer-Encoding: ${enc}`, '', payload, ].join(CRLF); }; let bodyText; if (attachments.length === 0 && !useHtml) { headers.push(['Content-Type', 'text/plain; charset=UTF-8']); const isAscii = /^[\x09\x0a\x0d\x20-\x7e]*$/.test(msg.text || ''); headers.push(['Content-Transfer-Encoding', isAscii ? '7bit' : 'base64']); bodyText = isAscii ? (msg.text || '').replace(/\r?\n/g, CRLF) : wrap76(Buffer.from(msg.text || '', 'utf8').toString('base64')); } else if (attachments.length === 0 && useHtml) { const alt = 'alt-' + crypto.randomBytes(10).toString('hex'); headers.push(['Content-Type', `multipart/alternative; boundary="${alt}"`]); bodyText = [ `--${alt}`, textPart(msg.text || '', 'text/plain'), '', `--${alt}`, textPart(msg.html, 'text/html'), '', `--${alt}--`, '', ].join(CRLF); } else { const mixed = 'mix-' + crypto.randomBytes(10).toString('hex'); headers.push(['Content-Type', `multipart/mixed; boundary="${mixed}"`]); const chunks = []; if (useHtml) { const alt = 'alt-' + crypto.randomBytes(10).toString('hex'); chunks.push(`--${mixed}`, `Content-Type: multipart/alternative; boundary="${alt}"`, ''); chunks.push(`--${alt}`, textPart(msg.text || '', 'text/plain'), ''); chunks.push(`--${alt}`, textPart(msg.html, 'text/html'), ''); chunks.push(`--${alt}--`, ''); } else { chunks.push(`--${mixed}`, textPart(msg.text || '', 'text/plain'), ''); } for (const att of attachments) { const content = Buffer.isBuffer(att.content) ? att.content : Buffer.from(att.content || '', 'base64'); const name = encodeHeaderValue(att.filename || 'attachment'); chunks.push(`--${mixed}`); chunks.push(`Content-Type: ${att.contentType || 'application/octet-stream'}; name="${name}"`); chunks.push('Content-Transfer-Encoding: base64'); chunks.push(`Content-Disposition: attachment; filename="${name}"`); chunks.push(''); chunks.push(wrap76(content.toString('base64'))); chunks.push(''); } chunks.push(`--${mixed}--`, ''); bodyText = chunks.join(CRLF); } const head = headers.map(([k, v]) => `${k}: ${v}`).join(CRLF); return Buffer.from(head + CRLF + CRLF + bodyText, 'utf8'); } module.exports = { parseMessage, buildMessage, parseHeaders, parseAddressList, parseContentType, decodeWords, decodeParameter, smartDecodeHeader, decodeCharset, decodeQuotedPrintable, decodeTransfer, encodeHeaderValue, htmlToText, splitMultipart, filenameFrom, getHeader, rfc2822Date, generateMessageId, };