@portable/mime-stream
Splits a raw MIME message into a stream of header blocks, bodies and multipart structure, and writes it back out byte for byte. Use it to change a message on its way through, such as removing attachments or hashing bodies, without parsing the whole message into objects. Runs in browsers, Cloudflare Workers, Deno, Bun and Node 24+.
Replaces mailsplit (now @zone-eu/mailsplit). To parse a message into text, HTML and attachments, use postal-mime.
npm install @portable/mime-streamimport * as MimeStream from '@portable/mime-stream';
// Replace every PDF attachment with a note, and keep the rest of the message as it was.
const output = message
.pipeThrough(new MimeStream.DecoderStream())
.pipeThrough(
new TransformStream<
MimeStream.Chunk,
MimeStream.EncoderStream.Input
>({
transform(chunk, controller) {
if (chunk.part.contentType !== 'application/pdf') {
controller.enqueue(chunk);
} else if (chunk.type === 'headers') {
controller.enqueue(
'Content-Type: text/plain\r\n\r\nAttachment removed.',
);
}
},
}),
)
.pipeThrough(new MimeStream.EncoderStream());message is a ReadableStream<Uint8Array>, such as request.body or file.stream().
To read only the header block, for example to check a DKIM signature, use splitHeaders. It doesn't look for parts, so the body passes through untouched:
const { headers, body } = await MimeStream.splitHeaders(message);
// headers: the header block as received, with the empty line that ends it
// body: a ReadableStream of every byte after itAPI
| Export | Description |
|---|---|
new DecoderStream(options?) | TransformStream<Uint8Array, Chunk>. Splits a message into chunks. Nothing is decoded: bodies are still base64 or quoted-printable. |
new EncoderStream(options?) | TransformStream<Input, Uint8Array>. Writes each chunk's bytes. It also takes a Uint8Array, or a string, which it writes as UTF-8. |
splitHeaders(stream, options?) | Resolves to { headers, body }: the root header block as a Uint8Array and the rest as a ReadableStream<Uint8Array>. If the input ends before an empty line, all of it is the header block. |
MaxHeaderBytesError | Thrown when a header block is longer than maxHeaderBytes. Stores maxHeaderBytes, and extends Errors.MaxBytesError, so maxBytes holds the same limit. |
MaxBoundaryLineBytesError | Thrown when a boundary line is longer than maxBoundaryLineBytes. Stores maxBoundaryLineBytes, and extends Errors.MaxBytesError, so maxBytes holds the same limit. |
MaxPartsError | Thrown when a message has more than maxParts parts. Stores maxParts. |
A Chunk is { type, part, bytes }. Joining every chunk's bytes in order gives back the input.
type | bytes | part |
|---|---|---|
'headers' | A header block, with the empty line that ends it. | The part the headers belong to. Every part starts with one. |
'body' | Content of a part with no children. | That part. |
'preamble' | Text before the first delimiter. | The multipart. |
'delimiter' | A boundary line, with the line break before it and its own line break. | The multipart. |
'epilogue' | Text after the close delimiter. | The multipart. |
A Part is a plain object, shared by every chunk of that part:
| Field | Description |
|---|---|
parent | The multipart, or the message/rfc822 part, that contains it. Missing on the root. |
headers | The header fields, { name, value }[] in order and unfolded, read as UTF-8. See @portable/mime-headers to decode values. |
contentType | Lowercase media type without parameters. text/plain when missing or invalid, and message/rfc822 inside multipart/digest (RFC 2046). |
encoding | Lowercase Content-Transfer-Encoding, 7bit when missing. |
boundary | Set on a multipart/* part that has a boundary. Its children follow. |
Parts are split as RFC 2046 says. A delimiter is -- and the boundary on a line of its own, optionally followed by -- (the close delimiter) and spaces or tabs. The line break before a delimiter belongs to the delimiter, so a body never ends with it. Lines may end in CRLF or LF. A delimiter of an outer multipart also ends every multipart inside it, and a close delimiter in the preamble is preamble text. After the close delimiter, lines that look like delimiters of that multipart are epilogue text.
Options
The size limits default to Infinity. Set maxHeaderBytes, maxBoundaryLineBytes and maxParts when you read mail from outside your program, because without them a message can make the decoder buffer a header block or boundary line of any size, or report any number of parts. maxHeaderBytes: 1024 * 1024, maxBoundaryLineBytes: 1000 and maxParts: 1000 are enough for real mail.
| Option | Of | Default | Description |
|---|---|---|---|
embedded | DecoderStream | false | Split the message inside a message/rfc822 or message/global part into parts too, unless it is base64 or quoted-printable. Its root part's parent is the message/rfc822 part. |
maxHeaderBytes | DecoderStream, splitHeaders | Infinity | Longest header block of a part, counted with the empty line that ends it. |
maxBoundaryLineBytes | DecoderStream | Infinity | Longest boundary line, line break included. Only whitespace after the boundary makes a line this long. Other lines are never held in memory. |
maxParts | DecoderStream | Infinity | Most parts in a message, the root and embedded messages included. |
signal | all | When it aborts, the streams fail with signal.reason and cancel their source. splitHeaders rejects with it while it reads the header block, and fails body with it after. |
Types
| Type | Description |
|---|---|
MimeStream.Chunk | A run of bytes from the message, with its type and the part it belongs to. |
MimeStream.Part | One part of the message: the root, a part of a multipart, or the message inside a message/rfc822 part. |
MimeStream.EncoderStream.Input | What EncoderStream takes: a Chunk or any object with bytes, a Uint8Array, or a string. |
Why use this over mailsplit
mailsplit is a Node Transform stream with libmime, libqp and libbase64 as dependencies. DecoderStream is a TransformStream with no dependencies outside @portable/*, so it reads a fetch body or a Workers email in any runtime.
- Splitting and joining 1.5 MB of mail took 1.3 ms on Node 24, against 7.9 ms for mailsplit.
splitHeadersreads the header block and passes on the body of the same message in 0.06 ms. mailsplit has no equivalent, and withignoreEmbeddedit took 7.9 ms. - A part that has headers and no body, followed by a delimiter (
--B\r\nX-Only: headers\r\n--B), is kept. mailsplit 5.4.23 drops its bytes, so its output is not the input. - A delimiter followed by spaces or tabs is read as a delimiter, as RFC 2046 allows. mailsplit reads it as body text, so it sees different parts from mail clients.
- Header values are decoded as UTF-8 and the raw bytes are kept in the
headerschunk. mailsplit returns Latin-1 strings, which turns UTF-8 addresses into mojibake (mailsplit #21). - A part with no
Content-Typeistext/plain, as RFC 2045 says. mailsplit guesses a type from the file name. - Chunks are plain objects. To change a header, enqueue new bytes for the
headerschunk, for example fromMimeHeaders.fold. There is no headers object to keep in sync. - mailsplit splits embedded messages unless
ignoreEmbeddedis set.DecoderStreamsplits them only withembedded: true. - mailsplit's
maxHeadSizedefaults to 1 MiB and counts each line as at least 64 bytes.maxHeaderBytescounts bytes and defaults to no limit, so passmaxHeaderBytes: 1024 * 1024to keep mailsplit's limit. - mailsplit's
maxChildNodesdefaults to 1000, andmaxPartsdefaults to no limit, so passmaxParts: 1000to keep mailsplit's limit. - mailsplit's limit errors have the
code'EMAXLEN'. For an exceeded limit,DecoderStreamthrowsMaxHeaderBytesError,MaxBoundaryLineBytesErrororMaxPartsError. - A part has no
charset,filenameordispositionfield as mailsplit's nodes do. Read them withMimeHeaders.parseValuefrom theContent-TypeorContent-Dispositionfield. - mailsplit's
headers.mboxandheaders.httphave no equivalent. The mboxFromline or HTTP status line stays at the start of the rootheaderschunk, andpart.headersskips it. part.headersskips a first line that starts with whitespace and a line with no valid field name. A bare CR doesn't end a line.encodingdrops every comment, including an unclosed one, sobase64 (xisbase64. mailsplit keeps the text when a comment isn't closed.- An empty part between two delimiters is reported as a part. mailsplit reports nothing for it.
| @portable/mime-stream | mailsplit | TransformStream copy | |
|---|---|---|---|
| Split and join 1.5 MB of mail | 745 | 126 | 13,600 |
| Read the header block of 1.5 MB of mail | 17,000 | 126 | – |
Operations per second from pnpm bench, higher is better, in Node 24.22.0, on Intel(R) Xeon(R) Processor @ 2.10GHz (4 cores). A dash means the library has no equivalent.
| @portable/mime-stream | mailsplit | TransformStream copy | |
|---|---|---|---|
| Minified and gzipped | 5.5 kB | 263 kB | built in |
| Runtime dependencies | 5 | 6 | – |
| Node built-ins used | none | buffer, path, stream, string_decoder | none |
| TypeScript types | included | included | – |
| Version compared | – | 5.4.23 | – |
Sizes cover the imports in the usage example, or the equivalent imports of each library, bundled for the browser by scripts/bundle-size.ts. Runtime dependencies count every package installed with the published package. A library that uses Node built-ins needs polyfills in a browser and the nodejs_compat flag in Cloudflare Workers.