use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignedHeaderEntry {
pub value: String,
pub hash: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BodyPartEntry {
pub content_hash: String,
pub mime_headers_hash: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttachmentEntry {
pub content_hash: String,
pub mime_headers_hash: String,
pub filename: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmailSignatureHeaders {
pub from: SignedHeaderEntry,
pub to: SignedHeaderEntry,
#[serde(skip_serializing_if = "Option::is_none")]
pub cc: Option<SignedHeaderEntry>,
pub subject: SignedHeaderEntry,
pub date: SignedHeaderEntry,
pub message_id: SignedHeaderEntry,
#[serde(skip_serializing_if = "Option::is_none")]
pub in_reply_to: Option<SignedHeaderEntry>,
#[serde(skip_serializing_if = "Option::is_none")]
pub references: Option<SignedHeaderEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmailSignaturePayload {
pub headers: EmailSignatureHeaders,
#[serde(skip_serializing_if = "Option::is_none")]
pub body_plain: Option<BodyPartEntry>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body_html: Option<BodyPartEntry>,
pub attachments: Vec<AttachmentEntry>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_signature_hash: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JacsEmailMetadata {
pub issuer: String,
pub document_id: String,
pub created_at: String,
pub hash: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JacsEmailSignature {
pub key_id: String,
pub algorithm: String,
pub signature: String,
pub signed_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JacsEmailSignatureDocument {
pub version: String,
pub document_type: String,
pub payload: EmailSignaturePayload,
pub metadata: JacsEmailMetadata,
pub signature: JacsEmailSignature,
}
#[derive(Debug, Clone)]
pub struct ParsedEmailParts {
pub headers: std::collections::HashMap<String, Vec<String>>,
pub body_plain: Option<ParsedBodyPart>,
pub body_html: Option<ParsedBodyPart>,
pub attachments: Vec<ParsedAttachment>,
pub jacs_attachments: Vec<ParsedAttachment>,
}
#[derive(Debug, Clone)]
pub struct ParsedBodyPart {
pub content: Vec<u8>,
pub content_type: Option<String>,
pub content_transfer_encoding: Option<String>,
pub content_disposition: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ParsedAttachment {
pub filename: String,
pub content_type: String,
pub content: Vec<u8>,
pub content_transfer_encoding: Option<String>,
pub content_disposition: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FieldStatus {
Pass,
Modified,
Fail,
Unverifiable,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FieldResult {
pub field: String,
pub status: FieldStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub original_hash: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub current_hash: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub original_value: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub current_value: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChainEntry {
pub signer: String,
pub jacs_id: String,
pub valid: bool,
pub forwarded: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerifiedEmailDocument {
pub payload: EmailSignaturePayload,
pub signer_id: String,
pub raw_jacs_document: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContentVerificationResult {
pub valid: bool,
pub field_results: Vec<FieldResult>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub chain: Vec<ChainEntry>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn signed_header_entry_serializes_with_value_and_hash() {
let entry = SignedHeaderEntry {
value: "agent@example.com".to_string(),
hash: "sha256:abc123".to_string(),
};
let json = serde_json::to_value(&entry).unwrap();
assert_eq!(json["value"], "agent@example.com");
assert_eq!(json["hash"], "sha256:abc123");
}
#[test]
fn email_signature_payload_roundtrips_through_serde() {
let payload = EmailSignaturePayload {
headers: EmailSignatureHeaders {
from: SignedHeaderEntry {
value: "sender@example.com".to_string(),
hash: "sha256:from_hash".to_string(),
},
to: SignedHeaderEntry {
value: "recipient@example.com".to_string(),
hash: "sha256:to_hash".to_string(),
},
cc: None,
subject: SignedHeaderEntry {
value: "Test Subject".to_string(),
hash: "sha256:subject_hash".to_string(),
},
date: SignedHeaderEntry {
value: "Fri, 28 Feb 2026 12:00:00 +0000".to_string(),
hash: "sha256:date_hash".to_string(),
},
message_id: SignedHeaderEntry {
value: "<test@example.com>".to_string(),
hash: "sha256:mid_hash".to_string(),
},
in_reply_to: None,
references: None,
},
body_plain: Some(BodyPartEntry {
content_hash: "sha256:body_hash".to_string(),
mime_headers_hash: "sha256:mime_hash".to_string(),
}),
body_html: None,
attachments: vec![],
parent_signature_hash: None,
};
let json_str = serde_json::to_string(&payload).unwrap();
let roundtripped: EmailSignaturePayload = serde_json::from_str(&json_str).unwrap();
assert_eq!(roundtripped.headers.from.value, "sender@example.com");
assert!(roundtripped.body_html.is_none());
assert!(roundtripped.parent_signature_hash.is_none());
}
#[test]
fn jacs_email_signature_document_roundtrips_through_serde() {
let doc = JacsEmailSignatureDocument {
version: "1.0".to_string(),
document_type: "email_signature".to_string(),
payload: EmailSignaturePayload {
headers: EmailSignatureHeaders {
from: SignedHeaderEntry {
value: "a@b.com".to_string(),
hash: "sha256:f".to_string(),
},
to: SignedHeaderEntry {
value: "c@d.com".to_string(),
hash: "sha256:t".to_string(),
},
cc: None,
subject: SignedHeaderEntry {
value: "s".to_string(),
hash: "sha256:s".to_string(),
},
date: SignedHeaderEntry {
value: "d".to_string(),
hash: "sha256:d".to_string(),
},
message_id: SignedHeaderEntry {
value: "m".to_string(),
hash: "sha256:m".to_string(),
},
in_reply_to: None,
references: None,
},
body_plain: None,
body_html: None,
attachments: vec![],
parent_signature_hash: None,
},
metadata: JacsEmailMetadata {
issuer: "test-agent".to_string(),
document_id: "doc-1".to_string(),
created_at: "2026-02-28T00:00:00Z".to_string(),
hash: "sha256:meta_hash".to_string(),
},
signature: JacsEmailSignature {
key_id: "key-1".to_string(),
algorithm: "ed25519".to_string(),
signature: "c2lnbmF0dXJl".to_string(),
signed_at: "2026-02-28T00:00:00Z".to_string(),
},
};
let json_str = serde_json::to_string(&doc).unwrap();
let roundtripped: JacsEmailSignatureDocument = serde_json::from_str(&json_str).unwrap();
assert_eq!(roundtripped.version, "1.0");
assert_eq!(roundtripped.document_type, "email_signature");
assert_eq!(roundtripped.metadata.issuer, "test-agent");
assert_eq!(roundtripped.signature.algorithm, "ed25519");
}
#[test]
fn content_verification_result_with_mixed_field_status() {
let result = ContentVerificationResult {
valid: false,
field_results: vec![
FieldResult {
field: "headers.from".to_string(),
status: FieldStatus::Pass,
original_hash: Some("sha256:a".to_string()),
current_hash: Some("sha256:a".to_string()),
original_value: Some("a@b.com".to_string()),
current_value: Some("a@b.com".to_string()),
},
FieldResult {
field: "headers.subject".to_string(),
status: FieldStatus::Fail,
original_hash: Some("sha256:b".to_string()),
current_hash: Some("sha256:c".to_string()),
original_value: Some("Original".to_string()),
current_value: Some("Tampered".to_string()),
},
FieldResult {
field: "headers.message_id".to_string(),
status: FieldStatus::Unverifiable,
original_hash: None,
current_hash: None,
original_value: None,
current_value: None,
},
FieldResult {
field: "headers.from".to_string(),
status: FieldStatus::Modified,
original_hash: Some("sha256:d".to_string()),
current_hash: Some("sha256:e".to_string()),
original_value: Some("User@Example.COM".to_string()),
current_value: Some("user@example.com".to_string()),
},
],
chain: vec![],
};
let json_str = serde_json::to_string(&result).unwrap();
let roundtripped: ContentVerificationResult = serde_json::from_str(&json_str).unwrap();
assert!(!roundtripped.valid);
assert_eq!(roundtripped.field_results.len(), 4);
assert_eq!(roundtripped.field_results[0].status, FieldStatus::Pass);
assert_eq!(roundtripped.field_results[1].status, FieldStatus::Fail);
assert_eq!(
roundtripped.field_results[2].status,
FieldStatus::Unverifiable
);
assert_eq!(roundtripped.field_results[3].status, FieldStatus::Modified);
}
#[test]
fn parsed_email_parts_holds_optional_body_parts() {
let parts = ParsedEmailParts {
headers: std::collections::HashMap::new(),
body_plain: Some(ParsedBodyPart {
content: b"Hello".to_vec(),
content_type: Some("text/plain; charset=utf-8".to_string()),
content_transfer_encoding: Some("7bit".to_string()),
content_disposition: None,
}),
body_html: None,
attachments: vec![],
jacs_attachments: vec![],
};
assert!(parts.body_plain.is_some());
assert!(parts.body_html.is_none());
assert_eq!(parts.body_plain.unwrap().content, b"Hello");
}
#[test]
fn field_status_serializes_to_lowercase() {
assert_eq!(
serde_json::to_string(&FieldStatus::Pass).unwrap(),
"\"pass\""
);
assert_eq!(
serde_json::to_string(&FieldStatus::Modified).unwrap(),
"\"modified\""
);
assert_eq!(
serde_json::to_string(&FieldStatus::Fail).unwrap(),
"\"fail\""
);
assert_eq!(
serde_json::to_string(&FieldStatus::Unverifiable).unwrap(),
"\"unverifiable\""
);
}
}