postal-mime review
postal-mime 3.0.0 reads a complete RFC 822 message and returns decoded headers, addresses, bodies, threading fields, and attachment bytes. The same parser runs in Node, browsers, Workers, and serverless runtimes. Version 3 changes header semantics: folded headers retain folding whitespace, the first duplicate wins for single-value fields, and recipient arrays follow document order. It also maps non-WHATWG charset labels instead of treating all of them as Windows-1252. Parsing supplies structure only; it does not authenticate senders, sanitize HTML, or scan files.
postal-mime is the practical choice when identical parsing code must run in Node and edge or browser runtimes. It stops at MIME decoding, so production ingestion still needs total-size limits, authentication, HTML cleaning, and file scanning.
We installed it
| Install | ✓ · 0.6s | 2 packages on disk · 1 MB |
| Import | ✓ | ESM import works · require() works · ESM package with exports map |
| Browser | 22.2 KB | gzipped (71.9 KB minified), bundled with esbuild |
| Types | ✓ | TypeScript types bundled |
| Known vulns | 0 | 0 critical · 0 high · 0 moderate · 0 low (npm audit) |
Answers from our run
Does postal-mime install cleanly?
Yes. In a fresh container with an empty cache, npm install postal-mime finished in 0.6s, leaving 2 packages and 1 MB on disk. npm audit reported no known vulnerabilities.
How much does postal-mime add to a browser bundle?
22.2 KB gzipped (71.9 KB minified) when the whole package is bundled for the browser with esbuild. Importing only part of it is usually smaller.
Does postal-mime work with both ESM and CommonJS?
Yes. Both import 'postal-mime' and require('postal-mime') worked in Node 22 in our run. The package is published as ESM with an exports map.
Does postal-mime include TypeScript types?
Yes, type declarations ship inside the package, so no @types install is needed.
postal-mime or mailparser: which should you use?
mailparser: Use Nodemailer's parser when a Node-only service wants its stream API and established mail tooling. postal-mime is the practical choice when identical parsing code must run in Node and edge or browser runtimes.
When should you not use postal-mime?
You need DKIM, SPF, DMARC, spam, or malware decisions. Parsed headers are data, not proof that a sender or attachment is safe.
Use it if
- Raw email reaches Node, a browser, a Web Worker, or a Cloudflare Email Worker and one parser must serve every runtime.
- You need decoded mailbox groups, nested MIME parts, inline content IDs, attachment bytes, and original header order.
- Untrusted messages need explicit header, MIME depth, and embedded-message recursion limits before downstream inspection.
- A dependency-free parser with bundled TypeScript definitions is preferable to a Node mail stack.
- You need DKIM, SPF, DMARC, spam, or malware decisions. Parsed headers are data, not proof that a sender or attachment is safe.
- HTML will be inserted into a page without another filter. `email.html` contains sender-controlled markup and postal-mime has no sanitizer.
- Attachments must stream through with constant memory. The resolved result holds body strings and attachment content rather than exposing an attachment stream.
- Your task is composing or sending MIME messages. This package parses existing messages and does not build an outbound envelope.
- Security code assumes the top-level attachment list contains every nested payload. Content below the RFC 822 recursion limit remains opaque in an attachment marked `rfc822DepthExceeded`.
Setup reality
We installed postal-mime 3.0.0 in a fresh unprivileged Node 22 Bookworm container with no cache. npm finished in 0.6 seconds and left two packages using 1 MB. The package itself has no direct or peer dependencies and is 380 KB unpacked. npm audit found zero known vulnerabilities. The license is MIT-0, and TypeScript declarations are bundled.
The package declares ESM and uses an exports map, with a generated CommonJS target for require(). Both require and ESM import worked in our test. Importing the full namespace into esbuild produced 71.9 KB minified and 22.2 KB gzipped. The parser accepts strings, byte arrays, Blob, Buffer, ArrayBuffer, and ReadableStream, but its final Email object materializes attachment data. Reject oversized input before parsing even when the source arrives as a stream.
Three parser options cap MIME nesting, aggregate header bytes, and inline message/rfc822 depth. Defaults are 256 levels, 2097152 header bytes, and 10 embedded messages. These checks do not limit multipart breadth or total raw bytes. When the embedded-message limit is crossed, the parser leaves those bytes in an attachment and sets rfc822DepthExceeded. A scanner must reject that case or reparse it under one global work budget.
Attachment content defaults to ArrayBuffer; base64 grows the representation, and UTF-8 is wrong for arbitrary binary files. Address values may be mailboxes or groups, so TypeScript callers need a guard before reading .address. Version 3 also changes duplicate-header selection and recipient order. Snapshot your real mail fixtures before upgrading from v2, then keep HTML cleaning, remote-image policy, sender authentication, and attachment scanning as separate stages.
Patterns
Parse a small raw message parse-email-string
import PostalMime from 'postal-mime'
const email = await PostalMime.parse(`From: Ada <ada@example.com>
Subject: Hello
Content-Type: text/plain; charset=utf-8
Message body`)
console.log(email.subject, email.text)Treat every returned field as sender-controlled, including display names and HTML.
Read an EML file in Node parse-node-buffer
import { readFile } from 'node:fs/promises'
import PostalMime from 'postal-mime'
const raw = await readFile('message.eml')
const email = await PostalMime.parse(raw)Check file size before reading an upload into memory; parsed attachments also occupy memory.
Accept a web ReadableStream parse-readable-stream
import PostalMime from 'postal-mime'
const response = await fetch(messageUrl)
if (!response.body) throw new Error('missing body')
const email = await PostalMime.parse(response.body)A streamed input does not make the returned attachments streaming. Enforce a response-size limit at the fetch boundary.
Return attachment bytes as base64 choose-attachment-encoding
const email = await PostalMime.parse(rawMessage, {
attachmentEncoding: 'base64',
})
for (const file of email.attachments) {
console.log(file.filename, file.encoding)
}Base64 takes more memory than the default ArrayBuffer. Choose it only when the next API requires text.
Tighten parser limits limit-untrusted-message
const email = await PostalMime.parse(rawMessage, {
maxNestingDepth: 64,
maxHeadersSize: 512 * 1024,
maxRfc822NestingDepth: 5,
})These settings do not cap raw message bytes or the number of sibling MIME parts.
Reject content below the recursion limit handle-hidden-rfc822
for (const file of email.attachments) {
if (file.rfc822DepthExceeded) {
throw new Error('nested message exceeds inspection budget')
}
}The hidden nested content is absent from the outer text, HTML, and expanded attachment list.
Distinguish groups from mailboxes narrow-address-union
import type { Address, Mailbox } from 'postal-mime'
function isMailbox(value: Address): value is Mailbox {
return !('group' in value) || value.group === undefined
}
if (email.from && isMailbox(email.from)) {
console.log(email.from.address)
}RFC address groups do not have the same shape as an individual mailbox.
Flatten an address header parse-address-list
import { addressParser } from 'postal-mime'
const recipients = addressParser(
'Team: Ada <ada@example.com>, Lin <lin@example.com>;',
{ flatten: true },
)Flattening is convenient for a recipient list but discards the original group boundary.
Decode one encoded header value decode-header-words
import { decodeWords } from 'postal-mime'
const value = decodeWords(
'Hello, =?utf-8?B?44Ko44Od44K544Kr44O844OJ?=',
)Full message parsing already decodes promoted fields such as subject and display names.
Read repeated raw headers in order inspect-duplicate-headers
const received = email.headers
.filter(header => header.key === 'received')
.map(header => header.value)Version 3 preserves document order. For single-value properties such as subject, the first duplicate wins.
Parse a Cloudflare Email Worker message parse-cloudflare-email
import PostalMime from 'postal-mime'
export default {
async email(message, env, ctx) {
const email = await PostalMime.parse(message.raw)
ctx.waitUntil(env.QUEUE.send({
subject: email.subject,
text: email.text,
}))
},
}Avoid logging full bodies or attachment bytes, and enforce routing-layer size policy.
Load the CommonJS export use-commonjs
const PostalMime = require('postal-mime')
const { addressParser } = require('postal-mime')
const email = await PostalMime.parse(rawMessage)Version 3's exports map selects the generated CommonJS file for require callers.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| mailparser | npm | Use Nodemailer's parser when a Node-only service wants its stream API and established mail tooling. |
| emailjs-mime-parser | npm | Use it for a lower-level MIME tree in browser-oriented email applications. |
| mailsplit | npm | Use it when MIME nodes must be inspected or rewritten as an object stream instead of collected into one result. |
More utils guides
lru-cache · ajv · type-fest · p-limit · find-up · js-yaml · the whole shelf →
How this guide is made: grounded in the library's documentation, release notes, changelog, and issue history, on a fixed rubric — not a hands-on install of every release. The 50 most-downloaded entries are additionally install-verified in clean containers. Corrections: contact the desk.

