a06673b
use std::collections::HashMap;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use lettre::message::header::ContentType;
use lettre::message::{
Attachment as LettreAttachment, Mailbox, Message, MessageBuilder, MultiPart, SinglePart,
};
use crate::error::EmailError;
use crate::events::{Attachment, EmailAddress, SendEmailRequest};
const MAX_ATTACHMENT_BYTES_DEFAULT: usize = 25 * 1024 * 1024;
pub struct MimeBuildOptions {
pub max_attachment_bytes: usize,
}
#[allow(clippy::too_many_lines)]
pub fn build_message(
req: &SendEmailRequest,
from: Mailbox,
opts: &MimeBuildOptions,
) -> Result<Message, EmailError> {
let builder = MessageBuilder::new()
.from(from)
.subject(req.subject.clone());
let builder = apply_recipients(builder, req);
let builder = apply_reply_to(builder, req);
let body_part = build_body_part(req)?;
let message = if req.attachments.is_empty() {
builder.multipart(body_part)?
} else {
let mut mixed = MultiPart::mixed().multipart(body_part);
for a in &req.attachments {
let single = build_attachment_single(a, opts)?;
mixed = mixed.singlepart(single);
}
builder.multipart(mixed)?
};
Ok(message)
}
fn apply_recipients(builder: MessageBuilder, req: &SendEmailRequest) -> MessageBuilder {
let builder = req.to.iter().fold(builder, |b, a| match to_mailbox(a) {
Ok(mb) => b.to(mb),
Err(_) => b,
});
let builder = req.cc.iter().fold(builder, |b, a| match to_mailbox(a) {
Ok(mb) => b.cc(mb),
Err(_) => b,
});
req.bcc.iter().fold(builder, |b, a| match to_mailbox(a) {
Ok(mb) => b.bcc(mb),
Err(_) => b,
})
}
fn apply_reply_to(builder: MessageBuilder, req: &SendEmailRequest) -> MessageBuilder {
match req.reply_to.as_ref().and_then(|a| to_mailbox(a).ok()) {
Some(mb) => builder.reply_to(mb),
None => builder,
}
}
fn to_mailbox(addr: &EmailAddress) -> Result<Mailbox, EmailError> {
let parsed = addr.email.parse().map_err(EmailError::Address)?;
Ok(Mailbox::new(addr.name.clone(), parsed))
}
fn build_body_part(req: &SendEmailRequest) -> Result<MultiPart, EmailError> {
match (req.text.as_ref(), req.html.as_ref()) {
(Some(text), Some(html)) => {
let alt = MultiPart::alternative()
.singlepart(SinglePart::plain(text.clone()))
.singlepart(SinglePart::html(html.clone()));
Ok(alt)
}
(Some(text), None) => {
Ok(MultiPart::alternative().singlepart(SinglePart::plain(text.clone())))
}
(None, Some(html)) => {
Ok(MultiPart::alternative().singlepart(SinglePart::html(html.clone())))
}
(None, None) => Err(EmailError::Missing("body (text or html)".to_string())),
}
}
fn build_attachment_single(
a: &Attachment,
opts: &MimeBuildOptions,
) -> Result<SinglePart, EmailError> {
let bytes = BASE64.decode(a.data_b64.as_bytes())?;
if bytes.len() > opts.max_attachment_bytes {
return Err(EmailError::AttachmentTooLarge(
bytes.len(),
opts.max_attachment_bytes,
));
}
let content_type: ContentType = a.content_type.parse().map_err(|_| {
EmailError::Config(format!(
"invalid attachment content_type: {}",
a.content_type
))
})?;
let attachment = match &a.content_id {
Some(cid) => LettreAttachment::new_inline_with_name(cid.clone(), a.filename.clone())
.body(bytes, content_type),
None => LettreAttachment::new(a.filename.clone()).body(bytes, content_type),
};
Ok(attachment)
}
#[allow(dead_code)]
fn default_opts() -> MimeBuildOptions {
MimeBuildOptions {
max_attachment_bytes: MAX_ATTACHMENT_BYTES_DEFAULT,
}
}
#[allow(dead_code)]
fn _unused_hashmap_import_for_feature_gates() -> HashMap<(), ()> {
HashMap::new()
}
#[cfg(test)]
mod tests {
use super::*;
fn make_addr(email: &str) -> EmailAddress {
EmailAddress {
email: email.to_string(),
name: Some("X".to_string()),
}
}
fn base_request() -> SendEmailRequest {
SendEmailRequest {
to: vec![make_addr("a@example.com")],
subject: "test".to_string(),
text: None,
html: None,
attachments: vec![],
headers: vec![],
cc: vec![],
bcc: vec![],
reply_to: None,
locale: None,
request_id: None,
metadata: HashMap::new(),
}
}
fn from() -> Mailbox {
Mailbox::new(Some("Me".to_string()), "me@example.com".parse().unwrap())
}
#[test]
fn multipart_alternative_when_both_text_and_html() {
let req = SendEmailRequest {
text: Some("hi".into()),
html: Some("<p>hi</p>".into()),
..base_request()
};
let msg = build_message(&req, from(), &default_opts()).unwrap();
let formatted = format!("{msg:?}");
assert!(formatted.contains("multipart/alternative"));
}
#[test]
fn html_only() {
let req = SendEmailRequest {
html: Some("<p>x</p>".into()),
..base_request()
};
let msg = build_message(&req, from(), &default_opts()).unwrap();
let formatted = format!("{msg:?}");
assert!(formatted.contains("text/html"));
}
#[test]
fn text_only() {
let req = SendEmailRequest {
text: Some("x".into()),
..base_request()
};
let msg = build_message(&req, from(), &default_opts()).unwrap();
let formatted = format!("{msg:?}");
assert!(formatted.contains("text/plain"));
}
#[test]
fn missing_body_returns_error() {
let req = base_request();
let err = build_message(&req, from(), &default_opts()).unwrap_err();
assert!(matches!(err, EmailError::Missing(_)));
}
#[test]
fn attachment_over_limit_rejected() {
let huge = BASE64.encode(vec![0u8; 2 * 1024 * 1024]);
let req = SendEmailRequest {
attachments: vec![Attachment {
filename: "big.bin".into(),
content_type: "application/octet-stream".into(),
data_b64: huge,
content_id: None,
}],
text: Some("x".into()),
..base_request()
};
let opts = MimeBuildOptions {
max_attachment_bytes: 1024,
};
let err = build_message(&req, from(), &opts).unwrap_err();
assert!(matches!(err, EmailError::AttachmentTooLarge(_, _)));
}
#[test]
fn attachment_with_content_id_builds_inline() {
let req = SendEmailRequest {
text: Some("x".into()),
attachments: vec![Attachment {
filename: "logo.png".into(),
content_type: "image/png".into(),
data_b64: BASE64.encode(b"\x89PNG\r\n"),
content_id: Some("logo".into()),
}],
..base_request()
};
let opts = MimeBuildOptions {
max_attachment_bytes: 1024 * 1024,
};
let _msg = build_message(&req, from(), &opts).unwrap();
}
#[test]
fn invalid_address_skipped_silently() {
let req = SendEmailRequest {
to: vec![
EmailAddress {
email: "not-an-email".to_string(),
name: None,
},
EmailAddress::new("valid@example.com"),
],
text: Some("x".into()),
..base_request()
};
let _msg = build_message(&req, from(), &default_opts()).unwrap();
}
}