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.
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.
moon add moonbitstack/moonmail
That writes one entry. The protocol clients are sub-packages of the same module, so the manifest needs nothing else:
{
"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.
{
"import": [
"moonbitstack/moonmail",
"moonbitstack/moonmail/smtp"
]
}
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.
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()))
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.
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.
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)
multipart/mixed
multipart/alternative
text/plain
54 bytes
text/html
61 bytes
image/jpeg
3 bytesWhen you do not want to walk it yourself, message.attachments() collects every leaf that is not the body, already decoded and named.
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.
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()
wire · 520 bytes
multipart/mixed
text/plain
35 bytes
image/jpeg
3 bytesEncoding folds at 78 columns and writes CRLF. Pass utf8=true to write non-ASCII headers in place (RFC 6532) instead of as encoded words.
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.
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,
)
[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.
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.
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.
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.
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.
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)
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.
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.
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())
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.
