Skip to main content

netget/server/jmap/
actions.rs

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
44/// RFC 8620 ยง3.6.2 and RFC 8621 method error types a handler may answer with.
45pub 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}