JavaScript 如何解析 Outlook .msg 文件 —— @kenjiuno/msgreader

前言

最近有个需求是要实现 .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

将我的需求拆解成三部分:

  1. MSG文件上传
  2. 拿到上传的文件后实现预览
  3. 附件下载

首先实现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

三、踩坑记录

  1. 返回的 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: [] }
);
  1. body 是包含换行符 \r\n 的文本字符串,如果需要渲染 body 字段,需要对换行符进行处理:
jsx 复制代码
<div style={{ whiteSpace: 'pre-wrap' }}>
  {messageData?.body}
</div>
  1. 如果想要还原邮件正文的格式,则需要对 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);
}
// ...

这里需要注意以下几点:

  1. compressedRtf 是压缩过的 RFT 数据,需要使用 decompressRTF 进行解压缩,返回一个解压后的 Uint8Array,里面就是原始的、可读的 RTF 格式内容。
  2. decompressedData 是原始 RTF 字节流,因此需要使用 iconv-litedecode 方法,将字节流按 GBK 解码成 JavaScript 字符串(rtfString
  3. 最后再使用 deEncapsulateSync 将 RTF 格式转换为 HTML(这里传入 decode 函数,用于处理 RTF 内部可能嵌套的编码)
  1. 使用 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,通过不断踩坑实践才完美实现了我的需求。若需要更复杂的操作(如邮件生成或格式转换),还需要结合其他库扩展使用。

相关推荐
西柚小萌新1 小时前
【论文写作】--LaTeX详细入门教程2
前端·html
LuckyDog阿祥1 小时前
卸载Microsoft Edge:EdgeRemover完整操作指南
前端·edge
zhangxingchao1 小时前
AI应用开发九:从 Hermes Agent 学长期记忆
前端·人工智能·后端
xiaofeichaichai2 小时前
Vite原理与Webpack对比
前端·webpack·node.js
_杨瀚博2 小时前
小程序拍证件并局部裁剪
前端
张元清2 小时前
React useReducedMotion Hook:尊重 prefers-reduced-motion(2026)
javascript·react.js
William_Xu3 小时前
React 新旧生命周期对比
前端