// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
///|
/// Every corner of moonmail in one run: reading a message, its headers and
/// encodings, then writing it back, then a POP3 dialogue and a DKIM signature.
///
/// moon run src/examples/tour
///
/// The point is not the output — the same paths are asserted in the test suite —
/// but that each is reached the same way a caller would reach it.
///|
fn prefix(s : String, n : Int32) -> String {
let m = if s.length() < n { s.length() } else { n }
s[0:m].to_owned()
}
///|
/// A client session against a recorded POP3 server: the server's lines go in
/// one by one, and the client's writes in between are dropped.
fn pop3_tour() -> Unit raise @pop3.Refused {
let pop = @pop3.Client::new()
let events = []
let replay = (text : String) => {
let _ = pop.next_write()
events.append(pop.feed(@utf8.encode(text + "\r\n")))
}
replay("+OK POP3 server ready <1896.697170952@dbc.mtview.ca.us>")
pop.capa()
replay("+OK capability list follows\r\nTOP\r\nUIDL\r\nSASL PLAIN\r\n.")
pop.apop("mrose", "tanstaaf")
replay("+OK maildrop has 2 messages")
pop.stat()
replay("+OK 2 320")
pop.list()
replay("+OK 2 messages\r\n1 120\r\n2 200\r\n.")
pop.retr(1)
replay(
"+OK 120 octets\r\nSubject: one\r\n\r\n..a line that starts with a dot\r\n.",
)
pop.uidl(message=2)
replay("+OK 2 QhdPYR:00WBw1Ph7x7")
pop.quit()
replay("+OK bye")
println("--- pop3")
for event in events {
match event {
Reply(command, Content(data)) =>
println(
@debug.to_string(command) +
" -> " +
@utf8.decode_lossy(data[:]).escape(),
)
Reply(command, reply) =>
println(@debug.to_string(command) + " -> " + @debug.to_string(reply))
Sasl(why) => println("sasl -> " + @debug.to_string(why))
}
}
}
///|
/// Run an SMTP client against an SMTP server in memory until neither has
/// anything to send, answering each request with `decide`, and collect what
/// the client reports.
fn converse(
client : @smtp.Client,
server : @smtp.Server,
decide : (@smtp.Server, @smtp.Request) -> Unit raise @smtp.Refused,
) -> Array[@smtp.Event] raise @smtp.Refused {
let events = []
let mut moved = true
while moved {
moved = false
if server.next_write() is Some(data) {
events.append(client.feed(data))
moved = true
}
if client.next_write() is Some(data) {
server.feed(data)
moved = true
}
while server.next() is Some(request) {
decide(server, request)
moved = true
}
}
events
}
///|
fn accept_all(
server : @smtp.Server,
request : @smtp.Request,
) -> Unit raise @smtp.Refused {
match request {
Rset | Quit => ()
_ => server.accept()
}
}
///|
/// The sending side against a relay: hello, a whole transaction in one call,
/// and goodbye. LMTP is the same client with `lmtp=true`, where the message
/// gets one reply per recipient.
fn smtp_tour(raw : Bytes) -> Unit raise @smtp.Refused {
let relay = @smtp.Server::new("relay.example")
let smtp = @smtp.Client::new()
smtp.ehlo("client.example")
let _ = converse(smtp, relay, accept_all)
smtp.send("alice@example.com", ["bob@example.com"], raw[:])
smtp.quit()
let replies = converse(smtp, relay, accept_all)
println("--- smtp")
println("extensions -> \{smtp.extensions().length()}")
println("pipelining -> \{smtp.has("PIPELINING")}")
println("replies -> \{replies.length()}")
let lmtp_server = @smtp.Server::new("lmtp.example", lmtp=true)
let lmtp = @smtp.Client::new(lmtp=true)
lmtp.ehlo("client.example")
let _ = converse(lmtp, lmtp_server, accept_all)
lmtp.send(
"alice@example.com",
["bob@example.com", "carol@example.com"],
raw[:],
)
let deliveries = converse(lmtp, lmtp_server, (server, request) => {
match request {
Rcpt(to, _) if to.has_prefix("carol") =>
server.reply(550, "5.1.1 No such user")
_ => accept_all(server, request)
}
}).filter(event => event is Reply(Delivery(_), _))
println("lmtp replies -> \{deliveries.length()}")
let chunked = @smtp.Server::new("relay.example")
let bdat = @smtp.Client::new()
bdat.ehlo("client.example")
let _ = converse(bdat, chunked, accept_all)
let body = @utf8.encode("Subject: Hi\r\n\r\n")
bdat.mail("alice@example.com", size=body.length(), body=Binary)
bdat.rcpt("bob@example.com", notify=[Success, Failure])
bdat.bdat(body[:], last=true)
let chunks = converse(bdat, chunked, accept_all)
println("bdat replies -> \{chunks.length()}")
}
///|
/// A client session against a recorded server: the server's lines go in one
/// by one, and the client's writes in between are dropped.
fn imap_tour() -> Unit raise @imap.Refused {
let imap = @imap.Client::new()
let events = []
let replay = (text : String) => {
let _ = imap.next_write()
events.append(imap.feed(@utf8.encode(text + "\r\n")))
}
replay("* OK [CAPABILITY IMAP4rev2 IDLE AUTH=PLAIN] ready")
let _ = imap.login("alice", "secret")
replay("A0001 OK LOGIN completed")
let _ = imap.select("INBOX")
replay("* 5 EXISTS")
replay("* OK [UIDVALIDITY 77] UIDs valid")
replay("A0002 OK [READ-WRITE] SELECT completed")
let _ = imap.search("UNSEEN", uid=true)
replay("* ESEARCH (TAG \"A0003\") UID ALL 101:103")
replay("A0003 OK SEARCH completed")
let _ = imap.fetch("1", "(FLAGS BODY[HEADER.FIELDS (SUBJECT)])")
replay("* 1 FETCH (FLAGS (\\Seen) BODY[HEADER.FIELDS (SUBJECT)] {13}")
replay("Subject: hi\r\n)")
replay("A0004 OK FETCH completed")
let _ = imap.idle()
replay("+ idling")
replay("* 6 EXISTS")
imap.done()
replay("A0005 OK IDLE terminated")
let _ = imap.logout()
replay("* BYE logging out")
replay("A0006 OK LOGOUT completed")
println("--- imap")
println("responses -> \{events.length()}")
println("state -> \{@debug.to_string(imap.state())}")
for event in events {
if event is Data(Fetch(n, items)) {
let subject = items
.get("BODY[HEADER.FIELDS (SUBJECT)]")
.bind(v => v.text())
.unwrap_or("")
println("fetch \{n} -> \{subject.trim()}")
}
}
}
///|
/// A message read into a tree, its readers, one built from scratch, and the
/// round trip that writes the same octets twice.
fn message_tour() -> Unit raise @moonmail.Refused {
let raw = @utf8.encode(
"From: =?utf-8?q?Ren=C3=A9e?= \r\nTo: Bob , team: carol@example.com, dan@example.com;\r\nSubject: =?utf-8?B?Q2Fmw6k=?=\r\nDate: Tue, 1 Jul 2003 10:52:37 +0200\r\nContent-Type: multipart/alternative; boundary=\"BOUND\"\r\n\r\n--BOUND\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\nCaf=C3=A9 and cr=C3=A8me\r\n--BOUND\r\nContent-Type: text/html; charset=utf-8\r\n\r\nCaf\u{e9}
\r\n--BOUND--\r\n",
)
let message = @moonmail.Message::parse(raw[:])
println("--- message")
println("subject -> \{message.subject().unwrap_or("")}")
println(
"from -> \{message.from().map(m => m.encode(utf8=true)).join(", ")}",
)
println(
"to -> \{message.to().map(a => a.encode(utf8=true)).join(", ")}",
)
println("date -> \{@moonmail.format_date(message.date().unwrap())}")
println("type -> \{message.content_type().essence}")
println("text -> \{message.text().unwrap_or("")}")
println("html -> \{message.html().unwrap_or("")}")
let built = @moonmail.Message::new(
from=@moonmail.Mailbox::new("alice@example.com", name="Alice"),
to=[Mailbox(@moonmail.Mailbox::new("bob@example.com"))],
subject="Grüße",
text="Hello",
html="Hello
",
attachments=[@moonmail.Attachment::new("notes.txt", b"one\r\ntwo\r\n")],
)
let wire = built.encode()
let again = @moonmail.Message::parse(wire[:]).encode()
println(
"built -> \{built.content_type().essence}, \{built.attachments().length()} attachment",
)
println("stable -> \{wire == again}")
}
///|
/// Sign with the Ed25519 key of RFC 8463 and check the signature against the
/// record that publishes its public half.
fn dkim_tour(raw : Bytes) -> Unit raise {
let seed = @base64.decode("nWGxne/9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A=")
let key = @ed25519.PrivateKey::of_secret(seed[:])
let field = @dkim.sign(
raw[:],
Ed25519(key),
domain="football.example.com",
selector="brisbane",
)
let signed = @utf8.encode(field + "\r\n" + @utf8.decode_lossy(raw[:]))
let record = "v=DKIM1; k=ed25519; p=11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo="
let verdicts = @dkim.verify(
signed[:],
name => {
if name == "brisbane._domainkey.football.example.com" {
Some(record)
} else {
None
}
},
now=0,
)
println("--- dkim")
println("field -> \{prefix(field, 48)}…")
println("verdicts -> \{@debug.to_string(verdicts)}")
}
///|
#coverage.skip
fn main {
// --------------------------------------------------------------- message
message_tour() catch {
e => println("message tour stopped: " + @debug.to_string(e))
}
let raw = @utf8.encode(
"From: Alice \r\nTo: Bob \r\nSubject: Hi\r\n\r\nHello\r\n",
)
// ----------------------------------------------------- quoted-printable
//
// The transfer encoding on its own: high octets become =XY, and decoding
// turns them back. Round-tripping is the only safe way to show both.
let greeting = @utf8.encode("Café ☕ — naïve")[:]
let encoded = @qp.encode(greeting)
let decoded = @qp.decode(encoded[:]) catch { _ => b"" }
println("--- quoted-printable")
println("encoded -> \{@utf8.decode_lossy(encoded[:])}")
println("round trip -> \{@utf8.decode_lossy(decoded[:])}")
// ------------------------------------------------------- encoded words
//
// RFC 2047: a header that is not plain ASCII is written as encoded words.
let words = @moonmime.encode_words("Grüße aus München") catch { _ => "" }
println("--- encoded words")
println("encoded -> \{words}")
println("decoded -> \{@moonmime.decode_words(words[:])}")
// ----------------------------------------------------------- charsets
//
// Non-UTF-8 bodies. Encoding refuses what a set cannot hold, decoding never
// does, so a stray octet costs a character and not the whole part.
let latin = @moonmime.encode_charset(name="iso-8859-1", text="é") catch {
_ => b""
}
let back = @moonmime.decode_charset(name="iso-8859-1", data=latin[:]) catch {
_ => ""
}
println("--- charsets")
println("iso-8859-1 -> \{latin.length()} octet, back to \{back}")
let refused = try {
let _ = @moonmime.encode_charset(name="us-ascii", text="é")
false
} catch {
_ => true
}
println("ascii refuses é -> \{refused}")
// --------------------------------------------------------------- pop3
pop3_tour() catch {
e => println("pop3 tour stopped: " + @debug.to_string(e))
}
// --------------------------------------------------------------- smtp
smtp_tour(raw) catch {
e => println("smtp tour stopped: " + @debug.to_string(e))
}
// --------------------------------------------------------------- imap
imap_tour() catch {
e => println("imap tour stopped: " + @debug.to_string(e))
}
// --------------------------------------------------------------- dkim
dkim_tour(raw) catch {
e => println("dkim tour stopped: " + @debug.to_string(e))
}
}