///| RFC 5322 / MIME parsing: just enough to extract a subject and readable
/// body text from what IMAP hands us.
///
/// Messages are fetched as `BODY.PEEK[HEADER]` plus `BODY.PEEK[TEXT]`, so the
/// parser deals with header bytes and body bytes separately. Multipart bodies
/// are walked recursively; `text/plain` is preferred, `text/html` is stripped
/// of tags when no plain part exists, and attachments are skipped.
///|
pub struct MailMessage {
uid : String
mailbox : String
subject : String
from : String
to : String
date : String
message_id : String
body : String
raw_bytes : Int32
}
///|
fn header_value(headers : Array[(String, String)], name : String) -> String {
let wanted = name.to_lower()
let mut found = ""
for pair in headers {
if pair.0 == wanted {
found = pair.1
}
}
found
}
///|
fn header_value_all(
headers : Array[(String, String)],
name : String,
) -> Array[String] {
let wanted = name.to_lower()
let out : Array[String] = []
for pair in headers {
if pair.0 == wanted {
out.push(pair.1)
}
}
out
}
///|
fn hex_nibble(c : Char) -> Int32? {
if c >= '0' && c <= '9' {
Some(c.to_int() - '0'.to_int())
} else if c >= 'a' && c <= 'f' {
Some(c.to_int() - 'a'.to_int() + 10)
} else if c >= 'A' && c <= 'F' {
Some(c.to_int() - 'A'.to_int() + 10)
} else {
None
}
}
///|
fn decode_quoted_printable_bytes(
text : StringView,
underscore_is_space : Bool,
) -> Bytes {
let out : Array[Byte] = []
let chars = text.to_array()
let mut index = 0
while index < chars.length() {
let c = chars[index]
if c == '=' {
if index + 2 < chars.length() {
match (hex_nibble(chars[index + 1]), hex_nibble(chars[index + 2])) {
(Some(high), Some(low)) => {
out.push(((high * 16 + low) & 0xFF).to_byte())
index = index + 3
continue
}
_ => ()
}
}
index = index + 1
} else if c == '_' && underscore_is_space {
out.push(b' ')
index = index + 1
} else if c == '\r' || c == '\n' {
index = index + 1
} else {
for byte in @utf8.encode(c.to_string()) {
out.push(byte)
}
index = index + 1
}
}
Bytes::from_array(out[:])
}
///|
/// Decode RFC 2047 encoded words (`=?utf-8?B?...?=`) in a header value.
pub fn decode_encoded_words(value : String) -> String {
let out = StringBuilder()
let mut rest = value
let mut attempts = 0
while attempts < 64 {
attempts = attempts + 1
match rest.find("=?") {
None => {
out.write_string(rest)
break
}
Some(start) => {
out.write_string(rest[0:start].to_owned())
let after = rest[start + 2:].to_owned()
match after.find("?") {
None => {
out.write_string(rest[start:].to_owned())
break
}
Some(charset_end) => {
let charset = after[0:charset_end].to_owned()
let after2 = after[charset_end + 1:].to_owned()
match after2.find("?") {
None => {
out.write_string(rest[start:].to_owned())
break
}
Some(encoding_end) => {
let encoding = after2[0:encoding_end].to_owned()
let after3 = after2[encoding_end + 1:].to_owned()
match after3.find("?=") {
None => {
out.write_string(rest[start:].to_owned())
break
}
Some(text_end) => {
let encoded = after3[0:text_end].to_owned()
let decoded = match encoding.to_lower() {
"b" =>
@base64.decode_lossy(encoded, ignore_whitespace=true)
"q" => decode_quoted_printable_bytes(encoded, true)
_ => string_to_bytes(encoded)
}
out.write_string(decode_charset(decoded, charset))
rest = after3[text_end + 2:].to_owned()
}
}
}
}
}
}
}
}
}
out.to_string()
}
///|
/// Split raw header bytes into unfolded `(lowercase-name, value)` pairs.
pub fn parse_headers(raw : String) -> Array[(String, String)] {
let headers : Array[(String, String)] = []
let mut current_name = ""
let mut current_value = ""
let mut have_current = false
for raw_line in raw.split("\n") {
let mut line = raw_line.to_owned()
if line.has_suffix("\r") {
line = line[0:line.length() - 1].to_owned()
}
if line == "" {
continue
}
let first = if line.length() > 0 { line[0:1] } else { "" }
if (first == " " || first == "\t") && have_current {
current_value = current_value + " " + trim(line)
continue
}
if have_current {
headers.push((current_name, trim(current_value)))
have_current = false
}
match line.find(":") {
Some(index) => {
current_name = trim(line[0:index].to_owned()).to_lower()
current_value = trim(line[index + 1:].to_owned())
have_current = true
}
None => ()
}
}
if have_current {
headers.push((current_name, trim(current_value)))
}
headers
}
///|
/// Parse `Content-Type`-style values into a lowercased type plus parameters.
pub fn parse_content_type(value : String) -> (String, Array[(String, String)]) {
let parts : Array[String] = []
for part in value.split(";") {
parts.push(part.to_owned())
}
let media_type = if parts.length() > 0 {
trim(parts[0]).to_lower()
} else {
"text/plain"
}
let params : Array[(String, String)] = []
let mut index = 1
while index < parts.length() {
let part = parts[index]
match part.find("=") {
Some(eq) => {
let key = trim(part[0:eq].to_owned()).to_lower()
let mut raw = trim(part[eq + 1:].to_owned())
if raw.length() >= 2 {
let first = raw[0:1]
let last = raw[raw.length() - 1:]
if (first == "\"" && last == "\"") || (first == "'" && last == "'") {
raw = raw[1:raw.length() - 1].to_owned()
}
}
params.push((key, raw))
}
None => ()
}
index = index + 1
}
(media_type, params)
}
///|
fn param_value(params : Array[(String, String)], name : String) -> String? {
let mut found : String? = None
for pair in params {
if pair.0 == name {
found = Some(pair.1)
}
}
found
}
///|
/// Split `data` on every occurrence of `separator`, returning the pieces.
fn bytes_split(data : Bytes, separator : Bytes) -> Array[Bytes] {
let out : Array[Bytes] = []
let mut start = 0
while start <= data.length() {
let found = data[start:].find(separator[:])
match found {
Some(offset) => {
out.push(data[start:start + offset].to_owned())
start = start + offset + separator.length()
}
None => {
out.push(data[start:].to_owned())
break
}
}
}
out
}
///|
/// Decode a quoted-printable body directly on bytes.
///
/// The MIME walker used to go through a lossy `String` first, which destroyed
/// every byte above 0x7F and turned GBK mail into U+FFFD before the charset
/// decoder ever saw it.
fn decode_quoted_printable_data(data : Bytes) -> Bytes {
let out : Array[Byte] = []
let length = data.length()
let mut index = 0
while index < length {
let value = data[index].to_int()
if value == 0x3D {
// `=` starts either a soft line break or an `=XX` escape.
let mut handled = false
if index + 1 < length && data[index + 1].to_int() == 0x0A {
// Soft break: the encoder wrapped a long line. Both the `=` and the
// break belong to the encoding, not to the text.
index = index + 2
handled = true
} else if index + 2 < length &&
data[index + 1].to_int() == 0x0D &&
data[index + 2].to_int() == 0x0A {
index = index + 3
handled = true
}
if !handled && index + 2 < length {
let high = hex_nibble(data[index + 1].to_int().unsafe_to_char())
let low = hex_nibble(data[index + 2].to_int().unsafe_to_char())
match (high, low) {
(Some(high), Some(low)) => {
out.push(((high * 16 + low) & 0xFF).to_byte())
index = index + 3
handled = true
}
_ => ()
}
}
if !handled {
// Not a valid escape: keep the character instead of eating it.
out.push(data[index])
index = index + 1
}
} else if value == 0x0D {
// A hard line break is content: keep it, normalised to `\n`.
out.push(b'\n')
if index + 1 < length && data[index + 1].to_int() == 0x0A {
index = index + 2
} else {
index = index + 1
}
} else if value == 0x0A {
out.push(b'\n')
index = index + 1
} else {
out.push(data[index])
index = index + 1
}
}
Bytes::from_array(out[:])
}
///|
/// Charset of a body part: the `charset` parameter when the headers carry one,
/// otherwise the one an HTML `` tag declares.
///
/// Chinese providers routinely leave the charset out of the MIME headers and
/// only declare it inside the HTML, which used to decode as UTF-8 mojibake.
fn charset_of(declared : String?, bytes : Bytes, is_html : Bool) -> String {
match declared {
Some(value) => value
None =>
if is_html {
match sniff_html_charset(bytes) {
Some(value) => value
None => "utf-8"
}
} else {
"utf-8"
}
}
}
///|
/// Read `charset=` out of the first few hundred bytes of an HTML document.
fn sniff_html_charset(bytes : Bytes) -> String? {
let limit = if bytes.length() < 4096 { bytes.length() } else { 4096 }
let head = bytes_to_string_lossy(bytes[0:limit].to_owned()).to_lower()
// Only trust a declaration that mentions , so a stray "charset=" in
// the text of a plain-looking document is ignored.
if !head.contains(" return None
Some(index) => {
let after = search[index + "charset=".length():].to_owned()
// The value is often quoted: `charset="gb2312"`.
let mut body = after
while body.length() > 0 && (body[0] == ' ' || body[0] == '\t') {
body = body[1:].to_owned()
}
if body.length() > 0 && (body[0] == '"' || body[0] == '\'') {
body = body[1:].to_owned()
}
let name = StringBuilder()
for c in body {
let stop = c == '"' ||
c == '\'' ||
c == ';' ||
c == ' ' ||
c == '\t' ||
c == '\r' ||
c == '\n' ||
c == '>'
if stop {
break
}
name.write_char(c)
}
let candidate = trim(name.to_string())
if candidate != "" && candidate.length() <= 40 {
return Some(candidate)
}
search = after
}
}
}
None
}
///|
fn decode_transfer(body : Bytes, encoding : String) -> Bytes {
match encoding.to_lower() {
"base64" =>
// Base64 payloads are ASCII, so the lossy read is harmless here.
@base64.decode_lossy(bytes_to_string_lossy(body), ignore_whitespace=true)
"quoted-printable" => decode_quoted_printable_data(body)
_ => body
}
}
///|
/// Block-level tags whose boundaries end a line in the plain text rendering of
/// an HTML message. The source layout of an HTML mail carries no meaning, so the
/// tags are what tell the reader where one paragraph stops and the next starts.
fn html_line_break_tag(tag : String) -> Bool {
let names = [
"br", "hr", "p", "div", "li", "tr", "h1", "h2", "h3", "h4", "h5", "h6", "pre",
"blockquote", "section", "article", "table", "ul", "ol",
]
// Compare the whole tag name: `has_prefix("`, `` or `` and inject breaks where the message has
// none.
let mut start = 1
if tag.length() > 1 && tag[1] == '/' {
start = 2
}
let mut end = start
while end < tag.length() {
let c = tag[end]
let is_name = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')
if !is_name {
break
}
end = end + 1
}
if end == start {
return false
}
let name = tag[start:end].to_owned()
names.contains(name)
}
///|
fn strip_html(html : String) -> String {
// Characters are collected into an array instead of written straight into a
// StringBuilder so that HTML whitespace can be squeezed the way a browser
// does: runs of spaces and tabs become one space, spaces next to a line break
// disappear, and the block tags below supply the line breaks rather than the
// source layout.
let out : Array[Char] = []
let push_space = () => {
if out.length() > 0 {
let last = out[out.length() - 1]
if last != ' ' && last != '\n' {
out.push(' ')
}
}
}
let push_break = () => if out.length() > 0 { out.push('\n') }
let mut index = 0
let chars = html.to_array()
let mut in_tag = false
let mut in_script = false
while index < chars.length() {
let c = chars[index]
if c == '<' {
let rest : Array[Char] = []
let mut peek = index
while peek < chars.length() && rest.length() < 16 {
rest.push(chars[peek])
peek = peek + 1
}
let tag = String::from_array(rest[:]).to_lower()
if tag.has_prefix("