use crate::core::{AsxError, ErrorCode, ErrorContext, Result};
use std::io::Write;
pub const MIME_BOUNDARY_PREFIX: &str = "----boundary-asx-";
#[derive(Debug, Clone)]
pub struct MimeAttachment {
pub content_id: String,
pub content_type: String,
pub transfer_encoding: String,
pub body: Vec<u8>,
pub disposition: Option<String>,
}
impl MimeAttachment {
pub fn new(
content_id: impl Into<String>,
content_type: impl Into<String>,
body: impl Into<Vec<u8>>,
transfer_encoding: impl Into<String>,
) -> Self {
Self {
content_id: content_id.into(),
content_type: content_type.into(),
transfer_encoding: transfer_encoding.into(),
body: body.into(),
disposition: None,
}
}
pub fn with_disposition(mut self, disposition: impl Into<String>) -> Self {
self.disposition = Some(disposition.into());
self
}
pub fn content_id_from_digest(payload: &[u8]) -> String {
use openssl::hash::MessageDigest;
let digest =
openssl::hash::hash(MessageDigest::sha256(), payload).expect("SHA-256 should not fail");
let hex_str = digest
.iter()
.take(8)
.map(|b| format!("{:02x}", b))
.collect::<String>();
format!("payload-{}@example.com", hex_str)
}
}
pub struct MimePackageBuilder {
boundary: String,
root_soap_content_type: String,
soap_attachment: Option<MimeAttachment>,
attachments: Vec<MimeAttachment>,
}
impl MimePackageBuilder {
fn write_crlf_line(body: &mut Vec<u8>, line: &str, stage: &'static str) -> Result<()> {
body.write_all(line.as_bytes()).map_err(|_| {
AsxError::new(
ErrorCode::ReliabilityFailure,
"Failed to write MIME line",
ErrorContext::new(stage),
)
})?;
body.write_all(b"\r\n").map_err(|_| {
AsxError::new(
ErrorCode::ReliabilityFailure,
"Failed to write MIME CRLF",
ErrorContext::new(stage),
)
})
}
pub fn new() -> Self {
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
let combined = nanos.wrapping_add(counter);
let boundary = format!("{}{:016x}", MIME_BOUNDARY_PREFIX, combined);
Self {
boundary,
root_soap_content_type: "application/soap+xml".to_string(),
soap_attachment: None,
attachments: Vec::new(),
}
}
pub fn with_root_soap_content_type(mut self, soap_content_type: impl Into<String>) -> Self {
self.root_soap_content_type = soap_content_type.into();
self
}
pub fn with_soap_body(mut self, soap_xml: Vec<u8>) -> Self {
let content_type = format!(
"application/xop+xml; charset=UTF-8; type=\"{}\"",
self.root_soap_content_type
);
self.soap_attachment = Some(MimeAttachment::new(
"soap-body@example.com",
content_type,
soap_xml,
"8bit",
));
self
}
pub fn add_attachment(mut self, attachment: MimeAttachment) -> Self {
self.attachments.push(attachment);
self
}
pub fn add_attachments(mut self, attachments: Vec<MimeAttachment>) -> Self {
self.attachments.extend(attachments);
self
}
pub fn build(self) -> Result<Vec<u8>> {
if self.soap_attachment.is_none() {
return Err(AsxError::new(
ErrorCode::PolicyViolation,
"MIME package requires at least a SOAP body attachment",
ErrorContext::new("mime_packaging"),
));
}
let mut body = Vec::new();
if let Some(ref soap) = self.soap_attachment {
Self::write_crlf_line(&mut body, &format!("--{}", self.boundary), "mime_packaging")?;
Self::write_attachment(&mut body, soap)?;
}
for attachment in &self.attachments {
Self::write_crlf_line(&mut body, &format!("--{}", self.boundary), "mime_packaging")?;
Self::write_attachment(&mut body, attachment)?;
}
Self::write_crlf_line(
&mut body,
&format!("--{}--", self.boundary),
"mime_packaging",
)?;
Ok(body)
}
pub fn content_type(&self) -> String {
format!(
"multipart/related; boundary=\"{}\"; type=\"application/xop+xml\"",
self.boundary
)
}
pub fn boundary(&self) -> &str {
&self.boundary
}
fn write_attachment(body: &mut Vec<u8>, attachment: &MimeAttachment) -> Result<()> {
Self::write_crlf_line(
body,
&format!("Content-Type: {}", attachment.content_type),
"mime_packaging",
)?;
Self::write_crlf_line(
body,
&format!(
"Content-Transfer-Encoding: {}",
attachment.transfer_encoding
),
"mime_packaging",
)?;
Self::write_crlf_line(
body,
&format!("Content-ID: <{}>", attachment.content_id),
"mime_packaging",
)?;
if let Some(ref disposition) = attachment.disposition {
Self::write_crlf_line(
body,
&format!("Content-Disposition: {}", disposition),
"mime_packaging",
)?;
}
body.write_all(b"\r\n").map_err(|_| {
AsxError::new(
ErrorCode::ReliabilityFailure,
"Failed to write empty line before attachment body",
ErrorContext::new("mime_packaging"),
)
})?;
body.extend_from_slice(&attachment.body);
body.write_all(b"\r\n").map_err(|_| {
AsxError::new(
ErrorCode::ReliabilityFailure,
"Failed to write CRLF after attachment body",
ErrorContext::new("mime_packaging"),
)
})?;
Ok(())
}
}
impl Default for MimePackageBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mime_attachment_with_digest_generates_stable_content_id() {
let payload = b"test payload data";
let cid_1 = MimeAttachment::content_id_from_digest(payload);
let cid_2 = MimeAttachment::content_id_from_digest(payload);
assert_eq!(cid_1, cid_2);
assert!(cid_1.starts_with("payload-"));
assert!(cid_1.ends_with("@example.com"));
}
#[test]
fn mime_attachment_different_payloads_different_content_ids() {
let payload_a = b"payload A";
let payload_b = b"payload B";
let cid_a = MimeAttachment::content_id_from_digest(payload_a);
let cid_b = MimeAttachment::content_id_from_digest(payload_b);
assert_ne!(cid_a, cid_b);
}
#[test]
fn mime_package_builder_generates_boundary() {
let builder_1 = MimePackageBuilder::new();
let builder_2 = MimePackageBuilder::new();
let boundary_1 = builder_1.boundary();
let boundary_2 = builder_2.boundary();
assert_ne!(boundary_1, boundary_2);
assert!(boundary_1.starts_with(MIME_BOUNDARY_PREFIX));
assert!(boundary_2.starts_with(MIME_BOUNDARY_PREFIX));
}
#[test]
fn mime_package_content_type_header() {
let builder = MimePackageBuilder::new();
let content_type = builder.content_type();
assert!(content_type.starts_with("multipart/related"));
assert!(content_type.contains("boundary="));
assert!(content_type.contains("type=\"application/xop+xml\""));
}
#[test]
fn mime_package_builder_requires_soap_body() {
let builder = MimePackageBuilder::new();
let result = builder.build();
assert!(result.is_err());
if let Err(e) = result {
assert_eq!(e.code, ErrorCode::PolicyViolation);
}
}
#[test]
fn mime_package_builder_with_soap_and_attachment() {
let soap_body = b"<soap:Envelope>...</soap:Envelope>".to_vec();
let payload = b"binary payload".to_vec();
let attachment = MimeAttachment::new(
"payload-001@example.com",
"application/octet-stream",
payload,
"binary",
);
let builder = MimePackageBuilder::new()
.with_soap_body(soap_body)
.add_attachment(attachment);
let result = builder.build();
assert!(result.is_ok());
let mime_body = result.unwrap();
assert!(String::from_utf8_lossy(&mime_body).contains("--"));
assert!(String::from_utf8_lossy(&mime_body).contains("Content-ID:"));
}
}