moonmime

    MIME for MoonBit: media types, Content-Disposition and multipart bodies.

    mime
    media-type
    mime-type
    mime-sniffing
    content-type
    mimetypes
    multipart
    multipart-mixed
    multipart-related
    related
    form-data
    upload
    boundary
    streaming
    streaming-parser
    content-disposition
    content-id
    quoted-printable
    encoded-word
    charset
    parser
    rfc2045
    rfc2046
    rfc2387
    rfc2047
    rfc6838
    rfc7578
    rfc6266
    rfc5987
    email
    whatwg
    moonbit
    Download zip
    Version
    0.3.0
    License
    Apache-2.0
    Last updated
    23 hours ago
    Downloads
    6K
    moonmime


    moonmime reads and writes the parts of a message that describe what the content is: the Content-Type header and its parameters, the Content-Disposition that names a file, and the multipart body that carries a request or an email. It holds no sockets, opens no files, and keeps no global state — you hand it bytes and a header, it hands back data.

    #What it does

    • Media types. Parse a Content-Type into an essence and ordered parameters, and write it back with correct quoting (RFC 2045 §5.1, RFC 2046 §4.2). Strict reading follows the MIME Sniffing standard for the places where a wrong guess is worse than a refusal.
    • The registry. Map a filename to the media type its extension implies, and a media type to the extension people actually use.
    • Content-Disposition. Build and parse the header that names a download, with filename* (RFC 5987) for names that leave ASCII.
    • Multipart. Split any multipart body into parts (RFC 2046 §5.1) — the shape an email and a form upload share.
    • Streaming. Read a part's header fields before its body has arrived and take the body as it comes; write a body a part at a time into a sink. The bounds hold while reading, not after.
    • Related. multipart/related (RFC 2387) with its type, start and start-info: which part is the root, and what the root is to be read as.
    • Form data. Read multipart/form-data (RFC 7578) into text fields and file uploads.
    • Writing. Frame parts as multipart/mixed, or a Form as multipart/form-data, with a boundary proven absent from the content and filename* (RFC 5987) for a file name that leaves ASCII.
    • Encoded words. Write a header value as RFC 2047 encoded words, B or Q, split so no word passes 75 characters, and read any mix of words and plain text back.
    • Quoted-printable. moonmime/qp encodes and decodes a body (RFC 2045 §6.7): soft breaks at 76 columns, =XY escapes, trailing whitespace protected.
    • Charsets. UTF-8, US-ASCII and ISO-8859-1 come with the library; any other set is a Charset you pass in, so nothing is registered globally.

    #Install

    moon add moonbitstack/moonmime

    import {
    "moonbitstack/moonmime",
    }

    #Use it

    #Read a media type

    let mt = @moonmime.media_type("Text/HTML; charset=UTF-8")
    println(mt.essence) // text/html
    println(mt.type_()) // text
    println(mt.subtype()) // html
    println(mt.param("charset").unwrap_or("")) // UTF-8
    println(mt.to_string()) // text/html; charset=UTF-8

    A value it cannot make sense of is dropped, not guessed at: media_type("") has an empty essence and no parameters.

    When a header decides what a file is, ask the strict reader instead. MediaType::parse follows the MIME Sniffing standard: a type/subtype of tokens, ;name=value parameters in order, and None for anything else — including a value that is a list, a lone type, or a parameter name with a space in it.

    let mt = @moonmime.MediaType::parse("Text/HTML; charset=\"utf-8\"").unwrap()
    println(mt.essence) // text/html
    println(mt.param("charset").unwrap_or("")) // utf-8

    let listed = @moonmime.MediaType::parse("text/html, text/plain")
    println(listed is None) // true

    #Name a download

    println(@moonmime.content_disposition("menu.pdf"))
    // attachment; filename="menu.pdf"

    println(@moonmime.content_disposition("receta \"de la abuela\".pdf"))
    // attachment; filename*=utf-8''receta%20%22de%20la%20abuela%22.pdf

    On the way in, Disposition::parse reads a header back, and filename prefers filename* over filename:

    let d = @moonmime.Disposition::parse(
    "attachment; filename=\"fallback.txt\"; filename*=utf-8''r%C3%A9sum%C3%A9.txt",
    )
    println(d.filename().unwrap_or("")) // résumé.txt

    #Guess from a filename

    println(@moonmime.guess_type("photo.jpg")) // image/jpeg
    println(@moonmime.guess_type("archive.tar.gz")) // application/gzip
    println(@moonmime.guess_extension("image/jpeg").unwrap_or("")) // .jpg

    Anything the registry has not heard of is application/octet-stream, the value fallback holds.

    #Split a body, then read it as a form

    // `body` is the raw bytes; `boundary` is the delimiter without its leading dashes.
    let boundary = "----edge"
    let body = b"------edge\r\nContent-Disposition: form-data; name=\"recipe\"\r\n\r\nchiles en nogada\r\n------edge--\r\n"
    let parts = @moonmime.multipart(body[:], boundary)
    assert_eq(parts.length(), 1)
    assert_eq(parts[0].header("content-disposition").unwrap_or(""), "form-data; name=\"recipe\"")

    let form = @moonmime.formdata(body[:], boundary)
    assert_eq(form.field("recipe"), Some("chiles en nogada"))
    assert_eq(form.uploads().length(), 0)

    A part becomes a file when its Content-Disposition carries a filename, and a text field when it does not — the only thing distinguishing them on the wire.

    #Write one back

    Form::encode frames a form the way a browser sends one: fields first, then files. A boundary you do not give is generated and proven absent from the parts, so the same form always comes out as the same octets.

    let fields : Array[@moonmime.Field] = [
    { name: "recipe", value: "chiles en nogada" },
    { name: "mood", value: "happy" },
    ]
    let written = @moonmime.Form::new(fields~).encode(boundary="----edge")
    assert_eq(written.content_type(), "multipart/form-data; boundary=----edge")
    assert_eq(
    @moonmime.formdata(written.body[:], written.boundary).field("mood"),
    Some("happy"),
    )

    A file name that leaves ASCII goes out as filename*; a boundary you pass has to be RFC 2046's bchars and cannot already stand inside a part.

    let named = @moonmime.Form::new(
    files=[
    {
    name: "doc",
    filename: "résumé.pdf",
    content_type: "application/pdf",
    content: b"%PDF",
    headers: [],
    },
    ],
    ).encode(boundary="----edge")
    let part = @moonmime.multipart(named.body[:], named.boundary)[0]
    assert_eq(
    part.header("content-disposition"),
    Some("form-data; name=\"doc\"; filename*=utf-8''r%C3%A9sum%C3%A9.pdf"),
    )

    Multipart::new frames parts that are not a form — multipart/mixed, the shape an email with an attachment has:

    let mixed = @moonmime.Multipart::new([
    { headers: [("content-type", "text/plain")], body: b"hola" },
    ])
    assert_eq(mixed.content_type(), "multipart/mixed; boundary=----moonmime")
    assert_eq(@moonmime.multipart(mixed.body[:], mixed.boundary)[0].body, b"hola")

    #Read a body as it arrives

    multipart hands back a body whole. When the octets come from a socket or a file, PartsReader takes them as they arrive: the header fields of a part first, then its body in chunks. A caller that decides it does not want a part stops reading it, and what stands behind it is never pulled.

    let raw = b"------edge\r\nContent-Disposition: form-data; name=\"recipe\"\r\n\r\nchiles en nogada\r\n------edge--\r\n"
    let reader = @moonmime.PartsReader::new(@moonmime.Chunks::new(raw[:]), "----edge")
    let head = reader.next_part().unwrap()
    assert_eq(head.header("content-disposition").unwrap_or(""), "form-data; name=\"recipe\"")
    assert_eq(head.disposition().unwrap().param("name"), Some("recipe"))
    assert_eq(reader.read().unwrap(), b"chiles en nogada")
    assert_eq(reader.next_part() is None, true)

    A source is anything that hands over octets a piece at a time — the Source trait has one method, next, which answers None once the body has ended. Chunks is the adapter for a body already in hand. PartsReader::over(body, boundary) is the same reader without the adapter.

    Writing goes the other way: MultipartWriter hands the delimiter, the header block and the body octets to a Sink as it is given them, so a body larger than memory is framed without ever standing in it. Gathered is a sink that keeps the octets, for a caller that wants them after all.

    let sink = @moonmime.Gathered::new()
    let writer = @moonmime.MultipartWriter::new(
    sink,
    boundary="----edge",
    kind=@moonmime.MultipartKind::FormData,
    )
    writer.begin_part([("content-disposition", "form-data; name=\"recipe\"")])
    writer.body(b"chiles en nogada")
    writer.close()
    assert_eq(writer.content_type(), "multipart/form-data; boundary=----edge")
    assert_eq(
    @moonmime.formdata(sink.bytes()[:], "----edge").field("recipe"),
    Some("chiles en nogada"),
    )

    A form is a list of parts, so Form::parts() is what a caller streams: same headers, same order, one part at a time. The octets are the ones Form::encode writes for the same boundary, and a test in this repository holds the two to be equal byte for byte.

    multipart/related says the parts belong together and names one of them as the root. The framing is the one every multipart subtype shares; Related adds the three parameters RFC 2387 defines and reads start back into the part it names.

    let related = @moonmime.Related::new(
    [
    {
    headers: [("content-id", "<root>"), ("content-type", "text/html")],
    body: b"<img src=\"cid:logo\">",
    },
    {
    headers: [("content-id", "<logo>"), ("content-type", "image/png")],
    body: b"\x89PNG",
    },
    ],
    boundary="----rel",
    type_="text/html",
    start="<root>",
    )
    assert_eq(
    related.content_type(),
    "multipart/related; boundary=----rel; type=\"text/html\"; start=\"<root>\"",
    )
    let read = @moonmime.related(related.content_type(), related.body())
    assert_eq(read.root().unwrap().content_id(), Some("root"))
    assert_eq(read.root().unwrap().body, b"<img src=\"cid:logo\">")

    start matches a part's Content-ID with or without its angle brackets, and a header that names nothing gives root() == None rather than a guess. With no start at all the first part is the root, which is the RFC's default. The header is not related at all, or names no boundary, and related refuses it with NotRelated or Boundary.

    #Write a header that leaves ASCII, and a body that must stay 7-bit

    let subject = @moonmime.encode_words("Grüße aus München")
    assert_eq(@moonmime.decode_words(subject), "Grüße aus München")

    let qp_bytes = @qp.encode(b"caf\xc3\xa9 = coffee")
    assert_eq(@qp.decode(qp_bytes), b"caf\xc3\xa9 = coffee")

    The three charsets that ship — UTF-8, US-ASCII and ISO-8859-1 — answer to their names directly; anything else is a Charset handed to the call that needs it, keyed by its lower-case name and consulted before the shipped sets:

    assert_eq(@moonmime.encode_charset(name="iso-8859-1", text="é"), b"\xe9")
    assert_eq(@moonmime.decode_charset(name="iso-8859-1", data=b"\xe9"), "é")

    #Interface

    ItemWhat it does
    media_type(s)Parse a Content-Type header into a MediaType, keeping what it can.
    MediaType::parse(s)Read a value strictly, as the MIME Sniffing standard does; None for anything else.
    MediaTypetype_, subtype, param(name), to_string.
    guess_type(filename)The media type a filename's extension implies.
    guess_extension(type)The extension people use for a media type.
    fallbackapplication/octet-stream.
    content_disposition(name, inline?)Build a Content-Disposition header.
    Dispositionparse, param(name), filename().
    multipart(body, boundary, limits?, strict?)Split a multipart body into Parts.
    PartsReader::new(source, boundary, limits?, strict?)Read parts as they arrive: next_part() for a head, read() for the body in chunks.
    PartsReader::over(body, boundary, limits?, strict?)The same reader over a body already in hand.
    Source / ChunksThe trait a byte source implements (next), and the adapter for a body held whole.
    PartHeadheader(name), content_type(), disposition(), content_id().
    Partheader(name), content_type(), disposition(), content_id().
    formdata(body, boundary, limits?, strict?)Read multipart/form-data into a Form.
    Form::encode(boundary?)Write the form as a multipart/form-data body.
    Multipart::new(parts, boundary?, kind?)Frame parts as a multipart body, mixed unless told otherwise.
    Multipartcontent_type(), and the kind, boundary and body it was built with.
    MultipartKindFormData or Mixed; code() is the subtype as written.
    MultipartWriter::new(sink, boundary?, kind?)Frame a body a part at a time: begin_part(headers), body(bytes), close(), content_type().
    Sink / GatheredThe trait a sink implements (write(chunk)) and a sink that keeps the octets.
    Related::new(parts, boundary?, type_?, start?, start_info?)Frame a multipart/related body (RFC 2387).
    related(content_type, body, limits?, strict?)Read one: its parameters and its parts.
    Relatedbody(), content_type(), root(), and the type_, start, start_info, boundary and parts it holds.
    Form::parts()The parts a form frames to, for a caller that writes them itself.
    Formfield, values, file, entries, uploads, is_empty, new.
    Uploadsize(), header(name).
    Limits / limitsBounds on part count and part size.
    encode_word(text, charset?, encoding?, charsets?)One RFC 2047 encoded word.
    encode_words(text, ...)A header value as encoded words, split to fit; ASCII stays as it is.
    decode_words(value, charsets?)Read encoded words back, dropping the folding between them.
    WordEncodingBase64 or QuotedPrintable, the B and Q of RFC 2047.
    CharsetThe trait a further charset implements: decode, encode.
    decode_charset(name~, data~, charsets?) / encode_charset(...)Bytes to text in a named charset, and back.
    RefusedWhy a body was not read, or not written: Parts, Part, Malformed, NotAForm, NotRelated, Charset, Boundary.
    @qp.encode(bytes) / @qp.decode(bytes, strict?)Quoted-printable, from moonbitstack/moonmime/qp.

    #Bounds

    multipart and formdata take a Limits — a thousand parts of a megabyte each by default. A body over its bound is refused whole, not truncated. A body that stops making sense returns what it read so far; strict turns that into an error.

    PartsReader holds the same bound while it reads: a part that passes its size is refused where it is crossed, and the octets behind it are never pulled. What a reader has already handed over cannot be taken back, so a part the caller abandons is discarded — and still counted against the bound — before the next head.

    multipart is PartsReader walked to its end, so the two cannot drift apart. The tests read the same bodies at one-octet, four-octet and nine-octet chunks — a folded header, a part with no header field, a delimiter's stem inside a body, an empty part, a body with no parts — and require every part to be the one the whole-body read gives. The interop check then writes all seventeen cases a second time through the streaming writer and compares the octets.

    let small = @moonmime.Limits::new(parts=8, part_size=64 * 1024)
    assert_eq(@moonmime.formdata(body[:], boundary, limits=small).field("recipe"), Some("chiles en nogada"))

    #Examples

    src/examples/ holds small programs that run:

    • src/examples/lookup — filenames and media types, both directions.
    • src/examples/form — read a multipart/form-data upload, then write it back.
    • src/examples/stream — read a body a chunk at a time, write one a part at a time.
    • src/examples/tour — every call once, end to end.
    • src/examples/browser — the same library compiled to JavaScript for the live demos.

    moon run src/examples/lookup moon run src/examples/form moon run src/examples/stream moon run src/examples/tour

    #Conformance

    Media type parsing is checked against the Web Platform Tests suite for the MIME Sniffing standard. The suite's data files are kept in scripts/wpt/mimesniff/ at a pinned commit and turned into MoonBit cases by scripts/gen_wpt_vectors.py. MediaType::parse is what those cases run against; media_type is the forgiving reader and is not the subject of the suite.

    Data fileCasesPassing
    mimesniff/mime-types/resources/mime-types.json7474
    mimesniff/mime-types/resources/generated-mime-types.json881881

    Pinned at commit 2ed27cab3330f835ebdae3d4cd1ad14b729fdd85 of web-platform-tests/wpt, dated 2026-11-06.

    The rest of that directory is out of scope here, and nothing in it is claimed:

    • mimesniff/sniffing/* and mimesniff/media/* ask what a browser does with a response — whether an <img>, an <iframe>, or a navigation keeps the type it was handed. Answering them needs an HTML parser and a network stack. This library decides the type of a value it is given; it does not sniff content.
    • mimesniff/mime-types/charset-parameter.window.js checks which encoding a frame derives from a charset parameter, which is document decoding rather than the header itself.
    • resources/mime-groups.json lists the groups a type belongs to for Accept computation; this library has no group API.
    • resources/mime-types-minimized.json covers the navigation algorithm's minimized MIME type, which also needs a browsing context.

    #The extension table

    guess_type and guess_extension are decided by a table of the common extensions, the part the implementations most often disagree on. It was differed against Python's mimetypes and Go's mime; every extension the references know about is replayed by a test, and where the references disagree the table takes a side: IANA where it registers one, the more specific name where it does not. The task also named .ts and .js, so they are decided here too — one line each:

    ExtensionmoonmimePython mimetypesGo mimeWhy
    .3gpvideo/3gppaudio/3gpp—IANA registers video/3gpp for the 3GPP container; Python's audio/3gpp keeps only the audio track.
    .heicimage/heicimage/heic—IANA registers image/heic for .heic, as Python does; the table's old image/heif borrowed the .heif sibling's subtype, so it is corrected to the IANA and Python side.
    .srtapplication/x-subriptext/plain—neither side is IANA-registered; keeps the specific x- name over Python's generic text/plain.
    .wavaudio/wavaudio/x-wav—IANA registered audio/wav in 2018, retiring audio/x-wav; takes the IANA side over Python's x- form.
    .xmlapplication/xmltext/xmltext/xmlIANA registers application/xml (RFC 7303); the text/xml alias is retired, which Go still ships.
    .shtext/x-shellscriptapplication/x-sh—neither side is IANA-registered; keeps the semantic x- name over Python's application/x-sh.
    .tsvideo/mp2t——IANA registers video/mp2t for the MPEG-2 transport stream, the common meaning of .ts; neither reference ships it.
    .jstext/javascripttext/javascripttext/javascriptall three agree and IANA registers text/javascript; Go's charset=utf-8 is a default parameter, not a different essence.

    The rest of the union the library has no entry for — it answers application/octet-stream for them, which is what guess_type promises for a name it has not heard.

    #Bidirectional interop

    Multipart framing is checked against two other implementations, in both directions. Each one writes the body for every case, and every implementation — the writer among them — reads that body back; what it read is compared with what the case says went in. scripts/check_multipart_interop.py drives the three, and scripts/interop/cases.json is the corpus. The library's side of the exchange is integration/interop, a program outside the package that is built and run for the check only.

    PartnerCasesBodies it wrote, read by the other two
    this library1717 of 17
    Python 3.13, email1717 of 17
    Go 1.25, mime/multipart1717 of 17

    The same run also writes every case a second time through the streaming writer and compares the two bodies byte for byte: 17 of 17 are the same octets, so "a part at a time" is not a second framing.

    The corpus holds fields sharing a name, a value with CRLF in it, a binary file, a file name with an accent and a quote in it, a part with no Content-Type, a boundary with a space, a boundary the writer chooses, multipart/mixed bodies, multipart/related bodies with type, start and start-info, a root that is not the first part, a header whose start names a part the body does not hold, and a body with no parts at all.

    Two behaviours the corpus pins because they are what a reader has to survive:

    • Folded headers. Python's generator folds a long Content-Disposition across lines, so a part's header block arrives folded. Reading unfolds it (RFC 5322 §2.2.3): the break is dropped and the whitespace that opens the continuation stands for it.
    • A body with no parts. This library writes one closing delimiter. Python's generator writes an opening delimiter, a blank line and then the closing one, which every reader in reach takes for a single part with no header field and no content. The case says so, and the check requires the disagreement to still be there rather than pass over it.

    #Licence

    Apache-2.0. See LICENSE.

    Charset

    pub(open) trait Charset {
    fn decode(Self, BytesView) -> String raise Refused
    fn encode(Self, StringView) -> Bytes raise Refused
    }

    How the bytes of a body turn into the text they carry, and back. Three come with the library; everything else arrives through this trait, so a codebase that needs windows-1252 does not need a fork of this one.

    Sink

    pub(open) trait Sink {
    fn write(Self, BytesView) -> Unit raise
    }

    Octets on their way out: what a caller's sink takes, a piece at a time.

    The writer hands over the delimiter, the header block and the body octets of a part as it is given them, so a body larger than memory never stands in it.

    test {
    let gathered = @moonmime.Gathered::new()
    let writer = @moonmime.MultipartWriter::new(gathered, boundary="b")
    writer.begin_part([("content-type", "text/plain")])
    writer.body(b"hola")
    writer.close()
    let parts = @moonmime.multipart(gathered.bytes()[:], "b")
    inspect(parts[0].body, content="b\"hola\"")
    }

    Source

    pub(open) trait Source {
    fn next(Self) -> Bytes?
    }

    A body arriving in pieces: the next octets, and None once there are none.

    The chunk is the source's to size — a socket read, a slice of a file, a window of a buffer. A reader asks for one only when it needs the octets in it, which is what lets a bound stop a body where it is crossed instead of after the whole of it has been read.

    test {
    let source : &@moonmime.Source = @moonmime.Chunks::new(b"--b--\r\n")
    match source.next() {
    Some(chunk) => assert_eq(chunk, b"--b--\r\n")
    None => fail("the body was not handed over")
    }
    assert_eq(source.next() is None, true)
    }

    Refused

    pub(all) suberror Refused {
    Parts(limit~ : Int)
    Part(limit~ : Int, got~ : Int)
    Malformed(at~ : Int)
    Charset(name~ : String)
    NotAForm(content_type~ : String)
    NotRelated(content_type~ : String)
    Boundary(got~ : String)
    } derive(Eq,
    Debug
    )

    Why a body was not read as a form.

    A body over its bounds is refused whole rather than truncated: a handler given the first thousand parts of a larger body would be answering a request nobody sent.

    Refused::equal

    fn Refused::equal(Refused, Refused) -> Bool

    Refused::not_equal

    fn Refused::not_equal(x : Refused, y : Refused) -> Bool

    Refused::to_repr

    Chunks

    type Chunks

    A body held whole, handed over as one chunk.

    It is the adapter for a caller that already has the octets: streaming from memory and streaming from a socket reach the reader the same way.
    impl Source for Chunks

    Chunks::new

    fn Chunks::new(body : BytesView) -> Chunks

    The body body holds, as a source that hands it over once.

    Chunks::next

    fn Chunks::next(self : Chunks) -> Bytes?

    Disposition

    pub(all) struct Disposition {
    kind : String
    params : Array[(String, String)]
    } derive(Eq,
    Debug
    )

    A parsed Content-Disposition header (RFC 6266).

    kind is the lowercased disposition type — inline, attachment, form-data — and params the rest, names lowercased and quoted values unquoted.

    Disposition::equal

    fn Disposition::equal(Disposition, Disposition) -> Bool

    Disposition::filename

    fn Disposition::filename(self : Disposition) -> String?

    The filename the disposition names, filename* first (RFC 6266 §4.3).

    A filename* that is not UTF-8 falls back to the plain filename.

    Disposition::not_equal

    fn Disposition::not_equal(x : Disposition, y : Disposition) -> Bool

    Disposition::param

    fn Disposition::param(self : Disposition, name : StringView) -> String?

    A parameter by lowercased name.

    Disposition::parse

    fn Disposition::parse(s : StringView) -> Disposition

    Parse a header value. Lenient, like media_type.

    Field

    pub(all) struct Field {
    name : String
    value : String
    } derive(Eq,
    Debug
    )

    A named text value from a form.

    Field::equal

    fn Field::equal(Field, Field) -> Bool

    Field::not_equal

    fn Field::not_equal(x : Field, y : Field) -> Bool

    Field::to_repr

    Form

    type Form

    A parsed form: its text fields and its files.

    Form::encode

    fn Form::encode(self : Form, boundary? : StringView) -> Multipart raise Refused

    Write the form as a multipart/form-data body (RFC 7578).

    Fields go out first, then files, each in the order the form holds them: a Form keeps the two apart, so it cannot reproduce the interleaving of the body it was read from. A file whose name leaves ASCII is named with filename* (RFC 5987), the one form a receiver decodes back to the same characters.

    Form::entries

    fn Form::entries(self : Form) -> Array[Field]

    Every field, in the order they were sent.

    Form::field

    fn Form::field(self : Form, name : StringView) -> String?

    The first value under this name.

    Form::file

    fn Form::file(self : Form, name : StringView) -> Upload?

    The first file under this name.

    Form::is_empty

    fn Form::is_empty(self : Form) -> Bool

    Whether anything was parsed at all.

    Form::new

    fn Form::new(fields? : Array[Field], files? : Array[Upload]) -> Form

    A form holding these fields and files, for a decoder of another encoding to hand back the same type this package reads multipart into.

    Form::parts

    fn Form::parts(self : Form) -> Array[Part]

    The parts the form frames to, in the order a body carries them: fields first, then files.

    A form is a multipart body, so a caller that writes one part at a time — streaming an upload rather than holding the body — takes the parts from here and frames them itself.

    Form::uploads

    fn Form::uploads(self : Form) -> Array[Upload]

    Every file, in the order they were sent.

    Form::values

    fn Form::values(self : Form, name : StringView) -> Array[String]

    Every value under this name — a set of checkboxes sends the same name many times, and taking only the first silently drops the rest.

    Gathered

    type Gathered

    A sink that keeps what it is handed, for a caller that wants the octets after all.
    impl Sink for Gathered

    Gathered::bytes

    fn Gathered::bytes(self : Gathered) -> Bytes

    Everything written so far, as one value.

    Gathered::new

    fn Gathered::new() -> Gathered

    Gathered::write

    fn Gathered::write(self : Gathered, chunk : BytesView) -> Unit raise

    Limits

    pub(all) struct Limits {
    parts : Int
    part_size : Int
    } derive(Eq,
    Debug
    )

    What a body is allowed to be.

    A body arrives from whoever sent it, so both bounds are needed: a million empty parts and one enormous part are different ways of asking a server to allocate more than it has.

    Limits::equal

    fn Limits::equal(Limits, Limits) -> Bool

    Limits::new

    fn Limits::new(parts? : Int, part_size? : Int) -> Limits

    Build a bound by naming the parts that differ from the default.

    Limits::not_equal

    fn Limits::not_equal(x : Limits, y : Limits) -> Bool

    Limits::to_repr

    MediaType

    pub(all) struct MediaType {
    essence : String
    params : Array[(String, String)]
    } derive(Eq,
    Debug
    )

    A media type: text/plain; charset=utf-8.

    essence is the lowercased type/subtype; params keeps the parameters in order, names lowercased and quoted values unquoted. A malformed parameter is dropped rather than guessed at.
    impl Show for MediaType

    MediaType::equal

    fn MediaType::equal(MediaType, MediaType) -> Bool

    MediaType::not_equal

    fn MediaType::not_equal(x : MediaType, y : MediaType) -> Bool

    MediaType::output

    fn MediaType::output(self : MediaType, logger : &Logger) -> Unit

    MediaType::param

    fn MediaType::param(self : MediaType, name : StringView) -> String?

    A parameter by lowercased name, quoted or bare.

    MediaType::parse

    fn MediaType::parse(s : StringView) -> MediaType?

    Parse a header value the way the MIME Sniffing standard reads one, and refuse the rest: None when the value is not a type/subtype of tokens.

    Two readers, two jobs. media_type above takes whatever a server sent and keeps what it can; this one answers "is this a media type at all", which is the question worth asking when a value decides what a file is. A parameter whose name is not a token, or whose value cannot be written into a quoted string, is dropped; the first of two with the same name wins. Both are the standard's readings.

    test {
    let mt = @moonmime.MediaType::parse("Text/HTML; charset=\"utf-8\"").unwrap()
    inspect(mt.essence, content="text/html")
    inspect(mt.param("charset").unwrap_or(""), content="utf-8")
    inspect(
    @moonmime.MediaType::parse("text/html, text/plain") is None,
    content="true",
    )
    }

    MediaType::subtype

    fn MediaType::subtype(self : MediaType) -> String

    The subtype half: plain in text/plain, empty when there is no slash.

    MediaType::to_string

    fn MediaType::to_string(self : MediaType) -> String

    MediaType::type_

    fn MediaType::type_(self : MediaType) -> String

    The type half: text in text/plain.

    Multipart

    pub(all) struct Multipart {
    kind : MultipartKind
    boundary : String
    body : Bytes
    } derive(Eq,
    Debug
    )

    A multipart body on its way out: the octets, the boundary that delimits them, and the subtype the two have to agree on.

    The octets are what a Content-Type of content_type() describes and what multipart reads back with the same boundary.

    Multipart::content_type

    fn Multipart::content_type(self : Multipart) -> String

    The Content-Type that goes with the body: the subtype, and the boundary the receiver needs to split it.

    Written as a header value, so a boundary with a space in it — RFC 2046 allows the space, not as the last character — comes out quoted.

    Multipart::equal

    fn Multipart::equal(Multipart, Multipart) -> Bool

    Multipart::new

    fn Multipart::new(parts : Array[Part], boundary? : StringView, kind? : MultipartKind) -> Multipart raise Refused

    Frame parts as a multipart body (RFC 2046 §5.1).

    A part's headers go out in the order the part holds them, with a lowercased name — the shape a parse hands back — restored to Content-Type casing. A part with no headers at all is still a part: it is delimited like any other and its body is read back as it stands.

    The boundary is generated when the caller gives none, from a fixed stem and a single pass over the parts, so the same parts always come out as the same octets and the boundary is proven absent from them rather than merely unlikely. A caller that gives one keeps control of what the receiver sees — the dash-stem browsers use, or a value it has already named elsewhere.

    test {
    let form = @moonmime.Form::new(fields=[{ name: "title", value: "a report", }])
    let body = form.encode(boundary="----edge")
    inspect(body.content_type(), content="multipart/form-data; boundary=----edge")
    let read = @moonmime.formdata(body.body[:], body.boundary)
    inspect(read.field("title").unwrap_or(""), content="a report")
    }

    Multipart::not_equal

    fn Multipart::not_equal(x : Multipart, y : Multipart) -> Bool

    MultipartKind

    pub(all) enum MultipartKind {
    FormData
    Mixed
    } derive(Eq,
    Debug
    )

    Which multipart type a written body announces.

    RFC 2046 §5.1 is the framing either way; the subtype says what the parts mean. FormData is the one a form upload carries (RFC 7578), Mixed the one an email with an attachment carries (RFC 2046 §5.1.3).

    MultipartKind::code

    fn MultipartKind::code(self : MultipartKind) -> String

    The subtype as it is written on the wire: the half that follows multipart/.

    MultipartKind::equal

    MultipartKind::not_equal

    fn MultipartKind::not_equal(x : MultipartKind, y : MultipartKind) -> Bool

    MultipartWriter

    type MultipartWriter

    Writes a multipart body a part at a time: the delimiter and header block when a part begins, its body octets as they are handed over, and the closing delimiter when it ends.

    The boundary is the caller's or a fixed stem, and the octets that would make it a delimiter inside a part are watched for as they go by: a body that holds the boundary is refused at that point rather than framed into a body the receiver would split somewhere else. A refusal cannot take back what the sink already holds, which is why a caller that cannot lose the octets passes a boundary of its own.

    test {
    let gathered = @moonmime.Gathered::new()
    let writer = @moonmime.MultipartWriter::new(gathered, boundary="b")
    inspect(writer.content_type(), content="multipart/mixed; boundary=b")
    writer.begin_part([("content-type", "text/plain")])
    writer.body(b"hola")
    writer.begin_part([("content-type", "text/html")])
    writer.body(b"<b>hola</b>")
    writer.close()
    let parts = @moonmime.multipart(gathered.bytes()[:], "b")
    inspect(parts.length(), content="2")
    inspect(parts[1].body, content="b\"<b>hola</b>\"")
    }

    MultipartWriter::begin_part

    fn MultipartWriter::begin_part(self : MultipartWriter, headers : Array[(String, String)]) -> Unit raise

    Begin a part: its delimiter, then its header fields and the empty line that ends them.

    A part is over when the next one begins; there is nothing to write between them but the delimiter, which carries the CRLF the receiver reads as the end of the previous body.

    MultipartWriter::body

    fn MultipartWriter::body(self : MultipartWriter, bytes : BytesView) -> Unit raise

    The next octets of the current part's body.

    MultipartWriter::boundary

    fn MultipartWriter::boundary(self : MultipartWriter) -> String

    The boundary the delimiters are written with.

    MultipartWriter::close

    fn MultipartWriter::close(self : MultipartWriter) -> Unit raise

    Write the closing delimiter. Closing a body with no parts writes it alone, which is the shape a reader takes for a body that carries none.

    MultipartWriter::content_type

    fn MultipartWriter::content_type(self : MultipartWriter) -> String

    The Content-Type that goes with the body, to be written before or beside it: the subtype and the boundary the receiver splits on.

    MultipartWriter::new

    fn MultipartWriter::new(sink : &Sink, boundary? : StringView, kind? : MultipartKind) -> MultipartWriter raise Refused

    A writer that frames parts for sink.

    The boundary is generated when the caller gives none: a fixed stem, so the same parts always frame the same way. Unlike a body written in one call it cannot be proven absent from parts that have not arrived yet, so it is watched for as they go by and a collision is refused.

    Part

    pub(all) struct Part {
    headers : Array[(String, String)]
    body : Bytes
    } derive(Eq,
    Debug
    )

    One part of a multipart body: its header fields and its body octets.

    Part::content_id

    fn Part::content_id(self : Part) -> String?

    The part's Content-ID without its angle brackets, which is the form the start parameter of multipart/related names it by; None when it has none.

    Part::content_type

    fn Part::content_type(self : Part) -> MediaType?

    The part's Content-Type, parsed; None when it has none.

    Part::disposition

    fn Part::disposition(self : Part) -> Disposition?

    The part's Content-Disposition, parsed; None when it has none.

    Part::equal

    fn Part::equal(Part, Part) -> Bool

    Part::header

    fn Part::header(self : Part, name : StringView) -> String?

    One of the part's own headers, by lowercased name.

    Part::not_equal

    fn Part::not_equal(x : Part, y : Part) -> Bool

    Part::to_repr

    PartHead

    pub(all) struct PartHead {
    headers : Array[(String, String)]
    } derive(Eq,
    Debug
    )

    The head of a part: its header fields, handed over before its body.

    What a reader sees first, and all it needs to decide whether it wants the body at all — a form field can be dropped on its name without its octets having been read.

    PartHead::content_id

    fn PartHead::content_id(self : PartHead) -> String?

    The part's Content-ID without its angle brackets, which is the form the start parameter of multipart/related names it by; None when it has none.

    PartHead::content_type

    fn PartHead::content_type(self : PartHead) -> MediaType?

    The part's Content-Type, parsed; None when it has none.

    PartHead::disposition

    fn PartHead::disposition(self : PartHead) -> Disposition?

    The part's Content-Disposition, parsed; None when it has none.

    PartHead::equal

    fn PartHead::equal(PartHead, PartHead) -> Bool

    PartHead::header

    fn PartHead::header(self : PartHead, name : StringView) -> String?

    One of the part's own headers, by lowercased name.

    PartHead::not_equal

    fn PartHead::not_equal(x : PartHead, y : PartHead) -> Bool

    PartHead::to_repr

    PartsReader

    type PartsReader

    Reads a multipart body as it arrives: a part's header fields first, then its body, one chunk at a time.

    The bound is part of the reading rather than a check afterwards: a part that passes its size is refused where it is crossed, and the octets after it are never pulled. What is already handed over cannot be taken back, so an unread remainder of a part is discarded — and still counted — before the next head is read.

    test {
    let reader = @moonmime.PartsReader::over(
    b"--b\r\nContent-Type: text/plain\r\n\r\nhola\r\n--b--\r\n", "b",
    )
    let head = reader.next_part().unwrap()
    inspect(head.header("content-type").unwrap_or(""), content="text/plain")
    inspect(reader.read().unwrap(), content="b\"hola\"")
    inspect(reader.read() is None, content="true")
    inspect(reader.next_part() is None, content="true")
    }

    PartsReader::boundary

    fn PartsReader::boundary(self : PartsReader) -> String

    The boundary the parts are delimited by.

    PartsReader::new

    fn PartsReader::new(source : &Source, boundary : StringView, limits? : Limits, strict? : Bool) -> PartsReader raise Refused

    Read boundary-delimited parts from source.

    The boundary is refused when it cannot delimit a body at all — the same bchars and the same 70-octet limit a written body is held to.

    PartsReader::next_part

    fn PartsReader::next_part(self : PartsReader) -> PartHead? raise Refused

    The next part's head, or None once the body has ended.

    A body the caller did not read to its end is discarded first, and its octets are counted against the bound like any other.

    PartsReader::over

    fn PartsReader::over(body : BytesView, boundary : StringView, limits? : Limits, strict? : Bool) -> PartsReader raise Refused

    Read parts out of a body that is already in hand.

    PartsReader::read

    fn PartsReader::read(self : PartsReader) -> Bytes? raise Refused

    The next octets of the current part's body, or None at its end.

    A part with an empty body answers None on the first call; the delimiter after it has already been consumed by then, so the next next_part reads a head rather than looking for a boundary it passed.
    pub(all) struct Related {
    type_ : String
    start : String
    start_info : String
    boundary : String
    parts : Array[Part]
    } derive(Eq,
    Debug
    )

    A multipart/related body (RFC 2387): a compound object whose parts belong together, one of them the root the rest hang off.

    The framing is the one every multipart subtype shares; what this type adds is the three parameters that say what the parts are — type for the media type of the root, start for the root's Content-ID, start-info passed through to that type — and the reading of start back into the part it names.

    parts is what a reader found and what a writer frames; body() writes the octets those parts frame to, so a value read from a body that carried a preamble or a folded header writes back this library's own framing of the same parts.

    Related::body

    fn Related::body(self : Related) -> Bytes

    The body the parts frame to, delimited by this value's boundary.

    Related::content_type

    fn Related::content_type(self : Related) -> String

    The Content-Type that announces this body: boundary first, because the receiver cannot read the parts without it, then the three parameters RFC 2387 defines, in the order it names them. A parameter that was not given is not written.

    Related::equal

    fn Related::equal(Related, Related) -> Bool

    Related::new

    fn Related::new(parts : Array[Part], boundary? : StringView, type_? : String, start? : String, start_info? : String) -> Related raise Refused

    A related body of parts.

    The boundary works as it does for any other multipart body: the caller's, checked against RFC 2046 and against the parts, or generated from a stem stepped on until it is proven absent.

    Left without parts the value is the header alone, which is what a caller streaming the body needs: the writer frames the parts, the value names them.

    test {
    let related = @moonmime.Related::new(
    [
    @moonmime.Part::{
    headers: [("content-id", "<root>"), ("content-type", "text/html")],
    body: b"<b>hola</b>",
    },
    ],
    boundary="b",
    type_="text/html",
    start="<root>",
    )
    inspect(
    related.content_type(),
    content="multipart/related; boundary=b; type=\"text/html\"; start=\"<root>\"",
    )
    inspect(related.root().unwrap().body, content="b\"<b>hola</b>\"")
    }

    Related::not_equal

    fn Related::not_equal(x : Related, y : Related) -> Bool

    Related::root

    fn Related::root(self : Related) -> Part?

    The root part: the one start names by its Content-ID, or the first part when the header names none, which is RFC 2387's default.

    None when the header names a part the body does not hold, which is a disagreement between the two halves rather than something to guess at.

    Related::to_repr

    Upload

    pub(all) struct Upload {
    name : String
    filename : String
    content_type : String
    content : Bytes
    headers : Array[(String, String)]
    } derive(Eq,
    Debug
    )

    A file from a multipart/form-data body.

    The content is held whole. Streaming a part to disk instead of into memory is what limits exists to make unnecessary until it is built.

    Upload::equal

    fn Upload::equal(Upload, Upload) -> Bool

    Upload::header

    fn Upload::header(self : Upload, name : StringView) -> String?

    One of the part's own headers, by lowercased name.

    Upload::not_equal

    fn Upload::not_equal(x : Upload, y : Upload) -> Bool

    Upload::size

    fn Upload::size(self : Upload) -> Int

    The size of the uploaded content in bytes.

    Upload::to_repr

    WordEncoding

    pub(all) enum WordEncoding {
    Base64
    QuotedPrintable
    } derive(Eq)

    The two encodings RFC 2047 §4 allows inside an encoded word, named for the letter that announces them in the header itself.

    WordEncoding::equal

    WordEncoding::not_equal

    fn WordEncoding::not_equal(x : WordEncoding, y : WordEncoding) -> Bool

    content_disposition

    fn content_disposition(filename : StringView, inline? : Bool) -> String

    The Content-Disposition value naming filename (RFC 6266).

    A name that is plain ASCII goes out quoted; anything else — a space is not enough, but an accent, a quote, a newline, a byte outside ASCII — goes out as filename*, the only form that carries them and the only one a name cannot break out of.

    inline asks the browser to show the content rather than save it.

    decode_charset

    fn decode_charset(name~ : StringView, data~ : BytesView, charsets? : Map[String, &Charset]) -> String raise Refused

    Read data as name says it was written. Unmapping octets become U+FFFD so that one bad byte costs one character and not the whole part.

    test {
    let text = @moonmime.decode_charset(name="iso-8859-1", data=b"\xe9") catch {
    _ => ""
    }
    inspect(text, content="é")
    }

    decode_words

    fn decode_words(value : StringView, charsets? : Map[String, &Charset]) -> String

    Undo what encode_words did, and what every other mail library did too: encoded words are the one corner of a header everybody writes the same way.

    Whitespace between two adjacent encoded words disappears, because that was folding and not content; whitespace next to ordinary text stays put.

    test {
    inspect(@moonmime.decode_words("=?utf-8?B?Q2Fmw6k=?="), content="Café")
    }

    encode_charset

    fn encode_charset(name~ : StringView, text~ : StringView, charsets? : Map[String, &Charset]) -> Bytes raise Refused

    Write text in name, refusing a character the set has no room for.

    test {
    let bytes = @moonmime.encode_charset(name="iso-8859-1", text="é") catch {
    _ => b""
    }
    inspect(bytes == b"\xe9", content="true")
    }

    encode_word

    fn encode_word(text : StringView, charset? : String, encoding? : WordEncoding, charsets? : Map[String, &Charset]) -> String raise Refused

    One encoded word, however long it comes out.

    test {
    let word = @moonmime.encode_word("Café") catch { _ => "" }
    inspect(@moonmime.decode_words(word[:]), content="Café")
    }

    encode_words

    fn encode_words(text : StringView, charset? : String, encoding? : WordEncoding, column? : Int, max? : Int, charsets? : Map[String, &Charset]) -> String raise Refused

    A header value written as encoded words, splitting whenever the next word would run past max.

    Text that is already printable US-ASCII goes back unchanged: it needs no encoding, and writing it plainly is what keeps Subject: Hello readable in a raw view.

    fallback

    let fallback : String

    What a response says it is when nothing says otherwise.

    formdata

    fn formdata(body : BytesView, boundary : StringView, limits? : Limits, strict? : Bool) -> Form raise Refused

    Parse a multipart/form-data body (RFC 7578) given its boundary.

    A part goes into the files when its Content-Disposition carries a filename and into the fields when it does not, which is the only thing distinguishing them on the wire.

    guess_extension

    fn guess_extension(value : StringView) -> String?

    The extension most people use for a media type, with its dot; None when the type is not in the table. Parameters are ignored, so the value may be given with its charset.

    guess_type

    fn guess_type(filename : StringView) -> String

    The media type a filename's extension implies; fallback for anything else.

    The last dot wins, so archive.tar.gz resolves on gz. The table is the common part of the IANA media-type registry; a caller that knows its type names it rather than hoping the extension does.

    limits

    let limits : Limits

    A thousand parts of a megabyte each — a bound wide enough for a usual form upload, tight enough to stop a body from asking for more than a server has. A caller that knows its own shape should say so.

    media_type

    fn media_type(s : StringView) -> MediaType

    Parse a header value into a media type. Lenient: anything that is not a parameter is left out, so an empty or foreign value yields an empty essence rather than an error.

    multipart

    fn multipart(body : BytesView, boundary : StringView, limits? : Limits, strict? : Bool) -> Array[Part] raise Refused

    Split a multipart body into its parts (RFC 2046 §5.1) given its boundary.

    Preamble and epilogue are dropped; the two CRLF before each delimiter belong to the delimiter, not to the part. A body that stops making sense returns what it read so far, unless strict says to refuse it.

    This is the reader in PartsReader walked to its end: a caller that does not want the whole body in hand reads the parts as they arrive instead, and gets the same parts out.
    fn related(content_type : StringView, body : BytesView, limits? : Limits, strict? : Bool) -> Related raise Refused

    Read a multipart/related body given the Content-Type that announces it.

    The parameters come off the header, the parts off the body; root() is what ties the two together.