Initial commit

This commit is contained in:
Maurus Decimus
2026-05-29 18:02:15 +02:00
commit 576073f8c9
263 changed files with 78638 additions and 0 deletions
+685
View File
@@ -0,0 +1,685 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use indexmap::IndexSet;
use serde::de::DeserializeOwned;
use serde_json::{Map, Value, json};
use crate::jmap::error::JmapError;
use crate::jmap::http::HttpClient;
use crate::jmap::session::Limits;
use crate::jmap::wire::JmapId;
pub const URN_CORE: &str = "urn:ietf:params:jmap:core";
pub fn using_urn(method: &str) -> &'static str {
let prefix = method.split('/').next().unwrap_or(method);
match prefix {
"Mailbox" | "Email" | "Identity" => "urn:ietf:params:jmap:mail",
"SieveScript" => "urn:ietf:params:jmap:sieve",
"AddressBook" | "ContactCard" => "urn:ietf:params:jmap:contacts",
"Calendar" | "CalendarEvent" | "ParticipantIdentity" => "urn:ietf:params:jmap:calendars",
"FileNode" => "urn:ietf:params:jmap:filenode",
"Principal" => "urn:ietf:params:jmap:principals",
"x:Account" | "x:Domain" => "urn:stalwart:jmap",
_ => URN_CORE,
}
}
#[derive(Debug, Clone)]
pub struct MethodCall {
pub name: String,
pub args: Value,
pub call_id: String,
}
#[derive(Debug, Clone, Default)]
pub struct Request {
calls: Vec<MethodCall>,
}
impl Request {
pub fn new() -> Request {
Request { calls: Vec::new() }
}
pub fn call(
&mut self,
name: impl Into<String>,
args: Value,
call_id: impl Into<String>,
) -> &mut Request {
self.calls.push(MethodCall {
name: name.into(),
args,
call_id: call_id.into(),
});
self
}
pub fn len(&self) -> usize {
self.calls.len()
}
pub fn is_empty(&self) -> bool {
self.calls.is_empty()
}
pub fn using(&self) -> Vec<String> {
let mut set: IndexSet<String> = IndexSet::new();
set.insert(URN_CORE.to_owned());
for c in &self.calls {
set.insert(using_urn(&c.name).to_owned());
}
set.into_iter().collect()
}
fn envelope(&self) -> Value {
let method_calls: Vec<Value> = self
.calls
.iter()
.map(|c| json!([c.name, c.args, c.call_id]))
.collect();
json!({ "using": self.using(), "methodCalls": method_calls })
}
pub fn fits(&self, limits: &Limits) -> Result<(), JmapError> {
if self.calls.len() as u64 > limits.max_calls_in_request {
return Err(JmapError::RequestTooLarge);
}
let size = serde_json::to_vec(&self.envelope())?.len() as u64;
if size > limits.max_size_request {
return Err(JmapError::RequestTooLarge);
}
Ok(())
}
pub fn send(&self, client: &HttpClient, api_url: &str) -> Result<Response, JmapError> {
let value = client.post_json(api_url, &self.envelope())?;
Response::parse(value)
}
}
#[derive(Debug)]
pub struct Response {
pub method_responses: Vec<MethodCall>,
}
impl Response {
fn parse(value: Value) -> Result<Response, JmapError> {
let arr = value
.get("methodResponses")
.and_then(Value::as_array)
.ok_or_else(|| JmapError::malformed("response has no methodResponses array"))?;
let mut out = Vec::with_capacity(arr.len());
for entry in arr {
let triple = entry.as_array().filter(|t| t.len() == 3).ok_or_else(|| {
JmapError::malformed("methodResponse is not a [name,args,id] triple")
})?;
let name = triple[0]
.as_str()
.ok_or_else(|| JmapError::malformed("methodResponse name is not a string"))?
.to_owned();
let call_id = triple[2]
.as_str()
.ok_or_else(|| JmapError::malformed("methodResponse callId is not a string"))?
.to_owned();
out.push(MethodCall {
name,
args: triple[1].clone(),
call_id,
});
}
Ok(Response {
method_responses: out,
})
}
pub fn first(&self) -> Result<&MethodCall, JmapError> {
self.method_responses
.first()
.ok_or_else(|| JmapError::malformed("empty methodResponses"))
}
pub fn by_call_id(&self, call_id: &str) -> Result<&MethodCall, JmapError> {
self.method_responses
.iter()
.find(|m| m.call_id == call_id)
.ok_or_else(|| JmapError::malformed(format!("no response for callId {call_id}")))
}
}
pub fn check_method_error(mr: &MethodCall) -> Result<(), JmapError> {
if mr.name != "error" {
return Ok(());
}
let error_type = mr
.args
.get("type")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_owned();
if error_type == "anchorNotFound" {
return Err(JmapError::AnchorNotFound);
}
let description = mr
.args
.get("description")
.and_then(Value::as_str)
.map(str::to_owned);
Err(JmapError::Method {
call_id: mr.call_id.clone(),
error_type,
description,
})
}
fn ids_array(ids: &[JmapId]) -> Value {
Value::Array(ids.iter().map(|i| Value::String(i.0.clone())).collect())
}
pub fn query_all_ids(
client: &HttpClient,
api_url: &str,
account_id: &str,
type_name: &str,
limits: &Limits,
) -> Result<Vec<JmapId>, JmapError> {
let mut restarts = 0u32;
loop {
match query_pages(client, api_url, account_id, type_name, limits) {
Ok(ids) => return Ok(ids),
Err(JmapError::AnchorNotFound) if restarts < 2 => {
restarts += 1;
}
Err(e) => return Err(e),
}
}
}
fn query_pages(
client: &HttpClient,
api_url: &str,
account_id: &str,
type_name: &str,
limits: &Limits,
) -> Result<Vec<JmapId>, JmapError> {
let limit = limits.max_objects_in_get.max(1);
let mut collected: Vec<JmapId> = Vec::new();
let mut page_size: Option<usize> = None;
let mut anchor: Option<String> = None;
loop {
let mut args = Map::new();
args.insert("accountId".to_owned(), Value::String(account_id.to_owned()));
args.insert("limit".to_owned(), Value::from(limit));
if let Some(a) = &anchor {
args.insert("anchor".to_owned(), Value::String(a.clone()));
args.insert("anchorOffset".to_owned(), Value::from(1));
}
let mut req = Request::new();
req.call(format!("{type_name}/query"), Value::Object(args), "q");
let resp = req.send(client, api_url)?;
let mr = resp.first()?;
check_method_error(mr)?;
let ids = mr
.args
.get("ids")
.and_then(Value::as_array)
.ok_or_else(|| JmapError::malformed("query response has no ids array"))?;
let this_len = ids.len();
for v in ids {
let s = v
.as_str()
.ok_or_else(|| JmapError::malformed("query id is not a string"))?;
collected.push(JmapId(s.to_owned()));
}
match page_size {
None => {
if this_len == 0 {
break;
}
page_size = Some(this_len);
}
Some(full) => {
if this_len == 0 || this_len < full {
break;
}
}
}
let last = collected
.last()
.map(|i| i.0.clone())
.ok_or_else(|| JmapError::malformed("non-empty page yielded no anchor"))?;
anchor = Some(last);
}
Ok(collected)
}
#[derive(Debug)]
pub struct GetResult<T> {
pub list: Vec<T>,
pub not_found: Vec<JmapId>,
}
impl<T> Default for GetResult<T> {
fn default() -> Self {
GetResult {
list: Vec::new(),
not_found: Vec::new(),
}
}
}
struct GetCtx<'a> {
client: &'a HttpClient,
api_url: &'a str,
account_id: &'a str,
type_name: &'a str,
properties: Option<&'a [&'a str]>,
limits: &'a Limits,
}
pub fn get_objects<T: DeserializeOwned>(
client: &HttpClient,
api_url: &str,
account_id: &str,
type_name: &str,
ids: &[JmapId],
properties: Option<&[&str]>,
limits: &Limits,
) -> Result<GetResult<T>, JmapError> {
let ctx = GetCtx {
client,
api_url,
account_id,
type_name,
properties,
limits,
};
let mut out = GetResult::default();
let chunk = limits.max_objects_in_get.max(1) as usize;
let mut start = 0;
while start < ids.len() {
let end = (start + chunk).min(ids.len());
get_chunk(&ctx, &ids[start..end], &mut out)?;
start = end;
}
Ok(out)
}
fn get_chunk<T: DeserializeOwned>(
ctx: &GetCtx<'_>,
ids: &[JmapId],
out: &mut GetResult<T>,
) -> Result<(), JmapError> {
let mut args = Map::new();
args.insert(
"accountId".to_owned(),
Value::String(ctx.account_id.to_owned()),
);
args.insert("ids".to_owned(), ids_array(ids));
if let Some(props) = ctx.properties {
let mut p: Vec<Value> = props
.iter()
.map(|s| Value::String((*s).to_owned()))
.collect();
p.push(Value::String("id".to_owned()));
args.insert("properties".to_owned(), Value::Array(p));
}
let mut req = Request::new();
req.call(format!("{}/get", ctx.type_name), Value::Object(args), "g");
let send_result: Result<(), JmapError> = if req.fits(ctx.limits).is_err() {
Err(JmapError::RequestTooLarge)
} else {
match req.send(ctx.client, ctx.api_url) {
Ok(resp) => {
let mr = resp.first()?;
check_method_error(mr)?;
decode_get(mr, out)
}
Err(e) => Err(e),
}
};
match send_result {
Ok(()) => Ok(()),
Err(JmapError::RequestTooLarge) => {
if ids.len() <= 1 {
return Err(JmapError::SingleObjectTooLarge(format!(
"{}/get of a single id exceeds maxSizeRequest",
ctx.type_name
)));
}
let mid = ids.len() / 2;
get_chunk(ctx, &ids[..mid], out)?;
get_chunk(ctx, &ids[mid..], out)
}
Err(e) => Err(e),
}
}
pub fn get_all<T: DeserializeOwned>(
client: &HttpClient,
api_url: &str,
account_id: &str,
type_name: &str,
) -> Result<GetResult<T>, JmapError> {
let mut args = Map::new();
args.insert("accountId".to_owned(), Value::String(account_id.to_owned()));
args.insert("ids".to_owned(), Value::Null);
let mut req = Request::new();
req.call(format!("{type_name}/get"), Value::Object(args), "g");
let resp = req.send(client, api_url)?;
let mr = resp.first()?;
check_method_error(mr)?;
let mut out = GetResult::default();
decode_get(mr, &mut out)?;
Ok(out)
}
fn decode_get<T: DeserializeOwned>(
mr: &MethodCall,
out: &mut GetResult<T>,
) -> Result<(), JmapError> {
let list = mr
.args
.get("list")
.and_then(Value::as_array)
.ok_or_else(|| JmapError::malformed("get response has no list array"))?;
for item in list {
out.list.push(serde_json::from_value(item.clone())?);
}
if let Some(nf) = mr.args.get("notFound").and_then(Value::as_array) {
for v in nf {
if let Some(s) = v.as_str() {
out.not_found.push(JmapId(s.to_owned()));
}
}
}
Ok(())
}
#[derive(Debug, Default)]
pub struct SetOutcome {
pub created: Vec<(String, Value)>,
pub updated: Vec<String>,
pub destroyed: Vec<String>,
pub not_created: Vec<(String, Value)>,
pub not_updated: Vec<(String, Value)>,
pub not_destroyed: Vec<(String, Value)>,
}
impl SetOutcome {
fn absorb(&mut self, mut other: SetOutcome) {
self.created.append(&mut other.created);
self.updated.append(&mut other.updated);
self.destroyed.append(&mut other.destroyed);
self.not_created.append(&mut other.not_created);
self.not_updated.append(&mut other.not_updated);
self.not_destroyed.append(&mut other.not_destroyed);
}
}
#[derive(Debug, Default)]
pub struct SetRequest<'a> {
pub create: Option<Value>,
pub update: Option<Value>,
pub destroy: Option<Value>,
pub extra_args: &'a [(&'a str, Value)],
}
#[derive(Debug, Clone, Copy)]
enum Section {
Create,
Update,
Destroy,
}
fn collect_items(value: Option<&Value>, section: Section) -> Vec<(Section, String, Value)> {
match (section, value) {
(Section::Destroy, Some(Value::Array(ids))) => ids
.iter()
.filter_map(|v| v.as_str().map(|s| (section, s.to_owned(), Value::Null)))
.collect(),
(_, Some(Value::Object(m))) => m
.iter()
.map(|(k, v)| (section, k.clone(), v.clone()))
.collect(),
_ => Vec::new(),
}
}
pub fn set_call(
client: &HttpClient,
api_url: &str,
account_id: &str,
type_name: &str,
body: SetRequest<'_>,
limits: &Limits,
) -> Result<SetOutcome, JmapError> {
let mut items = collect_items(body.create.as_ref(), Section::Create);
items.extend(collect_items(body.update.as_ref(), Section::Update));
items.extend(collect_items(body.destroy.as_ref(), Section::Destroy));
if items.is_empty() {
return set_send(
client,
api_url,
account_id,
type_name,
&[],
body.extra_args,
limits,
);
}
let chunk = limits.max_objects_in_set.max(1) as usize;
let mut outcome = SetOutcome::default();
let mut start = 0;
while start < items.len() {
let end = (start + chunk).min(items.len());
let part = set_send(
client,
api_url,
account_id,
type_name,
&items[start..end],
body.extra_args,
limits,
)?;
outcome.absorb(part);
start = end;
}
Ok(outcome)
}
fn set_send(
client: &HttpClient,
api_url: &str,
account_id: &str,
type_name: &str,
items: &[(Section, String, Value)],
extra_args: &[(&str, Value)],
limits: &Limits,
) -> Result<SetOutcome, JmapError> {
let mut args = Map::new();
args.insert("accountId".to_owned(), Value::String(account_id.to_owned()));
let mut create = Map::new();
let mut update = Map::new();
let mut destroy: Vec<Value> = Vec::new();
for (section, key, val) in items {
match section {
Section::Create => {
create.insert(key.clone(), val.clone());
}
Section::Update => {
update.insert(key.clone(), val.clone());
}
Section::Destroy => destroy.push(Value::String(key.clone())),
}
}
if !create.is_empty() {
args.insert("create".to_owned(), Value::Object(create));
}
if !update.is_empty() {
args.insert("update".to_owned(), Value::Object(update));
}
if !destroy.is_empty() {
args.insert("destroy".to_owned(), Value::Array(destroy));
}
for (k, v) in extra_args {
args.insert((*k).to_owned(), v.clone());
}
let mut req = Request::new();
req.call(format!("{type_name}/set"), Value::Object(args), "s");
let oversize = req.fits(limits).is_err();
let send_result = if oversize {
Err(JmapError::RequestTooLarge)
} else {
req.send(client, api_url).and_then(|resp| {
let mr = resp.first()?;
check_method_error(mr)?;
Ok(decode_set(mr))
})
};
match send_result {
Ok(outcome) => Ok(outcome),
Err(JmapError::RequestTooLarge) => {
if items.len() <= 1 {
return Err(JmapError::SingleObjectTooLarge(format!(
"{type_name}/set of a single object exceeds maxSizeRequest"
)));
}
let mid = items.len() / 2;
let mut left = set_send(
client,
api_url,
account_id,
type_name,
&items[..mid],
extra_args,
limits,
)?;
let right = set_send(
client,
api_url,
account_id,
type_name,
&items[mid..],
extra_args,
limits,
)?;
left.absorb(right);
Ok(left)
}
Err(e) => Err(e),
}
}
fn pairs(args: &Value, key: &str) -> Vec<(String, Value)> {
args.get(key)
.and_then(Value::as_object)
.map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
.unwrap_or_default()
}
fn keys(args: &Value, key: &str) -> Vec<String> {
args.get(key)
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default()
}
fn decode_set(mr: &MethodCall) -> SetOutcome {
SetOutcome {
created: pairs(&mr.args, "created"),
updated: mr
.args
.get("updated")
.and_then(Value::as_object)
.map(|m| m.keys().cloned().collect())
.unwrap_or_default(),
destroyed: keys(&mr.args, "destroyed"),
not_created: pairs(&mr.args, "notCreated"),
not_updated: pairs(&mr.args, "notUpdated"),
not_destroyed: pairs(&mr.args, "notDestroyed"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn using_union_includes_core_and_dedups() {
let mut r = Request::new();
r.call("Mailbox/get", json!({}), "a");
r.call("Email/query", json!({}), "b");
let u = r.using();
assert!(u.contains(&URN_CORE.to_owned()));
assert!(u.contains(&"urn:ietf:params:jmap:mail".to_owned()));
assert_eq!(u.iter().filter(|x| x.as_str() == URN_CORE).count(), 1);
}
#[test]
fn using_maps_principal_and_stalwart() {
assert_eq!(
using_urn("Principal/query"),
"urn:ietf:params:jmap:principals"
);
assert_eq!(using_urn("x:Account/set"), "urn:stalwart:jmap");
assert_eq!(
using_urn("CalendarEvent/set"),
"urn:ietf:params:jmap:calendars"
);
}
#[test]
fn fits_rejects_too_many_calls() {
let limits = Limits {
max_objects_in_get: 10,
max_objects_in_set: 10,
max_calls_in_request: 2,
max_concurrent_requests: 4,
max_concurrent_upload: 4,
max_size_request: 10_000_000,
max_size_upload: 10_000_000,
};
let mut r = Request::new();
r.call("Mailbox/get", json!({}), "a");
r.call("Mailbox/get", json!({}), "b");
r.call("Mailbox/get", json!({}), "c");
assert!(matches!(r.fits(&limits), Err(JmapError::RequestTooLarge)));
}
#[test]
fn parse_response_and_method_error() {
let v = json!({
"methodResponses": [
["Mailbox/get", {"list": [], "notFound": []}, "g"],
["error", {"type": "invalidArguments"}, "x"]
]
});
let r = Response::parse(v).unwrap();
assert!(check_method_error(r.by_call_id("g").unwrap()).is_ok());
let err = check_method_error(r.by_call_id("x").unwrap()).unwrap_err();
assert!(matches!(err, JmapError::Method { .. }));
}
#[test]
fn anchor_not_found_is_classified() {
let v = json!({ "methodResponses": [["error", {"type": "anchorNotFound"}, "q"]] });
let r = Response::parse(v).unwrap();
let err = check_method_error(r.first().unwrap()).unwrap_err();
assert!(matches!(err, JmapError::AnchorNotFound));
}
}