use std::collections::HashMap;
use jmap_types::{Date, Id, UTCDate};
use serde::{Deserialize, Serialize};
use crate::keyword::Keyword;
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EmailAddress {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub email: String,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
impl EmailAddress {
pub fn new(email: impl Into<String>) -> Self {
Self {
name: None,
email: email.into(),
extra: serde_json::Map::new(),
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EmailAddressGroup {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub addresses: Vec<EmailAddress>,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
impl EmailAddressGroup {
pub fn new(addresses: Vec<EmailAddress>) -> Self {
Self {
name: None,
addresses,
extra: serde_json::Map::new(),
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EmailHeader {
pub name: String,
pub value: String,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
impl EmailHeader {
pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
Self {
name: name.into(),
value: value.into(),
extra: serde_json::Map::new(),
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EmailBodyValue {
pub value: String,
#[serde(default)]
pub is_encoding_problem: bool,
#[serde(default)]
pub is_truncated: bool,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
impl EmailBodyValue {
pub fn new(value: impl Into<String>) -> Self {
Self {
value: value.into(),
is_encoding_problem: false,
is_truncated: false,
extra: serde_json::Map::new(),
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EmailBodyPart {
#[serde(skip_serializing_if = "Option::is_none")]
pub part_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub blob_id: Option<Id>,
#[serde(skip_serializing_if = "Option::is_none")]
pub size: Option<u64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub headers: Vec<EmailHeader>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub type_: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub charset: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub disposition: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cid: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub language: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub location: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sub_parts: Option<Vec<EmailBodyPart>>,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Email {
pub id: Id,
pub blob_id: Id,
pub thread_id: Id,
pub mailbox_ids: HashMap<Id, bool>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub keywords: HashMap<Keyword, bool>,
pub size: u64,
pub received_at: UTCDate,
#[serde(skip_serializing_if = "Option::is_none")]
pub message_id: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub in_reply_to: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub references: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sender: Option<Vec<EmailAddress>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub from: Option<Vec<EmailAddress>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub to: Option<Vec<EmailAddress>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cc: Option<Vec<EmailAddress>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bcc: Option<Vec<EmailAddress>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reply_to: Option<Vec<EmailAddress>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub subject: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sent_at: Option<Date>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub headers: Vec<EmailHeader>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub body_values: HashMap<String, EmailBodyValue>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub text_body: Vec<EmailBodyPart>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub html_body: Vec<EmailBodyPart>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub attachments: Vec<EmailBodyPart>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body_structure: Option<EmailBodyPart>,
#[serde(default)]
pub has_attachment: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub preview: Option<String>,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
impl Email {
pub fn new(
id: Id,
blob_id: Id,
thread_id: Id,
mailbox_ids: HashMap<Id, bool>,
size: u64,
received_at: UTCDate,
) -> Self {
Self {
id,
blob_id,
thread_id,
mailbox_ids,
keywords: HashMap::new(),
size,
received_at,
message_id: None,
in_reply_to: None,
references: None,
sender: None,
from: None,
to: None,
cc: None,
bcc: None,
reply_to: None,
subject: None,
sent_at: None,
headers: Vec::new(),
body_values: HashMap::new(),
text_body: Vec::new(),
html_body: Vec::new(),
attachments: Vec::new(),
body_structure: None,
has_attachment: false,
preview: None,
extra: serde_json::Map::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn email_address_preserves_vendor_extras() {
let raw = json!({
"name": "Alice",
"email": "alice@example.com",
"acmeCorpVerified": true
});
let addr: EmailAddress = serde_json::from_value(raw).unwrap();
assert_eq!(
addr.extra.get("acmeCorpVerified").and_then(|v| v.as_bool()),
Some(true)
);
let back = serde_json::to_value(&addr).unwrap();
assert_eq!(back["acmeCorpVerified"], true);
}
#[test]
fn email_address_group_preserves_vendor_extras() {
let raw = json!({
"name": "Engineering",
"addresses": [],
"acmeCorpDistributionId": "dl-eng"
});
let grp: EmailAddressGroup = serde_json::from_value(raw).unwrap();
assert_eq!(
grp.extra
.get("acmeCorpDistributionId")
.and_then(|v| v.as_str()),
Some("dl-eng")
);
let back = serde_json::to_value(&grp).unwrap();
assert_eq!(back["acmeCorpDistributionId"], "dl-eng");
}
#[test]
fn email_header_preserves_vendor_extras() {
let raw = json!({
"name": "X-Custom",
"value": "v",
"acmeCorpOrigin": "edge-1"
});
let hdr: EmailHeader = serde_json::from_value(raw).unwrap();
assert_eq!(
hdr.extra.get("acmeCorpOrigin").and_then(|v| v.as_str()),
Some("edge-1")
);
let back = serde_json::to_value(&hdr).unwrap();
assert_eq!(back["acmeCorpOrigin"], "edge-1");
}
#[test]
fn email_body_value_preserves_vendor_extras() {
let raw = json!({
"value": "hello",
"isEncodingProblem": false,
"isTruncated": false,
"acmeCorpScanResult": "clean"
});
let bv: EmailBodyValue = serde_json::from_value(raw).unwrap();
assert_eq!(
bv.extra.get("acmeCorpScanResult").and_then(|v| v.as_str()),
Some("clean")
);
let back = serde_json::to_value(&bv).unwrap();
assert_eq!(back["acmeCorpScanResult"], "clean");
}
#[test]
fn email_body_part_preserves_vendor_extras() {
let raw = json!({
"partId": "1",
"blobId": "b1",
"size": 42,
"type": "text/plain",
"acmeCorpChecksum": "sha256:deadbeef"
});
let part: EmailBodyPart = serde_json::from_value(raw).unwrap();
assert_eq!(
part.extra.get("acmeCorpChecksum").and_then(|v| v.as_str()),
Some("sha256:deadbeef")
);
let back = serde_json::to_value(&part).unwrap();
assert_eq!(back["acmeCorpChecksum"], "sha256:deadbeef");
}
#[test]
fn email_preserves_vendor_extras() {
let raw = json!({
"id": "e1",
"blobId": "b1",
"threadId": "t1",
"mailboxIds": {"m1": true},
"size": 1024,
"receivedAt": "2024-06-01T00:00:00Z",
"acmeCorpClassification": {"label": "internal", "score": 0.9}
});
let email: Email = serde_json::from_value(raw).unwrap();
assert_eq!(
email
.extra
.get("acmeCorpClassification")
.and_then(|v| v["label"].as_str()),
Some("internal")
);
let back = serde_json::to_value(&email).unwrap();
assert_eq!(back["acmeCorpClassification"]["score"], 0.9);
}
#[test]
fn email_address_empty_extras_omitted_from_wire() {
let addr = EmailAddress::new("a@b");
let serialized = serde_json::to_value(&addr).unwrap();
let obj = serialized.as_object().expect("must be object");
assert_eq!(
obj.len(),
1,
"empty extras must not add wire keys; got {serialized}"
);
assert!(obj.contains_key("email"));
}
#[test]
fn extra_collision_with_typed_field_round_trip_fails() {
let mut addr = EmailAddress::new("real@example.com");
addr.extra.insert(
"email".into(),
serde_json::Value::from("override@example.com"),
);
let serialised = serde_json::to_string(&addr).expect("serialize must succeed");
let occurrences = serialised.matches("\"email\":").count();
assert_eq!(
occurrences, 2,
"wire output must contain two duplicate keys; got {serialised}"
);
let err = serde_json::from_str::<EmailAddress>(&serialised)
.expect_err("deserialize must reject duplicate-key wire form");
assert!(
err.to_string().contains("duplicate field"),
"error must mention duplicate field; got: {err}"
);
}
#[test]
fn email_extras_multi_field_nested_and_string_roundtrip() {
let raw_str = r#"{
"id": "e1",
"blobId": "b1",
"threadId": "t1",
"mailboxIds": {"m1": true},
"size": 1024,
"receivedAt": "2024-06-01T00:00:00Z",
"acmeCorpFoo": "bar",
"siteHint": "high-priority",
"acmeCorpNested": {
"version": 2,
"signed": [
{"by": "alice", "at": "2024-06-01T00:00:00Z"},
{"by": "bob", "at": "2024-06-01T00:01:00Z"}
],
"tags": ["x", "y", "z"]
}
}"#;
let email: Email =
serde_json::from_str(raw_str).expect("from_str must accept the wire form");
assert!(
email.extra.contains_key("acmeCorpFoo"),
"scalar vendor field lost"
);
assert!(
email.extra.contains_key("siteHint"),
"second scalar vendor field lost"
);
assert!(
email.extra.contains_key("acmeCorpNested"),
"nested vendor field lost"
);
assert_eq!(
email.extra.len(),
3,
"vendor key count must be exactly three; got {:?}",
email.extra.keys().collect::<Vec<_>>()
);
let nested = email
.extra
.get("acmeCorpNested")
.expect("acmeCorpNested key present");
assert_eq!(nested["version"], 2);
assert_eq!(nested["signed"][0]["by"], "alice");
assert_eq!(nested["signed"][1]["by"], "bob");
assert_eq!(nested["tags"][2], "z");
let serialised = serde_json::to_string(&email).expect("to_string must succeed");
let reparsed: Email =
serde_json::from_str(&serialised).expect("from_str must re-accept own output");
assert_eq!(reparsed.extra.len(), 3);
assert_eq!(
reparsed.extra.get("acmeCorpFoo").and_then(|v| v.as_str()),
Some("bar")
);
assert_eq!(
reparsed.extra.get("siteHint").and_then(|v| v.as_str()),
Some("high-priority")
);
let nested2 = reparsed.extra.get("acmeCorpNested").expect("present");
assert_eq!(nested2["signed"][0]["by"], "alice");
assert_eq!(nested2["tags"][2], "z");
}
}