use mail_parser::{MessageParser, MimeHeaders as _};
use super::error::EmailError;
pub const DEFAULT_JACS_SIGNATURE_FILENAME: &str = "jacs-signature.json";
pub const JACS_SIGNATURE_FILENAME: &str = DEFAULT_JACS_SIGNATURE_FILENAME;
pub(crate) fn rfind_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
if needle.is_empty() || needle.len() > haystack.len() {
return None;
}
(0..=haystack.len() - needle.len())
.rev()
.find(|&i| &haystack[i..i + needle.len()] == needle)
}
pub fn add_jacs_attachment_named(
raw_email: &[u8],
doc: &[u8],
filename: &str,
) -> Result<Vec<u8>, EmailError> {
add_jacs_attachment_inner(raw_email, doc, filename)
}
pub fn add_jacs_attachment(raw_email: &[u8], doc: &[u8]) -> Result<Vec<u8>, EmailError> {
add_jacs_attachment_inner(raw_email, doc, DEFAULT_JACS_SIGNATURE_FILENAME)
}
fn add_jacs_attachment_inner(
raw_email: &[u8],
doc: &[u8],
filename: &str,
) -> Result<Vec<u8>, EmailError> {
let message = MessageParser::default().parse(raw_email).ok_or_else(|| {
EmailError::InvalidEmailFormat("Cannot parse email for attachment injection".into())
})?;
let content_type = message
.content_type()
.map(|ct| format!("{}/{}", ct.ctype(), ct.subtype().unwrap_or("")));
match content_type.as_deref() {
Some("multipart/mixed") => {
let boundary = message
.content_type()
.and_then(|ct| ct.attribute("boundary"))
.ok_or_else(|| {
EmailError::InvalidEmailFormat("multipart/mixed without boundary".into())
})?
.to_string();
insert_part_before_closing_boundary_named(raw_email, &boundary, doc, filename)
}
Some(ct) if ct.starts_with("multipart/") => {
wrap_in_multipart_mixed_named(raw_email, doc, filename)
}
_ => {
wrap_in_multipart_mixed_named(raw_email, doc, filename)
}
}
}
pub fn get_jacs_attachment_named(raw_email: &[u8], filename: &str) -> Result<Vec<u8>, EmailError> {
let message = MessageParser::default().parse(raw_email).ok_or_else(|| {
EmailError::InvalidEmailFormat("Cannot parse email for attachment extraction".into())
})?;
for part in message.parts.iter() {
let part_filename = part
.attachment_name()
.or_else(|| part.content_type().and_then(|ct| ct.attribute("name")));
if let Some(name) = part_filename {
if name == filename {
return Ok(part.contents().to_vec());
}
}
}
Err(EmailError::MissingJacsSignature)
}
pub fn get_jacs_attachment(raw_email: &[u8]) -> Result<Vec<u8>, EmailError> {
get_jacs_attachment_named(raw_email, DEFAULT_JACS_SIGNATURE_FILENAME)
}
pub fn remove_jacs_attachment_named(
raw_email: &[u8],
filename: &str,
) -> Result<Vec<u8>, EmailError> {
let message = MessageParser::default().parse(raw_email).ok_or_else(|| {
EmailError::InvalidEmailFormat("Cannot parse email for attachment removal".into())
})?;
let mut jacs_part_idx = None;
for (idx, part) in message.parts.iter().enumerate() {
let part_filename = part
.attachment_name()
.or_else(|| part.content_type().and_then(|ct| ct.attribute("name")));
if let Some(name) = part_filename {
if name == filename {
jacs_part_idx = Some(idx);
break;
}
}
}
let part_idx = jacs_part_idx.ok_or(EmailError::MissingJacsSignature)?;
let jacs_part = &message.parts[part_idx];
let header_offset = jacs_part.raw_header_offset() as usize;
let end_offset = jacs_part.raw_end_offset() as usize;
let boundary = message
.content_type()
.and_then(|ct| ct.attribute("boundary"))
.map(|b| b.to_string());
if let Some(boundary) = boundary {
let boundary_marker = format!("--{}", boundary);
let before_part = &raw_email[..header_offset];
if let Some(boundary_start) = rfind_bytes(before_part, boundary_marker.as_bytes()) {
let mut result = Vec::new();
result.extend_from_slice(&raw_email[..boundary_start]);
result.extend_from_slice(&raw_email[end_offset..]);
return Ok(result);
}
}
let mut result = Vec::new();
result.extend_from_slice(&raw_email[..header_offset]);
result.extend_from_slice(&raw_email[end_offset..]);
Ok(result)
}
pub fn remove_jacs_attachment(raw_email: &[u8]) -> Result<Vec<u8>, EmailError> {
remove_jacs_attachment_named(raw_email, DEFAULT_JACS_SIGNATURE_FILENAME)
}
fn insert_part_before_closing_boundary_named(
raw_email: &[u8],
boundary: &str,
doc: &[u8],
filename: &str,
) -> Result<Vec<u8>, EmailError> {
let closing = format!("--{}--", boundary);
let closing_pos = rfind_bytes(raw_email, closing.as_bytes()).ok_or_else(|| {
EmailError::InvalidEmailFormat("Cannot find closing boundary in multipart/mixed".into())
})?;
let jacs_part = build_jacs_mime_part_named(boundary, doc, filename);
let mut result = Vec::new();
result.extend_from_slice(&raw_email[..closing_pos]);
result.extend_from_slice(jacs_part.as_bytes());
result.extend_from_slice(closing.as_bytes());
let after_closing = closing_pos + closing.len();
if after_closing < raw_email.len() {
result.extend_from_slice(&raw_email[after_closing..]);
} else {
result.extend_from_slice(b"\r\n");
}
Ok(result)
}
fn wrap_in_multipart_mixed_named(
raw_email: &[u8],
doc: &[u8],
filename: &str,
) -> Result<Vec<u8>, EmailError> {
let boundary = generate_boundary();
let header_end = find_header_body_boundary(raw_email);
let headers = &raw_email[..header_end];
let body_start = skip_blank_line(raw_email, header_end);
let body = &raw_email[body_start..];
let message = MessageParser::default()
.parse(raw_email)
.ok_or_else(|| EmailError::InvalidEmailFormat("Cannot parse email for wrapping".into()))?;
let original_ct = message
.content_type()
.map(|ct| {
let mut s = format!("{}/{}", ct.ctype(), ct.subtype().unwrap_or("plain"));
if let Some(attrs) = ct.attributes() {
for attr in attrs {
s.push_str(&format!("; {}={}", attr.name, attr.value));
}
}
s
})
.unwrap_or_else(|| "text/plain; charset=utf-8".to_string());
let original_cte = message
.parts
.first()
.and_then(|p| p.content_transfer_encoding())
.unwrap_or("7bit");
let new_headers = rebuild_headers_for_multipart(headers, &boundary)?;
let mut result: Vec<u8> = Vec::new();
result.extend_from_slice(&new_headers);
result.extend_from_slice(b"\r\n");
result.extend_from_slice(format!("--{}\r\n", boundary).as_bytes());
result.extend_from_slice(format!("Content-Type: {}\r\n", original_ct).as_bytes());
result.extend_from_slice(format!("Content-Transfer-Encoding: {}\r\n", original_cte).as_bytes());
result.extend_from_slice(b"\r\n");
result.extend_from_slice(body);
if !body.ends_with(b"\r\n") && !body.ends_with(b"\n") {
result.extend_from_slice(b"\r\n");
}
let jacs_part = build_jacs_mime_part_bytes_named(&boundary, doc, filename);
result.extend_from_slice(&jacs_part);
result.extend_from_slice(format!("--{}--\r\n", boundary).as_bytes());
Ok(result)
}
pub(crate) fn ensure_multipart_mixed(raw_email: &[u8]) -> Result<Vec<u8>, EmailError> {
let message = MessageParser::default()
.parse(raw_email)
.ok_or_else(|| EmailError::InvalidEmailFormat("Cannot parse email for wrapping".into()))?;
let content_type = message
.content_type()
.map(|ct| format!("{}/{}", ct.ctype(), ct.subtype().unwrap_or("")));
if content_type.as_deref() == Some("multipart/mixed") {
return Ok(raw_email.to_vec());
}
let boundary = generate_boundary();
let header_end = find_header_body_boundary(raw_email);
let headers = &raw_email[..header_end];
let body_start = skip_blank_line(raw_email, header_end);
let body = &raw_email[body_start..];
let original_ct = message
.content_type()
.map(|ct| {
let mut s = format!("{}/{}", ct.ctype(), ct.subtype().unwrap_or("plain"));
if let Some(attrs) = ct.attributes() {
for attr in attrs {
s.push_str(&format!("; {}={}", attr.name, attr.value));
}
}
s
})
.unwrap_or_else(|| "text/plain; charset=utf-8".to_string());
let original_cte = message
.parts
.first()
.and_then(|p| p.content_transfer_encoding())
.unwrap_or("7bit");
let new_headers = rebuild_headers_for_multipart(headers, &boundary)?;
let mut result: Vec<u8> = Vec::new();
result.extend_from_slice(&new_headers);
result.extend_from_slice(b"\r\n");
result.extend_from_slice(format!("--{}\r\n", boundary).as_bytes());
result.extend_from_slice(format!("Content-Type: {}\r\n", original_ct).as_bytes());
result.extend_from_slice(format!("Content-Transfer-Encoding: {}\r\n", original_cte).as_bytes());
result.extend_from_slice(b"\r\n");
result.extend_from_slice(body);
if !body.ends_with(b"\r\n") && !body.ends_with(b"\n") {
result.extend_from_slice(b"\r\n");
}
result.extend_from_slice(format!("--{}--\r\n", boundary).as_bytes());
Ok(result)
}
fn rebuild_headers_for_multipart(headers: &[u8], boundary: &str) -> Result<Vec<u8>, EmailError> {
let mut result = Vec::new();
let mut replaced_ct = false;
let mut skip_current = false;
let mut pos = 0;
while pos < headers.len() {
let line_end = headers[pos..]
.iter()
.position(|&b| b == b'\n')
.map(|i| pos + i + 1)
.unwrap_or(headers.len());
let line = &headers[pos..line_end];
let trimmed = strip_line_ending(line);
if trimmed.is_empty() {
break;
}
if trimmed[0] == b' ' || trimmed[0] == b'\t' {
if !skip_current {
result.extend_from_slice(trimmed);
result.extend_from_slice(b"\r\n");
}
pos = line_end;
continue;
}
skip_current = false;
let lower: Vec<u8> = trimmed.iter().map(|b| b.to_ascii_lowercase()).collect();
if lower.starts_with(b"content-type:") {
skip_current = true;
if !replaced_ct {
result.extend_from_slice(
format!(
"Content-Type: multipart/mixed; boundary=\"{}\"\r\n",
boundary
)
.as_bytes(),
);
replaced_ct = true;
}
pos = line_end;
continue;
}
if lower.starts_with(b"content-transfer-encoding:") {
skip_current = true;
pos = line_end;
continue;
}
result.extend_from_slice(trimmed);
result.extend_from_slice(b"\r\n");
pos = line_end;
}
if !replaced_ct {
result.extend_from_slice(
format!(
"Content-Type: multipart/mixed; boundary=\"{}\"\r\n",
boundary
)
.as_bytes(),
);
}
Ok(result)
}
fn strip_line_ending(line: &[u8]) -> &[u8] {
let mut end = line.len();
if end > 0 && line[end - 1] == b'\n' {
end -= 1;
}
if end > 0 && line[end - 1] == b'\r' {
end -= 1;
}
&line[..end]
}
fn build_jacs_mime_part_bytes_named(boundary: &str, doc: &[u8], filename: &str) -> Vec<u8> {
let mut part = Vec::new();
part.extend_from_slice(format!("--{}\r\n", boundary).as_bytes());
part.extend_from_slice(
format!("Content-Type: application/json; name=\"{}\"\r\n", filename).as_bytes(),
);
part.extend_from_slice(
format!(
"Content-Disposition: attachment; filename=\"{}\"\r\n",
filename
)
.as_bytes(),
);
part.extend_from_slice(b"Content-Transfer-Encoding: 7bit\r\n");
part.extend_from_slice(b"\r\n");
part.extend_from_slice(doc);
part.extend_from_slice(b"\r\n");
part
}
fn build_jacs_mime_part_named(boundary: &str, doc: &[u8], filename: &str) -> String {
let bytes = build_jacs_mime_part_bytes_named(boundary, doc, filename);
String::from_utf8(bytes).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned())
}
fn generate_boundary() -> String {
use rand::Rng;
let mut rng = rand::rng();
let random: u64 = rng.random();
format!("jacs_{:016x}", random)
}
use super::canonicalize::find_header_body_boundary;
fn skip_blank_line(raw: &[u8], header_end: usize) -> usize {
let mut pos = header_end;
if pos < raw.len() && raw[pos] == b'\r' {
pos += 1;
}
if pos < raw.len() && raw[pos] == b'\n' {
pos += 1;
}
if pos < raw.len() && raw[pos] == b'\r' {
pos += 1;
}
if pos < raw.len() && raw[pos] == b'\n' {
pos += 1;
}
pos
}
#[cfg(test)]
mod tests {
use super::*;
fn simple_text_email() -> Vec<u8> {
b"From: sender@example.com\r\nTo: recipient@example.com\r\nSubject: Test\r\nDate: Fri, 28 Feb 2026 12:00:00 +0000\r\nMessage-ID: <test@example.com>\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nHello World\r\n".to_vec()
}
fn multipart_mixed_email() -> Vec<u8> {
b"From: sender@example.com\r\nTo: recipient@example.com\r\nSubject: Test\r\nDate: Fri, 28 Feb 2026 12:00:00 +0000\r\nMessage-ID: <test@example.com>\r\nContent-Type: multipart/mixed; boundary=\"testboundary\"\r\n\r\n--testboundary\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nHello World\r\n--testboundary--\r\n".to_vec()
}
fn multipart_alternative_email() -> Vec<u8> {
b"From: sender@example.com\r\nTo: recipient@example.com\r\nSubject: Test\r\nDate: Fri, 28 Feb 2026 12:00:00 +0000\r\nMessage-ID: <test@example.com>\r\nContent-Type: multipart/alternative; boundary=\"altbound\"\r\n\r\n--altbound\r\nContent-Type: text/plain\r\n\r\nPlain text\r\n--altbound\r\nContent-Type: text/html\r\n\r\n<p>HTML</p>\r\n--altbound--\r\n".to_vec()
}
#[test]
fn add_jacs_attachment_to_multipart_mixed() {
let email = multipart_mixed_email();
let doc = br#"{"test":"doc"}"#;
let result = add_jacs_attachment(&email, doc).unwrap();
let result_str = String::from_utf8_lossy(&result);
assert!(result_str.contains(DEFAULT_JACS_SIGNATURE_FILENAME));
assert!(result_str.contains(r#"{"test":"doc"}"#));
assert!(MessageParser::default().parse(&result).is_some());
}
#[test]
fn add_jacs_attachment_to_multipart_alternative() {
let email = multipart_alternative_email();
let doc = br#"{"test":"doc"}"#;
let result = add_jacs_attachment(&email, doc).unwrap();
let result_str = String::from_utf8_lossy(&result);
assert!(result_str.contains(DEFAULT_JACS_SIGNATURE_FILENAME));
assert!(result_str.contains("Plain text") || result_str.contains("<p>HTML</p>"));
}
#[test]
fn add_jacs_attachment_to_single_part() {
let email = simple_text_email();
let doc = br#"{"test":"doc"}"#;
let result = add_jacs_attachment(&email, doc).unwrap();
let result_str = String::from_utf8_lossy(&result);
assert!(result_str.contains(DEFAULT_JACS_SIGNATURE_FILENAME));
assert!(result_str.contains("multipart/mixed"));
assert!(result_str.contains("Hello World"));
}
#[test]
fn get_jacs_attachment_extracts_signature() {
let email = simple_text_email();
let doc = br#"{"version":"1.0"}"#;
let signed = add_jacs_attachment(&email, doc).unwrap();
let extracted = get_jacs_attachment(&signed).unwrap();
assert_eq!(extracted, doc);
}
#[test]
fn get_jacs_attachment_returns_error_when_missing() {
let email = simple_text_email();
let result = get_jacs_attachment(&email);
assert!(result.is_err());
match result.unwrap_err() {
EmailError::MissingJacsSignature => {}
other => panic!("Expected MissingJacsSignature, got {:?}", other),
}
}
#[test]
fn remove_jacs_attachment_removes_signature() {
let email = simple_text_email();
let doc = br#"{"version":"1.0"}"#;
let signed = add_jacs_attachment(&email, doc).unwrap();
assert!(get_jacs_attachment(&signed).is_ok());
let unsigned = remove_jacs_attachment(&signed).unwrap();
assert!(get_jacs_attachment(&unsigned).is_err());
assert!(MessageParser::default().parse(&unsigned).is_some());
}
#[test]
fn roundtrip_add_then_get() {
let email = simple_text_email();
let doc = br#"{"payload":"test","hash":"sha256:abc"}"#;
let signed = add_jacs_attachment(&email, doc).unwrap();
let extracted = get_jacs_attachment(&signed).unwrap();
assert_eq!(extracted, doc);
}
#[test]
fn wrap_preserves_folded_subject_header() {
let email = b"From: sender@example.com\r\nTo: recipient@example.com\r\nContent-Type: text/plain;\r\n charset=utf-8\r\nSubject: This is a very long subject line that\r\n wraps to the next line\r\nDate: Fri, 28 Feb 2026 12:00:00 +0000\r\nMessage-ID: <test@example.com>\r\n\r\nBody text\r\n";
let doc = br#"{"test":"doc"}"#;
let result = add_jacs_attachment(email, doc).unwrap();
let result_str = String::from_utf8_lossy(&result);
assert!(
result_str.contains("wraps to the next line"),
"Subject continuation line was truncated: {}",
result_str
);
assert!(result_str.contains("multipart/mixed"));
let outer_headers = result_str.split("\r\n\r\n").next().unwrap();
assert!(
!outer_headers.contains(" charset=utf-8"),
"Content-Type continuation should not be in outer headers"
);
}
#[test]
fn roundtrip_add_then_remove_parseable() {
let email = multipart_mixed_email();
let doc = br#"{"version":"1.0"}"#;
let signed = add_jacs_attachment(&email, doc).unwrap();
let unsigned = remove_jacs_attachment(&signed).unwrap();
let parsed = MessageParser::default().parse(&unsigned);
assert!(parsed.is_some());
}
#[test]
fn add_jacs_attachment_named_custom_name() {
let email = simple_text_email();
let doc = br#"{"test":"custom"}"#;
let result = add_jacs_attachment_named(&email, doc, "hai.ai.signature.jacs.json").unwrap();
let result_str = String::from_utf8_lossy(&result);
assert!(result_str.contains("hai.ai.signature.jacs.json"));
assert!(result_str.contains(r#"{"test":"custom"}"#));
}
#[test]
fn get_jacs_attachment_named_finds_custom() {
let email = simple_text_email();
let doc = br#"{"version":"2.0"}"#;
let signed = add_jacs_attachment_named(&email, doc, "custom.jacs.json").unwrap();
let extracted = get_jacs_attachment_named(&signed, "custom.jacs.json").unwrap();
assert_eq!(extracted, doc);
}
#[test]
fn get_jacs_attachment_named_misses_wrong_name() {
let email = simple_text_email();
let doc = br#"{"v":"1"}"#;
let signed = add_jacs_attachment_named(&email, doc, "custom.jacs.json").unwrap();
let result = get_jacs_attachment(&signed);
assert!(result.is_err());
}
#[test]
fn default_attachment_name_unchanged() {
let email = simple_text_email();
let doc = br#"{"test":"default"}"#;
let result = add_jacs_attachment(&email, doc).unwrap();
let result_str = String::from_utf8_lossy(&result);
assert!(
result_str.contains("jacs-signature.json"),
"Default should use jacs-signature.json, got: {}",
result_str
);
}
#[test]
fn remove_jacs_attachment_named_removes_custom() {
let email = simple_text_email();
let doc = br#"{"v":"1"}"#;
let signed = add_jacs_attachment_named(&email, doc, "hai.ai.signature.jacs.json").unwrap();
assert!(get_jacs_attachment_named(&signed, "hai.ai.signature.jacs.json").is_ok());
let unsigned = remove_jacs_attachment_named(&signed, "hai.ai.signature.jacs.json").unwrap();
assert!(get_jacs_attachment_named(&unsigned, "hai.ai.signature.jacs.json").is_err());
assert!(MessageParser::default().parse(&unsigned).is_some());
}
}