use std::collections::HashMap;
use jmap_types::{Id, PatchObject};
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub const JMAP_MDN_URI: &str = "urn:ietf:params:jmap:mdn";
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ActionMode {
ManualAction,
AutomaticAction,
}
impl std::fmt::Display for ActionMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
ActionMode::ManualAction => "manual-action",
ActionMode::AutomaticAction => "automatic-action",
})
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SendingMode {
MdnSentManually,
MdnSentAutomatically,
}
impl std::fmt::Display for SendingMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
SendingMode::MdnSentManually => "mdn-sent-manually",
SendingMode::MdnSentAutomatically => "mdn-sent-automatically",
})
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DispositionType {
Deleted,
Dispatched,
Displayed,
Processed,
}
impl std::fmt::Display for DispositionType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
DispositionType::Deleted => "deleted",
DispositionType::Dispatched => "dispatched",
DispositionType::Displayed => "displayed",
DispositionType::Processed => "processed",
})
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Disposition {
pub action_mode: ActionMode,
pub sending_mode: SendingMode,
#[serde(rename = "type")]
pub type_: DispositionType,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
impl Disposition {
pub fn new(action_mode: ActionMode, sending_mode: SendingMode, type_: DispositionType) -> Self {
Self {
action_mode,
sending_mode,
type_,
extra: serde_json::Map::new(),
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Mdn {
#[serde(skip_serializing_if = "Option::is_none")]
pub for_email_id: Option<Id>,
#[serde(skip_serializing_if = "Option::is_none")]
pub subject: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub text_body: Option<String>,
#[serde(default)]
pub include_original_message: bool,
#[serde(rename = "reportingUA", skip_serializing_if = "Option::is_none")]
pub reporting_ua: Option<String>,
pub disposition: Disposition,
#[serde(skip_serializing_if = "Option::is_none")]
pub mdn_gateway: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub original_recipient: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub final_recipient: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub original_message_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub extension_fields: Option<HashMap<String, String>>,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
impl Mdn {
pub fn new(disposition: Disposition) -> Self {
Self {
for_email_id: None,
subject: None,
text_body: None,
include_original_message: false,
reporting_ua: None,
disposition,
mdn_gateway: None,
original_recipient: None,
final_recipient: None,
original_message_id: None,
error: None,
extension_fields: None,
extra: serde_json::Map::new(),
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MdnSendRequest {
pub account_id: Id,
pub identity_id: Id,
pub send: HashMap<String, Mdn>,
#[serde(skip_serializing_if = "Option::is_none")]
pub on_success_update_email: Option<HashMap<Id, PatchObject>>,
#[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, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MdnSendResponse {
pub account_id: Id,
#[serde(skip_serializing_if = "Option::is_none")]
pub sent: Option<HashMap<String, Mdn>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub not_sent: Option<HashMap<String, Value>>,
#[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, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MdnParseRequest {
pub account_id: Id,
pub blob_ids: Vec<Id>,
#[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, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MdnParseResponse {
pub account_id: Id,
#[serde(skip_serializing_if = "Option::is_none")]
pub parsed: Option<HashMap<Id, Mdn>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub not_parsable: Option<Vec<Id>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub not_found: Option<Vec<Id>>,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn disposition_roundtrip() {
let json = r#"{"actionMode":"manual-action","sendingMode":"mdn-sent-manually","type":"displayed"}"#;
let d: Disposition = serde_json::from_str(json).unwrap();
assert_eq!(d.action_mode, ActionMode::ManualAction);
assert_eq!(d.sending_mode, SendingMode::MdnSentManually);
assert_eq!(d.type_, DispositionType::Displayed);
let serialized = serde_json::to_string(&d).unwrap();
assert_eq!(serialized, json);
}
#[test]
fn mdn_camel_case_wire_names() {
let json = r#"{
"forEmailId": "e1",
"subject": "Read: Hello",
"textBody": "This is a receipt.",
"reportingUA": "Acme Mail 1.0; example.com",
"disposition": {
"actionMode": "manual-action",
"sendingMode": "mdn-sent-manually",
"type": "displayed"
}
}"#;
let mdn: Mdn = serde_json::from_str(json).unwrap();
assert_eq!(mdn.for_email_id.as_ref().map(|id| id.as_ref()), Some("e1"));
assert_eq!(mdn.subject.as_deref(), Some("Read: Hello"));
assert!(!mdn.include_original_message, "should default to false");
assert_eq!(mdn.disposition.type_, DispositionType::Displayed);
}
#[test]
fn extension_fields_wire_name() {
let json = r#"{"disposition":{"actionMode":"manual-action","sendingMode":"mdn-sent-manually","type":"processed"},"extensionFields":{"X-Custom":"value"}}"#;
let mdn: Mdn = serde_json::from_str(json).unwrap();
let fields = mdn.extension_fields.as_ref().unwrap();
assert_eq!(fields.get("X-Custom").map(|s| s.as_str()), Some("value"));
let serialized = serde_json::to_string(&mdn).unwrap();
assert!(
serialized.contains("extensionFields"),
"must use extensionFields not extension"
);
}
#[test]
fn disposition_type_lowercase() {
let cases = [
(DispositionType::Deleted, "\"deleted\""),
(DispositionType::Dispatched, "\"dispatched\""),
(DispositionType::Displayed, "\"displayed\""),
(DispositionType::Processed, "\"processed\""),
];
for (variant, expected) in &cases {
let got = serde_json::to_string(variant).unwrap();
assert_eq!(&got, expected, "DispositionType wire value mismatch");
}
}
#[test]
fn action_and_sending_mode_wire_values() {
assert_eq!(
serde_json::to_string(&ActionMode::ManualAction).unwrap(),
"\"manual-action\""
);
assert_eq!(
serde_json::to_string(&ActionMode::AutomaticAction).unwrap(),
"\"automatic-action\""
);
assert_eq!(
serde_json::to_string(&SendingMode::MdnSentManually).unwrap(),
"\"mdn-sent-manually\""
);
assert_eq!(
serde_json::to_string(&SendingMode::MdnSentAutomatically).unwrap(),
"\"mdn-sent-automatically\""
);
}
#[test]
fn mdn_send_request_roundtrip() {
let json = r#"{
"accountId": "acc1",
"identityId": "idt1",
"send": {
"k1": {
"forEmailId": "e1",
"disposition": {
"actionMode": "manual-action",
"sendingMode": "mdn-sent-manually",
"type": "displayed"
}
}
}
}"#;
let req: MdnSendRequest = serde_json::from_str(json).unwrap();
assert_eq!(req.account_id.as_ref(), "acc1");
assert_eq!(req.identity_id.as_ref(), "idt1");
assert!(req.send.contains_key("k1"));
assert!(req.on_success_update_email.is_none());
}
#[test]
fn mdn_send_request_on_success_update_email_roundtrip() {
let json = r##"{
"accountId": "acc1",
"identityId": "idt1",
"send": {
"k1": {
"forEmailId": "e1",
"disposition": {
"actionMode": "manual-action",
"sendingMode": "mdn-sent-manually",
"type": "displayed"
}
}
},
"onSuccessUpdateEmail": {
"#k1": { "keywords/$mdnsent": true }
}
}"##;
let req: MdnSendRequest = serde_json::from_str(json).unwrap();
let patches = req
.on_success_update_email
.as_ref()
.expect("onSuccessUpdateEmail must deserialize as Some");
let key = Id::from("#k1");
let patch = patches
.get(&key)
.expect("patch for #k1 must be present after round-trip");
assert_eq!(
patch.as_map().get("keywords/$mdnsent"),
Some(&serde_json::json!(true)),
"patch leaf must round-trip the boolean true"
);
assert_eq!(patch.as_map().len(), 1, "exactly one leaf in the patch");
let re_serialized = serde_json::to_value(&req).unwrap();
let original: serde_json::Value = serde_json::from_str(json).unwrap();
assert_eq!(
re_serialized.get("onSuccessUpdateEmail"),
original.get("onSuccessUpdateEmail"),
"onSuccessUpdateEmail must round-trip wire-byte-identical \
through HashMap<Id, PatchObject>"
);
}
#[test]
fn mdn_parse_response_roundtrip() {
let json = r#"{
"accountId": "acc1",
"notParsable": ["blob2"],
"notFound": ["blob3"]
}"#;
let resp: MdnParseResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.account_id.as_ref(), "acc1");
assert!(resp.parsed.is_none());
assert_eq!(resp.not_parsable.as_deref(), Some(&[Id::from("blob2")][..]));
assert_eq!(resp.not_found.as_deref(), Some(&[Id::from("blob3")][..]));
}
#[test]
fn disposition_preserves_vendor_extras() {
let raw = serde_json::json!({
"actionMode": "manual-action",
"sendingMode": "mdn-sent-manually",
"type": "displayed",
"acmeCorpDispositionFlag": "auto-ack"
});
let d: Disposition = serde_json::from_value(raw).unwrap();
assert_eq!(
d.extra
.get("acmeCorpDispositionFlag")
.and_then(|v| v.as_str()),
Some("auto-ack")
);
let back = serde_json::to_value(&d).unwrap();
assert_eq!(back["acmeCorpDispositionFlag"], "auto-ack");
}
#[test]
fn mdn_preserves_vendor_extras() {
let raw = serde_json::json!({
"forEmailId": "e1",
"disposition": {
"actionMode": "manual-action",
"sendingMode": "mdn-sent-manually",
"type": "displayed"
},
"acmeCorpClientTrace": "ua-42"
});
let mdn: Mdn = serde_json::from_value(raw).unwrap();
assert_eq!(
mdn.extra
.get("acmeCorpClientTrace")
.and_then(|v| v.as_str()),
Some("ua-42")
);
let back = serde_json::to_value(&mdn).unwrap();
assert_eq!(back["acmeCorpClientTrace"], "ua-42");
}
#[test]
fn mdn_send_request_preserves_vendor_extras() {
let raw = serde_json::json!({
"accountId": "a1",
"identityId": "i1",
"send": {},
"acmeCorpRequestTag": "batch-7"
});
let req: MdnSendRequest = serde_json::from_value(raw).unwrap();
assert_eq!(
req.extra.get("acmeCorpRequestTag").and_then(|v| v.as_str()),
Some("batch-7")
);
let back = serde_json::to_value(&req).unwrap();
assert_eq!(back["acmeCorpRequestTag"], "batch-7");
}
#[test]
fn mdn_send_response_preserves_vendor_extras() {
let raw = serde_json::json!({
"accountId": "a1",
"acmeCorpServerTrace": "node-3"
});
let resp: MdnSendResponse = serde_json::from_value(raw).unwrap();
assert_eq!(
resp.extra
.get("acmeCorpServerTrace")
.and_then(|v| v.as_str()),
Some("node-3")
);
let back = serde_json::to_value(&resp).unwrap();
assert_eq!(back["acmeCorpServerTrace"], "node-3");
}
#[test]
fn mdn_parse_request_preserves_vendor_extras() {
let raw = serde_json::json!({
"accountId": "a1",
"blobIds": ["b1"],
"acmeCorpParseHint": "lenient"
});
let req: MdnParseRequest = serde_json::from_value(raw).unwrap();
assert_eq!(
req.extra.get("acmeCorpParseHint").and_then(|v| v.as_str()),
Some("lenient")
);
let back = serde_json::to_value(&req).unwrap();
assert_eq!(back["acmeCorpParseHint"], "lenient");
}
#[test]
fn mdn_parse_response_preserves_vendor_extras() {
let raw = serde_json::json!({
"accountId": "a1",
"acmeCorpStatus": "complete"
});
let resp: MdnParseResponse = serde_json::from_value(raw).unwrap();
assert_eq!(
resp.extra.get("acmeCorpStatus").and_then(|v| v.as_str()),
Some("complete")
);
let back = serde_json::to_value(&resp).unwrap();
assert_eq!(back["acmeCorpStatus"], "complete");
}
}