JMAP: HTTP 400 "notRequest" when a method invocation name contains a JSON-escaped solidus ("\/")

Issue Description

Stalwart returns urn:ietf:params:jmap:error:notRequest (HTTP 400) for an otherwise-valid JMAP request whenever the method invocation name — the first element of a methodCalls triple — contains a JSON-escaped forward slash (\/) instead of a literal /.

Per RFC 8259 §7, \/ is an explicitly permitted escape for the solidus (U+002F). A JSON parser must treat "Core\/echo" and "Core/echo" as the identical string Core/echo. Stalwart accepts \/ inside JSON values but rejects it in a method name, which indicates the method name is being matched before (or without) applying JSON string unescaping to that token.

Expected Behavior

"Core\/echo" and "Core/echo" are the same JSON string per RFC 8259 §7, so the escaped form should behave identically to the unescaped one: HTTP 200 with the echoed response.

Actual Behavior

The request is rejected with HTTP 400 urn:ietf:params:jmap:error:notRequest, treating the entire otherwise-valid request as malformed. The same \/ escape inside an argument value decodes correctly — only the method-name token is matched without unescaping.

Reproduction Steps

Core/echo is a safe no-op. The three requests differ by exactly one character; credential redacted as USER:PASS.

  1. Literal / in the method name → 200 OK:
    curl -s -u ‘USER:PASS’ -H ‘Content-Type: application/json’ https://HOST/jmap/ --data-binary ‘{“using”:[“urn:ietf:params:jmap:core”],“methodCalls”:[[“Core/echo”,{“hello”:“world”},“c1”]]}’
    → {“methodResponses”:[[“Core/echo”,{“hello”:“world”},“c1”]],…}

  2. JSON-escaped \/ in the method NAME → 400 notRequest (the bug):
    …methodCalls":[[“Core/echo”,{“hello”:“world”},“c1”]]}’
    → HTTP 400 {“type”:“urn:ietf:params:jmap:error:notRequest”,“status”:400,…}

  3. JSON-escaped \/ in an argument VALUE → 200 OK (decoded fine):
    …methodCalls":[[“Core/echo”,{“path”:“a/b”},“c1”]]}’
    → {“methodResponses”:[[“Core/echo”,{“path”:“a/b”},“c1”]],…} (the / became /)

Stalwart Version

v0.16.x

Installation Method

Docker

Database Backend

RocksDB

Blob Storage

RocksDB

Search Engine

Internal

Directory Backend

Internal

Additional Context

Real-world impact: Apple’s Foundation JSONSerialization always escapes / as \/ with no opt-out, so any JMAP client built on it (a great deal of Swift/Objective-C code) is rejected on every request, while JSONEncoder / Python json.dumps / Go encoding/json are unaffected — which makes this confusing to diagnose across clients.

I have reviewed the documentation and FAQ and confirm that my issue is NOT addressed there.

on

I have searched this support forum (open and closed topics) and confirm this is not a duplicate.

on

I understand that topics in this category are triaged by a bot first but a human reply will follow up. If I’d prefer a human-only reply, I’ll add the no-ai tag to my topic.

on

This is a confirmed parsing bug, and the root cause is visible in the source: the method-name token in a JMAP call is deserialized directly as a borrowed &str:

let method_name = seq
    .next_element::<&str>()?
    .ok_or_else(|| de::Error::invalid_length(0, &self))?;
let name = match MethodName::parse(method_name) {

(crates/jmap-proto/src/request/parser.rs)

serde_json can only produce a zero-copy &str when the JSON string contains no escape sequences; the moment the method name contains \/ (or any other escape), it can’t borrow it and the whole deserialization step fails, which bubbles up as serde_json::Error and gets wrapped into urn:ietf:params:jmap:error:notRequest. Argument values elsewhere in the request are deserialized into owned String/structs, so escapes there are unescaped normally, matching what you observed.

This is a genuine spec-compliance gap (RFC 8259 §7 requires \/ and literal / to be treated identically), not expected behavior, and it’s not something documented as a limitation. I don’t have a way to file this for you, but a staff or community member monitoring this category should pick it up from your detailed repro.

This is an automated reply from the Stalwart Help Bot. Other community members may follow up if this answer is incomplete or wrong.

Fixed in v0.16.15 (yet to be released).