postal-mime
postal-mime parses a raw RFC 822 email into a JavaScript object containing decoded headers, addresses, text and HTML bodies, threading identifiers, and attachment bytes. The same zero-dependency package runs in Node.js, browsers, Web Workers, serverless functions, and Cloudflare Email Workers, with ESM, CommonJS, and TypeScript declarations. It parses message structure only: it does not sanitize HTML, verify sender authenticity, scan malware, fetch mail, or send a reply.
postal-mime is an excellent small parser when the same code must run across browser, Worker, and Node environments. Treat its output as untrusted structured input and add independent size limits, HTML sanitization, authentication checks, and attachment scanning.
Use it if
- You receive raw email in a browser, Worker, serverless function, or Node service and need one cross-runtime parser
- You need typed access to nested MIME parts, address groups, inline images, and attachments
- A zero-runtime-dependency parser is preferable to a Node-only mail stack
- You need explicit nesting and header-size controls for untrusted messages
- You need DKIM, SPF, DMARC, spam, or malware decisions; the documented result contains parsed data but no authentication or security verdict
- You will render `email.html` directly: the API returns the sender's HTML string and provides no sanitization option, so a separate HTML sanitizer and content policy are required
- You need a streaming transformation that never holds attachment data in memory; `parse()` resolves to a complete object whose attachments contain ArrayBuffers or strings
- You are building or sending MIME messages rather than parsing them; this package's public API is parse, addressParser, and decodeWords
- Your security scanner assumes `attachments` is exhaustive after applying nesting limits; the README warns content below `maxRfc822NestingDepth` stays inside a flagged attachment and must be handled separately
Setup reality
`npm install postal-mime` has no runtime dependencies. Node ESM imports the package root, CommonJS can `require('postal-mime')`, and browser examples import `src/postal-mime.js`; real browser projects should let a bundler resolve that source rather than expose `node_modules` publicly. `PostalMime.parse` is asynchronous and accepts a string, Buffer, Blob, ArrayBuffer, Uint8Array, or ReadableStream, but the returned object materializes bodies and attachment content, so cap the raw message size before parsing. Attachment content defaults to ArrayBuffer; choose `base64` or `utf8` only when the next boundary needs it because either can increase memory or corrupt arbitrary binary data. Security limits are present and validated as non-negative integers: MIME nesting defaults to 256, total header bytes per parser to 2,097,152, and inline message/rfc822 depth to 10. Those controls limit depth, not breadth, and nested RFC 822 messages each get a parser, so a wide multipart message can still be expensive. When `rfc822DepthExceeded` is true, deeper content was intentionally left opaque; a scanner must reject it or reparse under its own global work budget. Address fields use a union of mailbox and group shapes, requiring a type guard before reading `.address`. Finally, parsing does not make hostile HTML safe, authenticate the sender, or validate attachment content, so those production steps remain separate.
Patterns
Parse a raw email stringparse-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);The parser returns untrusted content; do not render HTML or trust address strings without downstream policy checks.
Parse an email file in Node.jsparse-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);Reading the whole file and parsing attachments both consume memory; reject oversized files before `readFile` in an upload path.
Return attachment content as base64choose-attachment-encoding
import PostalMime from 'postal-mime';
const email = await PostalMime.parse(rawMessage, {
attachmentEncoding: 'base64',
});
for (const attachment of email.attachments) {
console.log(attachment.filename, attachment.encoding, attachment.content);
}Base64 increases the representation size; the default ArrayBuffer is better when you will write or inspect binary bytes directly.
Set parser depth and header limitslimit-untrusted-message
const email = await PostalMime.parse(rawMessage, {
maxNestingDepth: 64,
maxHeadersSize: 512 * 1024,
maxRfc822NestingDepth: 5,
});These values limit nesting and headers, not raw message bytes or multipart breadth; enforce a separate total input-size limit.
Handle messages hidden below the RFC 822 depth limitinspect-nested-attachments
for (const attachment of email.attachments) {
if (attachment.rfc822DepthExceeded) {
// Either reject it or reparse under a strict global work budget.
const nested = await PostalMime.parse(attachment.content, {
maxRfc822NestingDepth: 0,
});
await scan(nested);
}
}The README warns that deeper content is absent from the outer text, HTML, and attachment lists, so scanners must check this flag.
Distinguish a mailbox from an address groupnarrow-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);
}Address fields are unions; reading `.address` without narrowing fails for RFC address groups.
Parse and flatten an address headerparse-address-list
import { addressParser } from 'postal-mime';
const recipients = addressParser(
'Team: Ada <ada@example.com>, Lin <lin@example.com>;',
{ flatten: true },
);
console.log(recipients);Flattening discards group structure, which is convenient for delivery lists but loses the original semantic grouping.
Decode MIME encoded wordsdecode-header-words
import { decodeWords } from 'postal-mime';
const subject = decodeWords('Hello, =?utf-8?B?44Ko44Od44K544Kr44O844OJ?=');Use this utility for an isolated encoded header value; full message parsing already decodes standard structured fields.
Read a header not promoted to a propertyfind-raw-header
const listId = email.headers.find(({ key }) => key === 'list-id')?.value;Header keys in the returned array are lowercase, and repeated headers remain separate entries.
Parse a Cloudflare Email Worker messageparse-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 }));
},
};Bound the accepted message size at the email-routing boundary and avoid logging full bodies or attachments.
Load the CommonJS builduse-commonjs
const PostalMime = require('postal-mime');
const { addressParser, decodeWords } = require('postal-mime');
const email = await PostalMime.parse(rawMessage);The package provides a generated CommonJS build as well as ESM, so no dynamic-import workaround is required.
Sanitize HTML before browser renderingsanitize-html-body
import DOMPurify from 'dompurify';
const safeHtml = DOMPurify.sanitize(email.html || '', {
FORBID_TAGS: ['style'],
FORBID_ATTR: ['srcset'],
});
container.innerHTML = safeHtml;PostalMime parses HTML but does not sanitize it; also proxy or block remote image URLs if sender-controlled tracking is a concern.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| mailparser | npm | A Node-only service wants Nodemailer's established parser and Node stream integration |
| emailjs-mime-parser | npm | You want a lower-level MIME parser for browser-oriented email tooling |
| mailsplit | npm | You need streaming MIME node rewriting or inspection rather than one fully materialized result |