Repository navigation
Mail Mime
parseMessage() turns raw RFC 5322 bytes into a MailMessage: decoded
headers, the MIME part tree, and a flattened view of text, HTML and
attachments. It is synchronous, pure, and never throws on malformed input.
import { parseMessage } from '@tundralibs/mail/mime';
declare const raw: Uint8Array; // e.g. ImapMessage.body
const message = parseMessage(raw);
message.subject; // 'Café order – receipt'
message.from; // [{ name: 'Ada Lövelace', address: 'ada@example.com' }]
message.messageId; // 'm1@example.com'
message.references; // ['m-1@example.com', 'm0@example.com']
message.date; // Date | undefined
message.text; // first text/plain body, as sent
message.html; // first text/html body, as sent; never render it unsanitised
for (const a of message.attachments) {
a.filename; // 'résumé.pdf' (RFC 2231 decoded)
a.contentType; // 'application/pdf'
a.disposition; // 'attachment' | 'inline'
a.contentId; // for cid: references from the HTML
a.content; // Uint8Array, transfer-decoded
}
if (message.autoSubmitted || message.precedence === 'bulk' || message.listId) {
// an auto-reply, a bounce or list mail: do not answer it
}| Field | Source headers | Notes |
|---|---|---|
headers |
all | Unfolded, RFC 2047 decoded, original names and order |
subject |
Subject | Encoded words decoded, adjacent words joined without the whitespace |
from, to, cc, bcc, replyTo
|
the address headers | Groups flattened, addr (Name) comments become names |
sender |
Sender | First mailbox |
date |
Date | RFC 5322 incl. obsolete zones and two-digit years; undefined if not |
messageId, inReplyTo, references
|
Message-ID, In-Reply-To, References | Angle brackets removed; bare ids accepted |
autoSubmitted, precedence
|
Auto-Submitted, Precedence | Lowercased |
listId, xAutoreply
|
List-Id, X-Autoreply | As sent |
text, html, attachments
|
the part tree | See below |
root |
the whole message | The MailPart tree |
Every MailPart carries its own headers, contentType (type, subtype,
params, all lowercased keys), encoding, disposition, filename,
contentId, charset, the transfer-decoded content, text for text/*
parts, children for multiparts, and message for a message/rfc822
part, which is parsed recursively.
The tree is walked depth first. A leaf is a body when it is text/*
(or a multipart with no usable boundary, read as text), has no filename,
and is not Content-Disposition: attachment. The first text/plain body
becomes text and the first text/html body becomes html; a second
body of the same kind, as in a multipart/mixed with two text parts, is
appended with a newline. Every other leaf, and every message/rfc822
part, is an attachment. An attachment is inline when its disposition says
so, or when it has a Content-ID and no attachment disposition.
Transfer encodings: base64 (whitespace and stray bytes skipped, URL-safe
alphabet accepted), quoted-printable (soft breaks, protected trailing
whitespace, a lone = kept), and identity for 7bit, 8bit, binary and
anything unknown.
A text/* part is decoded with the runtime's TextDecoder under its
declared charset. A label the runtime does not know, or no label at all,
falls back to strict UTF-8 and then to Latin-1, so a string always comes
back. The declared label is kept in part.charset for callers that want
to know. Header blocks follow the same chain: UTF-8 when valid, Latin-1
otherwise, then RFC 2047 words decode under their own charsets.
Which labels TextDecoder knows differs by runtime; UTF-8, UTF-16 and the
ISO-8859 / Windows families are safe everywhere.
Real mail is malformed often enough that the parser degrades instead of throwing:
| Input | Result |
|---|---|
| LF-only line endings | Parsed as CRLF |
| Header line with no colon | Dropped |
| Continuation line with nothing before | Dropped |
| Bare 8-bit text in a header | Decoded as UTF-8, else Latin-1 |
| Unterminated or misspelt encoded word | Left as written |
Unparseable Date
|
date is undefined
|
<addr with no closing >
|
Address taken to the end |
| Multipart with no boundary | Body read as plain text |
| Missing closing boundary | Last part runs to the end |
| No blank line after the headers | Whole input is headers; text is empty |
Content-Transfer-Encoding: base64; |
Parameters and junk after ; ignored |
| Unknown charset | Latin-1 fallback |
| Empty input | Empty message: empty lists, text is empty |
The pieces the parser is built from are exported for callers working with
headers on their own, for example an IMAP ENVELOPE or a webhook payload.
| Function | Does |
|---|---|
parseRawHeaders(bytes) |
Header block to { name, value }[], unfolded, not RFC 2047 decoded |
getHeader(headers, name) |
First value, case-insensitive; getHeaders for all |
decodeEncodedWords(text) |
RFC 2047 =?charset?B/Q?…?= words |
parseAddressList(text) |
Address header to MailAddress[]
|
parseMessageIds(text) |
<id> list to string[]
|
parseDate(text) |
RFC 5322 date to Date, or undefined
|
parseParameterized(text) |
value; key=val with RFC 2231 continuations and charsets |
parseContentType(text) |
MailContentType, defaulting to text/plain
|
decodeTransfer(bytes, encoding) |
Apply a Content-Transfer-Encoding; decodeBase64, decodeQuotedPrintable individually |
decodeText(bytes, charset?) |
The charset fallback chain |
import { decodeEncodedWords, parseAddressList } from '@tundralibs/mail/mime';
decodeEncodedWords('=?UTF-8?Q?Caf=C3=A9?= =?UTF-8?Q?_order?='); // 'Café order'
parseAddressList('Team: a@example.com, "Bee, B" <b@example.com>;');
// [{ address: 'a@example.com' }, { name: 'Bee, B', address: 'b@example.com' }]- Mail-Imap - Fetch the bytes this parses.