// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
///|
/// A file that travels with a message. `inline` with an `id` makes it a part
/// an HTML body shows by `cid:`; otherwise it is offered as an attachment.
pub(all) struct Attachment {
name : String
media_type : String
data : Bytes
inline : Bool
id : String?
} derive(Eq, Debug)
///|
pub extend Attachment with Eq::{equal, not_equal}
///|
pub extend Attachment with Debug::{to_repr}
///|
pub fn Attachment::new(
name : String,
data : Bytes,
media_type? : String,
inline~ : Bool = false,
id? : String,
) -> Attachment {
{
name,
media_type: media_type.unwrap_or_else(() => @moonmime.guess_type(name)),
data,
inline,
id,
}
}
///|
/// A message ready to send. The structure follows what it carries: one body
/// alone; `multipart/alternative` for text and HTML together;
/// `multipart/related` around the body when attachments are inline with an
/// id; `multipart/mixed` around that when any are not. Text goes out as UTF-8,
/// in `7bit` while it is ASCII with short lines and in quoted-printable
/// otherwise; attachments go out in base64.
///
/// `date` and `message_id` are written only when given: this package reads no
/// clock and draws no randomness.
pub fn Message::new(
from~ : Mailbox,
to~ : Array[Address],
subject~ : String,
text? : String,
html? : String,
attachments? : Array[Attachment],
cc? : Array[Address],
date? : @moondate.Moment,
message_id? : String,
) -> Message raise Refused {
let attachments = attachments.unwrap_or([])
let core = match (text, html) {
(Some(t), Some(h)) =>
container("alternative", [leaf_text("plain", t), leaf_text("html", h)])
(None, Some(h)) => leaf_text("html", h)
(Some(t), None) => leaf_text("plain", t)
(None, None) => leaf_text("plain", "")
}
let inline = attachments.filter(a => a.inline && a.id is Some(_))
let related = if inline.is_empty() {
core
} else {
container(
"related",
[core] + inline.map(leaf_file),
extra="; type=\"" + core.content_type().essence + "\"",
)
}
let attached = attachments.filter(a => !(a.inline && a.id is Some(_)))
let top = if attached.is_empty() {
related
} else {
container("mixed", [related] + attached.map(leaf_file))
}
let headers = Headers::new()
headers.add("From", from.encode(utf8=true))
if !to.is_empty() {
headers.add("To", encode_addresses(to[:], utf8=true))
}
if cc is Some(list) && !list.is_empty() {
headers.add("Cc", encode_addresses(list[:], utf8=true))
}
headers.add("Subject", subject)
if date is Some(moment) {
headers.add("Date", format_date(moment))
}
if message_id is Some(id) {
headers.add("Message-ID", id)
}
headers.add("MIME-Version", "1.0")
for field in top.headers.iter() {
headers.add(field.0, field.1)
}
{ headers, body: top.body, }
}
///|
fn leaf_text(subtype : String, text : String) -> Message raise Refused {
let data = crlf(@utf8.encode(text))
let headers = Headers::new()
headers.add("Content-Type", "text/" + subtype + "; charset=\"utf-8\"")
headers.add(
"Content-Transfer-Encoding",
if fits(data, false) {
"7bit"
} else {
"quoted-printable"
},
)
{ headers, body: Leaf(data), }
}
///|
fn leaf_file(file : Attachment) -> Message raise Refused {
let headers = Headers::new()
headers.add("Content-Type", file.media_type)
headers.add(
"Content-Disposition",
@moonmime.content_disposition(file.name, inline=file.inline),
)
if file.id is Some(id) {
headers.add("Content-ID", "<" + id + ">")
}
headers.add("Content-Transfer-Encoding", "base64")
{ headers, body: Leaf(file.data), }
}
///|
fn container(
subtype : String,
parts : Array[Message],
extra~ : String = "",
) -> Message raise Refused {
let boundary = boundary_of(parts.map(p => p.encode(utf8=true)))
let headers = Headers::new()
headers.add(
"Content-Type",
"multipart/" + subtype + "; boundary=\"" + boundary + "\"" + extra,
)
{ headers, body: Multipart(parts), }
}