Skip to content
LogoLogo

@portable/mime-headers

Email header values: encoded-words (RFC 2047), folding (RFC 5322), parameters with RFC 2231 encoding, and address lists. Runs in browsers, Cloudflare Workers, Deno, Bun and Node 24+.

Replaces the header functions of libmime, addressparser (now nodemailer/lib/addressparser) and email-addresses.

npm install @portable/mime-headers
import * as MimeHeaders from '@portable/mime-headers';
 
MimeHeaders.fold(
  'Subject: ' + MimeHeaders.encodeWords('Tere õhtust, Mari'),
);
// 'Subject: Tere =?UTF-8?Q?=C3=B5htust=2C?= Mari'
MimeHeaders.decodeWords('Tere =?UTF-8?Q?=C3=B5htust=2C?= Mari'); // 'Tere õhtust, Mari'
 
MimeHeaders.parseValue("attachment; filename*=UTF-8''%C3%B5.pdf");
// { value: 'attachment', params: { filename: 'õ.pdf' } }
MimeHeaders.formatValue({
  value: 'attachment',
  params: { filename: 'õ.pdf' },
});
// "attachment; filename*0*=utf-8''%C3%B5.pdf"
 
MimeHeaders.parseAddresses(
  'Mari Maasikas <mari@example.com>, Team: a@example.com;',
);
// [{ name: 'Mari Maasikas', address: 'mari@example.com' },
//  { name: 'Team', group: [{ name: '', address: 'a@example.com' }] }]
MimeHeaders.formatAddresses([
  { name: 'Maasikas, Mari', address: 'mari@example.com' },
]);
// '"Maasikas, Mari" <mari@example.com>'
 
MimeHeaders.parseHeaders(
  'Subject: Tere\r\n õhtust\r\nTo: mari@example.com\r\n\r\nbody',
);
// [{ name: 'Subject', value: 'Tere õhtust' }, { name: 'To', value: 'mari@example.com' }]

API

Functions come in pairs. encode* and decode* convert text to encoded-words and back, and parse* and format* read structured values from header text and write them back. Every function that takes text also takes a Uint8Array, which it reads as UTF-8.

ExportTakesReturns
encodeWord(text, options?)textall of it as encoded words
encodeWords(text, options?)textthe text, with the part that needs it as encoded words
decodeWords(text)a header valuethe value with its encoded words decoded
fold(line, options?)a header linethe line with a CRLF inserted before whitespace where it is too long
unfold(text)folded textthe text with every CRLF before whitespace removed
parseHeaders(text, options?)a header blockHeader[], { name, value } in order, unfolded
parseValue(text)a value such as text/plain; charset=utf-8Value, { value, params }
formatValue(value)Valuethe header value
parseAddresses(text, options?)an address listAddress[], each a Mailbox { name, address } or a Group { name, group }
parseMailboxes(text, options?)an address listMailbox[], with the members of each group in place of the group
formatAddresses(addresses, options?)Address[]the address list
InvalidInputErrorthrown by parseAddresses and parseMailboxes in strict mode and by formatValue and formatAddresses
MaxLengthErrorthrown by parseHeaders when the header block is longer than maxLength. The error stores the limit in maxLength.

decodeWords joins encoded words that only whitespace separates, and decodes adjacent words in the same charset together, so a character split across two words still decodes. It decodes the charset with TextDecoder, and reads the text as UTF-8 if TextDecoder doesn't know the label, such as utf-7. It never throws.

parseValue lowercases parameter names, joins RFC 2231 sections (name*0*, name*1*), decodes their charset, and decodes encoded words in plain values. When a parameter has both forms, parseValue uses the RFC 2231 one (RFC 6266 section 4.3). formatValue quotes values that need it and writes values outside printable ASCII, or ones that look like encoded words, as UTF-8 RFC 2231 sections.

parseAddresses reads RFC 5322 address lists with the obsolete syntax, RFC 6532 UTF-8, comments, groups and encoded-word names. In the default mode it never throws and reads malformed input the way mail clients do: an unquoted name split by a comma is joined back, anything before a stray < is dropped, and text around the address becomes the name. A quoted string is never searched for an address. With strict: true it throws InvalidInputError for anything RFC 5322 doesn't allow.

Options

OptionOfDefaultDescription
wordEncodingencodeWord, encodeWords, formatAddresses'Q''Q' keeps ASCII readable, 'B' is base64.
wordLengthencodeWord, encodeWords75Longest encoded word, =? and ?= included, as RFC 2047 requires. Longer text is split into several words, and no character is split across two words. With Infinity, the text is written as one encoded word whatever its length.
charsetencodeWord, encodeWords, formatAddressesUTF-8A CharsetEncoder, { name, encode(text) }, for mail that has to use another charset. Every charset from @portable/charset is one.
lineLengthfold76Longest line, not counting the line break. A word longer than a line is not broken.
strictparseAddresses, parseMailboxesfalseThrow InvalidInputError on input RFC 5322 doesn't allow.
maxLengthparseHeadersInfinityLongest header block in UTF-16 code units. parseHeaders throws MaxLengthError for a longer block. The body doesn't count toward the limit.

Types

TypeDescription
MimeHeaders.AddressOne entry of an address list: a Mailbox or a Group.
MimeHeaders.Mailbox{ name, address }. address is '' when the entry has no address.
MimeHeaders.Group{ name, group }, a named group of mailboxes.
MimeHeaders.Header{ name, value }, one header field from parseHeaders.
MimeHeaders.Value{ value, params }, a header value with parameters.
MimeHeaders.CharsetEncoder{ name, encode(text) }, the charset that encoded words are written in. encode is called once per word, so the bytes from each call must decode on their own.
MimeHeaders.WordEncoding'Q' | 'B', the encodings of an encoded-word.

Why use this over libmime, addressparser and email-addresses

It has no dependencies outside @portable/*, takes and returns plain strings and Uint8Arrays, and decodes charsets with TextDecoder instead of bundling iconv-lite. parseAddresses replaces both addressparser and email-addresses, with a lenient mode for mail as it arrives and a strict mode that validates RFC 5322.

  • In the benchmarks on Node 24, parseAddresses was 2.4× faster than addressparser and 56× faster than email-addresses (24× in strict mode). Against libmime, fold was 7.7× faster than foldLines and parseHeaders was 3.7× faster than decodeHeaders, while encodeWords was 2.7× faster and formatValue was 2.2× faster. decodeWords was 1.9× faster on Q words and 1.4× faster on B words, and parseValue was 1.5× faster. encodeWord wrote B words as fast as libmime. Buffer and TextDecoder decode a single B word 3× faster than decodeWords, which also finds the words in the text and joins characters split across them.
  • parseAddresses reads an address hidden in a quoted local part, such as "evil@evil.com x"@good.com, as the quoted local part at good.com. addressparser before nodemailer 7.0.7 sent it to evil.com (GHSA-mm7p-fcc7-pg87), and 10.2.1 still moves a quoted display name that contains "@" into the address.
  • parseAddresses never takes the address from a comment: (evil@evil.com) Name <good@good.com> parses to good@good.com. nodemailer fixed this in 10.1.4.
  • encodeWords encodes text that looks like an encoded word, so a reader can't decode a subject the sender didn't encode. libmime 5.5.1 leaves it as is.
  • encodeWord keeps every astral character whole when it splits B words. libmime 5.4.9 turns a character outside the emoji block into two U+FFFD.
  • formatAddresses encodes a CRLF in a name and refuses an address that would read back as something else, so a header can't be split into two.
  • Every function runs in linear time on hostile input, and the tests check this with 45 adversarial inputs.
  • libmime's encodeWord writes one word unless you give it a length, but this package's encodeWord limits words to 75 characters by default. If you passed libmime a length of 0, pass wordLength: Infinity.
  • In a Q word, encodeWord writes a trailing space as _, where libmime writes =20.
  • encodeWords encodes words that contain control characters. libmime leaves them as they are.
  • libmime's encodeWords takes bytes in another charset, and encodeWords reads a Uint8Array as UTF-8, so decode such bytes with TextDecoder first.
  • fold allows a line of exactly lineLength, where libmime's foldLines keeps its lines one character shorter. fold never breaks in front of trailing whitespace, which would leave a line of only whitespace.
  • libmime's decodeHeaders lowercases names, collapses the whitespace a fold leaves to one space and groups values by name. parseHeaders keeps the case of names and the whitespace, and returns a list in order, so lowercase the names yourself to group them.
  • libmime's parseHeaderValue returns the decoded parameters in decodedParams. parseValue has no decodedParams, because it decodes every parameter in params.
  • Unlike libmime's parseHeaderValue, parseValue reads a backslash as an escape only inside quotes.
  • formatValue throws InvalidInputError for a control character in the value or a parameter name, where libmime 5.5's buildHeaderValue drops it. It marks every section of an encoded value with *.
  • libmime's encodeFlowed, decodeFlowed, detectMimeType and charset functions are not in this package.
  • parseAddresses decodes the encoded words in names. addressparser and email-addresses return them encoded.
  • parseAddresses drops quotes a local part doesn't need, inside angle brackets as well, so <"user"@example.com> gives user@example.com. It keeps whitespace inside a quoted name, where email-addresses collapses it.
  • When malformed input can be read two ways, parseAddresses takes the first complete address and makes the rest the name: <x@evil.com @good.com> gives x@evil.com named @good.com, where addressparser returns the whole value. PARITY.md lists each case.
  • email-addresses' parseAddressList and parseOneAddress return null for invalid input, and parseAddresses with strict: true throws InvalidInputError. There is no single-address function, so check that the result has one entry.
  • Strict mode accepts the obsolete syntax RFC 5322 says to read, such as H.P. Lovecraft <a@b.c>, and UTF-8 (RFC 6532). email-addresses' strict refuses the first and needs rfc6532: true for the second. parseAddresses has no equivalent of email-addresses' rejectTLD, atInDisplayName, commaInDisplayName or addressListSeparator options.
  • email-addresses returns local and domain fields, and parseAddresses returns only address, so split it at its last @. There is no IDN conversion: user@münchen.de stays as written.
@portable/mime-headerslibmimeaddressparseremail-addressesNode Buffer and TextDecoder
Decode 200 Q-encoded subjects1,130602–––
Decode 200 B-encoded words2,4701,810––7,530
Encode 200 subjects1,720631–––
Encode 200 subjects as B words7,3207,600––3,450
Fold a 2,000-word header79,20010,300–––
Parse 200 folded headers18,1004,930–––
Parse 200 header values with parameters1,330864–––
Format 200 header values with parameters1,880837–––
Parse 500 addresses2,070–86136.8–
Parse 500 addresses in strict mode969––41.0–

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-headerslibmimeaddressparseremail-addressesNode Buffer and TextDecoder
Minified and gzipped8.1 kB258 kB3.6 kB3.1 kBbuilt in
Runtime dependencies4500–
Node built-ins usednonebuffer, stream, string_decodernonenoneNode only
TypeScript typesincludednonenoneincluded–
Version compared–5.4.910.2.15.0.0–

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.