///|
/// Deterministic rendering controls. The date, message-id and boundary seed
/// are all injected by the caller so that tests are reproducible; nothing here
/// reads the system clock or an RNG.
pub struct RenderConfig {
date : String
message_id : String
boundary_seed : String
} derive(Eq, Debug)
///|
pub fn RenderConfig::new(
date? : String = "",
message_id? : String = "",
boundary_seed? : String = "moonmail",
) -> RenderConfig {
{ date, message_id, boundary_seed }
}
///|
/// A fully deterministic default configuration (no date, no message-id,
/// fixed seed).
pub fn RenderConfig::deterministic() -> RenderConfig {
{ date: "", message_id: "", boundary_seed: "moonmail" }
}
///|
/// Render a `MailMessage` to its RFC 5322 / MIME wire form (CRLF line
/// endings). All header values are validated against injection before being
/// emitted.
pub fn render_message(
msg : MailMessage,
config : RenderConfig,
) -> String raise MailFailure {
let headers = apply_render_config(msg.headers.copy(), config)
// RFC 2045 section 4: MIME messages carry a MIME-Version field.
if !has_header(headers, "MIME-Version") {
headers.push(("MIME-Version", "1.0"))
}
match msg.body {
Text(_) | Html(_) | Binary(_, _) => {
let part : Part = { headers, body: msg.body }
render_part(part, config.boundary_seed)
}
MultiPart(children, subtype) => {
let child_renders = []
for i = 0; i < children.length(); i = i + 1 {
let r = render_part(children[i], config.boundary_seed + "#\{i}")
child_renders.push(r)
}
let combined = fold_strings(child_renders)
let boundary = unique_boundary(config.boundary_seed, combined)
let body_text = assemble_multipart(child_renders, boundary)
set_header_value(
headers,
"Content-Type",
"multipart/\{subtype.to_string()}; boundary=\"\{boundary}\"",
)
let out = Buffer::Buffer()
out.write_string_utf16le(render_headers(headers))
out.write_string_utf16le("\r\n")
out.write_string_utf16le(body_text)
out.to_string()
}
}
}
///|
fn fold_strings(items : Array[String]) -> String {
let buf = Buffer::Buffer()
for s in items {
buf.write_string_utf16le(s)
}
buf.to_string()
}
///|
fn apply_render_config(
headers : Array[(String, String)],
config : RenderConfig,
) -> Array[(String, String)] {
if config.date != "" {
set_header_value(headers, "Date", config.date)
}
if config.message_id != "" {
set_header_value(headers, "Message-ID", config.message_id)
}
headers
}
///|
/// Render a single part (leaf or nested multipart) to its full wire text.
fn render_part(part : Part, seed : String) -> String raise MailFailure {
match part.body {
MultiPart(children, subtype) => {
let child_renders = []
for i = 0; i < children.length(); i = i + 1 {
let r = render_part(children[i], seed + "#\{i}")
child_renders.push(r)
}
let combined = fold_strings(child_renders)
let boundary = unique_boundary(seed, combined)
let body_text = assemble_multipart(child_renders, boundary)
let headers = part.headers.copy()
set_header_value(
headers,
"Content-Type",
"multipart/\{subtype.to_string()}; boundary=\"\{boundary}\"",
)
let out = Buffer::Buffer()
out.write_string_utf16le(render_headers(headers))
out.write_string_utf16le("\r\n")
out.write_string_utf16le(body_text)
out.to_string()
}
_ => render_leaf(part, seed)
}
}
///|
fn render_leaf(part : Part, seed : String) -> String raise MailFailure {
let headers = part.headers.copy()
let body_text = match part.body {
Text(content) => {
let enc = resolve_encoding(part)
let (body, used) = encode_body(content, enc)
if !has_header(headers, "Content-Type") {
headers.push(("Content-Type", "text/plain; charset=utf-8"))
}
if !has_header(headers, "Content-Transfer-Encoding") {
headers.push(("Content-Transfer-Encoding", used.to_string()))
}
body
}
Html(content) => {
let enc = resolve_encoding(part)
let (body, used) = encode_body(content, enc)
if !has_header(headers, "Content-Type") {
headers.push(("Content-Type", "text/html; charset=utf-8"))
}
if !has_header(headers, "Content-Transfer-Encoding") {
headers.push(("Content-Transfer-Encoding", used.to_string()))
}
body
}
Binary(data, filename) => {
let body = base64_mime_encode(data)
if !has_header(headers, "Content-Type") {
headers.push(("Content-Type", "application/octet-stream"))
}
if !has_header(headers, "Content-Transfer-Encoding") {
headers.push(("Content-Transfer-Encoding", "base64"))
}
if !has_header(headers, "Content-Disposition") {
let disp = make_disposition(Attachment, filename)
headers.push(("Content-Disposition", disp))
}
body
}
MultiPart(_, _) => raise MailFailure::mime("unreachable")
}
let _ = seed
let out = Buffer::Buffer()
out.write_string_utf16le(render_headers(headers))
out.write_string_utf16le("\r\n")
out.write_string_utf16le(body_text)
if !body_text.has_suffix("\r\n") {
out.write_string_utf16le("\r\n")
}
out.to_string()
}
///|
fn render_headers(
headers : Array[(String, String)],
) -> String raise MailFailure {
let buf = Buffer::Buffer()
for pair in headers {
let (name, value) = pair
buf.write_string_utf16le(make_header(name, value))
}
buf.to_string()
}
///|
fn has_header(headers : Array[(String, String)], name : String) -> Bool {
for pair in headers {
let (n, _) = pair
if n.to_lower() == name.to_lower() {
return true
}
}
false
}
///|
fn set_header_value(
headers : Array[(String, String)],
name : String,
value : String,
) -> Unit {
set_header(headers, name, value)
}
///|
fn resolve_encoding(part : Part) -> ContentTransferEncoding raise MailFailure {
match part.header("Content-Transfer-Encoding") {
Some(v) => parse_cte(v)
None => Auto
}
}
///|
pub fn parse_cte(v : String) -> ContentTransferEncoding raise MailFailure {
match v.trim().to_lower() {
"7bit" => SevenBit
"8bit" => EightBit
"base64" => Base64
"quoted-printable" => QuotedPrintable
_ => raise MailFailure::encode("unknown transfer encoding: \{v}")
}
}