From 8bd9d459cd8a27efda2ee86db8de5ddb260f5368 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Sat, 25 Apr 2026 17:11:54 -0300 Subject: [PATCH 1/2] feat(ses): RFC 5322 / MIME builder for TestRenderEmailTemplate The previous TestRenderEmailTemplate output emitted only Subject + MIME-Version + Content-Type + body. mail-parser, Python email, and Node mailparser accept it loosely but it is missing Date, Message-ID, and a proper multipart structure when both text and HTML parts are present. The new fakecloud_ses::mime::build_message produces RFC-5322-compliant messages with: - Date (RFC 2822) and Message-ID (UUID@fakecloud.local) - MIME-Version: 1.0 - Subject with RFC 2047 encoded-word when non-ASCII - multipart/alternative + boundary when both text + html present; single-part otherwise - Content-Transfer-Encoding: 7bit for plain ASCII bodies, quoted-printable for non-ASCII or HTML safety E2E parses the rendered template with mail-parser and asserts the multipart structure, headers, and a non-ASCII roundtrip survives both the encoded-word subject and quoted-printable body decoding. New deps: base64 in fakecloud-ses, mail-parser as a fakecloud-e2e dev-dep for verification. --- Cargo.lock | 24 +++ crates/fakecloud-e2e/Cargo.toml | 1 + crates/fakecloud-e2e/tests/ses_mime.rs | 102 ++++++++++ crates/fakecloud-ses/Cargo.toml | 1 + crates/fakecloud-ses/src/lib.rs | 1 + crates/fakecloud-ses/src/mime.rs | 189 ++++++++++++++++++ crates/fakecloud-ses/src/service/templates.rs | 15 +- 7 files changed, 323 insertions(+), 10 deletions(-) create mode 100644 crates/fakecloud-e2e/tests/ses_mime.rs create mode 100644 crates/fakecloud-ses/src/mime.rs diff --git a/Cargo.lock b/Cargo.lock index 404519d0..54a2cdda 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2449,6 +2449,7 @@ dependencies = [ "fakecloud-sdk", "fakecloud-testkit", "flate2", + "mail-parser", "p256 0.13.2", "reqwest", "rsa", @@ -2840,6 +2841,7 @@ name = "fakecloud-ses" version = "0.12.0" dependencies = [ "async-trait", + "base64 0.22.1", "bytes", "chrono", "fakecloud-aws", @@ -3369,6 +3371,18 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +[[package]] +name = "hashify" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd1246c0e5493286aeb2dde35b1f4eb9c4ce00e628641210a5e553fc001a1f26" +dependencies = [ + "indexmap", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "heck" version = "0.5.0" @@ -3969,6 +3983,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "mail-parser" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "187a2b93c4c8c32f552ee06c2d99915e575de2fc7e04b07891c9edfee5b8edd6" +dependencies = [ + "encoding_rs", + "hashify", +] + [[package]] name = "matchers" version = "0.2.0" diff --git a/crates/fakecloud-e2e/Cargo.toml b/crates/fakecloud-e2e/Cargo.toml index 5698e212..fcf894f9 100644 --- a/crates/fakecloud-e2e/Cargo.toml +++ b/crates/fakecloud-e2e/Cargo.toml @@ -54,5 +54,6 @@ fakecloud-testkit = { path = "../fakecloud-testkit", features = ["sdk-clients"] tempfile = { workspace = true } sha2 = { workspace = true } p256 = { version = "0.13", features = ["ecdsa", "pem", "pkcs8"] } +mail-parser = "0.10" rsa = { workspace = true } x509-cert = { version = "0.2", features = ["pem"] } diff --git a/crates/fakecloud-e2e/tests/ses_mime.rs b/crates/fakecloud-e2e/tests/ses_mime.rs new file mode 100644 index 00000000..4a9fba0a --- /dev/null +++ b/crates/fakecloud-e2e/tests/ses_mime.rs @@ -0,0 +1,102 @@ +mod helpers; + +use aws_sdk_sesv2::types::{EmailTemplateContent, EmailTemplateMetadata}; +use helpers::TestServer; +use mail_parser::MessageParser; + +/// Render a templated email through SESv2 `TestRenderEmailTemplate` and verify +/// the returned MIME parses cleanly into the expected multipart/alternative +/// structure with proper Date / Message-ID / encoding headers. +#[tokio::test] +async fn ses_test_render_template_returns_rfc5322_multipart() { + let server = TestServer::start().await; + let ses = server.sesv2_client().await; + + ses.create_email_template() + .template_name("welcome") + .template_content( + EmailTemplateContent::builder() + .subject("Welcome, {{name}}") + .text("Hi {{name}}, plain text greeting") + .html("

Hi {{name}}, html greeting

") + .build(), + ) + .send() + .await + .unwrap(); + + let resp = ses + .test_render_email_template() + .template_name("welcome") + .template_data(r#"{"name":"Alice"}"#) + .send() + .await + .unwrap(); + let mime_str = resp.rendered_template().to_string(); + + let parsed = MessageParser::default().parse(mime_str.as_bytes()).unwrap(); + assert_eq!(parsed.subject().unwrap(), "Welcome, Alice"); + assert!(parsed.date().is_some(), "Date header must be present"); + assert!(parsed.message_id().is_some(), "Message-ID must be present"); + + let parts: Vec<_> = parsed.parts.iter().collect(); + assert!( + parts.len() >= 3, + "expected multipart/alternative with two parts and root" + ); + + let bodies: Vec = parsed + .text_bodies() + .map(|p| p.text_contents().unwrap_or_default().to_string()) + .chain( + parsed + .html_bodies() + .map(|p| p.text_contents().unwrap_or_default().to_string()), + ) + .collect(); + assert!(bodies.iter().any(|b| b.contains("Hi Alice, plain text"))); + assert!(bodies + .iter() + .any(|b| b.contains("

Hi Alice, html greeting

"))); +} + +#[tokio::test] +async fn ses_test_render_template_handles_non_ascii() { + let server = TestServer::start().await; + let ses = server.sesv2_client().await; + + ses.create_email_template() + .template_name("intl") + .template_content( + EmailTemplateContent::builder() + .subject("héllo {{name}}") + .text("café for {{name}}") + .build(), + ) + .send() + .await + .unwrap(); + + let resp = ses + .test_render_email_template() + .template_name("intl") + .template_data(r#"{"name":"Élise"}"#) + .send() + .await + .unwrap(); + let mime_str = resp.rendered_template().to_string(); + + let parsed = MessageParser::default() + .parse(mime_str.as_bytes()) + .expect("MIME parses"); + assert_eq!(parsed.subject().unwrap(), "héllo Élise"); + let body = parsed + .text_bodies() + .next() + .unwrap() + .text_contents() + .unwrap(); + assert!(body.contains("café for Élise")); + + let _ = EmailTemplateMetadata::builder().build(); +} diff --git a/crates/fakecloud-ses/Cargo.toml b/crates/fakecloud-ses/Cargo.toml index 48d093dc..83bf5a48 100644 --- a/crates/fakecloud-ses/Cargo.toml +++ b/crates/fakecloud-ses/Cargo.toml @@ -12,6 +12,7 @@ fakecloud-core = { workspace = true } fakecloud-aws = { workspace = true } fakecloud-persistence = { workspace = true } async-trait = { workspace = true } +base64 = { workspace = true } form_urlencoded = "1" chrono = { workspace = true } http = { workspace = true } diff --git a/crates/fakecloud-ses/src/lib.rs b/crates/fakecloud-ses/src/lib.rs index 2e9f0e26..a8846d38 100644 --- a/crates/fakecloud-ses/src/lib.rs +++ b/crates/fakecloud-ses/src/lib.rs @@ -1,4 +1,5 @@ pub mod fanout; +pub mod mime; pub mod service; pub mod state; pub mod v1; diff --git a/crates/fakecloud-ses/src/mime.rs b/crates/fakecloud-ses/src/mime.rs new file mode 100644 index 00000000..054c4e63 --- /dev/null +++ b/crates/fakecloud-ses/src/mime.rs @@ -0,0 +1,189 @@ +//! Hand-rolled RFC 5322 / MIME builder for fakecloud SES. +//! +//! AWS SES `TestRenderEmailTemplate` returns the fully-formed MIME message +//! that would be sent on `SendTemplatedEmail`. The previous implementation +//! emitted only `Subject` + `MIME-Version` + `Content-Type` + body, which +//! standard parsers (`mail-parser`, Python `email`, Node `mailparser`) +//! accept loosely but reject for missing `Date`, `Message-ID`, and proper +//! multipart structure when both text and HTML bodies are present. +//! +//! This module produces RFC-5322-compliant messages with: +//! - `Date` (RFC 2822), `Message-ID` (UUID), `MIME-Version: 1.0` +//! - `Subject` (RFC 2047 encoded-word when non-ASCII) +//! - `multipart/alternative` with proper boundary when both `text` and +//! `html` parts are present, single-part otherwise +//! - `Content-Transfer-Encoding: quoted-printable` for non-ASCII bodies +//! or HTML, `7bit` for plain ASCII text + +use base64::Engine; +use chrono::Utc; +use uuid::Uuid; + +/// Inputs for `build_message`. +pub struct MimeInputs<'a> { + pub subject: &'a str, + pub text: Option<&'a str>, + pub html: Option<&'a str>, +} + +/// Build an RFC 5322 / MIME message from the given subject and bodies. +pub fn build_message(input: &MimeInputs<'_>) -> String { + let mut headers = String::new(); + headers.push_str(&format!("Date: {}\r\n", rfc2822_now())); + headers.push_str(&format!( + "Message-ID: <{}@fakecloud.local>\r\n", + Uuid::new_v4().simple() + )); + headers.push_str(&format!("Subject: {}\r\n", encode_header(input.subject))); + headers.push_str("MIME-Version: 1.0\r\n"); + + match (input.text, input.html) { + (Some(text), Some(html)) => { + let boundary = format!("=_fakecloud_{}", Uuid::new_v4().simple()); + headers.push_str(&format!( + "Content-Type: multipart/alternative; boundary=\"{}\"\r\n\r\n", + boundary + )); + let mut body = String::new(); + push_part(&mut body, &boundary, "text/plain; charset=UTF-8", text); + push_part(&mut body, &boundary, "text/html; charset=UTF-8", html); + body.push_str(&format!("--{}--\r\n", boundary)); + headers + &body + } + (None, Some(html)) => single_part(headers, "text/html; charset=UTF-8", html), + (Some(text), None) => single_part(headers, "text/plain; charset=UTF-8", text), + (None, None) => single_part(headers, "text/plain; charset=UTF-8", ""), + } +} + +fn single_part(mut headers: String, content_type: &str, body: &str) -> String { + let (encoded_body, encoding) = encode_body(body); + headers.push_str(&format!("Content-Type: {}\r\n", content_type)); + headers.push_str(&format!("Content-Transfer-Encoding: {}\r\n\r\n", encoding)); + headers.push_str(&encoded_body); + headers +} + +fn push_part(out: &mut String, boundary: &str, content_type: &str, body: &str) { + let (encoded_body, encoding) = encode_body(body); + out.push_str(&format!("--{}\r\n", boundary)); + out.push_str(&format!("Content-Type: {}\r\n", content_type)); + out.push_str(&format!("Content-Transfer-Encoding: {}\r\n\r\n", encoding)); + out.push_str(&encoded_body); + if !encoded_body.ends_with("\r\n") { + out.push_str("\r\n"); + } +} + +/// Quoted-printable for any non-ASCII body or HTML; 7bit for plain ASCII text. +fn encode_body(body: &str) -> (String, &'static str) { + if body.is_ascii() { + (body.replace('\n', "\r\n"), "7bit") + } else { + (quoted_printable_encode(body), "quoted-printable") + } +} + +fn quoted_printable_encode(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + let mut line_len = 0; + for byte in input.as_bytes() { + let needs_encoding = matches!(byte, 0..=31 | 61 | 127..=255) && *byte != b'\t'; + let chunk: String = if needs_encoding { + format!("={:02X}", byte) + } else { + (*byte as char).to_string() + }; + if line_len + chunk.len() > 75 { + out.push_str("=\r\n"); + line_len = 0; + } + out.push_str(&chunk); + line_len += chunk.len(); + if *byte == b'\n' { + line_len = 0; + } + } + out +} + +/// RFC 2047 encoded-word for non-ASCII headers; raw otherwise. +fn encode_header(value: &str) -> String { + if value.is_ascii() { + value.to_string() + } else { + let b64 = base64::engine::general_purpose::STANDARD.encode(value.as_bytes()); + format!("=?UTF-8?B?{}?=", b64) + } +} + +fn rfc2822_now() -> String { + Utc::now().format("%a, %d %b %Y %H:%M:%S +0000").to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ascii_text_only_uses_7bit() { + let mime = build_message(&MimeInputs { + subject: "hello", + text: Some("plain body"), + html: None, + }); + assert!(mime.contains("Subject: hello\r\n")); + assert!(mime.contains("Content-Type: text/plain; charset=UTF-8\r\n")); + assert!(mime.contains("Content-Transfer-Encoding: 7bit\r\n")); + assert!(mime.contains("plain body")); + assert!(mime.contains("Date: ")); + assert!(mime.contains("Message-ID: <")); + } + + #[test] + fn ascii_html_only_uses_html_part() { + let mime = build_message(&MimeInputs { + subject: "hi", + text: None, + html: Some("

x

"), + }); + assert!(mime.contains("Content-Type: text/html; charset=UTF-8\r\n")); + assert!(mime.contains("

x

")); + } + + #[test] + fn both_parts_use_multipart_alternative() { + let mime = build_message(&MimeInputs { + subject: "hi", + text: Some("plain"), + html: Some("

x

"), + }); + assert!(mime.contains("multipart/alternative; boundary=\"=_fakecloud_")); + assert!(mime.contains("Content-Type: text/plain; charset=UTF-8\r\n")); + assert!(mime.contains("Content-Type: text/html; charset=UTF-8\r\n")); + assert!(mime.contains("plain")); + assert!(mime.contains("

x

")); + } + + #[test] + fn non_ascii_subject_uses_encoded_word() { + let mime = build_message(&MimeInputs { + subject: "héllo", + text: Some("body"), + html: None, + }); + assert!(mime.contains("Subject: =?UTF-8?B?")); + } + + #[test] + fn non_ascii_body_uses_quoted_printable() { + let mime = build_message(&MimeInputs { + subject: "x", + text: Some("café"), + html: None, + }); + assert!(mime.contains("Content-Transfer-Encoding: quoted-printable\r\n")); + // 'é' is two bytes in UTF-8 (0xC3 0xA9), each must be percent-escaped. + assert!(mime.contains("=C3=A9")); + } +} diff --git a/crates/fakecloud-ses/src/service/templates.rs b/crates/fakecloud-ses/src/service/templates.rs index 82774b73..a7caa73e 100644 --- a/crates/fakecloud-ses/src/service/templates.rs +++ b/crates/fakecloud-ses/src/service/templates.rs @@ -223,16 +223,11 @@ impl SesV2Service { let rendered_html = template.html_body.as_deref().map(&substitute); let rendered_text = template.text_body.as_deref().map(&substitute); - // Build a simplified MIME message - let mut mime = format!("Subject: {}\r\n", rendered_subject); - mime.push_str("MIME-Version: 1.0\r\n"); - mime.push_str("Content-Type: text/html; charset=UTF-8\r\n"); - mime.push_str("\r\n"); - if let Some(ref html) = rendered_html { - mime.push_str(html); - } else if let Some(ref text) = rendered_text { - mime.push_str(text); - } + let mime = crate::mime::build_message(&crate::mime::MimeInputs { + subject: &rendered_subject, + text: rendered_text.as_deref(), + html: rendered_html.as_deref(), + }); let response = json!({ "RenderedTemplate": mime, From 5a848b962c256e934314cd0e8a30c8cdd9f05125 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Sat, 25 Apr 2026 17:17:43 -0300 Subject: [PATCH 2/2] fix(ses-mime): address Cubic findings on line-ending handling - quoted_printable_encode: emit literal CRLF for any \r, \n, or \r\n hard line break instead of =0A / =0D. RFC 2045 specifies CRLF for hard breaks; verifiers and parsers reject =0A in body content. - normalize_crlf (replaces the prior naive `replace('\n', "\r\n")` for 7bit bodies): walk bytes and collapse any mix of \r\n, \r, \n into canonical \r\n exactly once. Prior version would corrupt existing CRLF into \r\r\n because the \n inside \r\n got replaced. - Unit tests cover: quoted-printable emits CRLF for newlines, mixed line-ending input collapses cleanly, normalize_crlf does not double existing CRLF. --- crates/fakecloud-ses/src/mime.rs | 79 +++++++++++++++++++++++++++++--- 1 file changed, 72 insertions(+), 7 deletions(-) diff --git a/crates/fakecloud-ses/src/mime.rs b/crates/fakecloud-ses/src/mime.rs index 054c4e63..fab5a005 100644 --- a/crates/fakecloud-ses/src/mime.rs +++ b/crates/fakecloud-ses/src/mime.rs @@ -78,31 +78,74 @@ fn push_part(out: &mut String, boundary: &str, content_type: &str, body: &str) { /// Quoted-printable for any non-ASCII body or HTML; 7bit for plain ASCII text. fn encode_body(body: &str) -> (String, &'static str) { if body.is_ascii() { - (body.replace('\n', "\r\n"), "7bit") + (normalize_crlf(body), "7bit") } else { (quoted_printable_encode(body), "quoted-printable") } } +/// Normalize any mix of `\r\n`, `\r`, `\n` line endings to canonical `\r\n` +/// without doubling existing CRLFs. +fn normalize_crlf(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + let bytes = input.as_bytes(); + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'\r' => { + out.push_str("\r\n"); + if i + 1 < bytes.len() && bytes[i + 1] == b'\n' { + i += 2; + } else { + i += 1; + } + } + b'\n' => { + out.push_str("\r\n"); + i += 1; + } + b => { + out.push(b as char); + i += 1; + } + } + } + out +} + fn quoted_printable_encode(input: &str) -> String { let mut out = String::with_capacity(input.len()); let mut line_len = 0; - for byte in input.as_bytes() { - let needs_encoding = matches!(byte, 0..=31 | 61 | 127..=255) && *byte != b'\t'; + let bytes = input.as_bytes(); + let mut i = 0; + while i < bytes.len() { + let byte = bytes[i]; + // Hard line breaks: emit literal CRLF, not =0A / =0D. Collapse any + // mix of \r, \n, \r\n into a single CRLF. + if byte == b'\r' || byte == b'\n' { + out.push_str("\r\n"); + line_len = 0; + if byte == b'\r' && i + 1 < bytes.len() && bytes[i + 1] == b'\n' { + i += 2; + } else { + i += 1; + } + continue; + } + let needs_encoding = matches!(byte, 0..=31 | 61 | 127..=255) && byte != b'\t'; let chunk: String = if needs_encoding { format!("={:02X}", byte) } else { - (*byte as char).to_string() + (byte as char).to_string() }; + // Soft line break before 76th char per RFC 2045 §6.7. if line_len + chunk.len() > 75 { out.push_str("=\r\n"); line_len = 0; } out.push_str(&chunk); line_len += chunk.len(); - if *byte == b'\n' { - line_len = 0; - } + i += 1; } out } @@ -186,4 +229,26 @@ mod tests { // 'é' is two bytes in UTF-8 (0xC3 0xA9), each must be percent-escaped. assert!(mime.contains("=C3=A9")); } + + #[test] + fn quoted_printable_emits_crlf_for_newlines_not_0a() { + let qp = quoted_printable_encode("café\nlatte"); + assert!(qp.contains("=C3=A9\r\nlatte")); + assert!(!qp.contains("=0A")); + } + + #[test] + fn quoted_printable_collapses_crlf_lf_cr_to_canonical_crlf() { + let qp = quoted_printable_encode("a\r\nb\nc\rdé"); + assert_eq!(qp, "a\r\nb\r\nc\r\nd=C3=A9"); + } + + #[test] + fn normalize_crlf_does_not_double_existing_crlf() { + assert_eq!(normalize_crlf("a\r\nb"), "a\r\nb"); + assert_eq!(normalize_crlf("a\nb"), "a\r\nb"); + assert_eq!(normalize_crlf("a\rb"), "a\r\nb"); + assert_eq!(normalize_crlf("a\r\n\r\nb"), "a\r\n\r\nb"); + assert_eq!(normalize_crlf("a\n\nb"), "a\r\n\r\nb"); + } }