Email for MoonBit: the message model, MIME and DKIM, and SMTP, LMTP, IMAP and POP3.
let raw = @utf8.encode(
"From: Alice <alice@example.com>\r\n" +
"To: Bob <bob@example.com>\r\n" +
"Subject: =?utf-8?B?THVuY2g/?=\r\n" +
"Date: Fri, 06 Nov 2026 12:00:00 +0000\r\n" +
"MIME-Version: 1.0\r\n" +
"Content-Type: text/plain; charset=utf-8\r\n" +
"\r\n" +
"Noon works.\r\n",
)
let message = @moonmail.Message::parse(raw[:])
assert_eq(message.subject(), Some("Lunch?"))
assert_eq(message.from()[0].address, "alice@example.com")
assert_eq(message.text(), Some("Noon works.\r\n"))moon run src/examples/tourlet reply = @moonmail.Message::new(
from=@moonmail.Mailbox::new("alice@example.com", name="Alice"),
to=[@moonmail.Mailbox(@moonmail.Mailbox::new("bob@example.com"))],
subject="Re: lunch",
text="Noon works.\n",
)
let wire = reply.encode(utf8=true)
let again = @moonmail.Message::parse(wire[:])
assert_eq(again.subject(), Some("Re: lunch"))
assert_eq(again.text(), Some("Noon works.\n"))let pop = @pop3.Client::new()
ignore(pop.user("alice"))
ignore(pop.pass("secret"))
ignore(pop.retr(1))
let events = pop.feed(@utf8.encode("+OK 12 octets\r\nSubject: one\r\n\r\nbody\r\n.\r\n")[:])
assert_eq(events.length(), 1)let smtp = @smtp.Client::new()
smtp.ehlo("client.example")
let _ = smtp.mail("alice@example.com")
let _ = smtp.rcpt("bob@example.com")
let replies = smtp.feed(@utf8.encode("250 mx.example\r\n250 2.1.0 Ok\r\n250 2.1.5 Ok\r\n")[:])
assert_eq(replies.length(), 2)
for event in replies {
match event {
Reply(Rcpt(to), reply) if !reply.is_ok() => println("refused: " + to)
_ => ()
}
}let server = @smtp.Server::new("mx.example", auth=["PLAIN"])
server.feed(@utf8.encode("EHLO client.example\r\n")[:])
let request = server.next()
assert_true(request is Some(Hello(_, extended=true)))
server.reply(250, "mx.example")
assert_eq(server.next_write().unwrap().length() > 0, true)let request = @jmap.Request::new()
let boxes = request.mailbox_get("u-1")
let found = request.email_query("u-1", "mb-in", 25)
assert_eq(boxes, "c0")
assert_eq(found, "c1")
let answer = @jmap.Response::parse(
"{\"methodResponses\":[[\"Email/query\",{\"ids\":[\"em-1\"]},\"c1\"]],\"sessionState\":\"s-2\"}",
)
assert_eq(@jmap.email_ids(answer, found), ["em-1"])let request = @jmap.Request::new()
let _ = request.email_get(
"u-1",
["em-1"],
["id", "subject", "bodyValues"],
fetch_html_body_values=true,
max_body_value_bytes=256,
)
let _ = request.email_import(
"u-1",
Map([
(
"one",
@jmap.EmailImport::new(
blob_id="B1",
mailbox_ids=["mb-in"],
keywords=["$seen"],
),
),
]),
)
let _ = request.search_snippet_get("u-1", ["em-1"], filter=@jmap.text_search("noon"))
assert_eq(@jmap.method_names().length(), 27)assert_eq(@jmap.Kind::Email.methods().length(), 8)
assert_true(@jmap.has_property("Email", "subject"))
assert_false(@jmap.has_property("Email", "subjectLine"))let answer = @jmap.Response::parse(
"{\"methodResponses\":[[\"Email/get\",{\"list\":[{\"id\":\"em-1\",\"subject\":\"Lunch?\"}]},\"c0\"]],\"sessionState\":\"s-2\"}",
)
assert_true(@validate.valid("Email", @jmap.email_object(answer, "c0")))let object = @jmap.email_from_message(
raw[:],
mailbox_ids=["mb-in"],
keywords=["$seen"],
received_at="2026-11-06T12:00:01Z",
)
assert_true(@validate.valid("Email", object))flowchart LR
page["writing desk<br/>(a page, no socket)"]
desk["moonmail<br/>composes the MIME"]
jmap["JMAP server<br/>HTTPS · no helper"]
office["local post office<br/>127.0.0.1:12000"]
smtp["SMTP · IMAP · POP3"]
page --> desk
desk -->|"jmap.request"| jmap
desk -->|"compose"| office
office -->|"@moonmail/net"| smtplet letter = @moonmail.Message::new(
from=@moonmail.Mailbox::new("alice@example.com"),
to=[@moonmail.Mailbox(@moonmail.Mailbox::new("bob@example.com"))],
subject="Lunch",
text="Noon works.\n",
)
let smtp = @net.Smtp::connect("smtp.example.com", Clear)
smtp.auth(@sasl.Plain::new("alice", "secret"))
let refused = smtp.send("alice@example.com", ["bob@example.com"], letter.encode())
assert_eq(refused.length(), 0)
smtp.quit()// 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.
let mechanism = @sasl.ScramSha256::new("alice", "secret", nonce="fyko+d2lbbFgONRv9qkxdawL")
assert_eq(mechanism.name(), "SCRAM-SHA-256")
let first = mechanism.start().unwrap()
assert_true(first.length() > 0)let imap = @imap.Client::new()
let login = imap.login("alice", "secret")
let selected = imap.select("INBOX")
let fetch = imap.fetch("1:*", "(FLAGS BODY.PEEK[HEADER])", uid=true)
assert_eq([login, selected, fetch], [0, 1, 2])
let events = imap.feed(@utf8.encode("1 OK LOGIN completed\r\n")[:])
assert_eq(events.length(), 1)let verdicts = @dkim.verify(raw[:], _ => None, now=0)
assert_eq(verdicts.length(), 0)// `key` is a @dkim.Key from @x509.PrivateKey::pem; the lookup answers the TXT
// record of the selector, which verify asks for once per signature.
let domain_name = "sel._domainkey.example.com"
assert_eq(domain_name, "sel._domainkey.example.com")let store = @imap.Store::new()
let _ = store.deliver("INBOX", raw[:], date="06-Nov-2026 12:00:00 +0000")
let server = @imap.Server::new(
store~,
credentials=(user, password) => user == "tim" && password == "secret",
clock=() => "06-Nov-2026 12:00:00 +0000",
)
server.feed(@utf8.encode("a1 LOGIN tim secret\r\n")[:])
assert_true(server.next_write() is Some(_))| Package | Specification | Worth knowing |
|---|---|---|
| moonmail | RFC 5322, 2045, 2046, 6532 | Writing what was read and reading it again gives the same tree, byte for byte. |
| moonmail/smtp | RFC 5321, 1870, 6152, 6531, 2920, 3030, 3207, 4954, 3461 | Pipelining, chunking and DSN follow what the server announces; a command that needs an extension it did not announce is refused when it is queued, not when it is sent. |
| moonmail/imap | RFC 9051, 2177, 7162, 3501 | Toward a server that announced neither IMAP4rev2 nor UTF8=ACCEPT, names are written and read in modified UTF-7. A search for text outside US-ASCII has to name its charset and quote the string. Every one of imaptest's 677 scripted replies is read back as written: an LSUB is an LSUB — List carries the command in listing — the return data of LIST-EXTENDED (RFC 5258) is kept, and a line may hold more than one literal. |
| moonmail/pop3 | RFC 1939, 2449 | The line limit covers status lines, which the protocol bounds; a line of a message has no length of its own and is bounded by the message limit instead. |
| moonmail/dkim | RFC 6376, 8463 | Both canonicalizations, RSA-SHA256 and Ed25519-SHA256, l=, t=, x= and h= checked item by item, and interoperable with dkimpy in both directions. |
| moonmail/sasl | RFC 4616, 7677, 7628, 4013 | SCRAM-SHA-256 checks the server's signature before the session is trusted; PLAIN, LOGIN, CRAM-MD5, XOAUTH2 and OAUTHBEARER complete it. |
| moonmail/jmap | RFC 8620, 8621 | Twenty-seven methods and eighteen objects, the objects described once in moonmodel: @moonmail/jmap/validate checks a document against that description and reports what is wrong with it. |
moon clean && moon fmt && moon check --target all --deny-warn \
&& moon build --target all && moon test --target allfn Attachment::new(name : String, data : Bytes, media_type? : String, inline? : Bool, id? : String) -> AttachmentInstall
Download zipEmail for MoonBit: the message model, MIME and DKIM, and SMTP, LMTP, IMAP and POP3.