615 lines
23 KiB
JavaScript
615 lines
23 KiB
JavaScript
'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;
|
||
}
|
||
|
||
/** 地址列表解析:`"张三" <[email protected]>, [email protected]` */
|
||
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(/<style[\s\S]*?<\/style>/gi, '')
|
||
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
||
.replace(/<br\s*\/?>/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,
|
||
};
|