Initial commit: WpywMail 桌面客户端:Node 零依赖本地服务 + React 19 / shadcn-ui 界面,支持收发信、注册、找回密码、会话与账号管理

This commit is contained in:
WpyQwq
2026-09-19 11:20:43 +08:00
commit c7fb8f8f68
47 changed files with 11573 additions and 0 deletions
+614
View File
@@ -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(/&nbsp;/gi, ' ')
.replace(/&lt;/gi, '<').replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"').replace(/&#39;/gi, "'")
.replace(/&amp;/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,
};