///|
/// A single attachment or inline image attached to a message.
pub struct Attachment {
data : Bytes
filename : String
mut content_type : String
cid : String
} derive(Eq, Debug)
///|
pub fn Attachment::file(data : Bytes, filename : String) -> Attachment {
{ data, filename, content_type: "", cid: "" }
}
///|
pub fn Attachment::inline_image(
data : Bytes,
filename : String,
cid : String,
) -> Attachment {
{ data, filename, content_type: "image/*", cid }
}
///|
/// Fluent builder for `MailMessage`. Addresses and header values are validated
/// (including header-injection checks) as they are added, so a built message
/// is always safe to render.
pub struct MessageBuilder {
mut from : MailAddress?
to : Array[MailAddress]
cc : Array[MailAddress]
bcc : Array[MailAddress]
reply_to : Array[MailAddress]
mut subject : String
mut text_part : String?
mut html_part : String?
attachments : Array[Attachment]
inline_images : Array[Attachment]
custom_headers : Array[(String, String)]
mut date : String
mut message_id : String
mut boundary_seed : String
}
///|
pub fn MessageBuilder::new() -> MessageBuilder {
{
from: None,
to: [],
cc: [],
bcc: [],
reply_to: [],
subject: "",
text_part: None,
html_part: None,
attachments: [],
inline_images: [],
custom_headers: [],
date: "",
message_id: "",
boundary_seed: "moonmail",
}
}
///|
pub fn MessageBuilder::from(
self : MessageBuilder,
addr : String,
) -> MessageBuilder raise MailFailure {
self.from = Some(MailAddress::parse(addr))
self
}
///|
pub fn MessageBuilder::from_addr(
self : MessageBuilder,
addr : MailAddress,
) -> MessageBuilder {
self.from = Some(addr)
self
}
///|
pub fn MessageBuilder::to(
self : MessageBuilder,
list : String,
) -> MessageBuilder raise MailFailure {
for a in parse_address_list(list) {
self.to.push(a)
}
self
}
///|
pub fn MessageBuilder::cc(
self : MessageBuilder,
list : String,
) -> MessageBuilder raise MailFailure {
for a in parse_address_list(list) {
self.cc.push(a)
}
self
}
///|
pub fn MessageBuilder::bcc(
self : MessageBuilder,
list : String,
) -> MessageBuilder raise MailFailure {
for a in parse_address_list(list) {
self.bcc.push(a)
}
self
}
///|
pub fn MessageBuilder::reply_to(
self : MessageBuilder,
list : String,
) -> MessageBuilder raise MailFailure {
for a in parse_address_list(list) {
self.reply_to.push(a)
}
self
}
///|
pub fn MessageBuilder::subject(
self : MessageBuilder,
subject : String,
) -> MessageBuilder raise MailFailure {
reject_crlf(subject, "subject")
self.subject = subject
self
}
///|
pub fn MessageBuilder::text(
self : MessageBuilder,
content : String,
) -> MessageBuilder {
self.text_part = Some(content)
self
}
///|
pub fn MessageBuilder::html(
self : MessageBuilder,
content : String,
) -> MessageBuilder {
self.html_part = Some(content)
self
}
///|
/// Add a binary attachment with an optional explicit Content-Type.
pub fn MessageBuilder::attach(
self : MessageBuilder,
data : Bytes,
filename : String,
content_type? : String = "",
) -> MessageBuilder {
let a = Attachment::file(data, filename)
a.content_type = content_type
self.attachments.push(a)
self
} ///|
/// Add an inline image referenced from HTML via `cid:`.
///|
pub fn MessageBuilder::inline_image(
self : MessageBuilder,
data : Bytes,
filename : String,
cid : String,
) -> MessageBuilder {
self.inline_images.push(Attachment::inline_image(data, filename, cid))
self
}
///|
/// Add a custom header. The value is checked for CR/LF injection immediately.
pub fn MessageBuilder::header(
self : MessageBuilder,
name : String,
value : String,
) -> MessageBuilder raise MailFailure {
guard is_valid_field_name(name) else {
raise MailFailure::of(
InvalidHeader,
MM_HEADER_004,
"invalid header field name: \{name}",
)
}
reject_crlf(value, "header '\{name}'")
self.custom_headers.push((name, value))
self
}
///|
pub fn MessageBuilder::date(
self : MessageBuilder,
date : String,
) -> MessageBuilder {
self.date = date
self
}
///|
pub fn MessageBuilder::message_id(
self : MessageBuilder,
message_id : String,
) -> MessageBuilder {
self.message_id = message_id
self
}
///|
pub fn MessageBuilder::boundary_seed(
self : MessageBuilder,
seed : String,
) -> MessageBuilder {
self.boundary_seed = seed
self
}
///|
/// The envelope derived from the builder: the sender plus To/Cc/Bcc recipients.
/// The sender must be set before calling this.
pub fn MessageBuilder::envelope(
self : MessageBuilder,
) -> Envelope raise MailFailure {
let from = match self.from {
Some(f) => f
None =>
raise MailFailure::config(
"no sender: call from() before building the envelope",
)
}
let recipients = self.to + self.cc + self.bcc
Envelope::new(from, recipients)
}
///|
/// Assemble the final message tree plus headers.
pub fn MessageBuilder::build(
self : MessageBuilder,
) -> MailMessage raise MailFailure {
let headers = []
let from = match self.from {
Some(f) => f
None => raise MailFailure::config("no sender: call from() before build()")
}
headers.push(("From", from.to_string()))
if !self.to.is_empty() {
headers.push(("To", format_addresses(self.to)))
}
if !self.cc.is_empty() {
headers.push(("Cc", format_addresses(self.cc)))
}
if !self.reply_to.is_empty() {
headers.push(("Reply-To", format_addresses(self.reply_to)))
}
if !self.subject.is_empty() {
reject_crlf(self.subject, "subject")
headers.push(("Subject", self.subject))
}
for pair in self.custom_headers {
let (name, value) = pair
headers.push((name, value))
}
if self.date != "" {
headers.push(("Date", self.date))
}
if self.message_id != "" {
headers.push(("Message-ID", self.message_id))
}
let body = self.build_body()
{ headers, body }
}
///|
fn format_addresses(addrs : Array[MailAddress]) -> String {
addrs.map(fn(a) { a.to_string() }).join(", ")
}
///|
/// Build the MIME body tree:
/// - text+html+inline images -> multipart/alternative [text, multipart/related [html, images]]
/// - html+inline images -> multipart/related [html, images]
/// - text+html -> multipart/alternative
/// - attachments -> multipart/mixed wrapper around the above
fn MessageBuilder::build_body(
self : MessageBuilder,
) -> MimeBody raise MailFailure {
let text_part = match self.text_part {
Some(t) =>
Some(
(
{
headers: [("Content-Type", "text/plain; charset=utf-8")],
body: Text(t),
} : Part),
)
None => None
}
let html_part = match self.html_part {
Some(h) =>
Some(
(
{
headers: [("Content-Type", "text/html; charset=utf-8")],
body: Html(h),
} : Part),
)
None => None
}
let images = self.inline_images.map(build_image_part)
let attachments = self.attachments.map(build_attachment_part)
// html goes into a related container when there are inline images
let html_body = match (html_part, images) {
(Some(html), imgs) if !imgs.is_empty() => {
let related : Array[Part] = [{ headers: [], body: html.body }] + imgs
MultiPart(related, Related)
}
(Some(html), _) => html.body
(None, imgs) if !imgs.is_empty() =>
raise MailFailure::mime("inline images require an html part")
(None, _) => Text("")
}
// text + html alternative
let body_body = match (text_part, self.html_part) {
(Some(text), Some(_)) =>
MultiPart([text, ({ headers: [], body: html_body } : Part)], Alternative)
(Some(text), None) => text.body
(None, Some(_)) => html_body
(None, None) => Text("")
}
if attachments.is_empty() {
body_body
} else {
let children : Array[Part] = [{ headers: [], body: body_body }] +
attachments
MultiPart(children, Mixed)
}
}
///|
fn build_attachment_part(a : Attachment) -> Part raise MailFailure {
let headers = []
if a.content_type != "" {
headers.push(("Content-Type", a.content_type))
}
let disp = make_disposition(Attachment, a.filename)
headers.push(("Content-Disposition", disp))
if a.cid != "" {
headers.push(("Content-ID", "<\{a.cid}>"))
}
{ headers, body: Binary(a.data, a.filename) }
}
///|
fn build_image_part(a : Attachment) -> Part raise MailFailure {
let headers = []
if a.content_type != "" {
headers.push(("Content-Type", a.content_type))
}
headers.push(("Content-Disposition", make_disposition(Inline, a.filename)))
if a.cid != "" {
headers.push(("Content-ID", "<\{a.cid}>"))
}
{ headers, body: Binary(a.data, a.filename) }
}