MoonMail

the guide · eight steps

Start with a letter.

One message in, one message out, then a signature, a login, and finally a socket you own. Every step shows what you type and what comes back.

8steps 1dependency 0sockets to parse
before you start

Each step is one letter on the desk: what you type, then what comes back. Steps 1–5 need no network at all — they work on bytes you already have.

01

Add one dependency

The root package carries the message model. Protocol clients are sub-packages of the same module, so one entry is enough; moon add writes it for you.

shell
moon add moonbitstack/moonmail

That writes one entry. The protocol clients are sub-packages of the same module, so the manifest needs nothing else:

moon.mod.json
{
  "deps": {
    "moonbitstack/moonmail": "0.1.0"
  }
}

Then import the piece you need. Nothing is registered globally, so the compiler keeps you honest about which package a name came from.

moon.pkg.json
{
  "import": [
    "moonbitstack/moonmail",
    "moonbitstack/moonmail/smtp"
  ]
}
02

Read a letter

A .eml file is bytes. Message::parse turns them into headers and a tree of parts, undoing transfer encodings as it goes. The readers then hand back typed values, not strings you have to unpick.

moonbit
let message = @moonmail.Message::parse(raw[:])

println(message.subject().unwrap_or("(no subject)"))
println(message.message_id().unwrap_or("(no id)"))
println(message.text().unwrap_or(""))
println(@debug.to_string(message.from()))
out
Welcome aboard
<8f2c41@example.com>
Thanks for signing up — your first letter is inside.
[{ name: Some("Leo"), address: "leo@example.com" }]

Parsing is lenient by default: a boundary that never closes ends the parts instead of failing. Pass strict=true when you would rather see the error than a repaired letter.

03

Look inside

A part is a Message too, so walking the tree is a four-line recursion. Body has three cases and nothing else: a leaf, a multipart, or an embedded letter.

moonbit
fn walk(part : @moonmail.Message, depth : Int) -> Unit {
  let pad = "  ".repeat(depth)
  println("\{pad}\{part.content_type().essence}")
  match part.body {
    Leaf(bytes) => println("\{pad}  \{bytes.length()} bytes")
    Multipart(parts) => parts.each(fn(p) { walk(p, depth + 1) })
    Embedded(inner) => walk(inner, depth + 1)
  }
}

walk(message, 0)
out
multipart/mixed
  multipart/alternative
    text/plain
      54 bytes
    text/html
      61 bytes
  image/jpeg
    3 bytes

When you do not want to walk it yourself, message.attachments() collects every leaf that is not the body, already decoded and named.

04

Write one back

Message::new picks the nesting for you: plain text alone stays flat, text with HTML becomes alternative, and anything with an attachment becomes mixed. You name the parts, not the tree.

moonbit
let reply = @moonmail.Message::new(
  from=@moonmail.Mailbox::new("leo@example.com", name="Leo"),
  to=[Mailbox(@moonmail.Mailbox::new("you@example.com"))],
  subject="Re: Welcome aboard",
  text="Thanks — the lake photo was mine.",
  attachments=[
    @moonmail.Attachment::new("lake.jpg", lake, media_type="image/jpeg"),
  ],
)

let wire = reply.encode()
out
wire · 520 bytes
multipart/mixed
  text/plain
    35 bytes
  image/jpeg
    3 bytes

Encoding folds at 78 columns and writes CRLF. Pass utf8=true to write non-ASCII headers in place (RFC 6532) instead of as encoded words.

05

Sign it

DKIM signs a set of headers and a hash of the body. sign returns one finished DKIM-Signature line; you put it in front of the message you already have, untouched.

moonbit
let key = @ed25519.PrivateKey::of_secret(seed[:])

let signature = @dkim.sign(
  raw[:],
  Ed25519(key),
  domain="example.com",
  selector="moon",
)

let signed = @utf8.encode(signature + "\r\n" + @utf8.decode_lossy(raw[:]))

let verdicts = @dkim.verify(
  signed[:],
  name => {
    if name == "moon._domainkey.example.com" {
      Some(record)
    } else {
      None
    }
  },
  now=0,
)
out
[Pass(domain="example.com", selector="moon")]

Change one character of the body and the same call returns Fail; keep the body but rewrite a signed header and it fails too. lookup is where you do the DNS — this package never reaches for a resolver.

06

Log in

A mechanism is a value, not a global. Build the one you want and hand it to whichever client you are driving — the same object works on SMTP, IMAP and POP3.

moonbit
let mechanism = @sasl.ScramSha256::new(
  "leo@example.com",
  password,
  nonce="f3a91c7d2b",
)

// when the server offers nothing better
let plain = @sasl.Plain::new("leo@example.com", password)

println(mechanism.name())

SCRAM needs a nonce and this package will not invent one: randomness is your call, so a test can pin it. Everything else — the three round trips, the server signature check — is inside the mechanism.

one trap

Run passwords through @sasl.prep before you build a mechanism. It normalises the string and refuses the characters the spec forbids, which is exactly what a server in another locale will do.

07

Send it

The client is a state machine: you queue commands, and next_write hands back bytes only when the protocol allows them. With no PIPELINING announced that is strict turn-taking; with it, a whole envelope goes out at once.

moonbit
let client = @smtp.Client::new()
ignore(client.feed(b"220 mail.example.com ESMTP\r\n"))
client.ehlo("laptop.local")
ignore(client.next_write())
ignore(client.feed(b"250-mail.example.com\r\n250-8BITMIME\r\n250 SIZE 10240000\r\n"))

client.send("leo@example.com", ["you@example.com"], wire)
out
MAIL FROM:<leo@example.com> SIZE=208 BODY=8BITMIME
RCPT TO:<you@example.com>
DATA

send is the shortcut: it fills in SIZE, BODY=8BITMIME and, when the payload needs it, SMTPUTF8 — but only for extensions the server actually announced at EHLO.

08

Drive it yourself

Every protocol package speaks the same three-call contract — feed, next_write, and a way to see what is still pending. That is why a test needs no socket: you feed recorded bytes and assert on the bytes it wants to write back.

moonbit
let client = @smtp.Client::new()
ignore(client.feed(b"220 mail.example.com ESMTP\r\n"))
client.ehlo("test.local")

match client.next_write() {
  Some(out) => println(@utf8.decode_lossy(out))
  None => ()
}
println(client.pending())
out
EHLO test.local
1

IMAP adds tags — every command returns its number and every Done carries it back. POP3 adds pending, which is all the back-pressure that protocol needs. moonmail/net is the only package that owns a socket; everything above it is pure.