///| 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[:])
}
///|
fn decode_latin1(data : Bytes) -> String {
let out = StringBuilder()
for byte in data {
out.write_char(byte.to_int().unsafe_to_char())
}
out.to_string()
}
///|
/// Decode bytes in the named charset well enough for keyword matching.
pub fn decode_charset(data : Bytes, charset : String) -> String {
match charset.to_lower() {
"iso-8859-1" | "latin1" | "latin-1" | "windows-1252" => decode_latin1(data)
"us-ascii" | "ascii" => bytes_to_string_lossy(data)
_ => bytes_to_string_lossy(data)
}
}
///|
/// 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
}
///|
fn decode_transfer(body : String, encoding : String) -> Bytes {
match encoding.to_lower() {
"base64" => {
let compact = StringBuilder()
for c in body {
if c != ' ' && c != '\r' && c != '\n' && c != '\t' {
compact.write_char(c)
}
}
@base64.decode_lossy(compact.to_string())
}
"quoted-printable" => decode_quoted_printable_bytes(body, false)
_ => string_to_bytes(body)
}
}
///|
fn strip_html(html : String) -> String {
let out = StringBuilder()
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() < 8 {
rest.push(chars[peek])
peek = peek + 1
}
let tag = String::from_array(rest[:]).to_lower()
if tag.has_prefix("