Skip to main content

netget/client/jmap/
actions.rs

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
87/// Check a jmap_request and return (using, calls) with the defaults filled in.
88pub 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}