Initial commit: WpywMail 桌面客户端:Node 零依赖本地服务 + React 19 / shadcn-ui 界面,支持收发信、注册、找回密码、会话与账号管理
This commit is contained in:
+614
@@ -0,0 +1,614 @@
|
||||
'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,
|
||||
};
|
||||
Reference in New Issue
Block a user