You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@james.apache.org by bt...@apache.org on 2022/11/04 02:12:16 UTC

[james-project] branch master updated: JAMES-3830 Jmap Quota/query (#1277)

This is an automated email from the ASF dual-hosted git repository.

btellier pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/james-project.git


The following commit(s) were added to refs/heads/master by this push:
     new 83820b9467 JAMES-3830 Jmap Quota/query (#1277)
83820b9467 is described below

commit 83820b9467d53aaf3e26b683377844979c53e11a
Author: vttran <vt...@linagora.com>
AuthorDate: Fri Nov 4 09:12:10 2022 +0700

    JAMES-3830 Jmap Quota/query (#1277)
---
 .../james/jmap/rfc8621/RFC8621MethodsModule.java   |   2 +
 .../DistributedQuotaQueryMethodTest.java           |  55 ++
 .../contract/QuotaQueryMethodContract.scala        | 969 +++++++++++++++++++++
 .../rfc8621/memory/MemoryQuotaQueryMethodTest.java |  44 +
 .../doc/specs/spec/quotas/quota.mdown              |   9 +-
 .../scala/org/apache/james/jmap/core/Query.scala   |   4 +
 .../apache/james/jmap/json/QuotaSerializer.scala   |  48 +-
 .../scala/org/apache/james/jmap/mail/Quotas.scala  |  27 +-
 .../james/jmap/method/QuotaQueryMethod.scala       |  88 ++
 .../james/jmap/json/QuotaSerializerTest.scala      |  29 +-
 10 files changed, 1265 insertions(+), 10 deletions(-)

diff --git a/server/container/guice/protocols/jmap/src/main/java/org/apache/james/jmap/rfc8621/RFC8621MethodsModule.java b/server/container/guice/protocols/jmap/src/main/java/org/apache/james/jmap/rfc8621/RFC8621MethodsModule.java
index 571ef5434c..7889f3ba3c 100644
--- a/server/container/guice/protocols/jmap/src/main/java/org/apache/james/jmap/rfc8621/RFC8621MethodsModule.java
+++ b/server/container/guice/protocols/jmap/src/main/java/org/apache/james/jmap/rfc8621/RFC8621MethodsModule.java
@@ -66,6 +66,7 @@ import org.apache.james.jmap.method.PushSubscriptionGetMethod;
 import org.apache.james.jmap.method.PushSubscriptionSetMethod;
 import org.apache.james.jmap.method.QuotaChangesMethod;
 import org.apache.james.jmap.method.QuotaGetMethod;
+import org.apache.james.jmap.method.QuotaQueryMethod;
 import org.apache.james.jmap.method.SystemZoneIdProvider;
 import org.apache.james.jmap.method.ThreadChangesMethod;
 import org.apache.james.jmap.method.ThreadGetMethod;
@@ -146,6 +147,7 @@ public class RFC8621MethodsModule extends AbstractModule {
         methods.addBinding().to(PushSubscriptionSetMethod.class);
         methods.addBinding().to(QuotaChangesMethod.class);
         methods.addBinding().to(QuotaGetMethod.class);
+        methods.addBinding().to(QuotaQueryMethod.class);
         methods.addBinding().to(ThreadChangesMethod.class);
         methods.addBinding().to(ThreadGetMethod.class);
         methods.addBinding().to(VacationResponseGetMethod.class);
diff --git a/server/protocols/jmap-rfc-8621-integration-tests/distributed-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/distributed/DistributedQuotaQueryMethodTest.java b/server/protocols/jmap-rfc-8621-integration-tests/distributed-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/distributed/DistributedQuotaQueryMethodTest.java
new file mode 100644
index 0000000000..91b3b68e29
--- /dev/null
+++ b/server/protocols/jmap-rfc-8621-integration-tests/distributed-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/distributed/DistributedQuotaQueryMethodTest.java
@@ -0,0 +1,55 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *   http://www.apache.org/licenses/LICENSE-2.0                 *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.jmap.rfc8621.distributed;
+
+import org.apache.james.CassandraExtension;
+import org.apache.james.CassandraRabbitMQJamesConfiguration;
+import org.apache.james.CassandraRabbitMQJamesServerMain;
+import org.apache.james.DockerOpenSearchExtension;
+import org.apache.james.JamesServerBuilder;
+import org.apache.james.JamesServerExtension;
+import org.apache.james.jmap.rfc8621.contract.QuotaQueryMethodContract;
+import org.apache.james.modules.AwsS3BlobStoreExtension;
+import org.apache.james.modules.RabbitMQExtension;
+import org.apache.james.modules.TestJMAPServerModule;
+import org.apache.james.modules.blobstore.BlobStoreConfiguration;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+public class DistributedQuotaQueryMethodTest implements QuotaQueryMethodContract {
+
+    @RegisterExtension
+    static JamesServerExtension testExtension = new JamesServerBuilder<CassandraRabbitMQJamesConfiguration>(tmpDir ->
+        CassandraRabbitMQJamesConfiguration.builder()
+            .workingDirectory(tmpDir)
+            .configurationFromClasspath()
+            .blobStore(BlobStoreConfiguration.builder()
+                .s3()
+                .disableCache()
+                .deduplication()
+                .noCryptoConfig())
+            .build())
+        .extension(new DockerOpenSearchExtension())
+        .extension(new CassandraExtension())
+        .extension(new RabbitMQExtension())
+        .extension(new AwsS3BlobStoreExtension())
+        .server(configuration -> CassandraRabbitMQJamesServerMain.createServer(configuration)
+            .overrideWith(new TestJMAPServerModule()))
+        .build();
+}
diff --git a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/QuotaQueryMethodContract.scala b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/QuotaQueryMethodContract.scala
new file mode 100644
index 0000000000..19b66125a0
--- /dev/null
+++ b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/QuotaQueryMethodContract.scala
@@ -0,0 +1,969 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *   http://www.apache.org/licenses/LICENSE-2.0                 *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.jmap.rfc8621.contract
+
+import io.netty.handler.codec.http.HttpHeaderNames.ACCEPT
+import io.restassured.RestAssured.{`given`, requestSpecification}
+import io.restassured.http.ContentType.JSON
+import net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson
+import org.apache.http.HttpStatus.SC_OK
+import org.apache.james.GuiceJamesServer
+import org.apache.james.core.quota.{QuotaCountLimit, QuotaSizeLimit}
+import org.apache.james.jmap.core.ResponseObject.SESSION_STATE
+import org.apache.james.jmap.http.UserCredential
+import org.apache.james.jmap.rfc8621.contract.Fixture.{ACCEPT_RFC8621_VERSION_HEADER, ANDRE, ANDRE_PASSWORD, BOB, BOB_PASSWORD, DOMAIN, authScheme, baseRequestSpecBuilder}
+import org.apache.james.mailbox.model.MailboxACL.Right.Read
+import org.apache.james.mailbox.model.{MailboxACL, MailboxPath}
+import org.apache.james.modules.{ACLProbeImpl, MailboxProbeImpl, QuotaProbesImpl}
+import org.apache.james.utils.DataProbeImpl
+import org.awaitility.Awaitility
+import org.junit.jupiter.api.{BeforeEach, Test}
+
+import java.time.Duration
+import java.util.concurrent.TimeUnit
+
+trait QuotaQueryMethodContract {
+
+  private lazy val awaitAtMostTenSeconds = Awaitility.`with`
+    .await
+    .pollInterval(Duration.ofMillis(100))
+    .atMost(10, TimeUnit.SECONDS)
+
+  @BeforeEach
+  def setUp(server: GuiceJamesServer): Unit = {
+    server.getProbe(classOf[DataProbeImpl])
+      .fluent
+      .addDomain(DOMAIN.asString)
+      .addUser(BOB.asString, BOB_PASSWORD)
+      .addUser(ANDRE.asString, ANDRE_PASSWORD)
+
+    requestSpecification = baseRequestSpecBuilder(server)
+      .setAuth(authScheme(UserCredential(BOB, BOB_PASSWORD)))
+      .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .build
+  }
+
+  @Test
+  def queryShouldSucceedByDefault(): Unit = {
+    val response = `given`
+      .body(
+        s"""{
+           |  "using": [
+           |    "urn:ietf:params:jmap:core",
+           |    "urn:ietf:params:jmap:quota"],
+           |  "methodCalls": [[
+           |    "Quota/query",
+           |    {
+           |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+           |      "filter" : {}
+           |    },
+           |    "c1"]]
+           |}""".stripMargin)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |    "sessionState": "${SESSION_STATE.value}",
+         |    "methodResponses": [
+         |        [
+         |            "Quota/query",
+         |            {
+         |                "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |                "queryState": "00000000",
+         |                "canCalculateChanges": false,
+         |                "ids": [
+         |
+         |                ],
+         |                "position": 0,
+         |                "limit": 256
+         |            },
+         |            "c1"
+         |        ]
+         |    ]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def queryShouldReturnAllWhenFilterIsEmpty(server: GuiceJamesServer): Unit = {
+    val quotaProbe = server.getProbe(classOf[QuotaProbesImpl])
+    val bobQuotaRoot = quotaProbe.getQuotaRoot(MailboxPath.inbox(BOB))
+    quotaProbe.setMaxMessageCount(bobQuotaRoot, QuotaCountLimit.count(100L))
+    quotaProbe.setMaxStorage(bobQuotaRoot, QuotaSizeLimit.size(99L))
+
+    val response = `given`
+      .body(
+        s"""{
+           |  "using": [
+           |    "urn:ietf:params:jmap:core",
+           |    "urn:ietf:params:jmap:quota"],
+           |  "methodCalls": [[
+           |    "Quota/query",
+           |    {
+           |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+           |      "filter" : {}
+           |    },
+           |    "c1"]]
+           |}""".stripMargin)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |    "sessionState": "${SESSION_STATE.value}",
+         |    "methodResponses": [
+         |        [
+         |            "Quota/query",
+         |            {
+         |                "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |                "queryState": "a06886ff",
+         |                "canCalculateChanges": false,
+         |                "ids": [
+         |                    "08417be420b6dd6fa77d48fb2438e0d19108cd29424844bb109b52d356fab528",
+         |                    "eab6ce8ac5d9730a959e614854410cf39df98ff3760a623b8e540f36f5184947"
+         |                ],
+         |                "position": 0,
+         |                "limit": 256
+         |            },
+         |            "c1"
+         |        ]
+         |    ]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def filterResourceTypesShouldWork(server: GuiceJamesServer): Unit = {
+    val quotaProbe = server.getProbe(classOf[QuotaProbesImpl])
+    val bobQuotaRoot = quotaProbe.getQuotaRoot(MailboxPath.inbox(BOB))
+    quotaProbe.setMaxMessageCount(bobQuotaRoot, QuotaCountLimit.count(100L))
+    quotaProbe.setMaxStorage(bobQuotaRoot, QuotaSizeLimit.size(99L))
+
+    val response = `given`
+      .body(
+        s"""{
+           |  "using": [
+           |    "urn:ietf:params:jmap:core",
+           |    "urn:ietf:params:jmap:quota"],
+           |  "methodCalls": [[
+           |    "Quota/query",
+           |    {
+           |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+           |      "filter" : {
+           |        "resourceTypes": ["count"]
+           |      }
+           |    },
+           |    "c1"]]
+           |}""".stripMargin)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |    "sessionState": "${SESSION_STATE.value}",
+         |    "methodResponses": [
+         |        [
+         |            "Quota/query",
+         |            {
+         |                "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |                "queryState": "d8d3e770",
+         |                "canCalculateChanges": false,
+         |                "ids": [
+         |                    "08417be420b6dd6fa77d48fb2438e0d19108cd29424844bb109b52d356fab528"
+         |                ],
+         |                "position": 0,
+         |                "limit": 256
+         |            },
+         |            "c1"
+         |        ]
+         |    ]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def filterResourceTypesShoulFailWhenInvalidResourceTypes(server: GuiceJamesServer): Unit = {
+    val quotaProbe = server.getProbe(classOf[QuotaProbesImpl])
+    val bobQuotaRoot = quotaProbe.getQuotaRoot(MailboxPath.inbox(BOB))
+    quotaProbe.setMaxMessageCount(bobQuotaRoot, QuotaCountLimit.count(100L))
+    quotaProbe.setMaxStorage(bobQuotaRoot, QuotaSizeLimit.size(99L))
+
+    val response = `given`
+      .body(
+        s"""{
+           |  "using": [
+           |    "urn:ietf:params:jmap:core",
+           |    "urn:ietf:params:jmap:quota"],
+           |  "methodCalls": [[
+           |    "Quota/query",
+           |    {
+           |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+           |      "filter" : {
+           |        "resourceTypes": ["invalid"]
+           |      }
+           |    },
+           |    "c1"]]
+           |}""".stripMargin)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |    "sessionState": "${SESSION_STATE.value}",
+         |    "methodResponses": [
+         |        [
+         |            "error",
+         |            {
+         |                "type": "invalidArguments",
+         |                "description": "{\\"errors\\":[{\\"path\\":\\"obj.filter.resourceTypes[0]\\",\\"messages\\":[\\"Unexpected value invalid, only 'count' and 'octets' are managed\\"]}]}"
+         |            },
+         |            "c1"
+         |        ]
+         |    ]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def filterDataTypesShouldWork(server: GuiceJamesServer): Unit = {
+    val quotaProbe = server.getProbe(classOf[QuotaProbesImpl])
+    val bobQuotaRoot = quotaProbe.getQuotaRoot(MailboxPath.inbox(BOB))
+    quotaProbe.setMaxMessageCount(bobQuotaRoot, QuotaCountLimit.count(100L))
+    quotaProbe.setMaxStorage(bobQuotaRoot, QuotaSizeLimit.size(99L))
+
+    val response = `given`
+      .body(
+        s"""{
+           |  "using": [
+           |    "urn:ietf:params:jmap:core",
+           |    "urn:ietf:params:jmap:quota"],
+           |  "methodCalls": [[
+           |    "Quota/query",
+           |    {
+           |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+           |      "filter" : {
+           |        "dataTypes": ["Mail"]
+           |      }
+           |    },
+           |    "c1"]]
+           |}""".stripMargin)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |    "sessionState": "${SESSION_STATE.value}",
+         |    "methodResponses": [
+         |        [
+         |            "Quota/query",
+         |            {
+         |                "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |                "queryState": "a06886ff",
+         |                "canCalculateChanges": false,
+         |                "ids": [
+         |                    "08417be420b6dd6fa77d48fb2438e0d19108cd29424844bb109b52d356fab528",
+         |                    "eab6ce8ac5d9730a959e614854410cf39df98ff3760a623b8e540f36f5184947"
+         |                ],
+         |                "position": 0,
+         |                "limit": 256
+         |            },
+         |            "c1"
+         |        ]
+         |    ]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def filterDataTypesShouldFailWhenInvalidDataTypes(server: GuiceJamesServer): Unit = {
+    val quotaProbe = server.getProbe(classOf[QuotaProbesImpl])
+    val bobQuotaRoot = quotaProbe.getQuotaRoot(MailboxPath.inbox(BOB))
+    quotaProbe.setMaxMessageCount(bobQuotaRoot, QuotaCountLimit.count(100L))
+    quotaProbe.setMaxStorage(bobQuotaRoot, QuotaSizeLimit.size(99L))
+
+    val response = `given`
+      .body(
+        s"""{
+           |  "using": [
+           |    "urn:ietf:params:jmap:core",
+           |    "urn:ietf:params:jmap:quota"],
+           |  "methodCalls": [[
+           |    "Quota/query",
+           |    {
+           |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+           |      "filter" : {
+           |        "dataTypes": ["invalid"]
+           |      }
+           |    },
+           |    "c1"]]
+           |}""".stripMargin)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |    "sessionState": "${SESSION_STATE.value}",
+         |    "methodResponses": [
+         |        [
+         |            "error",
+         |            {
+         |                "type": "invalidArguments",
+         |                "description": "{\\"errors\\":[{\\"path\\":\\"obj.filter.dataTypes[0]\\",\\"messages\\":[\\"Unexpected value invalid, only 'Mail' are managed\\"]}]}"
+         |            },
+         |            "c1"
+         |        ]
+         |    ]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def filterScopeShouldWork(server: GuiceJamesServer): Unit = {
+    val quotaProbe = server.getProbe(classOf[QuotaProbesImpl])
+    val bobQuotaRoot = quotaProbe.getQuotaRoot(MailboxPath.inbox(BOB))
+    quotaProbe.setMaxMessageCount(bobQuotaRoot, QuotaCountLimit.count(100L))
+    quotaProbe.setMaxStorage(bobQuotaRoot, QuotaSizeLimit.size(99L))
+
+    val response = `given`
+      .body(
+        s"""{
+           |  "using": [
+           |    "urn:ietf:params:jmap:core",
+           |    "urn:ietf:params:jmap:quota"],
+           |  "methodCalls": [[
+           |    "Quota/query",
+           |    {
+           |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+           |      "filter" : {
+           |        "scope": ["account"]
+           |      }
+           |    },
+           |    "c1"]]
+           |}""".stripMargin)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |    "sessionState": "${SESSION_STATE.value}",
+         |    "methodResponses": [
+         |        [
+         |            "Quota/query",
+         |            {
+         |                "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |                "queryState": "a06886ff",
+         |                "canCalculateChanges": false,
+         |                "ids": [
+         |                    "08417be420b6dd6fa77d48fb2438e0d19108cd29424844bb109b52d356fab528",
+         |                    "eab6ce8ac5d9730a959e614854410cf39df98ff3760a623b8e540f36f5184947"
+         |                ],
+         |                "position": 0,
+         |                "limit": 256
+         |            },
+         |            "c1"
+         |        ]
+         |    ]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def filterScopeShouldFailWhenInvalidScope(server: GuiceJamesServer): Unit = {
+    val quotaProbe = server.getProbe(classOf[QuotaProbesImpl])
+    val bobQuotaRoot = quotaProbe.getQuotaRoot(MailboxPath.inbox(BOB))
+    quotaProbe.setMaxMessageCount(bobQuotaRoot, QuotaCountLimit.count(100L))
+    quotaProbe.setMaxStorage(bobQuotaRoot, QuotaSizeLimit.size(99L))
+
+    val response = `given`
+      .body(
+        s"""{
+           |  "using": [
+           |    "urn:ietf:params:jmap:core",
+           |    "urn:ietf:params:jmap:quota"],
+           |  "methodCalls": [[
+           |    "Quota/query",
+           |    {
+           |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+           |      "filter" : {
+           |        "scope": ["invalidScope"]
+           |      }
+           |    },
+           |    "c1"]]
+           |}""".stripMargin)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |    "sessionState": "${SESSION_STATE.value}",
+         |    "methodResponses": [
+         |        [
+         |            "error",
+         |            {
+         |                "type": "invalidArguments",
+         |                "description": "{\\"errors\\":[{\\"path\\":\\"obj.filter.scope[0]\\",\\"messages\\":[\\"Unexpected value invalidScope, only \'account\' is managed\\"]}]}"
+         |            },
+         |            "c1"
+         |        ]
+         |    ]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def filterQuotaNameShouldWork(server: GuiceJamesServer): Unit = {
+    val quotaProbe = server.getProbe(classOf[QuotaProbesImpl])
+    val bobQuotaRoot = quotaProbe.getQuotaRoot(MailboxPath.inbox(BOB))
+    quotaProbe.setMaxMessageCount(bobQuotaRoot, QuotaCountLimit.count(100L))
+    quotaProbe.setMaxStorage(bobQuotaRoot, QuotaSizeLimit.size(99L))
+
+    val response = `given`
+      .body(
+        s"""{
+           |  "using": [
+           |    "urn:ietf:params:jmap:core",
+           |    "urn:ietf:params:jmap:quota"],
+           |  "methodCalls": [[
+           |    "Quota/query",
+           |    {
+           |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+           |      "filter" : {
+           |        "name": "#private&bob@domain.tld@domain.tld:account:octets:Mail"
+           |      }
+           |    },
+           |    "c1"]]
+           |}""".stripMargin)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |    "sessionState": "${SESSION_STATE.value}",
+         |    "methodResponses": [
+         |        [
+         |            "Quota/query",
+         |            {
+         |                "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |                "queryState": "6cacd8a2",
+         |                "canCalculateChanges": false,
+         |                "ids": [
+         |                    "eab6ce8ac5d9730a959e614854410cf39df98ff3760a623b8e540f36f5184947"
+         |                ],
+         |                "position": 0,
+         |                "limit": 256
+         |            },
+         |            "c1"
+         |        ]
+         |    ]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def filterQuotaNameShouldReturnEmptyResultWhenNameIsNotFound(server: GuiceJamesServer): Unit = {
+    val quotaProbe = server.getProbe(classOf[QuotaProbesImpl])
+    val bobQuotaRoot = quotaProbe.getQuotaRoot(MailboxPath.inbox(BOB))
+    quotaProbe.setMaxMessageCount(bobQuotaRoot, QuotaCountLimit.count(100L))
+    quotaProbe.setMaxStorage(bobQuotaRoot, QuotaSizeLimit.size(99L))
+
+    val response = `given`
+      .body(
+        s"""{
+           |  "using": [
+           |    "urn:ietf:params:jmap:core",
+           |    "urn:ietf:params:jmap:quota"],
+           |  "methodCalls": [[
+           |    "Quota/query",
+           |    {
+           |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+           |      "filter" : {
+           |        "name": "notFound"
+           |      }
+           |    },
+           |    "c1"]]
+           |}""".stripMargin)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |    "sessionState": "${SESSION_STATE.value}",
+         |    "methodResponses": [
+         |        [
+         |            "Quota/query",
+         |            {
+         |                "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |                "queryState": "00000000",
+         |                "canCalculateChanges": false,
+         |                "ids": [],
+         |                "position": 0,
+         |                "limit": 256
+         |            },
+         |            "c1"
+         |        ]
+         |    ]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def filterMultiPropertyShouldWork(server: GuiceJamesServer): Unit = {
+    val quotaProbe = server.getProbe(classOf[QuotaProbesImpl])
+    val bobQuotaRoot = quotaProbe.getQuotaRoot(MailboxPath.inbox(BOB))
+    quotaProbe.setMaxMessageCount(bobQuotaRoot, QuotaCountLimit.count(100L))
+    quotaProbe.setMaxStorage(bobQuotaRoot, QuotaSizeLimit.size(99L))
+
+    val response = `given`
+      .body(
+        s"""{
+           |  "using": [
+           |    "urn:ietf:params:jmap:core",
+           |    "urn:ietf:params:jmap:quota"],
+           |  "methodCalls": [[
+           |    "Quota/query",
+           |    {
+           |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+           |      "filter" : {
+           |        "name": "#private&bob@domain.tld@domain.tld:account:octets:Mail",
+           |        "dataTypes": ["Mail"],
+           |        "scope": ["account"],
+           |        "resourceTypes": ["octets"]
+           |      }
+           |    },
+           |    "c1"]]
+           |}""".stripMargin)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |    "sessionState": "${SESSION_STATE.value}",
+         |    "methodResponses": [
+         |        [
+         |            "Quota/query",
+         |            {
+         |                "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |                "queryState": "6cacd8a2",
+         |                "canCalculateChanges": false,
+         |                "ids": [
+         |                    "eab6ce8ac5d9730a959e614854410cf39df98ff3760a623b8e540f36f5184947"
+         |                ],
+         |                "position": 0,
+         |                "limit": 256
+         |            },
+         |            "c1"
+         |        ]
+         |    ]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def filterShouldBeANDLogic(server: GuiceJamesServer): Unit = {
+    val quotaProbe = server.getProbe(classOf[QuotaProbesImpl])
+    val bobQuotaRoot = quotaProbe.getQuotaRoot(MailboxPath.inbox(BOB))
+    quotaProbe.setMaxMessageCount(bobQuotaRoot, QuotaCountLimit.count(100L))
+    quotaProbe.setMaxStorage(bobQuotaRoot, QuotaSizeLimit.size(99L))
+
+    val response = `given`
+      .body(
+        s"""{
+           |  "using": [
+           |    "urn:ietf:params:jmap:core",
+           |    "urn:ietf:params:jmap:quota"],
+           |  "methodCalls": [[
+           |    "Quota/query",
+           |    {
+           |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+           |      "filter" : {
+           |        "name": "#private&bob@domain.tld@domain.tld:account:octets:Mail",
+           |        "resourceTypes": ["count"]
+           |      }
+           |    },
+           |    "c1"]]
+           |}""".stripMargin)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |    "sessionState": "${SESSION_STATE.value}",
+         |    "methodResponses": [
+         |        [
+         |            "Quota/query",
+         |            {
+         |                "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |                "queryState": "00000000",
+         |                "canCalculateChanges": false,
+         |                "ids": [],
+         |                "position": 0,
+         |                "limit": 256
+         |            },
+         |            "c1"
+         |        ]
+         |    ]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def filterShouldFailWhenInvalidFilter(server: GuiceJamesServer): Unit = {
+    val quotaProbe = server.getProbe(classOf[QuotaProbesImpl])
+    val bobQuotaRoot = quotaProbe.getQuotaRoot(MailboxPath.inbox(BOB))
+    quotaProbe.setMaxMessageCount(bobQuotaRoot, QuotaCountLimit.count(100L))
+    quotaProbe.setMaxStorage(bobQuotaRoot, QuotaSizeLimit.size(99L))
+
+    val response = `given`
+      .body(
+        s"""{
+           |  "using": [
+           |    "urn:ietf:params:jmap:core",
+           |    "urn:ietf:params:jmap:quota"],
+           |  "methodCalls": [[
+           |    "Quota/query",
+           |    {
+           |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+           |      "filter" : {
+           |        "filterName1": "filterValue2"
+           |      }
+           |    },
+           |    "c1"]]
+           |}""".stripMargin)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |    "sessionState": "${SESSION_STATE.value}",
+         |    "methodResponses": [
+         |        [
+         |            "error",
+         |            {
+         |                "type": "invalidArguments",
+         |                "description": "{\\"errors\\":[{\\"path\\":\\"obj.filter\\",\\"messages\\":[\\"These '[filterName1]' was unsupported filter options\\"]}]}"
+         |            },
+         |            "c1"
+         |        ]
+         |    ]
+         |}""".stripMargin)
+  }
+
+
+  @Test
+  def quotaQueryShouldFailWhenWrongAccountId(): Unit = {
+    val request =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:quota"],
+         |  "methodCalls": [[
+         |    "Quota/query",
+         |    {
+         |      "accountId": "unknownAccountId",
+         |      "filter": {}
+         |    },
+         |    "c1"]]
+         |}""".stripMargin
+
+    val response = `given`
+      .body(request)
+    .when
+      .post
+    .`then`
+      .log().ifValidationFails()
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |  "sessionState": "${SESSION_STATE.value}",
+         |  "methodResponses": [
+         |    ["error", {
+         |      "type": "accountNotFound"
+         |    }, "c1"]
+         |  ]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def quotaQueryShouldFailWhenOmittingOneCapability(): Unit = {
+    val request =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core"],
+         |  "methodCalls": [[
+         |    "Quota/query",
+         |    {
+         |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |      "filter": {}
+         |    },
+         |    "c1"]]
+         |}""".stripMargin
+
+    val response = `given`
+      .body(request)
+    .when
+      .post
+    .`then`
+      .log().ifValidationFails()
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |  "sessionState": "${SESSION_STATE.value}",
+         |  "methodResponses": [[
+         |    "error",
+         |    {
+         |      "type": "unknownMethod",
+         |      "description":"Missing capability(ies): urn:ietf:params:jmap:quota"
+         |    },
+         |    "c1"]]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def quotaQueryShouldFailWhenOmittingAllCapability(): Unit = {
+    val request =
+      s"""{
+         |  "using": [],
+         |  "methodCalls": [[
+         |    "Quota/query",
+         |    {
+         |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |      "filter": {}
+         |    },
+         |    "c1"]]
+         |}""".stripMargin
+
+    val response = `given`
+      .body(request)
+    .when
+      .post
+    .`then`
+      .log().ifValidationFails()
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |  "sessionState": "${SESSION_STATE.value}",
+         |  "methodResponses": [[
+         |    "error",
+         |    {
+         |      "type": "unknownMethod",
+         |      "description":"Missing capability(ies): urn:ietf:params:jmap:quota, urn:ietf:params:jmap:core"
+         |    },
+         |    "c1"]]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def quotaQueryShouldReturnEmptyIdsWhenDoesNotPermission(server: GuiceJamesServer): Unit ={
+    val quotaProbe = server.getProbe(classOf[QuotaProbesImpl])
+    val bobQuotaRoot = quotaProbe.getQuotaRoot(MailboxPath.inbox(BOB))
+    quotaProbe.setMaxMessageCount(bobQuotaRoot, QuotaCountLimit.count(100L))
+
+    val andreMailbox = MailboxPath.forUser(ANDRE, "mailbox")
+    server.getProbe(classOf[MailboxProbeImpl]).createMailbox(andreMailbox)
+    quotaProbe.setMaxMessageCount(quotaProbe.getQuotaRoot(andreMailbox), QuotaCountLimit.count(88L))
+
+    val response = `given`
+      .body(
+        s"""{
+           |  "using": [
+           |    "urn:ietf:params:jmap:core",
+           |    "urn:ietf:params:jmap:quota",
+           |    "urn:apache:james:params:jmap:mail:shares" ],
+           |  "methodCalls": [[
+           |    "Quota/query",
+           |    {
+           |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+           |      "filter" : {
+           |        "name" : "#private&andre@domain.tld@domain.tld:account:count:Mail"
+           |      }
+           |    },
+           |    "c1"]]
+           |}""".stripMargin)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |    "sessionState": "${SESSION_STATE.value}",
+         |    "methodResponses": [
+         |        [
+         |            "Quota/query",
+         |            {
+         |                "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |                "queryState": "00000000",
+         |                "canCalculateChanges": false,
+         |                "ids": [],
+         |                "position": 0,
+         |                "limit": 256
+         |            },
+         |            "c1"
+         |        ]
+         |    ]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def quotaQueryShouldReturnIdsWhenHasPermission(server: GuiceJamesServer): Unit = {
+    val quotaProbe = server.getProbe(classOf[QuotaProbesImpl])
+    val bobQuotaRoot = quotaProbe.getQuotaRoot(MailboxPath.inbox(BOB))
+    quotaProbe.setMaxMessageCount(bobQuotaRoot, QuotaCountLimit.count(100L))
+
+    val andreMailbox = MailboxPath.forUser(ANDRE, "mailbox")
+    server.getProbe(classOf[MailboxProbeImpl]).createMailbox(andreMailbox)
+    server.getProbe(classOf[ACLProbeImpl])
+      .replaceRights(andreMailbox, BOB.asString, new MailboxACL.Rfc4314Rights(Read))
+
+    quotaProbe.setMaxMessageCount(quotaProbe.getQuotaRoot(andreMailbox), QuotaCountLimit.count(88L))
+
+    val response = `given`
+      .body(
+        s"""{
+           |  "using": [
+           |    "urn:ietf:params:jmap:core",
+           |    "urn:ietf:params:jmap:quota",
+           |    "urn:apache:james:params:jmap:mail:shares"],
+           |  "methodCalls": [[
+           |    "Quota/query",
+           |    {
+           |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+           |      "filter" : {
+           |        "name" : "#private&andre@domain.tld@domain.tld:account:count:Mail"
+           |      }
+           |    },
+           |    "c1"]]
+           |}""".stripMargin)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |    "sessionState": "${SESSION_STATE.value}",
+         |    "methodResponses": [
+         |        [
+         |            "Quota/query",
+         |            {
+         |                "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |                "queryState": "980c0716",
+         |                "canCalculateChanges": false,
+         |                "ids": ["04cbe4578878e02a74e47ae6be66c88cc8aafd3a5fc698457d712ee5f9a5b4ca"],
+         |                "position": 0,
+         |                "limit": 256
+         |            },
+         |            "c1"
+         |        ]
+         |    ]
+         |}""".stripMargin)
+  }
+
+}
diff --git a/server/protocols/jmap-rfc-8621-integration-tests/memory-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/memory/MemoryQuotaQueryMethodTest.java b/server/protocols/jmap-rfc-8621-integration-tests/memory-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/memory/MemoryQuotaQueryMethodTest.java
new file mode 100644
index 0000000000..05198b98c8
--- /dev/null
+++ b/server/protocols/jmap-rfc-8621-integration-tests/memory-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/memory/MemoryQuotaQueryMethodTest.java
@@ -0,0 +1,44 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *   http://www.apache.org/licenses/LICENSE-2.0                 *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.jmap.rfc8621.memory;
+
+import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT;
+
+import org.apache.james.JamesServerBuilder;
+import org.apache.james.JamesServerExtension;
+import org.apache.james.MemoryJamesConfiguration;
+import org.apache.james.MemoryJamesServerMain;
+import org.apache.james.jmap.rfc8621.contract.QuotaQueryMethodContract;
+import org.apache.james.modules.TestJMAPServerModule;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+public class MemoryQuotaQueryMethodTest implements QuotaQueryMethodContract {
+
+    @RegisterExtension
+    static JamesServerExtension testExtension = new JamesServerBuilder<MemoryJamesConfiguration>(tmpDir ->
+        MemoryJamesConfiguration.builder()
+            .workingDirectory(tmpDir)
+            .configurationFromClasspath()
+            .usersRepository(DEFAULT)
+            .build())
+        .server(configuration -> MemoryJamesServerMain.createServer(configuration)
+            .overrideWith(new TestJMAPServerModule()))
+        .build();
+}
diff --git a/server/protocols/jmap-rfc-8621/doc/specs/spec/quotas/quota.mdown b/server/protocols/jmap-rfc-8621/doc/specs/spec/quotas/quota.mdown
index e9215e303c..d1c109eecc 100644
--- a/server/protocols/jmap-rfc-8621/doc/specs/spec/quotas/quota.mdown
+++ b/server/protocols/jmap-rfc-8621/doc/specs/spec/quotas/quota.mdown
@@ -64,8 +64,8 @@ Servers MAY decide to add other properties to the list that they judge changing
 
 ## Quota/query
 
-> :warning:
-> Not implemented
+> :information_source:
+> Implemented
 
 This is a standard “/query” method as described in [@!RFC8620], Section 5.5.
 
@@ -84,6 +84,11 @@ The following Quota properties MUST be supported for sorting:
 * **name**
 * **used**
 
+> :warning:
+> Not implemented
+> The following properties are not supported: sort, position, anchor, anchorOffset, limit, calculateTotal.
+FilterOperators (AND/OR/NOT) are not supported.
+
 ## Quota/queryChanges
 
 > :warning:
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/Query.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/Query.scala
index a290561d70..3e87aed4fa 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/Query.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/Query.scala
@@ -24,6 +24,7 @@ import eu.timepit.refined.api.Refined
 import eu.timepit.refined.auto._
 import eu.timepit.refined.numeric.{NonNegative, Positive}
 import eu.timepit.refined.refineV
+import org.apache.james.jmap.core.Id.Id
 import org.apache.james.mailbox.model.{MailboxId, MessageId}
 
 case class PositionUnparsed(value: Int) extends AnyVal
@@ -67,6 +68,9 @@ object QueryState {
   def forMailboxIds(ids: Seq[MailboxId]): QueryState =
     forStrings(ids.map(_.serialize()))
 
+  def forQuotaIds(ids: Seq[Id]): QueryState =
+    forStrings(ids.map(_.value))
+
   def forStrings(strings: Seq[String]): QueryState = QueryState(
     Hashing.murmur3_32()
       .hashUnencodedChars(strings.mkString(" "))
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/QuotaSerializer.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/QuotaSerializer.scala
index bcd56b62a4..c9ee5512f2 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/QuotaSerializer.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/QuotaSerializer.scala
@@ -21,8 +21,9 @@ package org.apache.james.jmap.json
 
 import eu.timepit.refined
 import org.apache.james.jmap.core.Id.IdConstraint
-import org.apache.james.jmap.core.{Properties, UuidState}
-import org.apache.james.jmap.mail.{DataType, JmapQuota, QuotaChangesRequest, QuotaChangesResponse, QuotaDescription, QuotaGetRequest, QuotaGetResponse, QuotaIds, QuotaName, QuotaNotFound, ResourceType, Scope, UnparsedQuotaId}
+import org.apache.james.jmap.core.{CanCalculateChanges, Properties, QueryState, UuidState}
+import org.apache.james.jmap.mail.{AccountScope, CountResourceType, DataType, JmapQuota, MailDataType, OctetsResourceType, QuotaChangesRequest, QuotaChangesResponse, QuotaDescription, QuotaGetRequest, QuotaGetResponse, QuotaIds, QuotaName, QuotaNotFound, QuotaQueryFilter, QuotaQueryRequest, QuotaQueryResponse, ResourceType, Scope, UnparsedQuotaId}
+import play.api.libs.json
 import play.api.libs.json._
 
 object QuotaSerializer {
@@ -42,6 +43,26 @@ object QuotaSerializer {
   private implicit val stateWrites: Writes[UuidState] = Json.valueWrites[UuidState]
 
   private implicit val resourceTypeWrite: Writes[ResourceType] = resourceType => JsString(resourceType.asString())
+  private implicit val scopeReads: Reads[Scope] = {
+    case JsString("account") => json.JsSuccess(AccountScope)
+    case JsString(any) => JsError(s"Unexpected value $any, only 'account' is managed")
+    case _ => JsError(s"Expecting a JsString to represent a scope property")
+  }
+
+  private implicit val optionReads: Reads[QuotaName] = Json.valueReads[QuotaName]
+
+  private implicit val resourceTypeReads: Reads[ResourceType] = {
+    case JsString("count") => json.JsSuccess(CountResourceType)
+    case JsString("octets") => json.JsSuccess(OctetsResourceType)
+    case JsString(any) => JsError(s"Unexpected value $any, only 'count' and 'octets' are managed")
+    case _ => JsError(s"Expecting a JsString to represent a resourceType property")
+  }
+  private implicit val dataTypeReads: Reads[DataType] = {
+    case JsString("Mail") => json.JsSuccess(MailDataType)
+    case JsString(any) => JsError(s"Unexpected value $any, only 'Mail' are managed")
+    case _ => JsError(s"Expecting a JsString to represent a dataType property")
+  }
+
   private implicit val scopeWrites: Writes[Scope] = scope => JsString(scope.asString())
   private implicit val dataTypeWrites: Writes[DataType] = dataType => JsString(dataType.asString())
   private implicit val quotaNameWrites: Writes[QuotaName] = Json.valueWrites[QuotaName]
@@ -66,12 +87,34 @@ object QuotaSerializer {
       "updated" -> response.updated,
       "destroyed" -> response.destroyed)
 
+
+  private implicit val filterReads: Reads[QuotaQueryFilter] = {
+    case jsObject: JsObject =>
+      val unsupported: collection.Set[String] = jsObject.keys.diff(QuotaQueryFilter.SUPPORTED)
+      if (unsupported.nonEmpty) {
+        JsError(s"These '${unsupported.mkString("[", ", ", "]")}' was unsupported filter options")
+      } else {
+        Json.reads[QuotaQueryFilter].reads(jsObject)
+      }
+    case jsValue: JsValue => Json.reads[QuotaQueryFilter].reads(jsValue)
+  }
+
+  private implicit val quotaQueryRequestReads: Reads[QuotaQueryRequest] = Json.reads[QuotaQueryRequest]
+
+  private implicit val canCalculateChangesWrites: Writes[CanCalculateChanges] = Json.valueWrites[CanCalculateChanges]
+
+  private implicit val queryStateWrites: Writes[QueryState] = Json.valueWrites[QueryState]
+
+  private implicit val quotaQueryResponseWrites: OWrites[QuotaQueryResponse] = Json.writes[QuotaQueryResponse]
+
   def deserializeQuotaGetRequest(input: String): JsResult[QuotaGetRequest] = Json.parse(input).validate[QuotaGetRequest]
 
   def deserializeQuotaGetRequest(input: JsValue): JsResult[QuotaGetRequest] = Json.fromJson[QuotaGetRequest](input)
 
   def deserializeQuotaChangesRequest(input: JsValue): JsResult[QuotaChangesRequest] = Json.fromJson[QuotaChangesRequest](input)
 
+  def deserializeQuotaQueryRequest(input: JsValue) : JsResult[QuotaQueryRequest] = Json.fromJson[QuotaQueryRequest](input)
+
   def serialize(quotaGetResponse: QuotaGetResponse, properties: Properties): JsValue =
     Json.toJson(quotaGetResponse)
       .transform((__ \ "list").json.update {
@@ -87,4 +130,5 @@ object QuotaSerializer {
 
   def serializeChanges(changesResponse: QuotaChangesResponse): JsObject = Json.toJson(changesResponse).as[JsObject]
 
+  def serializeQuery(quotaQueryResponse: QuotaQueryResponse) : JsObject = Json.toJsObject(quotaQueryResponse)
 }
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/Quotas.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/Quotas.scala
index d24076b1ce..f085bbd785 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/Quotas.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/Quotas.scala
@@ -23,10 +23,11 @@ import com.google.common.hash.Hashing
 import eu.timepit.refined.auto._
 import org.apache.james.core.Domain
 import org.apache.james.core.quota.{QuotaCountLimit, QuotaCountUsage, QuotaSizeLimit, QuotaSizeUsage}
-import org.apache.james.jmap.api.change.Limit
 import org.apache.james.jmap.core.Id.Id
+import org.apache.james.jmap.core.Limit.Limit
+import org.apache.james.jmap.core.Position.Position
 import org.apache.james.jmap.core.UnsignedInt.UnsignedInt
-import org.apache.james.jmap.core.{AccountId, Id, Properties, UnsignedInt, UuidState}
+import org.apache.james.jmap.core.{AccountId, CanCalculateChanges, Id, Properties, QueryState, UnsignedInt, UuidState}
 import org.apache.james.jmap.method.WithAccountId
 import org.apache.james.mailbox.model.{Quota => ModelQuota, QuotaRoot => ModelQuotaRoot}
 
@@ -201,7 +202,7 @@ case class QuotaResponseGetResult(jmapQuotaSet: Set[JmapQuota] = Set(),
 
 case class QuotaChangesRequest(accountId: AccountId,
                                sinceState: UuidState,
-                               maxChanges: Option[Limit]) extends WithAccountId
+                               maxChanges: Option[org.apache.james.jmap.core.Limit.Limit]) extends WithAccountId
 
 object QuotaChangesResponse {
   def from(oldState: UuidState, newState: (UuidState, Seq[Id]), accountId: AccountId): QuotaChangesResponse =
@@ -224,4 +225,22 @@ case class QuotaChangesResponse(accountId: AccountId,
                                 updatedProperties: Option[Properties] = None,
                                 created: Set[Id] = Set(),
                                 updated: Set[Id] = Set(),
-                                destroyed: Set[Id] = Set())
\ No newline at end of file
+                                destroyed: Set[Id] = Set())
+
+case class QuotaQueryRequest(accountId: AccountId, filter: QuotaQueryFilter) extends WithAccountId
+
+object QuotaQueryFilter {
+  val SUPPORTED: Set[String] = Set("scope", "name", "resourceTypes", "dataTypes")
+}
+
+case class QuotaQueryFilter(scope: Option[Set[Scope]],
+                            name: Option[QuotaName],
+                            resourceTypes: Option[Set[ResourceType]],
+                            dataTypes: Option[Set[DataType]])
+
+case class QuotaQueryResponse(accountId: AccountId,
+                              queryState: QueryState,
+                              canCalculateChanges: CanCalculateChanges,
+                              ids: Seq[Id],
+                              position: Position,
+                              limit: Option[Limit])
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/QuotaQueryMethod.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/QuotaQueryMethod.scala
new file mode 100644
index 0000000000..2f537b56d5
--- /dev/null
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/QuotaQueryMethod.scala
@@ -0,0 +1,88 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ * http://www.apache.org/licenses/LICENSE-2.0                   *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.jmap.method
+
+import eu.timepit.refined.auto._
+import org.apache.james.jmap.core.CapabilityIdentifier.{CapabilityIdentifier, JMAP_CORE, JMAP_QUOTA}
+import org.apache.james.jmap.core.Invocation.{Arguments, MethodName}
+import org.apache.james.jmap.core.{CanCalculateChanges, ErrorCode, Invocation, Limit, Position, QueryState}
+import org.apache.james.jmap.json.{QuotaSerializer, ResponseSerializer}
+import org.apache.james.jmap.mail.{JmapQuota, QuotaQueryFilter, QuotaQueryRequest, QuotaQueryResponse}
+import org.apache.james.jmap.routes.SessionSupplier
+import org.apache.james.mailbox.MailboxSession
+import org.apache.james.mailbox.quota.{QuotaManager, UserQuotaRootResolver}
+import org.apache.james.metrics.api.MetricFactory
+import org.reactivestreams.Publisher
+import play.api.libs.json.JsError
+import reactor.core.scala.publisher.SMono
+
+import javax.inject.Inject
+
+class QuotaQueryMethod @Inject()(val metricFactory: MetricFactory,
+                                 val sessionSupplier: SessionSupplier,
+                                 val quotaManager: QuotaManager,
+                                 val quotaRootResolver: UserQuotaRootResolver) extends MethodRequiringAccountId[QuotaQueryRequest] {
+
+  override val methodName: Invocation.MethodName = MethodName("Quota/query")
+  override val requiredCapabilities: Set[CapabilityIdentifier] = Set(JMAP_QUOTA, JMAP_CORE)
+
+  val jmapQuotaManagerWrapper: JmapQuotaManagerWrapper = JmapQuotaManagerWrapper(quotaManager, quotaRootResolver)
+
+  override def doProcess(capabilities: Set[CapabilityIdentifier], invocation: InvocationWithContext, mailboxSession: MailboxSession, request: QuotaQueryRequest): Publisher[InvocationWithContext] =
+    processRequest(mailboxSession, invocation.invocation, request, capabilities)
+      .onErrorResume {
+        case e: IllegalArgumentException => SMono.just(
+          Invocation.error(
+            errorCode = ErrorCode.InvalidArguments,
+            description = e.getMessage,
+            methodCallId = invocation.invocation.methodCallId))
+        case e: Throwable => SMono.error(e)
+      }
+      .map(invocationResult => InvocationWithContext(invocationResult, invocation.processingContext))
+
+  override def getRequest(mailboxSession: MailboxSession, invocation: Invocation): Either[Exception, QuotaQueryRequest] =
+    QuotaSerializer.deserializeQuotaQueryRequest(invocation.arguments.value)
+      .asEither
+      .left.map(errors => new IllegalArgumentException(ResponseSerializer.serialize(JsError(errors)).toString))
+
+  private def processRequest(mailboxSession: MailboxSession, invocation: Invocation, request: QuotaQueryRequest, capabilities: Set[CapabilityIdentifier]): SMono[Invocation] =
+    jmapQuotaManagerWrapper.list(mailboxSession.getUser, capabilities)
+      .filter(filterPredicate(request.filter))
+      .collectSeq()
+      .map(quotas => QuotaQueryResponse(
+        accountId = request.accountId,
+        queryState = QueryState.forQuotaIds(quotas.map(_.id).sortBy(_.value)),
+        canCalculateChanges = CanCalculateChanges(false),
+        ids = quotas.map(_.id),
+        position = Position.zero,
+        limit = Some(Limit.default)))
+      .map(response => Invocation(methodName = methodName, arguments = Arguments(QuotaSerializer.serializeQuery(response)), methodCallId = invocation.methodCallId))
+
+  private def filterPredicate(filter: QuotaQueryFilter): (JmapQuota) => scala.Boolean =
+    quota => {
+      ((filter.name match {
+        case None => true
+        case Some(value) => value.string == quota.name.string
+      })
+        && filter.scope.forall(_.contains(quota.scope))
+        && filter.resourceTypes.forall(_.contains(quota.resourceType))
+        && filter.dataTypes.forall(dataTypesValue => quota.dataTypes.toSet.subsetOf(dataTypesValue)))
+    }
+}
diff --git a/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/json/QuotaSerializerTest.scala b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/json/QuotaSerializerTest.scala
index b1e790af23..26a6a13e1a 100644
--- a/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/json/QuotaSerializerTest.scala
+++ b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/json/QuotaSerializerTest.scala
@@ -21,10 +21,10 @@ package org.apache.james.jmap.json
 
 import eu.timepit.refined.auto._
 import net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson
-import org.apache.james.jmap.core.{AccountId, Id, Properties, UnsignedInt, UuidState}
+import org.apache.james.jmap.core.{AccountId, CanCalculateChanges, Id, Limit, Position, Properties, QueryState, UnsignedInt, UuidState}
 import org.apache.james.jmap.json.Fixture.id
 import org.apache.james.jmap.json.QuotaSerializerTest.ACCOUNT_ID
-import org.apache.james.jmap.mail.{AccountScope, CountResourceType, JmapQuota, MailDataType, OctetsResourceType, QuotaDescription, QuotaGetRequest, QuotaGetResponse, QuotaIds, QuotaName, QuotaNotFound, UnparsedQuotaId}
+import org.apache.james.jmap.mail.{AccountScope, CountResourceType, JmapQuota, MailDataType, OctetsResourceType, QuotaDescription, QuotaGetRequest, QuotaGetResponse, QuotaIds, QuotaName, QuotaNotFound, QuotaQueryResponse, UnparsedQuotaId}
 import org.scalatest.matchers.should.Matchers
 import org.scalatest.wordspec.AnyWordSpec
 import play.api.libs.json.{JsSuccess, Json}
@@ -219,4 +219,29 @@ class QuotaSerializerTest extends AnyWordSpec with Matchers {
       assertThatJson(Json.stringify(QuotaSerializer.serialize(actualValue))).isEqualTo(expectedJson)
     }
   }
+
+  "Serialize QuotaQueryResponse" should {
+    "succeed" in {
+      val quotaQueryResponse: QuotaQueryResponse = QuotaQueryResponse(accountId = ACCOUNT_ID,
+        queryState = QueryState.forStrings(Seq()),
+        canCalculateChanges = CanCalculateChanges(false),
+        ids = Id.validate("id1").toSeq,
+        position = Position.zero,
+        limit = Some(Limit.default))
+
+      val expectedJson: String =
+        """{
+          |    "accountId": "aHR0cHM6Ly93d3cuYmFzZTY0ZW5jb2RlLm9yZy8",
+          |    "queryState": "00000000",
+          |    "canCalculateChanges": false,
+          |    "ids": [
+          |        "id1"
+          |    ],
+          |    "position": 0,
+          |    "limit": 256
+          |}""".stripMargin
+
+      assertThatJson(Json.stringify(QuotaSerializer.serializeQuery(quotaQueryResponse))).isEqualTo(expectedJson)
+    }
+  }
 }


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@james.apache.org
For additional commands, e-mail: notifications-help@james.apache.org