1use crate::llm::actions::{
2 client_trait::{Client, ClientActionResult},
3 protocol_trait::Protocol,
4 ActionDefinition, ParameterDefinition,
5};
6use crate::protocol::{ConnectContext, EventType};
7use crate::server::jmap::actions::{action, parameter};
8use crate::server::jmap::request;
9use anyhow::{bail, ensure, Context, Result};
10use serde_json::{json, Value};
11use std::sync::LazyLock;
12
13#[derive(Default)]
14pub struct JmapClientProtocol;
15impl JmapClientProtocol {
16 pub fn new() -> Self {
17 Self
18 }
19}
20
21fn jmap_request() -> ActionDefinition {
22 action(
23 "jmap_request",
24 "Send one JMAP request: method calls run in order on the server and may reference earlier results (\"#ids\": {\"resultOf\": \"0\", \"name\": \"Email/query\", \"path\": \"/ids\"}). The answer raises jmap_response",
25 vec![
26 parameter("calls", "array", "Method calls as [name, arguments, id], e.g. [[\"Mailbox/get\", {}, \"0\"]]; accountId defaults to the primary account", true),
27 parameter("using", "array", "Capability URIs; derived from the method names when omitted", false),
28 ],
29 json!({"type": "jmap_request", "calls": [["Mailbox/get", {"ids": null}, "0"]]}),
30 )
31}
32fn disconnect() -> ActionDefinition {
33 action(
34 "disconnect",
35 "Stop the client",
36 vec![],
37 json!({"type": "disconnect"}),
38 )
39}
40
41pub static CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
42 EventType::new(
43 "jmap_connected",
44 "The JMAP session was fetched; send requests against its accounts",
45 jmap_request().example,
46 )
47 .with_parameters(vec![
48 parameter(
49 "username",
50 "string",
51 "The user the server authenticated",
52 true,
53 ),
54 parameter("accounts", "array", "[{id, name, capabilities}]", true),
55 parameter(
56 "primary_accounts",
57 "object",
58 "Capability URI → account id",
59 true,
60 ),
61 parameter(
62 "capabilities",
63 "array",
64 "The capability URIs the server supports",
65 true,
66 ),
67 parameter("state", "string", "The session state", true),
68 ])
69 .with_actions(vec![jmap_request(), disconnect()])
70});
71pub static RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
72 EventType::new(
73 "jmap_response",
74 "The server answered a jmap_request: one entry per method response, or a request-level problem",
75 json!({"type": "jmap_request", "calls": [["Email/get", {"ids": ["e1"]}, "0"]]}),
76 )
77 .with_parameters(vec![
78 parameter("status", "number", "The HTTP status", true),
79 parameter("method_responses", "array", "[name, arguments, call id] per response; name is \"error\" for a method error", false),
80 parameter("session_state", "string", "The server's session state", false),
81 parameter("session_changed", "boolean", "Whether the session state moved since the session was fetched", false),
82 parameter("problem", "object", "A request-level error (type, detail, limit)", false),
83 ])
84 .with_actions(vec![jmap_request(), disconnect()])
85});
86
87pub fn prepare(
89 v: &Value,
90 primary: &serde_json::Map<String, Value>,
91) -> Result<(Vec<String>, Vec<Value>)> {
92 let calls = v["calls"].as_array().context("calls is an array")?;
93 ensure!(
94 (1..=request::MAX_CALLS).contains(&calls.len()),
95 "1 to {} calls",
96 request::MAX_CALLS
97 );
98 ensure!(
99 request::depth(&v["calls"]) <= request::MAX_DEPTH,
100 "calls nest too deeply"
101 );
102 let mut using = vec![request::CORE.to_owned()];
103 let mut out = Vec::new();
104 for c in calls {
105 let c = c
106 .as_array()
107 .filter(|c| c.len() == 3)
108 .context("each call is [name, arguments, id]")?;
109 let name = c[0]
110 .as_str()
111 .filter(|n| n.contains('/') && n.len() <= 64)
112 .context("a call names Type/verb")?;
113 let mut args = c[1].as_object().context("arguments is an object")?.clone();
114 let id = c[2]
115 .as_str()
116 .filter(|i| !i.is_empty() && i.len() <= 64)
117 .context("a call id is a short string")?;
118 if let Some(cap) = request::capability(name) {
119 if !using.iter().any(|u| u == cap) {
120 using.push(cap.to_owned());
121 }
122 if cap != request::CORE && !args.contains_key("accountId") {
123 if let Some(a) = primary.get(cap).or_else(|| primary.get(request::MAIL)) {
124 args.insert("accountId".into(), a.clone());
125 }
126 }
127 }
128 out.push(json!([name, args, id]));
129 }
130 if let Some(u) = v.get("using").filter(|u| !u.is_null()) {
131 using = u
132 .as_array()
133 .context("using is an array")?
134 .iter()
135 .map(|x| x.as_str().map(str::to_owned).context("using lists URIs"))
136 .collect::<Result<_>>()?;
137 }
138 Ok((using, out))
139}
140
141impl Protocol for JmapClientProtocol {
142 fn protocol_name(&self) -> &'static str {
143 "JMAP"
144 }
145 fn stack_name(&self) -> &'static str {
146 "ETH>IP>TCP>HTTP>JMAP"
147 }
148 fn keywords(&self) -> Vec<&'static str> {
149 vec!["jmap", "jmap client", "jmap mail client"]
150 }
151 fn description(&self) -> &'static str {
152 "JMAP client: discovers the session, then sends batched method calls (mailboxes, emails, threads, submissions) and reports the responses"
153 }
154 fn get_async_actions(&self, _: &crate::state::app_state::AppState) -> Vec<ActionDefinition> {
155 vec![jmap_request(), disconnect()]
156 }
157 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
158 vec![jmap_request(), disconnect()]
159 }
160 fn get_event_types(&self) -> Vec<EventType> {
161 vec![CONNECTED_EVENT.clone(), RESPONSE_EVENT.clone()]
162 }
163 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
164 let p =
165 |name: &str, kind: &str, description: &str, example: Value, default: Option<Value>| {
166 ParameterDefinition {
167 name: name.into(),
168 type_hint: kind.into(),
169 description: description.into(),
170 required: false,
171 example,
172 default,
173 }
174 };
175 vec![
176 p("username", "string", "Basic authentication user", json!("alice@example.com"), None),
177 p("password", "string", "Basic authentication password", json!("secret"), None),
178 p("api_token", "string", "Bearer token instead of username and password", json!("t0k3n"), None),
179 p("tls", "boolean", "Use HTTPS (JMAP requires it); false for a plain-HTTP server", json!(true), Some(json!(true))),
180 p("ca_cert_path", "string", "PEM certificate to trust instead of the system roots (a private CA or the server's self-signed certificate)", json!("ca.pem"), None),
181 p("session_path", "string", "Where the session resource is", json!("/.well-known/jmap"), Some(json!(super::DEFAULT_SESSION_PATH))),
182 ]
183 }
184 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
185 use crate::protocol::metadata::*;
186 ProtocolMetadataV2::builder()
187 .state(DevelopmentState::Experimental)
188 .privilege_requirement(PrivilegeRequirement::None)
189 .implementation("reqwest over HTTP(S): session discovery (redirects followed on the same origin), Basic or Bearer authentication, request validation, default using and accountId")
190 .llm_control("Every request: which methods, arguments and result references")
191 .e2e_testing("tests/client/jmap: Stalwart 0.16.24 (independent mail server) — mailboxes, email creation, query with result references, changes, updates and errors")
192 .notes("No blob upload/download, EventSource push or WebSocket. apiUrl must be on the session's origin.")
193 .build()
194 }
195 fn example_prompt(&self) -> &'static str {
196 "Connect to the JMAP server at mail.example.com:443 as alice and list her mailboxes"
197 }
198 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
199 let llm = json!({"type":"open_client","protocol":"jmap","remote_addr":"127.0.0.1:8443","instruction":"List the mailboxes and the newest five emails","startup_params":{"username":"alice@example.com","password":"secret"}});
200 let mut static_example = llm.clone();
201 static_example["event_handlers"] = json!([{"event_pattern":"jmap_connected","handler":{"type":"static","actions":[{"type":"jmap_request","calls":[["Mailbox/get",{},"0"]]}]}}]);
202 let mut scripted = llm.clone();
203 scripted["event_handlers"] = json!([{"event_pattern":"jmap_connected","handler":{"type":"script","language":"python","code":"import json\nprint(json.dumps({'actions':[{'type':'jmap_request','calls':[['Email/query',{'limit':5},'0'],['Email/get',{'#ids':{'resultOf':'0','name':'Email/query','path':'/ids'}},'1']]}]}))"}}]);
204 crate::llm::actions::StartupExamples::new(llm, scripted, static_example)
205 }
206 fn group_name(&self) -> &'static str {
207 "Application"
208 }
209}
210
211impl Client for JmapClientProtocol {
212 fn connect(
213 &self,
214 ctx: ConnectContext,
215 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
216 {
217 Box::pin(super::connect(ctx))
218 }
219 fn execute_action(&self, v: Value) -> Result<ClientActionResult> {
220 match v["type"].as_str() {
221 Some("disconnect") => return Ok(ClientActionResult::Disconnect),
222 Some("jmap_request") => {
223 prepare(&v, &serde_json::Map::new())?;
224 }
225 _ => bail!("Unknown JMAP client action"),
226 }
227 Ok(ClientActionResult::Custom {
228 name: "jmap_request".into(),
229 data: v,
230 })
231 }
232}