mime - build and parse MIME messages
Import with import "mime.j" as mime;. Builds and parses MIME messages (RFC
5322 headers plus RFC 2045/2046 bodies) - the header-and-boundary structure
behind email. Pure Jennifer over strings, convert, and encoding; it
does no networking, so it runs on either binary and is the
message-structure foundation the mail protocol clients (SMTP / POP3 /
IMAP) build on.
use io;
import "mime.j" as mime;
def msg as mime.Part init mime.text("text/plain", "Hello, café.");
$msg = mime.withHeader($msg, "Subject", "Hi");
io.printf("%s", mime.encode($msg));
def back as mime.Part init mime.parse(mime.encode($msg));
io.printf("%s\n", mime.body($back)); # Hello, café.
Runnable: examples/modules/mime_demo.j.
The Part model
A message is a Part tree. A part is either a leaf (headers plus a
decoded-text body with a transfer encoding) or a multipart container
(headers plus child parts under a boundary):
export def struct Part {
headers as list of Header, body as string, encoding as string,
parts as list of Part, boundary as string
};
export def struct Header { name as string, value as string };
Bodies are held decoded, as text: encode applies the transfer encoding
and parse removes it, so you always read plain content.
Surface
| Call | Returns | Notes |
|---|---|---|
mime.text(contentType, body) | Part | Leaf text part; 7bit if ASCII, else quoted-printable. Adds charset=utf-8. |
mime.attachment(filename, contentType, body) | Part | Base64 leaf with a Content-Disposition filename. |
mime.multipart(subtype, boundary, parts) | Part | Container (multipart/subtype) over one boundary. |
mime.withHeader(part, name, value) | Part | Copy with a header set (case-insensitive replace, else append). |
mime.encode(part) | string | Serialize to a CRLF MIME message with transfer encodings applied. |
mime.parse(text) | Part | Parse a message: unfold headers, split multipart, transfer-decode. |
mime.headerValue(part, name) | string | Header value (case-insensitive) or "". |
mime.body(part) | string | A leaf’s decoded text body. |
mime.parts(part) | list of Part | A container’s child parts. |
mime.contentType(part) | string | Media type without parameters (e.g. text/plain). |
mime.address(name, email) | string | RFC 5322 mailbox: email, or Name <email> (name quoted, or RFC 2047-encoded when non-ASCII). |
mime.encodeWord(text) | string | RFC 2047 UTF-8 base64 encoded-word(s), =?UTF-8?B?...?=, folded when long. |
mime.decodeWord(value) | string | Decode every encoded-word in a header value back to text (B and Q). |
Encoding
encode produces a canonical message with CRLF line endings and picks the
transfer encoding automatically:
- 7bit -
textbodies that are pure ASCII pass through unencoded. - quoted-printable -
textbodies with non-ASCII are QP-encoded (e.g.cafétocaf=C3=A9). - base64 -
attachmentbodies are base64-encoded and folded at 76 columns.
A multipart container writes its child parts between --boundary delimiters
and closes with --boundary--; nesting works (a part can itself be a
multipart).
def parts as list of mime.Part init [];
$parts[] = mime.text("text/plain", "plain");
$parts[] = mime.text("text/html", "<b>rich</b>");
def msg as mime.Part init mime.multipart("alternative", "b0", $parts);
io.printf("%s", mime.encode($msg));
The boundary is yours to pass (not auto-generated), so output is deterministic and testable; pick a string that cannot appear in the content.
Parsing
parse is the inverse: it splits the header block from the body at the first
blank line, unfolds continuation lines (a header value wrapped onto an
indented next line), reads Content-Type / Content-Transfer-Encoding, and
either splits a multipart body on its boundary (recursively) or
transfer-decodes a leaf. encode and parse round-trip:
def back as mime.Part init mime.parse(wire);
for (def part in mime.parts($back)) {
io.printf("%s: %s\n", mime.contentType($part), mime.body($part));
}
Non-ASCII headers (RFC 2047 encoded-words)
Header values must be ASCII on the wire, so a non-ASCII Subject or display
name is carried as an RFC 2047 encoded-word (=?UTF-8?B?...?=). This is
applied automatically and symmetrically:
encodeencodes a non-ASCIISubject/Commentsvalue, and the display-name half of an address header (From/To/Cc/Bcc/Reply-To/Sender), leaving the<addr>untouched. Long values fold into several encoded-words split on rune boundaries.parsedecodes those same headers back to plain text - so a fetchedSubject: =?UTF-8?B?QmVyaWNodCBhdXMgTcO8bmNoZW4=?=reads back asBericht aus München. A word that fails to decode is left verbatim, so a malformed header never crashesparse.mime.address("Jörg Müller", "j@x.de")encodes the name for you.
def m as mime.Part init mime.withHeader(
mime.text("text/plain", "hi"), "Subject", "Grüße aus München");
io.printf("%s", mime.encode($m)); # Subject: =?UTF-8?B?R3LDvMOfZSBhdXMgTcO8bmNoZW4=?=
The primitives mime.encodeWord / mime.decodeWord are exposed for the cases
the auto-hooks don’t cover (a custom header, a multi-address line). Both B
(base64) and Q (quoted-printable) encoded-words decode; encoding always emits
B. us-ascii / iso-8859-* / windows-* charsets decode through
encoding; UTF-8 is the default.
Out of scope
Deliberately a foundation, not a full mail stack:
- Binary bodies. A
Partbody is text (UTF-8); anattachmenttakes text content. True binary attachments (abytesbody, e.g. an image) are not yet supported - that needs abytes-typed body field. - Multi-address name encoding. A comma-separated address list is left raw
on
encode; encode each mailbox’s name withmime.addresswhen building it. - Networking. This module only shapes messages; sending / fetching them is
the
mailSMTP / POP3 / IMAP clients (built onmime).
See also
- encoding.md -
toText/fromText(base64, quoted-printable), the transfer codecsmimedelegates to. - strings.md - the text operations the header and boundary handling build on.
- modules/index.md - the module catalog and import rules.