moonmime

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

    mime
    media-type
    mime-type
    mime-sniffing
    content-type
    mimetypes
    multipart
    form-data
    content-disposition
    quoted-printable
    encoded-word
    charset
    parser
    rfc2045
    rfc2046
    rfc2047
    rfc6838
    rfc7578
    rfc6266
    rfc5987
    whatwg
    moonbit
    Download zip
    Version
    0.2.3
    License
    Apache-2.0
    Last updated
    13 hours ago
    Downloads
    3K
    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.
    • Form data. Read multipart/form-data (RFC 7578) into text fields and file uploads, and build the same type from either.
    • 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 parts = @moonmime.multipart(body, boundary)
    for part in parts {
    println(part.header("content-type").unwrap_or(""))
    }

    let form = @moonmime.formdata(body, boundary)
    let title = form.field("title") // String?
    for tag in form.values("tags") { // every value under one name
    println(tag)
    }
    let upload = form.file("photo") // Upload?

    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 a header that leaves ASCII, and a body that must stay 7-bit

    let subject = @moonmime.encode_words("Grüße aus München") // =?utf-8?B?...?=
    let back = @moonmime.decode_words(subject) // Grüße aus München
    let body = @qp.encode(b"caf\xc3\xa9 = coffee") // caf=C3=A9 =3D coffee
    let text = @qp.decode(body)

    A charset the library does not ship is a Charset handed to the call that needs it, keyed by its lower-case name; it is consulted before the three that ship:

    let charsets : Map[String, &@moonmime.Charset] = { "windows-1252": cp1252 }
    let name = @moonmime.decode_words(header, charsets~)

    #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.
    Partheader(name), content_type(), disposition().
    formdata(body, boundary, limits?, strict?)Read multipart/form-data into a Form.
    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: Parts, Part, Malformed, NotAForm, Charset.
    @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.

    let small = @moonmime.Limits::new(parts=8, part_size=64 * 1024)
    let form = @moonmime.formdata(body, boundary, limits=small)

    #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 the raw parts.
    • 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/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.

    #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.

    Refused

    pub(all) suberror Refused {
    Parts(limit~ : Int)
    Part(limit~ : Int, got~ : Int)
    Malformed(at~ : Int)
    Charset(name~ : String)
    NotAForm(content_type~ : 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

    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::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::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.

    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.

    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_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

    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.