postal-mime is an email parsing library for Node.js, browsers (including Web Workers), and serverless environments (like Cloudflare Email Workers). It takes in a raw email message (RFC822 format) and outputs a structured object containing headers, recipients, attachments, and more.
Tip
PostalMime is developed by the makers of EmailEngine, a self-hosted email gateway that provides a REST API for IMAP and SMTP servers and sends webhooks whenever something changes in registered accounts.
- Browser & Node.js compatible - Works in browsers, Web Workers, Node.js, and serverless environments
- Written in TypeScript - Type declarations are generated from the source and shipped for both module formats
- Dual package - Published as ES modules and as CommonJS, compiled from the same source
- Zero dependencies - No external dependencies
- RFC compliant - Follows RFC 2822/5322 email standards
- Handles complex MIME structures - Multipart messages, nested parts, attachments
- Security limits - Built-in protection against deeply nested or very wide messages, oversized headers and runaway nested message parsing
Note
Full documentation is available at postal-mime.postalsys.com.
The source code is available on GitHub.
Try out a live demo using the example page.
Install the module from npm:
npm install postal-mimeThe package needs a runtime with the TextDecoder, Blob and ReadableStream globals, which means Node.js 20 or newer, any modern browser, Deno, Bun or Cloudflare Workers.
Version 4 is the TypeScript rewrite. Code that imports postal-mime by name keeps working and the parsed output is unchanged. What did change:
- Browser deep imports. The ES module build moved from
src/postal-mime.jstodist/esm/postal-mime.js. Only code that loaded the file fromnode_modulesby its path is affected, see Browser. - Node.js 20 or newer is required.
Attachment.dispositionis typed asAttachmentDisposition | null. The parser has always passed the Content-Disposition token through as it is, so the type now says so instead of claiming that onlyattachmentandinlineoccur.- Stream input is typed as
ReadableStream<Uint8Array>. AReadableStream<ArrayBuffer>orReadableStream<string>never parsed correctly and no longer type-checks. AnyArrayBufferView, including aDataView, is accepted. addressParserno longer reads the undocumented_depthoption.- CommonJS TypeScript projects get a declaration that matches what
require()returns, see CommonJS. A project that compiles with bothesModuleInteropandallowSyntheticDefaultImportsswitched off has to useimport PostalMime = require('postal-mime')instead of a default import.
You can import the PostalMime class differently depending on your environment:
With a bundler such as Vite, webpack or esbuild, import the package by name and the bundler picks up the ES module build:
import PostalMime from 'postal-mime';To load PostalMime in the browser without a bundler (including in Web Workers), import the ES module build directly:
import PostalMime from './node_modules/postal-mime/dist/esm/postal-mime.js';
const email = await PostalMime.parse(`Subject: My awesome email 🤓
Content-Type: text/html; charset=utf-8
<p>Hello world 😵💫</p>`);
console.log(email.subject); // "My awesome email 🤓"TypeScript
import PostalMime from 'postal-mime';
import type { Email } from 'postal-mime';
const email: Email = await PostalMime.parse(`Subject: My awesome email 🤓
Content-Type: text/html; charset=utf-8
<p>Hello world 😵💫</p>`);
console.log(email.subject); // "My awesome email 🤓"In Node.js (including serverless functions), import it directly from postal-mime:
import PostalMime from 'postal-mime';
import util from 'node:util';
const email = await PostalMime.parse(`Subject: My awesome email 🤓
Content-Type: text/html; charset=utf-8
<p>Hello world 😵💫</p>`);
// Use 'util.inspect' for pretty-printing
console.log(util.inspect(email, false, 22, true));TypeScript
import PostalMime from 'postal-mime';
import type { Email, PostalMimeOptions } from 'postal-mime';
import util from 'node:util';
const options: PostalMimeOptions = {
attachmentEncoding: 'base64'
};
const email: Email = await PostalMime.parse(
`Subject: My awesome email 🤓
Content-Type: text/html; charset=utf-8
<p>Hello world 😵💫</p>`,
options
);
// Use 'util.inspect' for pretty-printing
console.log(util.inspect(email, false, 22, true));For projects using CommonJS (with require()), postal-mime resolves to its CommonJS build:
const PostalMime = require('postal-mime');
const { addressParser, decodeWords } = require('postal-mime');
const email = await PostalMime.parse(`Subject: My awesome email 🤓
Content-Type: text/html; charset=utf-8
<p>Hello world 😵💫</p>`);
console.log(email.subject); // "My awesome email 🤓"TypeScript
The CommonJS declaration exports the class with export =, the shape require() returns, so both import forms are typed the same way, including under verbatimModuleSyntax. The types are available as members of the imported class:
import PostalMime = require('postal-mime');
// or, with esModuleInterop
// import PostalMime, { addressParser, decodeWords } from 'postal-mime';
const email: PostalMime.Email = await PostalMime.parse(raw);
const addresses: PostalMime.Address[] = PostalMime.addressParser('Name <name@example.com>');Note
The ES module build in dist/esm/ and the CommonJS build in dist/cjs/ are compiled from the same TypeScript source, and each ships its own type declarations. require('postal-mime') returns the PostalMime class itself, with addressParser and decodeWords attached as properties.
Use the message.raw as the raw email data for parsing:
import PostalMime from 'postal-mime';
export default {
async email(message, env, ctx) {
const email = await PostalMime.parse(message.raw);
console.log('Subject:', email.subject);
console.log('HTML:', email.html);
console.log('Text:', email.text);
}
};TypeScript
import PostalMime from 'postal-mime';
import type { Email } from 'postal-mime';
export default {
async email(message: ForwardableEmailMessage, env: Env, ctx: ExecutionContext): Promise<void> {
const email: Email = await PostalMime.parse(message.raw);
console.log('Subject:', email.subject);
console.log('HTML:', email.html);
console.log('Text:', email.text);
}
};PostalMime is written in TypeScript. The type declarations are generated from the source during the build and are resolved through the package exports map, so no separate @types package is needed. All types can be imported from the main package:
import PostalMime, { addressParser, decodeWords } from 'postal-mime';
import type {
Email,
Address,
Mailbox,
AddressGroup,
Header,
HeaderLine,
Attachment,
AttachmentDisposition,
AttachmentEncoding,
PostalMimeOptions,
AddressParserOptions,
RawEmail
} from 'postal-mime';Email- The main parsed email object returned byPostalMime.parse()Address- Union type representing either aMailboxor anAddressGroupMailbox- Individual email address with name and address fieldsAddressGroup- RFC 5322 address group with a name and a list ofMailboxmembersHeader- Email header with key, original key and valueHeaderLine- Raw header line with key and the complete line as it appeared in the messageAttachment- Email attachment with metadata and contentAttachmentDisposition- The lowercased Content-Disposition token of an attachment,attachmentorinlinefor most partsAttachmentEncoding- The accepted values of theattachmentEncodingoptionPostalMimeOptions- Configuration options for parsingAddressParserOptions- Configuration options for address parsingRawEmail- Union type for all accepted email input formatsPostalMimeError- An error thrown by the parser, anErrorwith acodepropertyErrorCode- Union of all error codes, see Errors
Every optional property is declared as T | undefined, so the types also work in projects that compile with exactOptionalPropertyTypes.
TypeScript users can use type guards to narrow address types:
import type { Address, Mailbox } from 'postal-mime';
function isMailbox(addr: Address): addr is Mailbox {
return addr.group === undefined;
}
// Usage
if (email.from && isMailbox(email.from)) {
console.log(email.from.address); // TypeScript knows this is a Mailbox
}PostalMime.parse(email, options) -> Promise<Email>- email: An RFC822 formatted email. This can be a
string, anArrayBuffer, aUint8Arrayor any otherArrayBufferView(including a Node.jsBufferand aDataView), aBlob, or a ReadableStream. A stream is read to completion before parsing starts. - options: Optional configuration object:
-
rfc822Attachments (boolean, default:
false): Treatmessage/rfc822attachments without a Content-Disposition as attachments. -
forceRfc822Attachments (boolean, default:
false): Treat allmessage/rfc822parts as attachments. -
attachmentEncoding (string, default:
"arraybuffer"): Determines how attachment content is decoded in the parsed email:"base64""utf8""arraybuffer"(no decoding, returnsArrayBuffer)
Any other value throws an
EINVALIDOPTIONerror. -
maxNestingDepth (number, default:
256): Maximum allowed MIME part nesting depth. Throws anEMAXNESTINGDEPTHerror if exceeded. -
maxHeadersSize (number, default:
2097152): Maximum allowed total header size in bytes (default 2MB), counted across every part and every inlinemessage/rfc822sub-message. Each header line counts for at least 64 bytes, since what the parser keeps of a line is far larger than a short line itself. Throws anEMAXHEADERSIZEerror if exceeded. -
maxPartCount (number, default:
10000): Maximum allowed number of MIME parts in a message, the top-level part and the parts of inlinemessage/rfc822sub-messages included. Throws anEMAXPARTCOUNTerror if exceeded. -
maxRfc822NestingDepth (number, default:
10): Maximum allowed recursion depth for inlinemessage/rfc822sub-messages. Nested messages deeper than this are treated as regular attachments instead of being parsed inline, and the resulting attachment hasrfc822DepthExceeded: trueset. Use0to disable inline parsing entirely. -
maxForwardedAddresses (number, default:
10000): Maximum allowed number of From, To, Cc and Bcc addresses across all inlinemessage/rfc822sub-messages, a group and each of its members counted. These addresses are rendered into thetextandhtmlof the message, so the count is shared by every level of nesting. Throws anEMAXFORWARDEDADDRESSESerror if exceeded.
-
The limit options must be non-negative integers, and maxPartCount a positive one, since the message itself is a part. Any other value, including a numeric string, NaN or Infinity, throws an EINVALIDOPTION error. Passing 0 means a literal zero, not "use the default". The options are configuration: a large enough limit is as good as none, so do not pass an options object that comes from untrusted input as it is.
Important
The maxNestingDepth, maxHeadersSize, maxPartCount, maxRfc822NestingDepth and maxForwardedAddresses options provide built-in security against malicious emails with deeply nested or very wide MIME structures or oversized headers that could cause performance issues or memory exhaustion. maxHeadersSize and maxPartCount count the header bytes and the parts of every level of a message together, so a multipart cannot carry the budget again for each part it declares. Each inline message/rfc822 sub-message is parsed by a new parser instance that continues the counts of its parent, so the limits hold for the message as a whole however many sub-messages it carries. maxRfc822NestingDepth bounds how deep such sub-parsers can be nested, and maxForwardedAddresses bounds the addresses that all of them together render into the parent.
The whole message is held in memory while it is parsed, so untrusted input should still be bounded by size before it reaches the parser.
Warning
If you scan messages for malicious content, do not treat attachments as complete without checking rfc822DepthExceeded. Anything nested below maxRfc822NestingDepth stays inside the raw bytes of the flagged attachment and is not reflected in text, html or attachments, so a sender can push a payload past the limit to hide it from a scanner. Re-parse the flagged attachment's content if you need to see inside it:
for (const attachment of email.attachments) {
if (attachment.rfc822DepthExceeded) {
const nested = await PostalMime.parse(attachment.content);
// scan `nested` too, and bound how many times you do this
}
}Warning
The parsed values are the sender's, decoded but not sanitized. html is the message's own markup: run it through a sanitizer such as DOMPurify, or render it in an iframe with sandbox="allow-same-origin" and without allow-scripts, as the demo in example/ does. Otherwise a script in the message runs with the privileges of your page. text is plain text and has to be escaped before it is inserted into HTML. An attachment filename can hold path separators, control characters or bidirectional overrides, so treat it as untrusted before using it as a path or showing it. mimeType, disposition, contentId and method are the lowercased header tokens as sent, free of CR and LF but otherwise unvalidated: a mimeType can hold whitespace, quotes or path separators, and a contentId can lack its angle brackets, hold whitespace or repeat across attachments, so validate them before using one as a path, a file extension or a lookup key.
Note
A malformed message is read the way the parsers a mail scanner is usually built on read it, Python's email package and mailparser in particular, so that a scanner and this parser see the same message:
- The header block ends at the first line that is neither a header field (
Name:, an empty name or an mboxFromline included) nor a fold starting with SP or HTAB. That line and everything after it is the body, even when more header-like lines follow. A line of only whitespace is a fold unless the next line is not a header, in which case it ends the headers. - A bare CR inside a header line is folded into a space in
value, as Go and mailparser read it; it is not a line break, which Python reads it as. - Whitespace between a field name and its colon (
Name :, RFC 5322 obsolete syntax) keeps the line a header, listed under the key with the whitespace in it, as Go and mailparser read it; Python ends the header block there. - A
message/rfc822part with abase64orquoted-printabletransfer encoding, which RFC 2046 forbids, is an attachment with its content decoded, as mailparser returns it, rather than an inline forward. format=flowedis unfolded intext/plainparts only.- A multipart whose
boundaryholds a non-ASCII character has no parts: its body is read as plain text, like that of a multipart without a boundary. - The
Content-Transfer-Encodingis the token at the start of the value, so(comment) base64andbase64 xdecode and a quoted"base64"does not. - The UTF-16 charset labels honoured are the ones Python and iconv-lite both decode:
utf-16,utf16,utf_16,utf-16leandutf-16be. The labelsunicode,csunicode,iso-10646-ucs-2,unicodefeffandunicodefffe, which neither knows, anducs-2,ucs2,utf16leandutf16be, which only iconv-lite knows, fall back like an unknown charset. A byte order mark decides the byte order underutf-16only.
Returns: A Promise that resolves to a structured Email object with the following properties:
-
headers: An array of
Headerobjects, each containing:key: Lowercase header name (e.g.,"dkim-signature"). The name is otherwise kept as written, so a line with whitespace around the name, such as aFrom :line or a first line that starts with a space, is listed under that spelling ("from ") and does not count as thefromheader.originalKey: The header name as written in the message, preserving case.value: Header value as a string, unfolded per RFC 5322 and otherwise unprocessed. Unfolding removes the line break of a folded header and keeps the folding whitespace, soSubject: Hello\r\n Worldreads asHello World. Encoded words are not decoded here.
Headers appear in the order they were sent, including duplicates. Where a single value is exposed on its own property, such as
subjectorfrom, the first occurrence of the header wins. -
headerLines: An array of
HeaderLineobjects in the same order asheaders, each containing:key: Lowercase header name.line: The complete raw header line, including the name and the original line breaks of a folded header. The line is raw, so unlikevalueit can hold a bare CR; sanitize it before writing it into another message.
-
from, sender: Processed
Addressobjects (can be aMailboxor address group):name: Decoded display name, or an empty string if not set.address: Email address.group: Array ofMailboxobjects (only for address groups).
-
deliveredTo, returnPath: Single email addresses as strings.
-
to, cc, bcc, replyTo: Arrays of
Addressobjects (same structure asfrom). -
subject: Subject line of the email.
-
messageId, inReplyTo, references: Values from their corresponding headers.
-
date: The email's sending time in ISO 8601 format (or the original string if parsing fails).
-
html: String containing the HTML content of the email, with CRLF line endings normalized to LF.
-
text: String containing the plain text content of the email, with CRLF line endings normalized to LF. A text or HTML part that declares no charset is read as UTF-8, or as windows-1252 when its bytes are not valid UTF-8, and an HTML part without one may name its charset in a
<meta>tag. -
attachments: Array of
Attachmentobjects:filename: String ornullmimeType: String, the lowercased Content-Type value as sent, not validated (see the warning above)disposition: The lowercased Content-Disposition token, usually"attachment"or"inline", ornullif the part had no such header. Other tokens a message carries are passed through as they arerelated: Boolean (optional,trueif the part sits in amultipart/relatedtree and has a Content-ID, such as an inline image)contentId: String (optional, the Content-ID header value as sent, usually with its angle brackets, not validated)description: String (optional, the decoded Content-Description header)method: String (optional, the uppercasedmethodparameter of a calendar part, such as"REQUEST")rfc822DepthExceeded: Boolean (optional, see the warning above)content:ArrayBufferor string, depending onattachmentEncoding. The decoded bytes of the part exactly as sent, line endings included. Calendar parts are the exception: they are normalized to UTF-8 text with LF line endings and returned as aUint8Arrayencoding:"base64"or"utf8"(optional)
TypeScript Types
import type { Email, Address, Mailbox, Header, Attachment, PostalMimeOptions, RawEmail } from 'postal-mime';
// Main email parsing
const email: Email = await PostalMime.parse(rawEmail);
// With options
const options: PostalMimeOptions = {
attachmentEncoding: 'base64',
maxNestingDepth: 100
};
const email: Email = await PostalMime.parse(rawEmail, options);
// Working with addresses
if (email.from) {
// Address can be either a Mailbox or a Group
if ('group' in email.from && email.from.group) {
// It's a group
email.from.group.forEach((member: Mailbox) => {
console.log(member.address);
});
} else {
// It's a mailbox
const mailbox = email.from as Mailbox;
console.log(mailbox.address);
}
}
// Working with attachments
email.attachments.forEach((att: Attachment) => {
if (att.encoding === 'base64') {
// content is a string
const base64Content: string = att.content as string;
} else {
// content is ArrayBuffer (default)
const buffer: ArrayBuffer = att.content as ArrayBuffer;
}
});Every error the parser throws has a code property that identifies it. Match on code rather than on the message, which may change between releases. PostalMime.parse() reports all of them by rejecting the returned promise. new PostalMime(options) throws EINVALIDOPTION synchronously, and parser.parse() rejects with the rest.
| Code | Error type | Cause |
|---|---|---|
EINVALIDOPTION |
TypeError |
An option has an invalid value: a limit option that is not a non-negative integer (maxPartCount: a positive one), or an unknown attachmentEncoding |
EINVALIDINPUT |
TypeError |
The email is not of a supported type, such as a number or a plain object, or a ReadableStream produced a chunk that is not an ArrayBufferView |
EPARSERREUSED |
Error |
parse() was called a second time on the same PostalMime instance |
EMAXNESTINGDEPTH |
Error |
MIME parts are nested deeper than maxNestingDepth |
EMAXHEADERSIZE |
Error |
The headers of the message are larger than maxHeadersSize |
EMAXPARTCOUNT |
Error |
The message has more MIME parts than maxPartCount |
EMAXFORWARDEDADDRESSES |
Error |
Inline message/rfc822 sub-messages hold more addresses than maxForwardedAddresses |
Errors without a code do not come from the parser itself, for example an error from the ReadableStream or Blob the email is read from, which is passed through unchanged. Malformed message content is not an error: the parser recovers from it and returns what it could read. null, undefined and an empty string parse to an empty message.
import PostalMime from 'postal-mime';
try {
const email = await PostalMime.parse(rawEmail, { maxNestingDepth: 50 });
console.log(email.subject);
} catch (err) {
switch (err.code) {
case 'EMAXNESTINGDEPTH':
case 'EMAXHEADERSIZE':
case 'EMAXPARTCOUNT':
case 'EMAXFORWARDEDADDRESSES':
// the message exceeds a resource limit, it is likely malicious or broken
console.error('Rejected email:', err.message);
break;
default:
// EINVALIDOPTION, EINVALIDINPUT and EPARSERREUSED point to a bug in the calling code
throw err;
}
}import { addressParser } from 'postal-mime';
addressParser(addressStr, opts) -> Address[]- addressStr: A raw address header string.
- opts: Optional configuration:
- flatten (boolean, default:
false): Iftrue, ignores address groups and returns a flat array of addresses.
- flatten (boolean, default:
Returns: An array of Address objects, which can be nested if address groups are present.
An address is only ever read from the raw header. An RFC 2047 encoded word is decoded into the display name, and a word that decodes to something that looks like a mailbox, such as =?utf-8?B?...?= standing for Name <name@example.com>, gives a name and an empty address: the raw header holds no mailbox, so no DMARC verifier has authenticated one.
Example:
import { addressParser } from 'postal-mime';
const addressStr = '=?utf-8?B?44Ko44Od44K544Kr44O844OJ?= <support@example.com>';
console.log(addressParser(addressStr));
// [ { name: 'エポスカード', address: 'support@example.com' } ]TypeScript
import { addressParser } from 'postal-mime';
import type { Address, AddressParserOptions } from 'postal-mime';
const addressStr = '=?utf-8?B?44Ko44Od44K544Kr44O844OJ?= <support@example.com>';
const addresses: Address[] = addressParser(addressStr);
// With options
const options: AddressParserOptions = { flatten: true };
const flatAddresses: Address[] = addressParser(addressStr, options);import { decodeWords } from 'postal-mime';
decodeWords(encodedStr) -> string- encodedStr: A string that may contain MIME encoded-words.
Returns: A Unicode string with all encoded-words decoded.
Example:
import { decodeWords } from 'postal-mime';
const encodedStr = 'Hello, =?utf-8?B?44Ko44Od44K544Kr44O844OJ?=';
console.log(decodeWords(encodedStr));
// Hello, エポスカードTypeScript
import { decodeWords } from 'postal-mime';
const encodedStr = 'Hello, =?utf-8?B?44Ko44Od44K544Kr44O844OJ?=';
const decoded: string = decodeWords(encodedStr);
console.log(decoded); // Hello, エポスカードThe source lives in src/ as TypeScript. npm run build compiles it twice, into dist/esm/ as ES modules and into dist/cjs/ as CommonJS, each with its own type declarations, source maps and declaration maps. dist/ and src/ are published, so stack traces and go-to-definition land in the TypeScript source. The build runs automatically on npm install.
npm install # installs dependencies and builds dist/
npm test # builds, then runs the test suite against src/ and the built package, one file at a time
npm run lint # ESLint and a full type-check of src/ and test/
npm run format # Prettier© 2021-2026 Andris Reinman
postal-mime is licensed under the MIT No Attribution license.