///|
/// Building an outgoing message.
///
/// Only what a watcher needs: one body part, an optional set of attachments,
/// UTF-8 everywhere, base64 for anything that is not plain ASCII. The output is
/// a complete RFC 5322 document with CRLF line endings; SMTP does the
/// dot-stuffing.
///|
pub struct Attachment {
name : String
mime : String
data : Bytes
}
///|
pub struct Draft {
from : String
to : Array[String]
cc : Array[String]
bcc : Array[String]
subject : String
body : String
html : Bool
attachments : Array[Attachment]
}
///|
/// Everyone the message has to reach: `To`, `Cc` and `Bcc`. `Bcc` is kept out
/// of the headers but still gets an envelope recipient.
pub fn draft_recipients(draft : Draft) -> Array[String] {
let out : Array[String] = []
let seen : Array[String] = []
for field in [draft.to, draft.cc, draft.bcc] {
for address in field {
// Some servers reject a repeated RCPT TO, and address case never
// matters for delivery, so compare case-insensitively.
let folded = trim(address).to_lower()
if seen.contains(folded) {
continue
}
seen.push(folded)
out.push(trim(address))
}
}
out
}
///|
let rfc5322_days : Array[String] = [
"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat",
]
///|
let rfc5322_months : Array[String] = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
]
///|
fn pad2(value : Int64) -> String {
if value < 10 {
"0" + value.to_string()
} else {
value.to_string()
}
}
///|
fn floor_div(a : Int64, b : Int64) -> Int64 {
if a >= 0 {
a / b
} else {
(a - b + 1) / b
}
}
///|
/// Days since 1970-01-01 to a Gregorian date, by Howard Hinnant's `civil_from_days`.
fn civil_from_days(days : Int64) -> (Int64, Int64, Int64) {
let z = days + 719468L
let era = floor_div(z, 146097L)
let doe = z - era * 146097L
let yoe = (doe - doe / 1460L + doe / 36524L - doe / 146096L) / 365L
let y = yoe + era * 400L
let doy = doe - (365L * yoe + yoe / 4L - yoe / 100L)
let mp = (5L * doy + 2L) / 153L
let d = doy - (153L * mp + 2L) / 5L + 1L
let m = if mp < 10L { mp + 3L } else { mp - 9L }
(if m <= 2L { y + 1L } else { y }, m, d)
}
///|
/// Format a Unix timestamp the way a `Date:` header wants it.
///
/// Always UTC (`+0000`). Formatting in the sender's local time would need the
/// zone database, and a UTC date is what most mail tools print anyway.
pub fn rfc5322_date(now : Int64) -> String {
let days = floor_div(now, 86400L)
let seconds = now - days * 86400L
let (year, month, day) = civil_from_days(days)
let weekday = (days % 7L + 11L) % 7L
let hour = seconds / 3600L
let minute = seconds % 3600L / 60L
let second = seconds % 60L
rfc5322_days[weekday.to_int()] +
", " +
pad2(day) +
" " +
rfc5322_months[(month - 1).to_int()] +
" " +
year.to_string() +
" " +
pad2(hour) +
":" +
pad2(minute) +
":" +
pad2(second) +
" +0000"
}
///|
/// RFC 2047 encoded words for a header value with non-ASCII characters.
///
/// Each word is limited to 75 characters, which leaves 45 bytes of payload
/// after `=?UTF-8?B?` and `?=`. A word never splits a character, so the
/// decoder can put the value back together.
pub fn encode_header_text(text : String) -> String {
let mut ascii = true
for c in text {
if c.to_int() > 0x7F {
ascii = false
break
}
}
if ascii {
return text
}
let words : Array[String] = []
let chunk = StringBuilder()
let mut chunk_bytes = 0
for c in text {
let size = @utf8.encode(c.to_string()).length()
if chunk_bytes + size > 45 && chunk_bytes > 0 {
words.push(
"=?UTF-8?B?" + base64_encode(string_to_bytes(chunk.to_string())) + "?=",
)
chunk.reset()
chunk_bytes = 0
}
chunk.write_char(c)
chunk_bytes = chunk_bytes + size
}
if chunk_bytes > 0 {
words.push(
"=?UTF-8?B?" + base64_encode(string_to_bytes(chunk.to_string())) + "?=",
)
}
words.join(" ")
}
///|
/// Fold a header at `;` or `,` once a line grows past 76 characters, the way a
/// mail client does for long recipient lists.
fn fold_header(name : String, value : String) -> String {
let prefix = name + ": "
if prefix.length() + value.length() <= 78 {
return prefix + value
}
let out = StringBuilder()
out.write_string(prefix)
let mut column = prefix.length()
let parts : Array[String] = []
let current = StringBuilder()
for c in value {
current.write_char(c)
if c == ',' || c == ';' {
parts.push(current.to_string())
current.reset()
}
}
let tail = current.to_string()
if tail != "" {
parts.push(tail)
}
let mut first = true
for part in parts {
let piece = if first { part } else { part }
if !first && column + trim(piece).length() > 76 {
out.write_string("\r\n ")
column = 1
}
out.write_string(piece)
column = column + piece.length()
first = false
}
out.to_string()
}
///|
/// Base64 folded into 76-column lines, as MIME wants it.
pub fn base64_body(data : Bytes) -> String {
let encoded = base64_encode(data)
let out = StringBuilder()
let mut index = 0
while index < encoded.length() {
let end = if index + 76 < encoded.length() {
index + 76
} else {
encoded.length()
}
out.write_string(encoded[index:end].to_owned())
out.write_string("\r\n")
index = end
}
out.to_string()
}
///|
/// A `Message-ID` that is unique enough without a random source: the timestamp
/// plus a caller-supplied token (the CLI uses bytes from `/dev/urandom`).
pub fn make_message_id(from : String, now : Int64, unique : String) -> String {
let domain = match from.rev_find("@") {
Some(index) => from[index + 1:].to_owned()
None => "mails.local"
}
""
}
///|
fn mime_boundary(message_id : String, suffix : String) -> String {
let token = StringBuilder()
for c in message_id {
let keep = (c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9')
if keep {
token.write_char(c)
}
}
"mails-" + suffix + "-" + token.to_string()
}
///|
/// Render the message: headers, blank line, body.
///
/// `now` and `unique` are parameters rather than reads of the clock and the
/// random device so the result is testable and reproducible.
pub fn render_outgoing_message(
draft : Draft,
now : Int64,
unique : String,
) -> String {
let message_id = make_message_id(draft.from, now, unique)
let subject = if trim(draft.subject) == "" {
"(no subject)"
} else {
draft.subject
}
let headers = StringBuilder()
headers.write_string(fold_header("From", draft.from) + "\r\n")
if draft.to.length() > 0 {
headers.write_string(fold_header("To", draft.to.join(", ")) + "\r\n")
}
if draft.cc.length() > 0 {
headers.write_string(fold_header("Cc", draft.cc.join(", ")) + "\r\n")
}
// Bcc is deliberately absent: it only exists as an envelope recipient.
headers.write_string("Subject: " + encode_header_text(subject) + "\r\n")
headers.write_string("Date: " + rfc5322_date(now) + "\r\n")
headers.write_string("Message-ID: " + message_id + "\r\n")
headers.write_string("MIME-Version: 1.0\r\n")
let body_text = trim_line_end(normalize_newlines(draft.body))
let body_base64 = base64_body(string_to_bytes(body_text))
if draft.attachments.length() == 0 {
headers.write_string(
"Content-Type: text/" +
(if draft.html { "html" } else { "plain" }) +
"; charset=utf-8\r\n",
)
headers.write_string("Content-Transfer-Encoding: base64\r\n")
headers.write_string("\r\n")
headers.write_string(body_base64)
} else {
let boundary = mime_boundary(message_id, "mix")
headers.write_string(
"Content-Type: multipart/mixed; boundary=\"" + boundary + "\"\r\n",
)
headers.write_string("\r\n")
headers.write_string("--" + boundary + "\r\n")
headers.write_string(
"Content-Type: text/" +
(if draft.html { "html" } else { "plain" }) +
"; charset=utf-8\r\n",
)
headers.write_string("Content-Transfer-Encoding: base64\r\n\r\n")
headers.write_string(body_base64)
for attachment in draft.attachments {
headers.write_string("--" + boundary + "\r\n")
headers.write_string("Content-Type: " + attachment.mime + "\r\n")
headers.write_string("Content-Transfer-Encoding: base64\r\n")
headers.write_string(
"Content-Disposition: attachment; filename=\"" +
attachment_filename(attachment.name) +
"\"\r\n\r\n",
)
headers.write_string(base64_body(attachment.data))
}
headers.write_string("--" + boundary + "--\r\n")
}
headers.to_string()
}
///|
/// A file name that is safe inside a quoted header parameter.
fn attachment_filename(name : String) -> String {
let base = match name.rev_find("/") {
Some(index) => name[index + 1:].to_owned()
None => name
}
let out = StringBuilder()
for c in base {
if c == '"' || c == '\\' || c == '\r' || c == '\n' || c.to_int() > 0x7E {
out.write_char('_')
} else {
out.write_char(c)
}
}
out.to_string()
}
///|
/// Guess a content type from the file name. Everything unknown is
/// `application/octet-stream`, which every client can still save to disk.
pub fn guess_mime_type(name : String) -> String {
let lower = name.to_lower()
let base = match lower.rev_find(".") {
Some(index) => lower[index + 1:].to_owned()
None => ""
}
match base {
"png" => "image/png"
"jpg" | "jpeg" => "image/jpeg"
"gif" => "image/gif"
"webp" => "image/webp"
"pdf" => "application/pdf"
"txt" | "log" => "text/plain; charset=utf-8"
"csv" => "text/csv; charset=utf-8"
"json" => "application/json"
"zip" => "application/zip"
"gz" => "application/gzip"
"html" | "htm" => "text/html; charset=utf-8"
_ => "application/octet-stream"
}
}