// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
///|
/// What a message carries after its header: content with the transfer
/// encoding taken off, the parts of a multipart body (their boundary is in
/// `Content-Type`), or a whole message attached as `message/rfc822`.
pub(all) enum Body {
Leaf(Bytes)
Multipart(Array[Message])
Embedded(Message)
} derive(Eq, Debug)
///|
pub extend Body with Eq::{equal, not_equal}
///|
pub extend Body with Debug::{to_repr}
///|
/// A message, or one part of a multipart body: parts are messages too, as in
/// Python's `email`.
pub(all) struct Message {
headers : Headers
body : Body
} derive(Eq, Debug)
///|
pub extend Message with Eq::{equal, not_equal}
///|
pub extend Message with Debug::{to_repr}
///|
/// Read a message (RFC 5322, MIME as RFC 2045 and RFC 2046 lay it out): the
/// header unfolded, the body split into parts by its boundary and each leaf's
/// transfer encoding taken off. Line ends may be CRLF or LF.
///
/// What a careful reader tolerates is tolerated: a line in the header that is
/// not a field ends the header there, a missing closing boundary ends the
/// last part at the end, an unknown or broken transfer encoding leaves the
/// octets as they came. `strict` refuses each of these instead.
pub fn Message::parse(
data : BytesView,
strict~ : Bool = false,
) -> Message raise Refused {
read(data, 0, strict, false)
}
///|
fn read(
data : BytesView,
base : Int32,
strict : Bool,
digest : Bool,
) -> Message raise Refused {
let (headers, start) = read_headers(data, base, strict)
let body = read_body(headers, data[start:], base + start, strict, digest)
{ headers, body, }
}
///|
/// The header fields, and where the body starts.
fn read_headers(
data : BytesView,
base : Int32,
strict : Bool,
) -> (Headers, Int32) raise Refused {
let fields : Array[(String, String)] = []
let mut at = 0
// An mbox `From ` line in front of the header is not a field.
if data.length() >= 5 && data[0:5] == b"From "[:] {
at = line_end(data, 0)
}
while at < data.length() {
let end = line_end(data, at)
let line = trim_eol(data[at:end])
if line.length() == 0 {
return ({ fields, }, end)
}
if (line[0] == b' ' || line[0] == b'\t') && fields.length() > 0 {
let last = fields.length() - 1
fields[last] = (fields[last].0, fields[last].1 + @utf8.decode_lossy(line))
at = end
continue
}
let text = @utf8.decode_lossy(line)
match text.find(":") {
Some(colon) if colon > 0 &&
text[:colon]
.trim_end()
.iter()
.all(c => c.to_int() > 32 && c.to_int() < 127) => {
let name = text[:colon].trim_end().to_owned()
let value = text[colon + 1:].trim_start(chars=" \t").to_owned()
fields.push((name, value))
}
_ => {
if strict {
raise Header(at=base + at)
}
return ({ fields, }, at)
}
}
at = end
}
({ fields, }, data.length())
}
///|
fn read_body(
headers : Headers,
data : BytesView,
base : Int32,
strict : Bool,
digest : Bool,
) -> Body raise Refused {
let kind = type_of(headers, digest)
match kind.type_() {
"multipart" => {
guard kind.param("boundary") is Some(boundary) && !boundary.is_empty() else {
if strict {
raise Boundary(at=base)
}
return Leaf(data.to_owned())
}
guard split(data, boundary, base, strict) is Some(parts) else {
return Leaf(data.to_owned())
}
let digest = kind.subtype() == "digest"
Multipart(parts.map(p => read(p.0, p.1, strict, digest)))
}
"message" if kind.subtype() is ("rfc822" | "global") => {
let inner = transfer_decode(headers, data, base, strict)
Embedded(read(inner[:], base, strict, false))
}
_ => Leaf(transfer_decode(headers, data, base, strict))
}
}
///|
/// The parts between the delimiters of `boundary`, each with its offset; the
/// line break before a delimiter belongs to the delimiter (RFC 2046 section
/// 5.1.1). `None` when no delimiter is there at all.
fn split(
data : BytesView,
boundary : String,
base : Int32,
strict : Bool,
) -> Array[(BytesView, Int32)]? raise Refused {
let dash = @utf8.encode("--" + boundary)
let parts = []
let mut open : Int32? = None
let mut closed = false
let mut at = 0
while at < data.length() {
let end = line_end(data, at)
let line = trim_eol(data[at:end])
if line.length() >= dash.length() && line[:dash.length()] == dash[:] {
let tail = line[dash.length():]
let close = tail.length() >= 2 && tail[0] == b'-' && tail[1] == b'-'
let padding = tail.iter().all(b => b == b' ' || b == b'\t')
if close || padding {
if open is Some(start) {
let stop = before_eol(data, at)
parts.push(
(data[start:if stop < start { start } else { stop }], base + start),
)
}
if close {
closed = true
break
}
open = Some(end)
}
}
at = end
}
if open is None {
if strict {
raise Boundary(at=base)
}
return None
}
if !closed {
if strict {
raise Boundary(at=base + data.length())
}
if open is Some(start) {
parts.push((data[start:], base + start))
}
}
Some(parts)
}
///|
/// The content of a leaf with its `Content-Transfer-Encoding` taken off.
fn transfer_decode(
headers : Headers,
data : BytesView,
base : Int32,
strict : Bool,
) -> Bytes raise Refused {
match encoding_of(headers) {
Some("base64") =>
if strict {
let text = @utf8.decode_lossy(data)
.iter()
.filter(c => !c.is_whitespace())
.collect()
@base64.decode(String::from_array(text)[:]) catch {
_ => raise Encoding(at=base)
}
} else {
@base64.decode_lossy(@utf8.decode_lossy(data)[:])
}
Some("quoted-printable") =>
@qp.decode(data, strict~) catch {
_ => raise Encoding(at=base)
}
None | Some("7bit" | "8bit" | "binary") => data.to_owned()
Some(_) => {
if strict {
raise Encoding(at=base)
}
data.to_owned()
}
}
}
///|
/// The media type, or RFC 2045's default: `text/plain; charset=us-ascii`, or
/// `message/rfc822` inside a digest (RFC 2046 section 5.1.5).
fn type_of(headers : Headers, digest : Bool) -> @moonmime.MediaType {
match headers.get("Content-Type") {
Some(value) if value.contains("/") => @moonmime.media_type(value)
_ =>
if digest {
{ essence: "message/rfc822", params: [], }
} else {
{ essence: "text/plain", params: [("charset", "us-ascii")], }
}
}
}
///|
fn encoding_of(headers : Headers) -> String? {
headers
.get("Content-Transfer-Encoding")
.map(v => v.trim().to_lower().to_owned())
}
///|
/// The media type, with RFC 2045's default when the field is missing.
pub fn Message::content_type(self : Message) -> @moonmime.MediaType {
type_of(self.headers, false)
}
///|
/// The subject, encoded words decoded.
pub fn Message::subject(self : Message) -> String? {
self.headers.get("Subject").map(v => @moonmime.decode_words(v))
}
///|
/// `Message-ID` as written, angle brackets included.
pub fn Message::message_id(self : Message) -> String? {
self.headers.get("Message-ID").map(v => v.trim().to_owned())
}
///|
/// The authors in `From`.
pub fn Message::from(self : Message) -> Array[Mailbox] raise Refused {
self
.addresses("From")
.map(a => a.mailboxes())
.fold(init=[], (all, m) => all + m)
}
///|
pub fn Message::to(self : Message) -> Array[Address] raise Refused {
self.addresses("To")
}
///|
pub fn Message::cc(self : Message) -> Array[Address] raise Refused {
self.addresses("Cc")
}
///|
pub fn Message::bcc(self : Message) -> Array[Address] raise Refused {
self.addresses("Bcc")
}
///|
pub fn Message::reply_to(self : Message) -> Array[Address] raise Refused {
self.addresses("Reply-To")
}
///|
/// Every address of every field named `name`.
pub fn Message::addresses(
self : Message,
name : StringView,
) -> Array[Address] raise Refused {
let out = []
for value in self.headers.all(name) {
out.append(parse_addresses(value))
}
out
}
///|
/// `Date`, read as RFC 5322 writes it.
pub fn Message::date(self : Message) -> @moondate.Moment? raise Refused {
match self.headers.get("Date") {
Some(value) => Some(parse_date(value))
None => None
}
}
///|
/// The plain text body: the `text/plain` leaf a reader would show, preferring
/// the last of an alternative, decoded by its charset. UTF-8, US-ASCII and
/// ISO-8859-1 are built in; others come in `charsets`.
pub fn Message::text(
self : Message,
charsets? : Map[String, &@moonmime.Charset],
) -> String? raise Refused {
self.body_text("plain", charsets)
}
///|
/// The HTML body, chosen and decoded as `text` chooses and decodes.
pub fn Message::html(
self : Message,
charsets? : Map[String, &@moonmime.Charset],
) -> String? raise Refused {
self.body_text("html", charsets)
}
///|
fn Message::body_text(
self : Message,
subtype : String,
charsets : Map[String, &@moonmime.Charset]?,
) -> String? raise Refused {
guard find_body(self, subtype) is Some(part) else { return None }
guard part.body is Leaf(data) else { return None }
let charset = part.content_type().param("charset").unwrap_or("us-ascii")
Some(
@moonmime.decode_charset(name=charset, data=data[:], charsets?) catch {
_ => raise Charset(name=charset)
},
)
}
///|
fn find_body(message : Message, subtype : String) -> Message? {
let kind = message.content_type()
match message.body {
Leaf(_) =>
if kind.essence == "text/" + subtype && !is_attachment(message) {
Some(message)
} else {
None
}
Multipart(parts) =>
match kind.subtype() {
"alternative" => {
for i = parts.length() - 1; i >= 0; i = i - 1 {
if find_body(parts[i], subtype) is Some(found) {
return Some(found)
}
}
None
}
"related" => parts.get(0).bind(root => find_body(root, subtype))
_ => {
for part in parts {
if find_body(part, subtype) is Some(found) {
return Some(found)
}
}
None
}
}
Embedded(_) => None
}
}
///|
fn is_attachment(message : Message) -> Bool {
match message.headers.get("Content-Disposition") {
Some(value) =>
@moonmime.Disposition::parse(value).kind.to_lower() == "attachment"
None => false
}
}
///|
/// Every part that is not a body a reader would show: what is marked as an
/// attachment, what sits inline beside an HTML body, an attached message, and
/// any leaf that is not text.
pub fn Message::attachments(self : Message) -> Array[Attachment] {
let out = []
if self.body is Multipart(parts) {
let bodies = [find_body(self, "plain"), find_body(self, "html")]
for part in parts {
collect(part, bodies, out)
}
} else if is_attachment(self) {
out.push(attachment_of(self))
}
out
}
///|
fn collect(
part : Message,
bodies : Array[Message?],
out : Array[Attachment],
) -> Unit {
match part.body {
Multipart(parts) =>
for p in parts {
collect(p, bodies, out)
}
Embedded(_) => out.push(attachment_of(part))
Leaf(_) => {
let chosen = bodies
.iter()
.any(b => b is Some(m) && physical_equal(m, part))
if is_attachment(part) {
out.push(attachment_of(part))
} else if !chosen &&
!(part.content_type().essence is ("text/plain" | "text/html")) {
out.push(attachment_of(part))
}
}
}
}
///|
fn attachment_of(part : Message) -> Attachment {
let kind = part.content_type()
let disposition = part.headers
.get("Content-Disposition")
.map(v => @moonmime.Disposition::parse(v))
let written = match disposition.bind(d => d.filename()) {
Some(n) => Some(n)
None => kind.param("name")
}
let name = written.map(n => @moonmime.decode_words(n)).unwrap_or("")
let data = match part.body {
Leaf(bytes) => bytes
Embedded(inner) => inner.encode(utf8=true)
Multipart(_) => b""
}
{
name,
media_type: kind.essence,
data,
inline: disposition is Some(d) && d.kind.to_lower() == "inline",
id: part.headers
.get("Content-ID")
.map(v => v.trim().trim_start(chars="<").trim_end(chars=">").to_owned()),
}
}
///|
/// The message as it goes on the wire: CRLF line ends, header fields folded
/// at 78 columns. Each leaf is written in its `Content-Transfer-Encoding`; a
/// leaf whose encoding cannot carry it (8-bit octets or lines past 998 in
/// `7bit`) is written in quoted-printable if it is text and base64 otherwise,
/// with the field changed to say so, and a multipart body without a boundary
/// gets one. `utf8` writes non-ASCII header text as UTF-8 (RFC 6532);
/// otherwise it goes out as encoded words.
///
/// Reading what this writes gives the same tree back, and writing that again
/// gives the same octets.
pub fn Message::encode(self : Message, utf8~ : Bool = false) -> Bytes {
let out = Buffer()
write(self, out, utf8)
out.to_bytes()
}
///|
fn write(message : Message, out : Buffer, utf8 : Bool) -> Unit {
let overrides : Array[(String, String)] = []
let body = Buffer()
match message.body {
Leaf(data) => {
let text = message.content_type().type_() == "text"
match encoding_of(message.headers) {
Some("base64") => body.write_bytes(base64_lines(data))
Some("quoted-printable") => body.write_bytes(@qp.encode(data[:]))
Some("binary") => body.write_bytes(data)
declared =>
if fits(data, declared is Some("8bit")) {
body.write_bytes(crlf(data))
} else if text {
overrides.push(("Content-Transfer-Encoding", "quoted-printable"))
body.write_bytes(@qp.encode(data[:]))
} else {
overrides.push(("Content-Transfer-Encoding", "base64"))
body.write_bytes(base64_lines(data))
}
}
}
Multipart(parts) => {
let encoded = parts.map(p => p.encode(utf8~))
let kind = message.content_type()
let boundary = match kind.param("boundary") {
Some(b) if kind.type_() == "multipart" && !b.is_empty() => b
_ => {
let fresh = boundary_of(encoded)
let essence = if kind.type_() == "multipart" {
kind.essence
} else {
"multipart/mixed"
}
overrides.push(
("Content-Type", essence + "; boundary=\"" + fresh + "\""),
)
fresh
}
}
for part in encoded {
body.write_bytes(@utf8.encode("--" + boundary + "\r\n"))
body.write_bytes(part)
body.write_bytes(b"\r\n")
}
body.write_bytes(@utf8.encode("--" + boundary + "--\r\n"))
}
Embedded(inner) => body.write_bytes(inner.encode(utf8~))
}
let written = []
for field in message.headers.iter() {
let (name, value) = field
let value = match overrides.search_by(o => same(o.0, name)) {
Some(i) => {
written.push(i)
overrides[i].1
}
None => value
}
out.write_bytes(@utf8.encode(fold(name, header_text(name, value, utf8))))
}
for i, field in overrides {
if !written.contains(i) {
out.write_bytes(@utf8.encode(fold(field.0, field.1)))
}
}
out.write_bytes(b"\r\n")
out.write_bytes(body.to_bytes())
}
///|
/// A field value as it goes out: unchanged when ASCII or under `utf8`;
/// otherwise address fields are written with encoded display names and the
/// rest as encoded words.
fn header_text(name : String, value : String, utf8 : Bool) -> String {
if utf8 || value.iter().all(c => c.to_int() < 0x80) {
return value
}
let lower = name.to_lower()
let address_field = lower
is ("from" | "to" | "cc" | "bcc" | "reply-to" | "sender") ||
lower.has_prefix("resent-")
if address_field {
let list = parse_addresses(value) catch { _ => [] }
if !list.is_empty() {
return encode_addresses(list[:])
}
}
@moonmime.encode_words(value, column=name.length() + 2) catch {
_ => value
}
}
///|
/// RFC 5321 section 4.5.3.1.6: no line of a message passes 998 octets.
let hard_line = 998
///|
/// Whether a leaf can go out as it is: ASCII unless 8-bit is declared, and no
/// line past 998 octets.
fn fits(data : Bytes, eight : Bool) -> Bool {
let mut run = 0
for b in data {
if b == b'\n' || b == b'\r' {
run = 0
} else {
run += 1
if run > hard_line || (!eight && b >= 0x80) || b == 0 {
return false
}
}
}
true
}
///|
/// Every line end made CRLF, whatever it was.
fn crlf(data : Bytes) -> Bytes {
let out = Buffer(size_hint=data.length() + 16)
let mut i = 0
while i < data.length() {
let b = data[i]
if b == b'\r' {
out.write_bytes(b"\r\n")
if i + 1 < data.length() && data[i + 1] == b'\n' {
i += 1
}
} else if b == b'\n' {
out.write_bytes(b"\r\n")
} else {
out.write_byte(b)
}
i += 1
}
out.to_bytes()
}
///|
/// RFC 2045 section 6.8: base64 lines of at most 76 characters.
fn base64_lines(data : Bytes) -> Bytes {
let text = @base64.encode(data[:])
let out = StringBuilder()
let mut at = 0
while at < text.length() {
let stop = if at + 76 < text.length() { at + 76 } else { text.length() }
out.write_string(text[at:stop].to_owned())
out.write_string("\r\n")
at = stop
}
@utf8.encode(out.to_string())
}
///|
/// A boundary drawn from the parts' own octets, so writing the same parts
/// again gives the same boundary, and none of them can contain it by chance.
fn boundary_of(parts : Array[Bytes]) -> String {
let hasher = @sha2.Hasher::new()
for part in parts {
hasher.write(part[:])
}
let digest = hasher.finish()
let digits = "0123456789abcdef"
let out = StringBuilder()
out.write_string("=_")
for b in digest[:12] {
out.write_char(digits[b.to_int() / 16].unsafe_to_char())
out.write_char(digits[b.to_int() % 16].unsafe_to_char())
}
out.to_string()
}
///|
fn line_end(data : BytesView, at : Int32) -> Int32 {
let mut i = at
while i < data.length() && data[i] != b'\n' {
i += 1
}
if i < data.length() {
i + 1
} else {
i
}
}
///|
/// Where the line break that ends before `at` starts.
fn before_eol(data : BytesView, at : Int32) -> Int32 {
let mut stop = at
if stop > 0 && data[stop - 1] == b'\n' {
stop -= 1
}
if stop > 0 && data[stop - 1] == b'\r' {
stop -= 1
}
stop
}
///|
fn trim_eol(line : BytesView) -> BytesView {
line[:before_eol(line, line.length())]
}