前言
最近有个需求是要实现 .msg 文件上传并预览,上传很简单,但是上传后需要预览如何实现呢?
网上搜了一下找不到满足我需求的文章,于是自己到 npm 上去找合适的包,经过各种关键词搜索,以及功能对比,最终选定了 @kenjiuno/msgreader。
一、@kenjiuno/msgreader 介绍
@kenjiuno/msgreader 是 JavaScript npm 模块中的 Outlook 项目文件 (.msg) 阅读器。
简单来说,这个包可以解析.msg二进制文件,调用 getFileData() 返回一个对象,包含邮件的所有属性(邮件主题、正文、发送人、收件人等)。
用法很简单:
js
import fs from 'fs'
import MsgReader from '@kenjiuno/msgreader'
const msgFileBuffer = fs.readFileSync('./data/test.msg')
const testMsg = new MsgReader(msgFileBuffer)
const testMsgInfo = testMsg.getFileData()
const testMsgAttachment0 = testMsg.getAttachment(testMsgInfo.attachments[0])
但是在实际开发过程中,还是遇到了很多问题,所以写了一个demo记录一下。
二、实战demo
将我的需求拆解成三部分:
- MSG文件上传
- 拿到上传的文件后实现预览
- 附件下载
首先实现MSG文件上传:
jsx
// MsgUpload.tsx
import React, { useState } from 'react';
import MsgPreview from '../MsgPreview';
const MsgUpload: React.FC = () => {
const [currentFile, setCurrentFile] = useState(null);
// 文件输入 change 事件
const onFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setCurrentFile(file);
}
// 清空 input 以便重复选择同一文件
e.target.value = '';
};
// 拖拽事件
const onDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.currentTarget.style.borderColor = '#3b82f6';
};
const onDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.currentTarget.style.borderColor = '#d1d9e6';
};
const onDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.currentTarget.style.borderColor = '#d1d9e6';
const file = e.dataTransfer.files[0];
if (file) {
setCurrentFile(file);
}
};
return (
<div
style={{
maxWidth: 800,
margin: '0 auto',
padding: 20,
fontFamily: 'sans-serif',
}}
>
<h1>📧 .msg 文件预览</h1>
{/* 上传区域 */}
<div
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
style={{
border: '2px dashed #d1d9e6',
borderRadius: 12,
padding: '2rem 1.5rem',
textAlign: 'center',
background: '#fafcff',
cursor: 'pointer',
transition: '0.2s',
marginBottom: 20,
}}
onClick={() => document.getElementById('fileInput')?.click()}
>
<p style={{ marginBottom: 10, fontWeight: 500 }}>
点击或拖拽 .msg 文件到此处
</p>
<input
id="fileInput"
type="file"
accept=".msg"
style={{ display: 'none' }}
onChange={onFileInputChange}
/>
<span style={{ color: '#94a3b8', fontSize: 14 }}>仅支持 .msg 格式</span>
</div>
{/* 预览结果 */}
{currentFile && <MsgPreview file={currentFile} />}
</div>
);
};
export default MsgUpload;
拿到上传的文件后实现预览:
jsx
// MsgPreview.tsx
import { useEffect, useRef, useState } from 'react';
import MsgReader, { type FieldsData } from '@kenjiuno/msgreader';
import { decompressRTF } from '@kenjiuno/decompressrtf';
import { deEncapsulateSync } from 'rtf-stream-parser';
import * as iconv from 'iconv-lite';
import dayjs from 'dayjs';
import './index.css';
import Attachment from './Attachment';
/** Internet Codepage → iconv-lite 编码名映射 */
const CODEPAGE_ENCODING: Record<number, string> = {
65001: 'utf-8',
65000: 'utf-7',
936: 'gbk',
950: 'big5',
1200: 'utf-16le',
1201: 'utf-16be',
1250: 'windows-1250',
1251: 'windows-1251',
1252: 'windows-1252',
1253: 'windows-1253',
1254: 'windows-1254',
1255: 'windows-1255',
1256: 'windows-1256',
1257: 'windows-1257',
1258: 'windows-1258',
874: 'windows-874',
437: 'cp437',
850: 'cp850',
932: 'shift_jis',
949: 'euc-kr',
};
/** 根据 internetCodepage 获取编码名,默认 gbk */
function getEncodingFromCodepage(codepage?: number): string {
if (codepage && CODEPAGE_ENCODING[codepage]) {
return CODEPAGE_ENCODING[codepage];
}
return 'gbk';
}
/**
* 解析 MSG 文件,返回完整的邮件数据
* @param {File} file
* @returns {Promise<Object>} 包含邮件所有信息的对象
*/
async function parseMsgFull(file: File) {
const arrayBuffer = await file.arrayBuffer();
let msg: MsgReader = null;
// Vite 在处理依赖时,可能会将某些第三方库识别为 CommonJS 模块,所以这里我判断了类型,做了一下处理
if (typeof MsgReader === 'function') {
msg = new MsgReader(arrayBuffer);
} else if (typeof MsgReader === 'object') {
if (typeof (MsgReader as any)['default'] === 'function') {
msg = new (MsgReader as any)['default'](arrayBuffer);
}
}
if (!msg) return;
// 获取文件数据
const result = msg.getFileData();
let htmlContent = '';
// 从 compressedRtf 转换 bodyHtml
if (result?.compressedRtf) {
try {
const decompressedData = decompressRTF(
Array.from(new Uint8Array(result.compressedRtf))
);
const encoding = getEncodingFromCodepage(result.internetCodepage);
const rtfString = iconv.decode(Buffer.from(decompressedData), encoding);
const res = deEncapsulateSync(rtfString, { decode: iconv.decode });
htmlContent = res.text as string;
} catch (e) {
console.warn('RTF 转换失败:', e);
}
}
return {
data: {
...result,
bodyHtml: result.bodyHtml || htmlContent,
} as FieldsData,
reader: msg,
};
}
export default function MsgPreview(props: any) {
const { file } = props;
const [messageData, setMessageData] = useState<FieldsData | null>(null);
const [error, setError] = useState<string | null>(null);
const [recipients, setRecipients] = useState<FieldsData[]>([]);
const [ccRecipients, setCCRecipients] = useState<FieldsData[]>([]);
const [bccRecipients, setBccRecipients] = useState<FieldsData[]>([]);
const [attachmentList, setAttachmentList] = useState([]);
const msgRender = useRef(null);
const parseMsg = async (file) => {
try {
const { data, reader } = await parseMsgFull(file);
msgRender.current = reader;
setMessageData(data);
const { to, cc, bcc } = (data.recipients || []).reduce(
(acc, item) => {
if (item.recipType === 'to') acc.to.push(item);
else if (item.recipType === 'cc') acc.cc.push(item);
else if (item.recipType === 'bcc') acc.bcc.push(item);
return acc;
},
{ to: [], cc: [], bcc: [] }
);
setRecipients(to);
setCCRecipients(cc);
setBccRecipients(bcc);
setAttachmentList(data.attachments || []);
} catch (err: any) {
setError(err?.message || '文件解析失败!');
}
};
useEffect(() => {
if (file) {
parseMsg(file);
}
}, [file]);
return (
<div className="msgPreviewContainer">
<div className="msgPreview">
{error ? (
<div className="errorContainer">
<div className="errorIcon">✕</div>
<div className="errorMessage">{error}</div>
</div>
) : (
<>
<div className="msgSubject">{messageData?.subject || '无主题'}</div>
<div className="msgHeader">
<div className="headerRow">
<span className="headerLabel">发件人:</span>
<span className="headerValue">
{messageData?.senderName
? `${messageData.senderName} <${
messageData.senderEmail || ''
}>`
: messageData?.senderEmail || ''}
</span>
</div>
<div className="headerRow">
<span className="headerLabel">收件人:</span>
<span className="headerValue">
{recipients.map((item: any, idx: number) => (
<span key={idx}>
{item.name ? `${item.name} <${item.email}>` : item.email}
{idx < (recipients.length || 0) - 1 && '; '}
</span>
))}
</span>
</div>
{ccRecipients.length > 0 && (
<div className="headerRow">
<span className="headerLabel">抄送:</span>
<span className="headerValue">
{ccRecipients.map((item: any, idx: number) => (
<span key={idx}>
{item.name
? `${item.name} <${item.email}>`
: item.email}
{idx < (ccRecipients.length || 0) - 1 && '; '}
</span>
))}
</span>
</div>
)}
{bccRecipients.length > 0 && (
<div className="headerRow">
<span className="headerLabel">密送:</span>
<span className="headerValue">
{bccRecipients.map((item: any, idx: number) => (
<span key={idx}>
{item.name
? `${item.name} <${item.email}>`
: item.email}
{idx < (bccRecipients.length || 0) - 1 && '; '}
</span>
))}
</span>
</div>
)}
{messageData?.clientSubmitTime && (
<div className="headerRow">
<span className="headerLabel">发送时间:</span>
<span className="headerValue">
{dayjs(messageData.clientSubmitTime).format(
'YYYY年MM月DD日 HH:mm:ss'
)}
</span>
</div>
)}
</div>
<div className="msgBody">
<div
className="msgBodyContent"
dangerouslySetInnerHTML={{
__html: messageData?.body
? (messageData?.bodyHtml as string)
: '<span style="color:#94a3b8">该邮件无正文内容</span>',
}}
/>
</div>
{/* <div style={{ whiteSpace: 'pre-wrap' }}>{messageData?.body}</div> */}
<Attachment attachmentList={attachmentList} msgRender={msgRender} />
</>
)}
</div>
</div>
);
}
附件下载:
jsx
// Attachment.tsx
import React from 'react';
import { type AttachmentData } from '@kenjiuno/msgreader';
const MIME_TYPE_MAP: Record<string, string> = {
pdf: 'application/pdf',
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
bmp: 'image/bmp',
webp: 'image/webp',
svg: 'image/svg+xml',
tif: 'image/tiff',
tiff: 'image/tiff',
txt: 'text/plain',
json: 'application/json',
xml: 'application/xml',
html: 'text/html',
htm: 'text/html',
csv: 'text/csv',
md: 'text/markdown',
log: 'text/plain',
xls: 'application/vnd.ms-excel',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
xlsm: 'application/vnd.ms-excel.sheet.macroEnabled.12',
};
export default function Attachment(props) {
const { attachmentList, msgRender } = props;
const handleDownload = (e: React.MouseEvent, item) => {
e.stopPropagation();
// 处理附件 url
const { content }: AttachmentData = msgRender.current.getAttachment(item);
const fileName = item.fileName || '附件';
const ext = fileName.split('.').pop()?.toLowerCase() || '';
const mimeType = MIME_TYPE_MAP[ext] || 'application/octet-stream';
const blob = new Blob([content.buffer as ArrayBuffer], {
type: mimeType,
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = item.fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
return (
<div className="msgAttachments">
<div className="attachmentsTitle">
附件
{attachmentList.length > 0 && <span>({attachmentList.length})</span>}
</div>
{attachmentList.length > 0 ? (
<div>
{attachmentList.map((item, idx) => (
<div className="attachmentItem" key={idx}>
<div
className="attachmentName"
onClick={(e) => handleDownload(e, item)}
>
{item.fileName}
</div>
</div>
))}
</div>
) : (
<span className="noAttachments">无附件</span>
)}
</div>
);
}
demo在线地址: MsgPreview(@kenjiuno/msgreader) - StackBlitz
三、踩坑记录
- 返回的
recipients包含了收件人、抄送人、密送人,如果需要区分的话要根据recipType字段自己拆分:
js
const { to, cc, bcc } = (messageData.recipients || []).reduce(
(acc, item) => {
if (item.recipType === 'to') acc.to.push(item);
else if (item.recipType === 'cc') acc.cc.push(item);
else if (item.recipType === 'bcc') acc.bcc.push(item);
return acc;
},
{ to: [], cc: [], bcc: [] }
);
body是包含换行符\r\n的文本字符串,如果需要渲染body字段,需要对换行符进行处理:
jsx
<div style={{ whiteSpace: 'pre-wrap' }}>
{messageData?.body}
</div>
- 如果想要还原邮件正文的格式,则需要对
compressedRtf进行处理,转成HTML进行渲染:
js
import MsgReader from '@kenjiuno/msgreader';
import { decompressRTF } from '@kenjiuno/decompressrtf';
import { deEncapsulateSync } from 'rtf-stream-parser';
import * as iconv from 'iconv-lite';
// ...
const msg = new MsgReader(arrayBuffer);
const result = msg.getFileData();
let htmlContent = '';
try {
const decompressedData = decompressRTF(
Array.from(new Uint8Array(result.compressedRtf))
);
const encoding = getEncodingFromCodepage(result.internetCodepage);
const rtfString = iconv.decode(Buffer.from(decompressedData), encoding);
const res = deEncapsulateSync(rtfString, { decode: iconv.decode });
htmlContent = res.text as string;
} catch (e) {
console.warn('RTF 转换失败:', e);
}
// ...
这里需要注意以下几点:
compressedRtf是压缩过的 RFT 数据,需要使用decompressRTF进行解压缩,返回一个解压后的Uint8Array,里面就是原始的、可读的 RTF 格式内容。decompressedData是原始 RTF 字节流,因此需要使用iconv-lite的decode方法,将字节流按 GBK 解码成 JavaScript 字符串(rtfString)- 最后再使用
deEncapsulateSync将 RTF 格式转换为 HTML(这里传入decode函数,用于处理 RTF 内部可能嵌套的编码)
- 使用
getAttachment方法拿到附件数据后,需要从content.buffer生成一个可访问的 URL(Blob URL)用于预览或下载:
js
// ...
const { content }: AttachmentData = msgRender.getAttachment(item);
const fileName = item.fileName || '附件';
const ext = fileName.split('.').pop()?.toLowerCase() || '';
const mimeType = MIME_TYPE_MAP[ext] || 'application/octet-stream';
const blob = new Blob([content.buffer as ArrayBuffer], {
type: mimeType,
});
const url = URL.createObjectURL(blob);
// ...
总结
@kenjiuno/msgreader 为开发者提供了高效、可靠的 .msg 文件处理能力,但对于正文和附件等嵌套信息,还是需要结合其他库自己再处理一下。
官方文档描述比较简单,结合官方在线 demo,通过不断踩坑实践才完美实现了我的需求。若需要更复杂的操作(如邮件生成或格式转换),还需要结合其他库扩展使用。