pub mod email;
pub mod identity;
pub mod mailbox;
pub mod search_snippet;
pub mod submission;
pub mod thread;
pub mod vacation;
use std::collections::HashMap;
use jmap_types::{Id, State};
pub use jmap_types::{
AddedItem, ChangesResponse, GetResponse, QueryChangesResponse, QueryResponse, SetError,
SetResponse,
};
#[derive(Debug, Default, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EmailGetParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub body_properties: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fetch_text_body_values: Option<bool>,
#[serde(
rename = "fetchHTMLBodyValues",
skip_serializing_if = "Option::is_none"
)]
pub fetch_html_body_values: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fetch_all_body_values: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_body_value_bytes: Option<u64>,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EmailCopyParams {
pub from_account_id: Id,
#[serde(skip_serializing_if = "Option::is_none")]
pub on_success_destroy_original: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub destroy_from_if_in_state: Option<jmap_types::State>,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
#[derive(Debug, Default, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MailboxSetParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub on_destroy_remove_emails: Option<bool>,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
#[derive(Debug, Default, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EmailSubmissionSetParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub on_success_update_email: Option<HashMap<String, jmap_types::PatchObject>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub on_success_destroy_email: Option<Vec<String>>,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EmailImportInput<'a> {
pub blob_id: &'a Id,
#[serde(serialize_with = "ser_mailbox_id_set")]
pub mailbox_ids: &'a [Id],
#[serde(
skip_serializing_if = "Option::is_none",
serialize_with = "ser_opt_keyword_set"
)]
pub keywords: Option<&'a [&'a str]>,
#[serde(skip_serializing_if = "Option::is_none")]
pub received_at: Option<&'a jmap_types::UTCDate>,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
fn ser_mailbox_id_set<S: serde::Serializer>(ids: &&[Id], s: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeMap;
let mut m = s.serialize_map(Some(ids.len()))?;
for id in *ids {
m.serialize_entry(id.as_ref(), &true)?;
}
m.end()
}
fn ser_opt_keyword_set<S: serde::Serializer>(
kws: &Option<&[&str]>,
s: S,
) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeMap;
let kws = kws.expect("skip_serializing_if guarantees Some");
let mut m = s.serialize_map(Some(kws.len()))?;
for k in kws {
m.serialize_entry(k, &true)?;
}
m.end()
}
#[non_exhaustive]
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EmailImportCreated {
pub id: Id,
pub blob_id: Id,
pub thread_id: Id,
pub size: u64,
#[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, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EmailImportResponse {
pub account_id: Id,
#[serde(default)]
pub old_state: Option<State>,
pub new_state: State,
#[serde(default)]
pub created: Option<HashMap<String, EmailImportCreated>>,
#[serde(default)]
pub not_created: Option<HashMap<String, SetError>>,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
#[derive(Debug, Default, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EmailParseParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub properties: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body_properties: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fetch_text_body_values: Option<bool>,
#[serde(
rename = "fetchHTMLBodyValues",
skip_serializing_if = "Option::is_none"
)]
pub fetch_html_body_values: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fetch_all_body_values: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_body_value_bytes: Option<u64>,
#[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, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EmailParseResponse {
pub account_id: Id,
#[serde(default)]
pub parsed: Option<HashMap<Id, jmap_mail_types::Email>>,
#[serde(default)]
pub not_parsable: Option<Vec<Id>>,
#[serde(default)]
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>,
}
pub(crate) const CALL_ID: &str = "r1";
pub(crate) const USING_MAIL: &[&str] =
&["urn:ietf:params:jmap:core", jmap_mail_types::JMAP_MAIL_URI];
pub(crate) const USING_SUBMISSION: &[&str] = &[
"urn:ietf:params:jmap:core",
jmap_mail_types::JMAP_MAIL_URI,
jmap_mail_types::JMAP_SUBMISSION_URI,
];
pub(crate) const USING_VACATION: &[&str] = &[
"urn:ietf:params:jmap:core",
jmap_mail_types::JMAP_VACATIONRESPONSE_URI,
];
pub(crate) fn build_request(
method: &str,
args: serde_json::Value,
using: &[&str],
) -> jmap_types::JmapRequest {
let using_vec: Vec<String> = using.iter().map(|&s| s.to_owned()).collect();
let invocation: jmap_types::Invocation = (method.to_owned(), args, CALL_ID.to_owned());
jmap_types::JmapRequest::new(using_vec, vec![invocation], None)
}
#[non_exhaustive]
#[derive(Clone)]
pub struct SessionClient {
pub(crate) client: jmap_base_client::JmapClient,
pub(crate) session: jmap_base_client::Session,
}
impl std::fmt::Debug for SessionClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SessionClient")
.field("client", &"<JmapClient>")
.field("session", &self.session)
.finish()
}
}
impl SessionClient {
pub fn client(&self) -> &jmap_base_client::JmapClient {
&self.client
}
pub fn session(&self) -> &jmap_base_client::Session {
&self.session
}
pub fn mail_account_id(&self) -> Result<&str, jmap_base_client::ClientError> {
self.session
.primary_account_id(jmap_mail_types::JMAP_MAIL_URI)
.ok_or_else(|| {
jmap_base_client::ClientError::InvalidSession(
"no primary account for urn:ietf:params:jmap:mail".into(),
)
})
}
pub(crate) fn session_parts(&self) -> Result<(&str, &str), jmap_base_client::ClientError> {
let api_url = self.session.api_url.as_str();
let account_id = self
.session
.primary_account_id(jmap_mail_types::JMAP_MAIL_URI)
.ok_or_else(|| {
jmap_base_client::ClientError::InvalidSession(
"no primary account for urn:ietf:params:jmap:mail".into(),
)
})?;
Ok((api_url, account_id))
}
pub(crate) async fn call_internal(
&self,
api_url: &str,
req: &jmap_types::JmapRequest,
) -> Result<jmap_types::JmapResponse, jmap_base_client::ClientError> {
self.client.call(api_url, req).await
}
}
#[allow(dead_code)]
fn _assert_session_client_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<SessionClient>();
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn build_request_method_name_and_call_id() {
let req = build_request(
"Email/get",
json!({"accountId": "acc1", "ids": null}),
USING_MAIL,
);
let v = serde_json::to_value(&req).expect("serialize JmapRequest");
let calls = v["methodCalls"]
.as_array()
.expect("methodCalls must be array");
assert_eq!(calls.len(), 1, "must have exactly 1 method call");
assert_eq!(calls[0][0], json!("Email/get"), "method name must match");
assert_eq!(calls[0][2], json!("r1"), "call_id must be CALL_ID constant");
}
#[test]
fn using_mail_contains_correct_uris() {
let req = build_request("Email/get", json!({}), USING_MAIL);
let v = serde_json::to_value(&req).expect("serialize");
let using = v["using"].as_array().expect("using must be array");
assert_eq!(using.len(), 2);
assert!(
using.contains(&json!("urn:ietf:params:jmap:core")),
"must include jmap:core"
);
assert!(
using.contains(&json!("urn:ietf:params:jmap:mail")),
"must include jmap:mail"
);
}
#[test]
fn using_submission_contains_correct_uris() {
let req = build_request("EmailSubmission/get", json!({}), USING_SUBMISSION);
let v = serde_json::to_value(&req).expect("serialize");
let using = v["using"].as_array().expect("using must be array");
assert_eq!(using.len(), 3);
assert!(
using.contains(&json!("urn:ietf:params:jmap:core")),
"must include jmap:core"
);
assert!(
using.contains(&json!("urn:ietf:params:jmap:mail")),
"must include jmap:mail (EmailSubmission references mail-typed fields)"
);
assert!(
using.contains(&json!("urn:ietf:params:jmap:submission")),
"must include jmap:submission"
);
}
#[test]
fn using_vacation_contains_correct_uris() {
let req = build_request("VacationResponse/get", json!({}), USING_VACATION);
let v = serde_json::to_value(&req).expect("serialize");
let using = v["using"].as_array().expect("using must be array");
assert_eq!(using.len(), 2);
assert!(
using.contains(&json!("urn:ietf:params:jmap:core")),
"must include jmap:core"
);
assert!(
using.contains(&json!("urn:ietf:params:jmap:vacationresponse")),
"must include jmap:vacationresponse"
);
assert!(
!using.contains(&json!("urn:ietf:params:jmap:mail")),
"must NOT include jmap:mail (VacationResponse is a standalone capability)"
);
}
#[test]
fn session_parts_err_no_primary_account() {
let session_json = json!({
"capabilities": {},
"accounts": {},
"primaryAccounts": {},
"username": "user@example.com",
"apiUrl": "https://jmap.example.com/api/",
"downloadUrl": "https://jmap.example.com/dl/{accountId}/{blobId}/{name}?accept={type}",
"uploadUrl": "https://jmap.example.com/ul/{accountId}/",
"eventSourceUrl": "https://jmap.example.com/sse/?types={types}&closeafter={closeafter}&ping={ping}",
"state": "s1"
});
let session: jmap_base_client::Session =
serde_json::from_value(session_json).expect("session must deserialize");
let result = session.primary_account_id("urn:ietf:params:jmap:mail");
assert!(
result.is_none(),
"must return None when mail capability is not in primaryAccounts"
);
}
#[test]
fn get_response_deserializes() {
let json = json!({
"accountId": "acc1",
"state": "s42",
"list": [],
"notFound": ["missing1"]
});
let resp: GetResponse<serde_json::Value> =
serde_json::from_value(json).expect("GetResponse must deserialize");
assert_eq!(resp.account_id, "acc1");
assert_eq!(resp.state, "s42");
assert!(resp.list.is_empty());
assert_eq!(
resp.not_found.as_deref(),
Some(["missing1".into()].as_slice())
);
}
#[test]
fn changes_response_deserializes() {
let json = json!({
"accountId": "acc1",
"oldState": "s10",
"newState": "s11",
"hasMoreChanges": false,
"created": ["id1"],
"updated": ["id2"],
"destroyed": []
});
let resp: ChangesResponse =
serde_json::from_value(json).expect("ChangesResponse must deserialize");
assert_eq!(resp.old_state, "s10");
assert_eq!(resp.new_state, "s11");
assert!(!resp.has_more_changes);
}
#[test]
fn set_response_deserializes() {
let json = json!({
"accountId": "acc1",
"oldState": "s10",
"newState": "s11",
"created": null,
"updated": null,
"destroyed": ["id1"],
"notCreated": null,
"notUpdated": null,
"notDestroyed": null
});
let resp: SetResponse = serde_json::from_value(json).expect("SetResponse must deserialize");
assert_eq!(resp.new_state, "s11");
assert_eq!(resp.destroyed.as_deref(), Some(["id1".into()].as_slice()));
}
#[test]
fn set_response_updated_accepts_null_values() {
let json = json!({
"accountId": "acc1",
"oldState": "s1",
"newState": "s2",
"updated": {
"M1": null,
"M2": null
}
});
let resp: SetResponse<jmap_mail_types::Email> = serde_json::from_value(json)
.expect("SetResponse must accept Id[Foo|null] per RFC 8620 §5.3");
let updated = resp.updated.expect("updated must be Some");
assert_eq!(updated.len(), 2, "two ids in updated map");
assert!(
updated
.get(&Id::from("M1"))
.expect("M1 key present")
.is_none(),
"M1 value must be None (null)"
);
assert!(
updated
.get(&Id::from("M2"))
.expect("M2 key present")
.is_none(),
"M2 value must be None (null)"
);
}
#[test]
fn set_response_updated_accepts_object_values() {
let json = json!({
"accountId": "acc1",
"oldState": "s1",
"newState": "s2",
"updated": {
"M1": { "id": "M1", "subject": "Hello" }
}
});
let resp: SetResponse<serde_json::Value> = serde_json::from_value(json)
.expect("SetResponse must accept Id[Foo] per RFC 8620 §5.3");
let updated = resp.updated.expect("updated must be Some");
let m1 = updated
.get(&Id::from("M1"))
.expect("M1 key present")
.as_ref()
.expect("M1 value must be Some when server reports deltas");
assert_eq!(m1["subject"], json!("Hello"));
}
#[test]
fn query_changes_response_deserializes() {
let json = json!({
"accountId": "acc1",
"oldQueryState": "qs1",
"newQueryState": "qs2",
"total": 5,
"removed": ["id3"],
"added": [{"id": "id4", "index": 0}]
});
let resp: QueryChangesResponse =
serde_json::from_value(json).expect("QueryChangesResponse must deserialize");
assert_eq!(resp.old_query_state, "qs1");
assert_eq!(resp.new_query_state, "qs2");
assert_eq!(resp.total, Some(5));
assert_eq!(resp.removed.len(), 1);
assert_eq!(resp.added.len(), 1);
assert_eq!(resp.added[0].index, 0);
}
#[test]
fn email_get_params_default_serializes_to_empty_object() {
let params = EmailGetParams::default();
let v = serde_json::to_value(¶ms).expect("serialize EmailGetParams");
assert_eq!(v, serde_json::json!({}), "default must serialize to {{}}");
}
#[test]
fn email_get_params_all_fields_serializes_correctly() {
let params = EmailGetParams {
body_properties: Some(vec!["partId".into(), "type".into()]),
fetch_text_body_values: Some(true),
fetch_html_body_values: Some(false),
fetch_all_body_values: Some(true),
max_body_value_bytes: Some(1024),
extra: serde_json::Map::new(),
};
let v = serde_json::to_value(¶ms).expect("serialize");
assert_eq!(
v["bodyProperties"],
json!(["partId", "type"]),
"bodyProperties"
);
assert_eq!(v["fetchTextBodyValues"], json!(true));
assert_eq!(v["fetchHTMLBodyValues"], json!(false));
assert!(
v.get("fetchHtmlBodyValues").is_none(),
"must NOT emit the lowercase-html wire key (RFC 8621 §4.2)"
);
assert_eq!(v["fetchAllBodyValues"], json!(true));
assert_eq!(v["maxBodyValueBytes"], json!(1024_u64));
}
#[test]
fn email_copy_params_serializes_correctly() {
let params = EmailCopyParams {
from_account_id: "acct-src".into(),
on_success_destroy_original: Some(true),
destroy_from_if_in_state: Some("s99".into()),
extra: serde_json::Map::new(),
};
let v = serde_json::to_value(¶ms).expect("serialize");
assert_eq!(v["fromAccountId"], json!("acct-src"));
assert_eq!(v["onSuccessDestroyOriginal"], json!(true));
assert_eq!(v["destroyFromIfInState"], json!("s99"));
}
#[test]
fn email_copy_params_omits_none_fields() {
let params = EmailCopyParams {
from_account_id: "acct-src".into(),
on_success_destroy_original: None,
destroy_from_if_in_state: None,
extra: serde_json::Map::new(),
};
let v = serde_json::to_value(¶ms).expect("serialize");
assert_eq!(v["fromAccountId"], json!("acct-src"));
assert!(
v.get("onSuccessDestroyOriginal").is_none() || v["onSuccessDestroyOriginal"].is_null(),
"onSuccessDestroyOriginal must be absent"
);
}
#[test]
fn email_get_params_propagates_vendor_extras() {
let mut params = EmailGetParams::default();
params
.extra
.insert("acmeCorpInline".into(), json!("aggressive"));
let v = serde_json::to_value(¶ms).expect("serialize EmailGetParams");
assert_eq!(v["acmeCorpInline"], json!("aggressive"));
}
#[test]
fn email_copy_params_propagates_vendor_extras() {
let mut extra = serde_json::Map::new();
extra.insert("acmeCorpAudit".into(), json!(true));
let params = EmailCopyParams {
from_account_id: "acct-src".into(),
on_success_destroy_original: None,
destroy_from_if_in_state: None,
extra,
};
let v = serde_json::to_value(¶ms).expect("serialize EmailCopyParams");
assert_eq!(v["acmeCorpAudit"], json!(true));
}
#[test]
fn mailbox_set_params_propagates_vendor_extras() {
let mut params = MailboxSetParams::default();
params
.extra
.insert("acmeCorpCascade".into(), json!("strict"));
let v = serde_json::to_value(¶ms).expect("serialize MailboxSetParams");
assert_eq!(v["acmeCorpCascade"], json!("strict"));
}
#[test]
fn email_submission_set_params_propagates_vendor_extras() {
let mut params = EmailSubmissionSetParams::default();
params
.extra
.insert("acmeCorpQueue".into(), json!("priority"));
let v = serde_json::to_value(¶ms).expect("serialize EmailSubmissionSetParams");
assert_eq!(v["acmeCorpQueue"], json!("priority"));
}
#[test]
fn email_import_input_propagates_vendor_extras() {
let blob = Id::from("blob1");
let mailboxes = [Id::from("mb1")];
let mut extra = serde_json::Map::new();
extra.insert("acmeCorpSource".into(), json!("mta-relay"));
let input = EmailImportInput {
blob_id: &blob,
mailbox_ids: &mailboxes,
keywords: None,
received_at: None,
extra,
};
let v = serde_json::to_value(&input).expect("serialize EmailImportInput");
assert_eq!(v["acmeCorpSource"], json!("mta-relay"));
}
#[test]
fn email_parse_params_propagates_vendor_extras() {
let mut params = EmailParseParams::default();
params.extra.insert("acmeCorpStrict".into(), json!(true));
let v = serde_json::to_value(¶ms).expect("serialize EmailParseParams");
assert_eq!(v["acmeCorpStrict"], json!(true));
}
#[test]
fn email_import_created_preserves_vendor_extras() {
let raw = json!({
"id": "M1",
"blobId": "B1",
"threadId": "T1",
"size": 1024,
"acmeCorpAntivirus": "clean"
});
let created: EmailImportCreated =
serde_json::from_value(raw).expect("EmailImportCreated must deserialize");
assert_eq!(
created
.extra
.get("acmeCorpAntivirus")
.and_then(|v| v.as_str()),
Some("clean")
);
}
#[test]
fn email_import_response_preserves_vendor_extras() {
let raw = json!({
"accountId": "acc1",
"newState": "s2",
"acmeCorpJobId": "job-42"
});
let resp: EmailImportResponse =
serde_json::from_value(raw).expect("EmailImportResponse must deserialize");
assert_eq!(
resp.extra.get("acmeCorpJobId").and_then(|v| v.as_str()),
Some("job-42")
);
}
#[test]
fn email_parse_response_preserves_vendor_extras() {
let raw = json!({
"accountId": "acc1",
"acmeCorpParser": "v3"
});
let resp: EmailParseResponse =
serde_json::from_value(raw).expect("EmailParseResponse must deserialize");
assert_eq!(
resp.extra.get("acmeCorpParser").and_then(|v| v.as_str()),
Some("v3")
);
}
}