1use crate::llm::actions::{
2 protocol_trait::{ActionResult, Protocol, Server},
3 ActionDefinition, Parameter, ParameterDefinition,
4};
5use crate::protocol::log_template::LogTemplate;
6use crate::protocol::{EventType, SpawnContext};
7use crate::state::app_state::AppState;
8use anyhow::{bail, ensure, Context, Result};
9use serde_json::{json, Value};
10use std::sync::LazyLock;
11
12#[derive(Default)]
13pub struct JmapProtocol;
14impl JmapProtocol {
15 pub fn new() -> Self {
16 Self
17 }
18}
19
20pub fn parameter(name: &str, kind: &str, description: &str, required: bool) -> Parameter {
21 Parameter {
22 name: name.into(),
23 type_hint: kind.into(),
24 description: description.into(),
25 required,
26 }
27}
28
29pub fn action(
30 name: &str,
31 description: &str,
32 parameters: Vec<Parameter>,
33 example: Value,
34) -> ActionDefinition {
35 ActionDefinition {
36 name: name.into(),
37 description: description.into(),
38 parameters,
39 example,
40 log_template: Some(LogTemplate::new().with_info(format!("JMAP {name}"))),
41 }
42}
43
44pub const METHOD_ERRORS: &[&str] = &[
46 "serverFail",
47 "serverUnavailable",
48 "unknownMethod",
49 "invalidArguments",
50 "forbidden",
51 "accountNotFound",
52 "accountNotSupportedByMethod",
53 "accountReadOnly",
54 "cannotCalculateChanges",
55 "stateMismatch",
56 "anchorNotFound",
57 "unsupportedSort",
58 "unsupportedFilter",
59 "tooManyChanges",
60 "fromAccountNotFound",
61 "fromAccountNotSupportedByMethod",
62];
63
64fn response() -> ActionDefinition {
65 action(
66 "jmap_response",
67 "Answer the method call with its response arguments, shaped as RFC 8620/8621 define for that method (e.g. Mailbox/get: accountId, state, list, notFound). State strings are yours: change them when the data changes",
68 vec![
69 parameter("arguments", "object", "The response arguments, e.g. {\"state\": \"s1\", \"list\": [...], \"notFound\": []}; accountId is filled in when omitted", true),
70 parameter("method", "string", "Response name when it differs from the call's (rare, e.g. an implicit Email/set after EmailSubmission/set); the call's name otherwise", false),
71 ],
72 json!({"type": "jmap_response", "arguments": {"state": "s1", "list": [{"id": "inbox", "name": "Inbox", "role": "inbox"}], "notFound": []}}),
73 )
74}
75
76fn method_error() -> ActionDefinition {
77 action(
78 "jmap_method_error",
79 "Answer the method call with a method-level error instead of a response",
80 vec![
81 parameter("error_type", "string", "One of serverFail, serverUnavailable, invalidArguments, forbidden, accountReadOnly, cannotCalculateChanges, stateMismatch, anchorNotFound, unsupportedSort, unsupportedFilter, tooManyChanges, unknownMethod, accountNotFound", true),
82 parameter("description", "string", "A sentence for the client, e.g. sinceState is too old", false),
83 ],
84 json!({"type": "jmap_method_error", "error_type": "cannotCalculateChanges", "description": "sinceState is too old"}),
85 )
86}
87
88pub static METHOD_CALL_EVENT: LazyLock<EventType> = LazyLock::new(|| {
89 EventType::new(
90 "jmap_method_call",
91 "One method call of a JMAP request (result references and creation ids already resolved); answer with its response or an error",
92 response().example,
93 )
94 .with_parameters(vec![
95 parameter("method", "string", "The method, e.g. Email/query", true),
96 parameter("account_id", "string", "The accountId argument, when the method takes one", false),
97 parameter("arguments", "object", "The call's arguments as resolved", true),
98 parameter("call_id", "string", "The client's method call id", true),
99 parameter("username", "string", "The authenticated user", true),
100 ])
101 .with_actions(vec![response(), method_error()])
102});
103
104pub fn validate(v: &Value) -> Result<()> {
105 match v["type"].as_str() {
106 Some("jmap_response") => {
107 let args = v["arguments"]
108 .as_object()
109 .context("arguments is an object")?;
110 ensure!(
111 super::request::depth(&Value::Object(args.clone())) <= super::request::MAX_DEPTH,
112 "arguments nest too deeply"
113 );
114 if let Some(m) = v.get("method").filter(|m| !m.is_null()) {
115 let m = m.as_str().context("method is a string")?;
116 ensure!(m.contains('/') && m.len() <= 64, "method names a Type/verb");
117 }
118 }
119 Some("jmap_method_error") => ensure!(
120 v["error_type"]
121 .as_str()
122 .is_some_and(|t| METHOD_ERRORS.contains(&t)),
123 "error_type is one of {}",
124 METHOD_ERRORS.join(", ")
125 ),
126 Some(other) => bail!("Unknown JMAP action {other}"),
127 None => bail!("an action names its type"),
128 }
129 Ok(())
130}
131
132impl Protocol for JmapProtocol {
133 fn protocol_name(&self) -> &'static str {
134 "JMAP"
135 }
136 fn stack_name(&self) -> &'static str {
137 "ETH>IP>TCP>HTTP>JMAP"
138 }
139 fn description(&self) -> &'static str {
140 "JMAP (RFC 8620 core, RFC 8621 mail) server: session discovery and batched method calls; the handler is the mail store and answers every method"
141 }
142 fn keywords(&self) -> Vec<&'static str> {
143 vec![
144 "jmap",
145 "jmap server",
146 "jmap mail",
147 "json meta application protocol",
148 ]
149 }
150 fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
151 vec![]
152 }
153 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
154 vec![response(), method_error()]
155 }
156 fn get_event_types(&self) -> Vec<EventType> {
157 vec![METHOD_CALL_EVENT.clone()]
158 }
159 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
160 let p =
161 |name: &str, kind: &str, description: &str, example: Value, default: Option<Value>| {
162 ParameterDefinition {
163 name: name.into(),
164 type_hint: kind.into(),
165 description: description.into(),
166 required: false,
167 example,
168 default,
169 }
170 };
171 vec![
172 p("accounts", "array", "The accounts the session lists, [{\"id\", \"name\"}]; the first is primary", json!([{"id": "a1", "name": "alice@example.com"}]), None),
173 p("users", "object", "Basic authentication: {username: password}. Without users or api_tokens every request is accepted", json!({"alice@example.com": "secret"}), None),
174 p("api_tokens", "object", "Bearer authentication: {token: username}", json!({"t0k3n": "alice@example.com"}), None),
175 p("tls", "boolean", "Serve HTTPS (JMAP requires it); false serves plain HTTP", json!(true), Some(json!(true))),
176 p("tls_cert_file", "string", "PEM certificate; without it and tls_key_file a self-signed one for localhost is generated and published as protocol_data.certificate_pem", json!("cert.pem"), None),
177 p("tls_key_file", "string", "PEM private key for tls_cert_file", json!("key.pem"), None),
178 ]
179 }
180 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
181 use crate::protocol::metadata::*;
182 ProtocolMetadataV2::builder()
183 .state(DevelopmentState::Experimental)
184 .privilege_requirement(PrivilegeRequirement::None)
185 .implementation("hyper HTTP/1.1 (rustls for HTTPS): the session resource, Basic/Bearer authentication, request validation and limits, capability and account checks, Core/echo, result references and creation ids in Rust")
186 .llm_control("Every other method call: Mailbox, Email, Thread, SearchSnippet, Identity, EmailSubmission and VacationResponse objects, their states and errors")
187 .e2e_testing("tests/server/jmap: jmapc 0.3.0 (independent, Python) discovers the session, queries mailboxes and emails with result references, sets and reads changes")
188 .notes("No blob upload/download, EventSource push or WebSocket (501); state strings and object storage are the handler's. 16 calls per request, 256 ids per get, 128 objects per set, 1 MiB requests.")
189 .request_only("JMAP is HTTP request/response; with no EventSource or WebSocket push there is nothing a server can send outside an answer")
190 .answers_on_failure()
191 .max_inbound_bytes(super::MAX_BODY)
192 .build()
193 }
194 fn example_prompt(&self) -> &'static str {
195 "JMAP mail server on port 8443 for alice@example.com with an Inbox holding two messages"
196 }
197 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
198 let llm = json!({"type":"open_server","base_stack":"jmap","port":8443,"instruction":"Be a mailbox for alice@example.com with an Inbox holding two messages","startup_params":{"accounts":[{"id":"a1","name":"alice@example.com"}],"users":{"alice@example.com":"secret"}}});
199 let mut static_example = llm.clone();
200 static_example["event_handlers"] = json!([{"event_pattern":"jmap_method_call","handler":{"type":"static","actions":[{"type":"jmap_method_error","error_type":"forbidden"}]}}]);
201 let mut scripted = llm.clone();
202 scripted["event_handlers"] = json!([{"event_pattern":"jmap_method_call","handler":{"type":"script","language":"python","code":"import json,sys\ne=json.load(sys.stdin)['event']\nif e['method']=='Mailbox/get':\n print(json.dumps({'actions':[{'type':'jmap_response','arguments':{'state':'s1','list':[{'id':'inbox','name':'Inbox','role':'inbox'}],'notFound':[]}}]}))\nelse:\n print(json.dumps({'actions':[{'type':'jmap_method_error','error_type':'forbidden'}]}))"}}]);
203 crate::llm::actions::StartupExamples::new(llm, scripted, static_example)
204 }
205 fn group_name(&self) -> &'static str {
206 "Application"
207 }
208}
209
210impl Server for JmapProtocol {
211 fn spawn(
212 &self,
213 ctx: SpawnContext,
214 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
215 {
216 Box::pin(super::spawn(ctx))
217 }
218 fn execute_action(&self, v: Value) -> Result<ActionResult> {
219 validate(&v)?;
220 Ok(ActionResult::Custom {
221 name: v["type"].as_str().unwrap_or_default().into(),
222 data: v,
223 })
224 }
225}