moonmail

    Email for MoonBit: the message model, MIME and DKIM, and SMTP, LMTP, IMAP and POP3.

    email
    mail
    smtp
    imap
    pop3
    dkim
    mime
    moonbit
    Download zip
    Version
    0.1.0
    License
    Apache-2.0
    Last updated
    yesterday
    Downloads
    148

    #moonmail

    Email for MoonBit: read an RFC 5322 message into a tree and write it back out, the MIME transfer encodings it carries, DKIM signing and verification, and the SMTP/LMTP, IMAP and POP3 dialogues. The protocols are state machines with no I/O, octets in and replies out, so a socket, a TLS stream and a test all look alike; @moonmail/net carries them over moonnet.

    let message = @moonmail.Message::parse(raw[:])
    message.subject() // encoded words already decoded
    message.from() // the mailboxes, display names decoded
    message.text() // the body a reader would show, by its charset
    message.attachments() // name, type, octets, inline and Content-ID

    let reply = @moonmail.Message::new(
    from=@moonmail.Mailbox::new("alice@example.com", name="Alice"),
    to=[Mailbox(@moonmail.Mailbox::new("bob@example.com"))],
    subject="Re: lunch",
    text="Noon works.",
    attachments=[@moonmail.Attachment::new("map.png", png)],
    )
    let wire = reply.encode() // CRLF, folded, encoded words; reads back the same

    Every call that talks to a server raises its protocol's error on a rejection; handle it with try/catch, or propagate it from a function declared raise.

    Run the whole surface in one go:

    moon run src/examples/tour

    #What it does

    • Messages — Message::parse and encode: the header unfolded and folded again at 78 columns, a Headers table that keeps order and ignores case and refuses a value that would start a second field, Body as a leaf, parts or an attached message, and the transfer encodings taken off and put back. Writing what was read and reading it again gives the same tree; what 7bit cannot carry goes out in quoted-printable or base64.
    • Addresses and dates — RFC 5322's address grammar in full, groups and the obsolete forms included, checked against Python's email; RFC 5322 dates with the old two-digit years and named zones.
    • Building — Message::new picks alternative, related and mixed from what it carries. Quoted-printable, encoded words and charsets come from moonmime.
    • SASL (@moonmail/sasl) — PLAIN, LOGIN, CRAM-MD5, XOAUTH2, OAUTHBEARER (RFC 7628) and SCRAM-SHA-256 (RFC 7677) with SASLprep (RFC 4013). A mechanism is a trait: one you write goes to authenticate like these.
    • POP3 (@moonmail/pop3) — client with no I/O, RFC 1939 and RFC 2449: USER/PASS, APOP, SASL AUTH, STLS, CAPA, PIPELINING, STAT, LIST, UIDL, RETR and TOP dot-unstuffed with their octets kept.
    • SMTP / LMTP (@moonmail/smtp) — client and server as state machines with no I/O, RFC 5321 and RFC 2033: PIPELINING groups, SIZE, 8BITMIME, BINARYMIME and CHUNKING, SMTPUTF8, DSN parameters, STARTTLS, SASL AUTH, and RFC 3463 enhanced status codes off any reply.
    • Over a socket (@moonmail/net, native and wasm) — the SMTP, LMTP, IMAP and POP3 state machines on moonnet: implicit TLS, STARTTLS that a server must offer, or the clear; sending one message in one call.
    • IMAP (@moonmail/imap) — IMAP4rev2 client with no I/O (RFC 9051) that also speaks to IMAP4rev1 servers: the response grammar with literals and response codes, pipelined UID commands, IDLE, CONDSTORE, LITERAL+, SASL-IR, and modified UTF-7 names toward rev1 servers.
    • DKIM (@moonmail/dkim) — RFC 6376 canonicalization (simple and relaxed), body hash, and RSA-SHA256 sign and verify.

    #POP3

    let pop = @pop3.Client::new()
    pop.user("alice")
    pop.pass("secret")
    pop.retr(1)
    // ... write pop.next_write(), feed the server's octets
    for event in pop.feed(octets) {
    if event is Reply(Retr(_), Content(message)) {
    println(message.length())
    }
    }

    #SMTP / LMTP

    The client queues commands and refuses, when they are queued, the ones that are out of sequence or need an extension the server did not announce. Octets go out through next_write and come back through feed, which reports each reply with the command it answers.

    let smtp = @smtp.Client::new()
    smtp.ehlo("client.example")
    // ... write smtp.next_write(), feed the server's octets until the EHLO reply
    smtp.send("alice@example.com", ["bob@example.com"], raw[:])
    smtp.quit()
    for event in smtp.feed(octets) {
    match event {
    Reply(Rcpt(to), reply) if !reply.is_ok() => println("refused: " + to)
    _ => ()
    }
    }

    The server answers syntax, sequence, limits, the 354 and the chunk replies itself, and hands the application what it decides:

    let server = @smtp.Server::new("mx.example", auth=["PLAIN"])
    server.feed(octets)
    while server.next() is Some(request) {
    match request {
    Rcpt(to, _) if !known(to) => server.reply(550, "5.1.1 No such user")
    Data(message) => { store(message); server.accept() }
    Rset | Quit => ()
    _ => server.accept()
    }
    }
    // write server.next_write() back to the client

    lmtp=true on either side speaks LMTP, where the message gets one reply per accepted recipient.

    #Over a socket

    @moonmail/net carries the state machines over moonnet. StartTls refuses a server that does not offer it rather than going on in the clear.

    let smtp = @net.Smtp::connect("smtp.example.com", StartTls(tls))
    smtp.auth(@sasl.Plain::new("alice", password))
    let refused = smtp.send("alice@example.com", ["bob@example.com"], raw[:])
    smtp.quit()

    let imap = @net.Imap::connect("imap.example.com", Tls(tls))
    imap.login("alice", password)
    let inbox = imap.wait(imap.client().select("INBOX"))

    let pop = @net.Pop3::connect("pop.example.com", Tls(tls))
    pop.login("alice", password)
    let first = pop.retr(1)

    tls is a @tls.Client from moontls with the roots to trust. The tests run against a local relay made of @smtp.Server, and in CI against Mailpit, which takes a message by SMTP and gives it back by POP3.

    #SASL

    SMTP's auth and IMAP's and POP3's authenticate take any &@sasl.Mechanism; the protocol does the base64 and the cancelling.

    // SCRAM never sends the password, and refuses a server that cannot prove it
    // holds the salted key. The nonce is fresh random printable ASCII each time.
    smtp.auth(@sasl.ScramSha256::new("alice", "secret", nonce~))
    imap.authenticate(@sasl.OAuthBearer::new(token, username="alice@example.com"))

    A mechanism of your own implements name, start and step, and finish when it expects proof from the server.

    #IMAP

    Each command returns its tag number; feed reports untagged responses as Data and each completion as Done with that number.

    let imap = @imap.Client::new()
    let _ = imap.login("alice", "secret")
    let _ = imap.select("INBOX")
    let fetch = imap.fetch("1:*", "(FLAGS BODY.PEEK[HEADER])", uid=true)
    // ... write imap.next_write(), feed the server's octets
    for event in imap.feed(octets) {
    match event {
    Data(Fetch(n, items)) => println(n.to_string() + " " + items.length().to_string())
    Done(tag, No, _, text) if tag == fetch => println("fetch failed: " + text)
    _ => ()
    }
    }

    #DKIM

    let options = @dkim.SignOptions::new(
    domain="example.com",
    selector="sel",
    headers=["From", "To", "Subject", "Date"],
    )
    let signature = @dkim.sign(raw[:], private_key, options)
    // "DKIM-Signature: " + signature is the header to add; verify() checks it back.

    #Beyond the basics

    The pieces a caller reaches for once the simple path works:

    // IMAP: ask about a mailbox without selecting it, wait for changes, and
    // store flags only where nothing changed them since (CONDSTORE).
    let _ = imap.status("INBOX", ["MESSAGES", "UIDNEXT", "UIDVALIDITY"])
    let _ = imap.idle() // changes arrive as Data until done()
    imap.done()
    let _ = imap.store("1:5", "+FLAGS (\\Seen)", uid=true, unchangedsince=modseq)

    // SMTP: chunk the body instead of using DATA, and ask for delivery status.
    smtp.mail("alice@example.com", size=1234, body=Binary)
    smtp.rcpt("bob@example.com", notify=[Success, Failure])
    smtp.bdat(chunk[:], last=true) // LAST ends the transaction

    #What it is not

    The protocol packages open no socket; @moonmail/net does that on the native and wasm backends.

    #The gate

    moon clean && moon fmt && moon check --target all --deny-warn \ && moon build --target all && moon test --target all

    #Licence

    Apache-2.0.

    Refused

    pub(all) suberror Refused {
    Header(at~ : Int)
    Address(at~ : Int)
    Boundary(at~ : Int)
    Encoding(at~ : Int)
    Charset(name~ : String)
    Date
    } derive(Eq,
    Debug
    )

    What this package refuses, and where it stopped reading. at is an octet offset into the message for Header, Boundary and Encoding, and a UTF-16 code unit offset into the address text for Address.

    Refused::equal

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

    Refused::not_equal

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

    Refused::to_repr

    Address

    pub(all) enum Address {
    Mailbox(Mailbox)
    Group(name~ : String, members~ : Array[Mailbox])
    } derive(Eq,
    Debug
    )

    One entry of an address list: a mailbox, or a named group of them, which may be empty (undisclosed-recipients:;).

    Address::encode

    fn Address::encode(self : Address, utf8? : Bool) -> String

    The address as a header carries it; see Mailbox::encode.

    Address::equal

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

    Address::mailboxes

    fn Address::mailboxes(self : Address) -> Array[Mailbox]

    The mailboxes, a group's members in place of the group.

    Address::not_equal

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

    Address::to_repr

    Attachment

    pub(all) struct Attachment {
    name : String
    media_type : String
    data : Bytes
    inline : Bool
    id : String?
    } derive(Eq,
    Debug
    )

    A file that travels with a message. inline with an id makes it a part an HTML body shows by cid:; otherwise it is offered as an attachment.

    Attachment::equal

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

    Attachment::new

    fn Attachment::new(name : String, data : Bytes, media_type? : String, inline? : Bool, id? : String) -> Attachment

    Attachment::not_equal

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

    Body

    pub(all) enum Body {
    Leaf(Bytes)
    Multipart(Array[Message])
    Embedded(Message)
    } derive(Eq,
    Debug
    )

    What a message carries after its header: content with the transfer encoding taken off, the parts of a multipart body (their boundary is in Content-Type), or a whole message attached as message/rfc822.

    Body::equal

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

    Body::not_equal

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

    Body::to_repr

    Headers

    pub struct Headers {
    // private fields
    } derive(Eq,
    Debug
    )

    The header of a message: fields in order, names compared without regard to case, each value as written with its folding taken out. Encoded words stay encoded here; the readers on Message decode them.

    Headers::add

    fn Headers::add(self : Headers, name : String, value : String) -> Unit raise Refused

    Add a field after the others. A name outside RFC 5322 section 2.2, or a value with a line break that could start a field of its own, is refused.

    Headers::all

    fn Headers::all(self : Headers, name : StringView) -> Array[String]

    Every value of the field name, in order.

    Headers::equal

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

    Headers::get

    fn Headers::get(self : Headers, name : StringView) -> String?

    The first value of the field name.

    Headers::iter

    fn Headers::iter(self : Headers) -> Iter[(String, String)]

    Headers::length

    fn Headers::length(self : Headers) -> Int

    Headers::new

    fn Headers::new() -> Headers

    Headers::not_equal

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

    Headers::remove

    fn Headers::remove(self : Headers, name : StringView) -> Unit

    Take out every field named name.

    Headers::set

    fn Headers::set(self : Headers, name : String, value : String) -> Unit raise Refused

    Give the field name this one value: the first occurrence takes it in its place and the others go; a field not there yet is added at the end.

    Headers::to_repr

    Mailbox

    pub(all) struct Mailbox {
    name : String?
    address : String
    } derive(Eq,
    Debug
    )

    One mailbox: an address and the display name in front of it, decoded. address is the addr-spec, its user part quoted only where a dot-atom cannot carry it.

    Mailbox::encode

    fn Mailbox::encode(self : Mailbox, utf8? : Bool) -> String

    The mailbox as a header carries it. A display name that is not a run of atoms is quoted; one that is not ASCII is written as encoded words, or as UTF-8 when utf8 is set (RFC 6532).

    Mailbox::equal

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

    Mailbox::new

    fn Mailbox::new(address : String, name? : String) -> Mailbox

    Mailbox::not_equal

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

    Mailbox::to_repr

    Message

    pub(all) struct Message {
    headers : Headers
    body : Body
    } derive(Eq,
    Debug
    )

    A message, or one part of a multipart body: parts are messages too, as in Python's email.

    Message::addresses

    fn Message::addresses(self : Message, name : StringView) -> Array[Address] raise Refused

    Every address of every field named name.

    Message::attachments

    fn Message::attachments(self : Message) -> Array[Attachment]

    Every part that is not a body a reader would show: what is marked as an attachment, what sits inline beside an HTML body, an attached message, and any leaf that is not text.

    Message::bcc

    fn Message::bcc(self : Message) -> Array[Address] raise Refused

    Message::cc

    fn Message::cc(self : Message) -> Array[Address] raise Refused

    Message::content_type

    The media type, with RFC 2045's default when the field is missing.

    Message::date

    Date, read as RFC 5322 writes it.

    Message::encode

    fn Message::encode(self : Message, utf8? : Bool) -> Bytes

    The message as it goes on the wire: CRLF line ends, header fields folded at 78 columns. Each leaf is written in its Content-Transfer-Encoding; a leaf whose encoding cannot carry it (8-bit octets or lines past 998 in 7bit) is written in quoted-printable if it is text and base64 otherwise, with the field changed to say so, and a multipart body without a boundary gets one. utf8 writes non-ASCII header text as UTF-8 (RFC 6532); otherwise it goes out as encoded words.

    Reading what this writes gives the same tree back, and writing that again gives the same octets.

    Message::equal

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

    Message::from

    fn Message::from(self : Message) -> Array[Mailbox] raise Refused

    The authors in From.

    Message::html

    fn Message::html(self : Message, charsets? : Map[String, &
    Charset
    ]) -> String? raise Refused

    The HTML body, chosen and decoded as text chooses and decodes.

    Message::message_id

    fn Message::message_id(self : Message) -> String?

    Message-ID as written, angle brackets included.

    Message::new

    fn Message::new(from~ : Mailbox, to~ : Array[Address], subject~ : String, text? : String, html? : String, attachments? : Array[Attachment], cc? : Array[Address], date? :
    Moment
    , message_id? : String) -> Message raise Refused

    A message ready to send. The structure follows what it carries: one body alone; multipart/alternative for text and HTML together; multipart/related around the body when attachments are inline with an id; multipart/mixed around that when any are not. Text goes out as UTF-8, in 7bit while it is ASCII with short lines and in quoted-printable otherwise; attachments go out in base64.

    date and message_id are written only when given: this package reads no clock and draws no randomness.

    Message::not_equal

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

    Message::parse

    fn Message::parse(data : BytesView, strict? : Bool) -> Message raise Refused

    Read a message (RFC 5322, MIME as RFC 2045 and RFC 2046 lay it out): the header unfolded, the body split into parts by its boundary and each leaf's transfer encoding taken off. Line ends may be CRLF or LF.

    What a careful reader tolerates is tolerated: a line in the header that is not a field ends the header there, a missing closing boundary ends the last part at the end, an unknown or broken transfer encoding leaves the octets as they came. strict refuses each of these instead.

    Message::reply_to

    fn Message::reply_to(self : Message) -> Array[Address] raise Refused

    Message::subject

    fn Message::subject(self : Message) -> String?

    The subject, encoded words decoded.

    Message::text

    fn Message::text(self : Message, charsets? : Map[String, &
    Charset
    ]) -> String? raise Refused

    The plain text body: the text/plain leaf a reader would show, preferring the last of an alternative, decoded by its charset. UTF-8, US-ASCII and ISO-8859-1 are built in; others come in charsets.

    Message::to

    fn Message::to(self : Message) -> Array[Address] raise Refused

    Message::to_repr

    encode_addresses

    fn encode_addresses(addresses : ArrayView[Address], utf8? : Bool) -> String

    An address list as a header carries it.

    format_date

    fn format_date(moment :
    Moment
    ) -> String

    A date-time as RFC 5322 writes it, Tue, 01 Jul 2003 10:52:37 +0200.

    parse_address

    fn parse_address(text : StringView) -> Address raise Refused

    Read one address (RFC 5322 section 3.4): a mailbox, with or without a display name, or a group. The obsolete forms of section 4.4 are read too: a phrase with periods, a route in the angle brackets, a user part or domain with comments between its pieces. Encoded words in a display name are decoded (RFC 2047).

    parse_addresses

    fn parse_addresses(text : StringView) -> Array[Address] raise Refused

    Read an address list, as To and Cc carry; empty entries between commas are skipped, as RFC 5322 section 4.4 allows.

    parse_date

    fn parse_date(text : StringView) ->
    Moment
    raise Refused

    Read an RFC 5322 date-time, Tue, 1 Jul 2003 10:52:37 +0200, with the obsolete forms of section 4.3: two- and three-digit years, a missing second, the named zones of RFC 822 and comments anywhere. A zone of -0000 or an unknown name reads as UTC, as section 3.3 says it means.

    Powered by MoonBit

    Site sourceReport issueModulesSkillsBuild queueStatistics

    © 2026 mooncakes.io