// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
///|
/// The one function the page imports. `op` names the operation and `args` is a
/// JSON object; the answer is a JSON object with `ok` and either `value` or
/// `error`. Strings are the only thing that crosses the boundary, which keeps
/// the binding to one function however many methods grow behind it.
pub fn engine_call(op : String, args : String) -> String {
let input = match read(args) {
Some(json) => json
None => return fail("args", "the arguments were not a JSON object")
}
match op {
"compose" => compose(input)
"parse" => parse(input)
"jmap.session" => session(input)
"jmap.request" => request(input)
"jmap.read" => read_response(input)
_ => fail("op", "there is no operation called " + op)
}
}
///|
fn main {
()
}
///|
fn read(text : String) -> Json? {
Some(@json.parse(text[:])) catch {
_ => None
}
}
///|
fn ok(value : Json) -> String {
object([("ok", Json::boolean(true)), ("value", value)]).stringify()
}
///|
fn fail(kind : String, message : String) -> String {
object([
("ok", Json::boolean(false)),
(
"error",
object([("kind", Json::string(kind)), ("message", Json::string(message))]),
),
]).stringify()
}
///|
fn object(pairs : Array[(String, Json)]) -> Json {
let map : Map[String, Json] = Map([])
for pair in pairs {
map[pair.0] = pair.1
}
Json::object(map)
}
///|
fn strings(values : Array[String]) -> Json {
Json::array(values.map(fn(value) { Json::string(value) }))
}
///|
fn text(input : Json, name : String) -> String? {
match input {
Json::Object(map) =>
match map.get(name) {
Some(Json::String(value)) => Some(value)
_ => None
}
_ => None
}
}
///|
fn flag(input : Json, name : String) -> Bool {
match input {
Json::Object(map) =>
match map.get(name) {
Some(Json::True) => true
_ => false
}
_ => false
}
}
///|
fn field(input : Json, name : String) -> Json? {
match input {
Json::Object(map) => map.get(name)
_ => None
}
}
///|
fn list(input : Json, name : String) -> Array[String] {
let out : Array[String] = []
match input {
Json::Object(map) =>
match map.get(name) {
Some(Json::Array(values)) =>
for value in values {
match value {
Json::String(item) => out.push(item)
_ => ()
}
}
_ => ()
}
_ => ()
}
out
}
///|
fn int(input : Json, name : String, fallback : Int32) -> Int32 {
match input {
Json::Object(map) =>
match map.get(name) {
Some(Json::Number(value, ..)) => value.to_int()
_ => fallback
}
_ => fallback
}
}
///|
fn addresses(
values : Array[String],
) -> Array[@moonmail.Address] raise @moonmail.Refused {
let out : Array[@moonmail.Address] = []
for value in values {
out.push(@moonmail.parse_address(value[:]))
}
out
}
///|
/// Build a letter out of the fields the page holds. The message is written by
/// `Message::encode`, so the text is UTF-8 with the transfer encoding, the
/// boundaries and the folding that the library already decided on.
fn compose(input : Json) -> String {
let from = text(input, "from").unwrap_or("")
if from == "" {
return fail("compose", "a letter needs a From address")
}
try {
let sender = @moonmail.parse_address(from[:])
let body_text = text(input, "text")
let body_html = text(input, "html")
// The card the reader picked travels with the letter: an inline part the
// HTML body shows by `cid:`, so the photograph arrives as part of the
// message rather than as a link that a client may refuse to load.
let attachments : Array[@moonmail.Attachment] = []
match field(input, "image") {
Some(image) => {
let carried = Some(@base64.decode(text(image, "data").unwrap_or(""))) catch {
_ => None
}
match carried {
Some(data) =>
if data.length() > 0 {
attachments.push(
@moonmail.Attachment::new(
text(image, "name").unwrap_or("card.jpg"),
data,
media_type=text(image, "type").unwrap_or("image/jpeg"),
inline=true,
id=text(image, "cid").unwrap_or("card"),
),
)
}
None => ()
}
}
None => ()
}
// Files the page hands over: an inline picture shown by `cid:`, or a plain
// attachment such as the Markdown the card could not hold.
match field(input, "attachments") {
Some(Json::Array(items)) =>
for item in items {
let bytes = Some(@base64.decode(text(item, "data").unwrap_or(""))) catch {
_ => None
}
match bytes {
Some(data) =>
if data.length() > 0 {
let name = text(item, "name").unwrap_or("attachment")
let kind = text(item, "type").unwrap_or(
"application/octet-stream",
)
// `id` is what makes a part inline: give it one and the picture
// is shown by `cid:`; leave it out and the file is an attachment.
match text(item, "cid") {
Some(cid) =>
attachments.push(
@moonmail.Attachment::new(
name,
data,
media_type=kind,
inline=true,
id=cid,
),
)
None =>
attachments.push(
@moonmail.Attachment::new(
name,
data,
media_type=kind,
inline=flag(item, "inline"),
),
)
}
}
None => ()
}
}
_ => ()
}
let carried : Array[@moonmail.Attachment]? = if attachments.is_empty() {
None
} else {
Some(attachments)
}
let message = @moonmail.Message::new(
from=@moonmail.Address::mailboxes(sender)[0],
to=addresses(list(input, "to")),
cc=addresses(list(input, "cc")),
subject=text(input, "subject").unwrap_or(""),
text?=body_text,
html?=body_html,
attachments?=carried,
)
let bytes = message.encode()
ok(
object([
("message", Json::string(@utf8.decode_lossy(bytes[:]))),
("bytes", Json::number(bytes.length().to_double())),
]),
)
} catch {
error => fail("compose", error.to_repr().to_string())
}
}
///|
/// Read a raw message back into the fields a page shows, which is the other
/// half of what the library is for.
fn parse(input : Json) -> String {
let raw = text(input, "raw").unwrap_or("")
if raw == "" {
return fail("parse", "there is nothing to parse")
}
try {
let message = @moonmail.Message::parse(@utf8.encode(raw))
let subject = message.subject().unwrap_or("")
let text_body = match message.text() {
Some(body) => body
None => ""
}
ok(
object([
("subject", Json::string(subject)),
("text", Json::string(text_body)),
("bytes", Json::number(@utf8.encode(raw).length().to_double())),
]),
)
} catch {
error => fail("parse", error.to_repr().to_string())
}
}
///|
/// `/.well-known/jmap`, read into the four things a client needs before it can
/// call anything.
fn session(input : Json) -> String {
let base = text(input, "base").unwrap_or("")
let body = text(input, "body").unwrap_or("")
try {
let found = @jmap.Session::parse(base, body)
ok(
object([
("apiUrl", Json::string(found.api_url)),
("accountId", Json::string(found.account_id)),
("username", Json::string(found.username.unwrap_or(""))),
("state", Json::string(found.state)),
]),
)
} catch {
error => fail("jmap", error.to_repr().to_string())
}
}
///|
/// Turn a named operation into the request document to post, plus the tag the
/// answer will come back under.
fn request(input : Json) -> String {
let op = text(input, "op").unwrap_or("")
let account = text(input, "account").unwrap_or("")
if account == "" {
return fail("account", "there is no account to call against")
}
let request = @jmap.Request::new()
let tag = match op {
"mailboxes" => request.mailbox_get(account)
"identities" => request.identity_get(account)
"query" =>
request.email_query(
account,
text(input, "mailbox").unwrap_or(""),
int(input, "limit", 20),
)
"get" =>
request.email_get(account, list(input, "ids"), [
"id", "subject", "from", "receivedAt", "preview", "keywords",
])
"draft" => {
let draft = @jmap.Draft::new(
to=list(input, "to"),
subject=text(input, "subject").unwrap_or(""),
text=text(input, "text").unwrap_or(""),
)
request.email_set_draft(
account,
text(input, "mailbox").unwrap_or(""),
draft,
)
}
"submit" =>
request.email_submission_set(
account,
text(input, "identity").unwrap_or(""),
text(input, "emailId").unwrap_or(""),
)
_ => return fail("op", "there is no JMAP operation called " + op)
}
ok(
object([
("tag", Json::string(tag)),
("body", Json::string(request.to_string())),
]),
)
}
///|
/// Read an answer back into the shape the page draws: mailboxes, letters, ids,
/// identities, or the id of something just created.
fn read_response(input : Json) -> String {
let op = text(input, "op").unwrap_or("")
let tag = text(input, "tag").unwrap_or("")
let body = text(input, "body").unwrap_or("")
try {
let response = @jmap.Response::parse(body)
let value = match op {
"mailboxes" => {
let list = @jmap.mailboxes(response, tag)
Json::array(
list.map(fn(mailbox) {
object([
("id", Json::string(mailbox.id)),
("name", Json::string(mailbox.name)),
("role", Json::string(mailbox.role.unwrap_or(""))),
("total", Json::number(mailbox.total.to_double())),
("unread", Json::number(mailbox.unread.to_double())),
])
}),
)
}
"identities" => {
let list = @jmap.identities(response, tag)
Json::array(
list.map(fn(identity) {
object([
("id", Json::string(identity.id)),
("name", Json::string(identity.name.unwrap_or(""))),
("email", Json::string(identity.email)),
])
}),
)
}
"query" => strings(@jmap.email_ids(response, tag))
"get" => {
let list = @jmap.emails(response, tag)
Json::array(
list.map(fn(letter) {
object([
("id", Json::string(letter.id)),
("subject", Json::string(letter.subject)),
("from", Json::string(letter.from.unwrap_or(""))),
("receivedAt", Json::string(letter.received_at.unwrap_or(""))),
("preview", Json::string(letter.preview)),
("unread", Json::boolean(letter.unread)),
])
}),
)
}
"draft" => Json::string(@jmap.created_id(response, tag, "draft"))
"submit" => Json::string(@jmap.created_id(response, tag, "submission"))
_ => Json::null()
}
ok(value)
} catch {
error => fail("jmap", error.to_repr().to_string())
}
}