moonmime

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

    mime
    media-type
    mimetypes
    multipart
    form-data
    content-disposition
    moonbit
    Download zip
    Version
    0.2.1
    License
    Apache-2.0
    Last updated
    yesterday
    Downloads
    888
    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).
    • 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.

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

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

    Powered by MoonBit

    Site sourceReport issueModulesSkillsBuild queueStatistics

    © 2026 mooncakes.io