跳到正文
DEV Community· Silkline·· 3 小時前AI 評分45

在瀏覽器讀取 Outlook .msg 檔案:複合檔案、MAPI 屬性及只以 RTF 存在的本文

Reading Outlook .msg files in the browser: the compound file, MAPI properties, and a body that only exists as RTF

AI 導讀

文章解析 Outlook .msg 在瀏覽器中的讀取方式,説明其以 CFB/OLE2 容器儲存 MAPI 屬性、郵件本文及附件。@kenjiuno/msgreader 負責讀取容器和解碼屬性,約 160 行的轉接程式則將結果整理成與 .eml 解析器相同的物件。對 Exchange 地址,程式依序採用 SMTP 地址、建立者 SMTP 地址,再退回原地址。

正文

An .eml file is text: headers, a blank line, then MIME parts. Open one in a text editor and you can read most of it. An Outlook .msg file is different. In an editor you see binary noise, with fragments of text that have a NUL byte between every letter.

I work on a browser tool that turns .eml and .msg emails into PDFs. This post is about the .msg side: what the file actually is, which part I left to a library, and the small decisions the library leaves to you.

A file system inside one file

A .msg is a Compound File Binary (CFB) file, also called OLE2 structured storage: the same container old .doc and .xls files use. Here are the first 32 bytes of the test file I use, a 23 KB Outlook message from msgreader's test data:

00000000: d0cf 11e0 a1b1 1ae1 0000 0000 0000 0000
00000010: 0000 0000 0000 0000 3e00 0300 feff 0900

D0 CF 11 E0 A1 B1 1A E1 is the CFB signature. 03 00 at offset 0x1A is major version 3, and 09 00 at 0x1E is the sector shift: sectors are 2⁹ = 512 bytes. Sectors are chained through a FAT, much like a floppy disk. Streams smaller than 4096 bytes are kept in a separate "mini stream" with 64-byte sectors, so a short subject doesn't waste a whole sector. A directory of 128-byte entries describes a tree of storages (folders) and streams (files).

This is that tree for the test file, shortened:

Root Entry/
  __properties_version1.0          864 bytes
  __substg1.0_0037001F              30 bytes   subject
  __substg1.0_0C1A001F              18 bytes   sender name
  __substg1.0_0C1F001F              52 bytes   sender address
  __substg1.0_007D001F            1164 bytes   transport headers
  __substg1.0_1000001F              38 bytes   plain-text body
  __substg1.0_10090102            1456 bytes   compressed RTF body
  __nameid_version1.0/              ...
  __recip_version1.0_#00000000/
    __substg1.0_3001001F                       display name
    __substg1.0_3003001F                       address
    __properties_version1.0
  __attach_version1.0_#00000000/
    __substg1.0_37010102           726 bytes   the attached file
    __substg1.0_3707001F                       long file name
    __substg1.0_370E001F                       MIME type
    __properties_version1.0
  __attach_version1.0_#00000001/    ...
  __attach_version1.0_#00000002/    ...

Stream names are property tags

Outlook stores a message as a bag of MAPI properties, and the stream name tells you which one you're looking at: __substg1.0_PPPPTTTT, where PPPP is the property ID and TTTT its type. 001F is a UTF-16LE string (that's where the NUL bytes come from), 001E an 8-bit string in the message's code page, 0102 binary. So 0037001F is PidTagSubject as a Unicode string.

Fixed-size values such as integers, flags and timestamps don't get streams. They sit in __properties_version1.0 as 16-byte records, after a 32-byte header at the top level and an 8-byte header inside each recipient or attachment storage. The test file has (864 − 32) / 16 = 52 of them at the top. IDs from 0x8000 up are named properties, which you resolve through __nameid_version1.0. All of this is in Microsoft's MS-OXMSG specification.

The part I didn't write

Reading CFB and decoding property streams is done by @kenjiuno/msgreader. Our own code is an adapter of about 160 lines that turns its output into the same object the .eml parser returns, so the preview and the PDF code don't care which format a message came from.

Loading the library had one surprise. It's a CommonJS module, and a dynamic import() hands back different shapes in Node and in the browser bundle:

/**
 * msgreader is a CommonJS module with `exports.default = MsgReader` and `__esModule`. Node and vitest hand back the class as
 * `default`; the Vite browser bundle hands back the whole exports object, so `default` is `{ default: MsgReader }`.
 */
async function loadMsgReader(): Promise<MsgReaderClass> {
  const loaded = (await import('@kenjiuno/msgreader')) as unknown as { default: MsgReaderClass | { default: MsgReaderClass } };
  return typeof loaded.default === 'function' ? loaded.default : loaded.default.default;
}

The adapter only depends on two methods, getFileData() and getAttachment(index), so most tests feed it plain objects. One test parses the real file above.

What the PDF header block shows, and where each value comes from:

Shown as msgreader field MAPI property
Subject subject PidTagSubject 0x0037
From senderName, senderEmail, senderAddressType 0x0C1A, 0x0C1F, 0x0C1E
To, Cc recipients[] with recipType PidTagRecipientType 0x0C15
Date messageDeliveryTime, then clientSubmitTime, then creationTime 0x0E06, 0x0039, 0x3007
Full headers page (optional) headers PidTagTransportMessageHeaders 0x007D

Exchange addresses

Inside an organization on Exchange, the sender's address type is often EX and the "address" is an internal directory name like /O=EXCHANGELABS/OU=.../CN=RECIPIENTS/CN=.... Printed on a PDF, that tells a reader nothing. Outlook usually stores an SMTP address next to it, so when the type is EX, the adapter looks for one in this order:

  1. PidTagSenderSmtpAddress (0x5D01), which msgreader exposes as senderSmtpAddress. This is the documented property for the sender's SMTP address.
  2. 0x5D0A, which msgreader calls creatorSMTPAddress. It isn't in Microsoft's property list; it's the SMTP address of whoever created the item, which for a received message is usually the sender, but not always.
  3. The EX address itself, if neither is there.
const EXCHANGE_ADDRESS_TYPE = 'EX';

function senderAddress(data: MsgData): string {
  const address =
    data.senderAddressType === EXCHANGE_ADDRESS_TYPE
      ? data.senderSmtpAddress || data.creatorSMTPAddress || data.senderEmail
      : data.senderEmail;
  return formatAddress(data.senderName, address);
}

function recipientAddress(recipient: MsgRecipient): { name?: string; address?: string } {
  const address = recipient.addressType === EXCHANGE_ADDRESS_TYPE ? recipient.smtpAddress || recipient.email : recipient.email;
  return { name: recipient.name, address };
}

The fallbacks use ||, not ??: a string property can come back as "" once its trailing NUL is stripped, and an empty string should not stop the search.

Recipients are grouped by type into To and Cc. A message you sent and then saved can also list Bcc recipients; the header block leaves them out.

Which date is "the date"

A .msg can carry several timestamps, and they mean different things:

function parseDate(data: MsgData): Date | null {
  const raw = data.messageDeliveryTime ?? data.clientSubmitTime ?? data.creationTime;
  if (!raw) return null;
  const date = new Date(raw);
  return Number.isNaN(date.getTime()) ? null : date;
}

We put delivery time first. On a received copy, PidTagMessageDeliveryTime is when it reached the mailbox, and a PDF saved as a record usually answers "when did we get this?". That isn't the only reasonable choice: when Outlook prints an email, its "Sent" line is the submit time (PidTagClientSubmitTime). The two differ when mail sits in a queue; if your PDFs should match Outlook's printouts, put clientSubmitTime first.

In the test file, delivery and submit time are both 00:48:31 UTC, but the creation time is 00:52:39: that's when this copy was saved, four minutes after the mail arrived. So creation time is only the last resort, for drafts that were never sent. The same goes for headers: a draft has no PidTagTransportMessageHeaders, so the optional full-headers page has nothing to show for it.

Three copies of the body

A message can hold its body up to three times: plain text in PidTagBody (0x1000), HTML in PidTagHtml (0x1013), and RTF in PidTagRtfCompressed (0x1009). The HTML often comes as bytes in the code page given by PidTagInternetCodepage (0x3FDE), which TextDecoder doesn't accept as a number. Most Windows code pages work as windows-<n>, but not the East Asian ones: windows-932, windows-936 and windows-950 aren't encoding labels, and new TextDecoder('windows-932') throws. So they get their own names:

const LABELS: Record<number, string> = {
  932: 'shift_jis',
  936: 'gbk',
  949: 'euc-kr',
  950: 'big5',
  65001: 'utf-8',
};

export function codepageLabel(codepage: number): string | undefined {
  const label = LABELS[codepage] ?? `windows-${codepage}`;
  return isKnownLabel(label) ? label : undefined;
}

/** Code page from the message, else the HTML's own <meta charset>, else UTF-8. */
function htmlCharset(bytes: Uint8Array, codepage: number | undefined): string {
  const fromCodepage = codepage ? codepageLabel(codepage) : undefined;
  if (fromCodepage) return fromCodepage;
  const head = new TextDecoder('latin1').decode(bytes.subarray(0, 2048));
  const declared = META_CHARSET.exec(head)?.[1];
  return declared && isKnownLabel(declared) ? declared : 'utf-8';
}

The test file declares code page 20127 (US-ASCII), which has no decoder under either name. That's fine: the fallbacks are the HTML's own <meta charset> and then UTF-8, and for ASCII text any of them gives the same result.

A body that only exists as RTF

The test file shows the catch. It has no 0x1013 stream at all. Its only formatted copy is the 1,456-byte compressed RTF. Decompressed, that's 3,073 bytes starting with:

{\rtf1\ansi\ansicpg932\fromhtml1 \fbidis \deff0{\fonttbl ...

\fromhtml1 means this RTF is a wrapper around HTML, as described in [MS-OXRTFEX]: the original tags sit in {\*\htmltag ...} groups, and RTF-only text sits between \htmlrtf and \htmlrtf0. Unwrap it and you get back the HTML Outlook's editor produced.

So when there's no HTML stream, the tool decompresses PidTagRtfCompressed and, if it wraps HTML, uses that. Plain RTF (no \fromhtml1) only fills in the text when PidTagBody is missing. The same decoder also reads winmail.dat attachments, where Outlook's "Rich Text" mail keeps its body in the same compressed RTF. The format ([MS-OXRTFCP], "LZFu") is a 16-byte header followed by an LZ77 stream with a 4 KB dictionary that starts out pre-filled with a fixed RTF prefix:

export function decompressRtf(data: Uint8Array): Uint8Array {
  if (data.length < HEADER_BYTES) throw new RtfError('compressed RTF too short');
  const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
  const rawSize = view.getUint32(4, true);
  const type = view.getUint32(8, true);
  const body = data.subarray(HEADER_BYTES);
  if (type === UNCOMPRESSED) return body.slice(0, rawSize);
  if (type !== COMPRESSED) throw new RtfError('unknown RTF compression');
  return lzfu(body, rawSize);
}

Each control byte covers the next eight items: a 0 bit is a literal byte, a 1 bit is a two-byte reference with a 12-bit dictionary offset and a 4-bit length (plus 2). A reference whose offset equals the current write position marks the end. Running this over the test file's RTF stream returns the 3,073 bytes above, and the preview shows the body in its original font and italics instead of a line of plain text.

One trap when turning that RTF back into text: non-ASCII bytes come in two forms. Most are escaped as \'hh, but in a double-byte code page the second byte of a character can be written as itself when it falls in the ASCII range. The Shift-JIS "テ" (0x83 0x65) appears as \'83e, and "本" (0x96 0x7B) as \'96\{. Decode each \'hh run on its own and those characters break in half. The tool keeps every byte of visible text, escaped or not, in one buffer and decodes it at the next control word or group edge. And don't read the RTF with TextDecoder('latin1') to get one character per byte: that label means windows-1252 in browsers, which maps 0x80 to U+20AC.

Attachments

Each __attach_version1.0_#NNNNNNNN storage is one attachment. The adapter picks the best name available and keeps the Content-ID, so cid: references in an HTML body can find their inline images:

function attachmentsOf(source: MsgSource, data: MsgData): MailAttachment[] {
  return (data.attachments ?? []).map((info, index) => {
    const { fileName, content } = source.getAttachment(index);
    return {
      fileName: fileName?.trim() || info.fileName || info.fileNameShort || `${NO_NAME}-${index + 1}`,
      mimeType: info.attachMimeTag || 'application/octet-stream',
      size: content.byteLength,
      contentId: info.pidContentId?.replace(/^<|>$/g, '') || null,
      data: content,
    };
  });
}

fileName is the long name (0x3707) and fileNameShort the old 8.3 name (0x3704). If both are empty, the attachment is called attachment-1, attachment-2 and so on, rather than disappearing.

An attachment can also be a whole email (an attached message). msgreader rebuilds it as a standalone .msg named after its display name, so it shows up in the attachment list as a .msg file. And if a message carries a winmail.dat, it's unpacked after parsing, for .msg and .eml alike: the files inside replace it in the attachment list.

It all stays in the tab

The file is read with File.arrayBuffer() and parsed in the page. Files over 50 MB are rejected, because bigger ones are slow or crash a phone browser. S/MIME-encrypted messages can't be opened, since the private key isn't there to decrypt them.

Summary

  • A .msg is a CFB container. Stream names are MAPI property tags: ID plus type.
  • Fixed-size properties live in __properties_version1.0, after a 32-byte header at the top level and 8 bytes in each recipient and attachment storage.
  • Prefer the SMTP address when the address type is EX.
  • Pick the date on purpose: we use delivery time, then submit time, because the PDF records when mail arrived; Outlook prints submit time as "Sent". Creation time is when the copy was saved.
  • Map PidTagInternetCodepage to a TextDecoder label yourself, with fallbacks; 932, 936 and 950 need their own names.
  • Check for a body that only exists as compressed RTF. Its HTML can be unwrapped from \fromhtml1, and double-byte text needs all its bytes decoded together.

If you have a .msg file and no Outlook, you can read it and save it as a PDF in the browser at emltopdf.net/msg-to-pdf. Each email's PDF is free.

This post was written with AI assistance.

來源:DEV Community · dev.to