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 { 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 { let parsed = addr.email.parse().map_err(EmailError::Address)?; Ok(Mailbox::new(addr.name.clone(), parsed)) } fn build_body_part(req: &SendEmailRequest) -> Result { 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 { 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("

hi

".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("

x

".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(); } }