use serde::{Deserialize, Serialize};
use crate::Id;
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SetError {
#[serde(rename = "type")]
pub error_type: SetErrorType,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub properties: Option<Vec<String>>,
#[serde(rename = "existingId", skip_serializing_if = "Option::is_none")]
pub existing_id: Option<Id>,
#[serde(rename = "maxRecipients", skip_serializing_if = "Option::is_none")]
pub max_recipients: Option<u64>,
#[serde(rename = "invalidRecipients", skip_serializing_if = "Option::is_none")]
pub invalid_recipients: Option<Vec<String>>,
#[serde(rename = "notFound", skip_serializing_if = "Option::is_none")]
pub not_found: Option<Vec<Id>>,
#[serde(rename = "maxSize", skip_serializing_if = "Option::is_none")]
pub max_size: Option<u64>,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
impl SetError {
pub fn new(error_type: impl Into<SetErrorType>) -> Self {
Self {
error_type: error_type.into(),
description: None,
properties: None,
existing_id: None,
max_recipients: None,
invalid_recipients: None,
not_found: None,
max_size: None,
extra: serde_json::Map::new(),
}
}
pub fn with_description(mut self, desc: impl Into<String>) -> Self {
self.description = Some(desc.into());
self
}
pub fn with_properties<I, S>(mut self, props: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.properties = Some(props.into_iter().map(|s| s.into()).collect());
self
}
pub fn with_existing_id(mut self, id: Id) -> Self {
self.existing_id = Some(id);
self
}
pub fn with_max_recipients(mut self, n: u64) -> Self {
self.max_recipients = Some(n);
self
}
pub fn with_invalid_recipients<I, S>(mut self, addrs: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.invalid_recipients = Some(addrs.into_iter().map(|s| s.into()).collect());
self
}
pub fn with_not_found(mut self, ids: Vec<Id>) -> Self {
self.not_found = Some(ids);
self
}
pub fn with_max_size(mut self, n: u64) -> Self {
self.max_size = Some(n);
self
}
pub fn with_extra(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
let key: String = key.into();
debug_assert!(
!RESERVED_SET_ERROR_WIRE_NAMES.contains(&key.as_str()),
"SetError::with_extra called with reserved wire-name key {key:?} \
— would produce a malformed JSON SetError on the wire. \
Choose an extension-namespace key that does not collide \
with the typed-field wire names \
({RESERVED_SET_ERROR_WIRE_NAMES:?})."
);
self.extra.insert(key, value);
self
}
pub fn validate_extras(&self) -> Result<(), ReservedExtrasKey> {
for key in self.extra.keys() {
if RESERVED_SET_ERROR_WIRE_NAMES.contains(&key.as_str()) {
return Err(ReservedExtrasKey { key: key.clone() });
}
}
Ok(())
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReservedExtrasKey {
pub key: String,
}
impl std::fmt::Display for ReservedExtrasKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"SetError.extra contains reserved wire-name key {:?} — would \
produce a malformed JSON SetError on the wire",
self.key
)
}
}
impl std::error::Error for ReservedExtrasKey {}
pub(crate) const RESERVED_SET_ERROR_WIRE_NAMES: &[&str] = &[
"type",
"description",
"properties",
"existingId",
"maxRecipients",
"invalidRecipients",
"notFound",
"maxSize",
];
impl std::fmt::Display for SetError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.error_type)?;
if let Some(ref desc) = self.description {
write!(f, ": {desc}")?;
}
Ok(())
}
}
impl From<&SetError> for serde_json::Value {
fn from(e: &SetError) -> Self {
fn strings(v: &[impl AsRef<str>]) -> serde_json::Value {
serde_json::Value::Array(
v.iter()
.map(|s| serde_json::Value::String(s.as_ref().to_owned()))
.collect(),
)
}
let mut m = serde_json::Map::new();
m.insert("type".into(), e.error_type.as_str().into());
if let Some(d) = &e.description {
m.insert("description".into(), d.as_str().into());
}
if let Some(p) = &e.properties {
m.insert("properties".into(), strings(p));
}
if let Some(id) = &e.existing_id {
m.insert("existingId".into(), id.as_ref().into());
}
if let Some(n) = e.max_recipients {
m.insert("maxRecipients".into(), n.into());
}
if let Some(r) = &e.invalid_recipients {
m.insert("invalidRecipients".into(), strings(r));
}
if let Some(ids) = &e.not_found {
m.insert("notFound".into(), strings(ids));
}
if let Some(n) = e.max_size {
m.insert("maxSize".into(), n.into());
}
m.extend(e.extra.iter().map(|(k, v)| (k.clone(), v.clone())));
serde_json::Value::Object(m)
}
}
impl From<SetError> for serde_json::Value {
fn from(e: SetError) -> Self {
Self::from(&e)
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum SetErrorType {
Forbidden,
OverQuota,
TooLarge,
RateLimit,
NotFound,
InvalidPatch,
WillDestroy,
InvalidProperties,
Singleton,
AlreadyExists,
MailboxHasChild,
MailboxHasEmail,
TooManyKeywords,
TooManyMailboxes,
BlobNotFound,
ForbiddenFrom,
InvalidEmail,
TooManyRecipients,
NoRecipients,
InvalidRecipients,
ForbiddenMailFrom,
ForbiddenToSend,
CannotUnsend,
Other(String),
}
crate::impl_string_enum!(
SetErrorType,
"a JMAP SetError type string",
"forbidden" => Forbidden,
"overQuota" => OverQuota,
"tooLarge" => TooLarge,
"rateLimit" => RateLimit,
"notFound" => NotFound,
"invalidPatch" => InvalidPatch,
"willDestroy" => WillDestroy,
"invalidProperties" => InvalidProperties,
"singleton" => Singleton,
"alreadyExists" => AlreadyExists,
"mailboxHasChild" => MailboxHasChild,
"mailboxHasEmail" => MailboxHasEmail,
"tooManyKeywords" => TooManyKeywords,
"tooManyMailboxes" => TooManyMailboxes,
"blobNotFound" => BlobNotFound,
"forbiddenFrom" => ForbiddenFrom,
"invalidEmail" => InvalidEmail,
"tooManyRecipients" => TooManyRecipients,
"noRecipients" => NoRecipients,
"invalidRecipients" => InvalidRecipients,
"forbiddenMailFrom" => ForbiddenMailFrom,
"forbiddenToSend" => ForbiddenToSend,
"cannotUnsend" => CannotUnsend,
);
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_str_canonicalises_known_wire_names_to_typed_variants() {
let cases: &[(&str, SetErrorType)] = &[
("forbidden", SetErrorType::Forbidden),
("overQuota", SetErrorType::OverQuota),
("invalidPatch", SetErrorType::InvalidPatch),
("mailboxHasChild", SetErrorType::MailboxHasChild),
("tooManyRecipients", SetErrorType::TooManyRecipients),
("cannotUnsend", SetErrorType::CannotUnsend),
];
for (name, expected) in cases {
let from_str = SetErrorType::from(*name);
assert_eq!(
&from_str, expected,
"from({name:?}) must canonicalise to the typed variant, not Other"
);
assert!(
!matches!(from_str, SetErrorType::Other(_)),
"from({name:?}) must NOT remain Other — known wire-name asymmetry"
);
}
let unknown = SetErrorType::from("mdnAlreadySent");
assert!(
matches!(unknown, SetErrorType::Other(ref s) if s == "mdnAlreadySent"),
"from('mdnAlreadySent') must remain Other (not a known wire-name)"
);
}
#[test]
fn set_error_type_other_round_trips_as_bare_string() {
let original = SetErrorType::from("mdnAlreadySent");
let serialized = serde_json::to_string(&original).expect("serialize");
assert_eq!(
serialized, r#""mdnAlreadySent""#,
"Other must serialize as bare string"
);
let deserialized: SetErrorType = serde_json::from_str(&serialized).expect("deserialize");
assert_eq!(
deserialized, original,
"Other must deserialize back to Other"
);
}
#[test]
fn set_error_extra_field_round_trips() {
let original = SetError::new(SetErrorType::from("rateLimited"))
.with_description("Slow mode is active")
.with_extra(
"serverRetryAfter",
serde_json::Value::String("2025-12-31T23:59:59Z".to_owned()),
);
let wire = serde_json::to_value(&original).expect("serialize");
assert_eq!(wire["type"], "rateLimited");
assert_eq!(wire["description"], "Slow mode is active");
assert_eq!(
wire["serverRetryAfter"], "2025-12-31T23:59:59Z",
"extra field must flatten into the SetError wire shape"
);
let round: SetError = serde_json::from_value(wire).expect("deserialize");
assert_eq!(round.error_type, original.error_type);
assert_eq!(round.description, original.description);
assert_eq!(
round.extra.get("serverRetryAfter").and_then(|v| v.as_str()),
Some("2025-12-31T23:59:59Z"),
"extra field must survive deserialize"
);
}
#[test]
fn set_error_empty_extra_is_invisible_on_the_wire() {
let err = SetError::new(SetErrorType::Forbidden);
let wire = serde_json::to_value(&err).expect("serialize");
let obj = wire.as_object().expect("object");
assert!(
!obj.contains_key("extra"),
"empty `extra` map must not appear on the wire (got {wire})"
);
assert_eq!(
obj.len(),
1,
"bare SetError must have exactly one key on the wire"
);
assert_eq!(obj["type"], "forbidden");
}
#[test]
fn set_error_unknown_field_lands_in_extra() {
let wire = serde_json::json!({
"type": "forbidden",
"futureSpecField": "future-value",
"anotherOne": 42
});
let err: SetError = serde_json::from_value(wire).expect("deserialize");
assert_eq!(err.error_type, SetErrorType::Forbidden);
assert_eq!(
err.extra.get("futureSpecField").and_then(|v| v.as_str()),
Some("future-value")
);
assert_eq!(
err.extra.get("anotherOne").and_then(|v| v.as_u64()),
Some(42)
);
}
#[test]
#[cfg(debug_assertions)]
fn with_extra_panics_on_reserved_wire_name() {
for &reserved in RESERVED_SET_ERROR_WIRE_NAMES {
let reserved_owned = reserved.to_owned();
let result = std::panic::catch_unwind(move || {
SetError::new(SetErrorType::Forbidden)
.with_extra(&reserved_owned, serde_json::Value::Null);
});
assert!(
result.is_err(),
"with_extra({reserved:?}, ...) must panic in debug builds; \
reserved wire-names collide with typed fields and would \
produce a malformed SetError on the wire"
);
}
}
#[test]
fn validate_extras_detects_reserved_key_planted_via_direct_mutation() {
for &reserved in RESERVED_SET_ERROR_WIRE_NAMES {
let mut err = SetError::new(SetErrorType::Forbidden);
err.extra
.insert(reserved.to_owned(), serde_json::Value::Null);
let collision = err
.validate_extras()
.expect_err("reserved-name extras key must be detected");
assert_eq!(
collision.key, reserved,
"validate_extras must report the colliding key verbatim"
);
}
}
#[test]
fn validate_extras_accepts_extension_namespace_keys() {
let mut err = SetError::new(SetErrorType::from("rateLimited"));
err.extra.insert(
"serverRetryAfter".to_owned(),
serde_json::Value::String("2025-12-31T23:59:59Z".to_owned()),
);
err.extra
.insert("retryAttempt".to_owned(), serde_json::Value::from(3));
err.validate_extras()
.expect("extension-namespace keys must pass validation");
}
#[test]
fn with_extra_accepts_extension_namespace_key() {
let err = SetError::new(SetErrorType::from("rateLimited")).with_extra(
"serverRetryAfter",
serde_json::Value::String("2025-12-31T23:59:59Z".to_owned()),
);
assert_eq!(
err.extra.get("serverRetryAfter").and_then(|v| v.as_str()),
Some("2025-12-31T23:59:59Z"),
"extension-namespace key must land in the extra map"
);
}
#[test]
fn reserved_set_error_wire_names_matches_serialized_surface() {
let err = SetError::new(SetErrorType::Forbidden)
.with_description("desc")
.with_properties(["p1"])
.with_existing_id(Id::from("eid"))
.with_max_recipients(10)
.with_invalid_recipients(["bad@example"])
.with_not_found(vec![Id::from("nfid")])
.with_max_size(1024);
let wire = serde_json::to_value(&err).expect("serialize");
let obj = wire.as_object().expect("SetError must serialize as object");
for key in obj.keys() {
assert!(
RESERVED_SET_ERROR_WIRE_NAMES.contains(&key.as_str()),
"wire-name {key:?} appears on the SetError surface but is \
not in RESERVED_SET_ERROR_WIRE_NAMES — adding a typed \
field to SetError requires extending the constant"
);
}
}
#[test]
fn set_error_type_all_known_variants_round_trip() {
let cases: &[(SetErrorType, &str)] = &[
(SetErrorType::Forbidden, "forbidden"),
(SetErrorType::OverQuota, "overQuota"),
(SetErrorType::TooLarge, "tooLarge"),
(SetErrorType::RateLimit, "rateLimit"),
(SetErrorType::NotFound, "notFound"),
(SetErrorType::InvalidPatch, "invalidPatch"),
(SetErrorType::WillDestroy, "willDestroy"),
(SetErrorType::InvalidProperties, "invalidProperties"),
(SetErrorType::Singleton, "singleton"),
(SetErrorType::AlreadyExists, "alreadyExists"),
(SetErrorType::MailboxHasChild, "mailboxHasChild"),
(SetErrorType::MailboxHasEmail, "mailboxHasEmail"),
(SetErrorType::TooManyKeywords, "tooManyKeywords"),
(SetErrorType::TooManyMailboxes, "tooManyMailboxes"),
(SetErrorType::BlobNotFound, "blobNotFound"),
(SetErrorType::ForbiddenFrom, "forbiddenFrom"),
(SetErrorType::InvalidEmail, "invalidEmail"),
(SetErrorType::TooManyRecipients, "tooManyRecipients"),
(SetErrorType::NoRecipients, "noRecipients"),
(SetErrorType::InvalidRecipients, "invalidRecipients"),
(SetErrorType::ForbiddenMailFrom, "forbiddenMailFrom"),
(SetErrorType::ForbiddenToSend, "forbiddenToSend"),
(SetErrorType::CannotUnsend, "cannotUnsend"),
];
for (variant, expected_wire) in cases {
assert_eq!(
variant.to_string(),
*expected_wire,
"Display arm for {variant:?} produced wrong wire string"
);
let serialized = serde_json::to_string(variant).expect("serialize");
assert_eq!(
serialized,
format!("\"{expected_wire}\""),
"Serialize for {variant:?} did not produce \"{expected_wire}\""
);
let deserialized: SetErrorType =
serde_json::from_str(&serialized).expect("deserialize");
assert_eq!(
&deserialized, variant,
"Deserialize of {expected_wire:?} did not rebuild {variant:?} \
(likely fell through to Other — Display and Deserialize \
match arms have drifted)"
);
assert!(
!matches!(deserialized, SetErrorType::Other(_)),
"Deserialize of {expected_wire:?} fell through to Other; \
Display has an arm but Deserialize visitor doesn't"
);
}
}
#[test]
fn value_from_set_error_matches_rfc8620_invalid_properties() {
let err = SetError::new(SetErrorType::InvalidProperties)
.with_properties(["name", "parentId"])
.with_description("bad");
let expected = serde_json::json!({
"type": "invalidProperties",
"description": "bad",
"properties": ["name", "parentId"]
});
assert_eq!(serde_json::Value::from(&err), expected);
assert_eq!(serde_json::Value::from(err), expected);
}
#[test]
fn value_from_set_error_matches_rfc8621_fields_and_extras() {
let err = SetError::new("rateLimited")
.with_existing_id(Id::from("obj-7"))
.with_max_recipients(50)
.with_invalid_recipients(["bad@", "no@no"])
.with_not_found(vec![Id::from("blob-1"), Id::from("blob-2")])
.with_max_size(10_485_760)
.with_extra(
"serverRetryAfter",
serde_json::json!("2025-12-31T23:59:59Z"),
);
let expected = serde_json::json!({
"type": "rateLimited",
"existingId": "obj-7",
"maxRecipients": 50,
"invalidRecipients": ["bad@", "no@no"],
"notFound": ["blob-1", "blob-2"],
"maxSize": 10485760,
"serverRetryAfter": "2025-12-31T23:59:59Z"
});
assert_eq!(serde_json::Value::from(&err), expected);
}
#[test]
fn value_from_set_error_agrees_with_serde_derive() {
let full = SetError::new(SetErrorType::TooManyRecipients)
.with_description("too many")
.with_properties(["to"])
.with_existing_id(Id::from("e1"))
.with_max_recipients(3)
.with_invalid_recipients(["x@"])
.with_not_found(vec![Id::from("b1")])
.with_max_size(1)
.with_extra("x-ext", serde_json::json!({"k": [1, 2]}));
for err in [SetError::new(SetErrorType::Forbidden), full] {
assert_eq!(
serde_json::Value::from(&err),
serde_json::to_value(&err).expect("serialize"),
"conversion drifted from derive(Serialize) for {err:?}"
);
}
}
}