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:10:25 UTC

[james-project] 02/02: JAMES-3830 Implement JMAP Quota/changes

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

commit 8ea77b7e361fb9f42b35262b15b8f54edba68e88
Author: Tung Van TRAN <vt...@linagora.com>
AuthorDate: Wed Oct 26 17:09:24 2022 +0700

    JAMES-3830 Implement JMAP Quota/changes
---
 .../james/jmap/rfc8621/RFC8621MethodsModule.java   |   2 +
 .../DistributedQuotaChangesMethodTest.java         |  55 +++
 .../contract/QuotaChangesMethodContract.scala      | 528 +++++++++++++++++++++
 .../rfc8621/contract/QuotaGetMethodContract.scala  | 173 +++----
 .../memory/MemoryQuotaChangesMethodTest.java       |  44 ++
 .../doc/specs/spec/quotas/quota.mdown              |   4 +-
 .../apache/james/jmap/json/QuotaSerializer.scala   |  19 +-
 .../scala/org/apache/james/jmap/mail/Quotas.scala  |  61 ++-
 .../james/jmap/method/QuotaChangesMethod.scala     |  79 +++
 .../apache/james/jmap/method/QuotaGetMethod.scala  |  55 +--
 10 files changed, 881 insertions(+), 139 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 fbfe7c036e..571ef5434c 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
@@ -64,6 +64,7 @@ import org.apache.james.jmap.method.MailboxSetMethod;
 import org.apache.james.jmap.method.Method;
 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.SystemZoneIdProvider;
 import org.apache.james.jmap.method.ThreadChangesMethod;
@@ -143,6 +144,7 @@ public class RFC8621MethodsModule extends AbstractModule {
         methods.addBinding().to(MDNSendMethod.class);
         methods.addBinding().to(PushSubscriptionGetMethod.class);
         methods.addBinding().to(PushSubscriptionSetMethod.class);
+        methods.addBinding().to(QuotaChangesMethod.class);
         methods.addBinding().to(QuotaGetMethod.class);
         methods.addBinding().to(ThreadChangesMethod.class);
         methods.addBinding().to(ThreadGetMethod.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/DistributedQuotaChangesMethodTest.java b/server/protocols/jmap-rfc-8621-integration-tests/distributed-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/distributed/DistributedQuotaChangesMethodTest.java
new file mode 100644
index 0000000000..3f274d9523
--- /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/DistributedQuotaChangesMethodTest.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.QuotaChangesMethodContract;
+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 DistributedQuotaChangesMethodTest implements QuotaChangesMethodContract {
+
+    @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/QuotaChangesMethodContract.scala b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/QuotaChangesMethodContract.scala
new file mode 100644
index 0000000000..86029b2262
--- /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/QuotaChangesMethodContract.scala
@@ -0,0 +1,528 @@
+/****************************************************************
+ * 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
+import org.apache.james.jmap.core.ResponseObject.SESSION_STATE
+import org.apache.james.jmap.core.UuidState.INSTANCE
+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.MessageManager.AppendCommand
+import org.apache.james.mailbox.model.MailboxACL.Right.Read
+import org.apache.james.mailbox.model.{MailboxACL, MailboxPath}
+import org.apache.james.mime4j.dom.Message
+import org.apache.james.modules.{ACLProbeImpl, MailboxProbeImpl, QuotaProbesImpl}
+import org.apache.james.utils.DataProbeImpl
+import org.assertj.core.api.Assertions.assertThat
+import org.awaitility.Awaitility
+import org.junit.jupiter.api.{BeforeEach, Test}
+
+import java.nio.charset.StandardCharsets
+import java.time.Duration
+import java.util.concurrent.TimeUnit
+
+
+trait QuotaChangesMethodContract {
+
+  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 quotaChangeShouldReturnCorrectResponse(server: GuiceJamesServer): Unit = {
+    val quotaProbe = server.getProbe(classOf[QuotaProbesImpl])
+    val bobQuotaRoot = quotaProbe.getQuotaRoot(MailboxPath.inbox(BOB))
+    quotaProbe.setMaxMessageCount(bobQuotaRoot, QuotaCountLimit.count(100L))
+
+    val response = `given`
+      .body(
+        s"""{
+           |  "using": [
+           |    "urn:ietf:params:jmap:core",
+           |    "urn:ietf:params:jmap:quota"],
+           |  "methodCalls": [[
+           |    "Quota/changes",
+           |    {
+           |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+           |      "sinceState": "${INSTANCE.value}"
+           |    },
+           |    "c1"]]
+           |}""".stripMargin)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |    "sessionState": "${SESSION_STATE.value}",
+         |    "methodResponses": [
+         |        [
+         |            "Quota/changes",
+         |            {
+         |                "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |                "oldState": "${INSTANCE.value}",
+         |                "newState": "84c40a2e-76a1-3f84-a1e8-862104c7a697",
+         |                "hasMoreChanges": false,
+         |                "updatedProperties": null,
+         |                "created": [],
+         |                "updated": ["08417be420b6dd6fa77d48fb2438e0d19108cd29424844bb109b52d356fab528"],
+         |                "destroyed": []
+         |            },
+         |            "c1"
+         |        ]
+         |    ]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def quotaChangeShouldReturnSameResponseWhenSameRequest(server: GuiceJamesServer): Unit = {
+    val quotaProbe = server.getProbe(classOf[QuotaProbesImpl])
+    val bobQuotaRoot = quotaProbe.getQuotaRoot(MailboxPath.inbox(BOB))
+    quotaProbe.setMaxMessageCount(bobQuotaRoot, QuotaCountLimit.count(100L))
+
+    val response1 = `given`
+      .body(
+        s"""{
+           |  "using": [
+           |    "urn:ietf:params:jmap:core",
+           |    "urn:ietf:params:jmap:quota"],
+           |  "methodCalls": [[
+           |    "Quota/changes",
+           |    {
+           |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+           |      "sinceState": "${INSTANCE.value}"
+           |    },
+           |    "c1"]]
+           |}""".stripMargin)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    val response2 = `given`
+      .body(
+        s"""{
+           |  "using": [
+           |    "urn:ietf:params:jmap:core",
+           |    "urn:ietf:params:jmap:quota"],
+           |  "methodCalls": [[
+           |    "Quota/changes",
+           |    {
+           |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+           |      "sinceState": "${INSTANCE.value}"
+           |    },
+           |    "c1"]]
+           |}""".stripMargin)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response1).isEqualTo(response2)
+  }
+
+  @Test
+  def hasMoreChangesShouldBeFalseWhenNoQuotaChanges(server: GuiceJamesServer): Unit = {
+    val quotaProbe = server.getProbe(classOf[QuotaProbesImpl])
+    val bobQuotaRoot = quotaProbe.getQuotaRoot(MailboxPath.inbox(BOB))
+    quotaProbe.setMaxMessageCount(bobQuotaRoot, QuotaCountLimit.count(100L))
+
+    val newState: String = getLastState()
+
+    val response = `given`
+      .body(
+        s"""{
+           |  "using": [
+           |    "urn:ietf:params:jmap:core",
+           |    "urn:ietf:params:jmap:quota"],
+           |  "methodCalls": [[
+           |    "Quota/changes",
+           |    {
+           |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+           |      "sinceState": "${newState}"
+           |    },
+           |    "c1"]]
+           |}""".stripMargin)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |    "sessionState": "${SESSION_STATE.value}",
+         |    "methodResponses": [
+         |        [
+         |            "Quota/changes",
+         |            {
+         |                "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |                "oldState": "${newState}",
+         |                "newState": "${newState}",
+         |                "hasMoreChanges": false,
+         |                "updatedProperties": null,
+         |                "created": [],
+         |                "updated": [],
+         |                "destroyed": []
+         |            },
+         |            "c1"
+         |        ]
+         |    ]
+         |}""".stripMargin)
+  }
+
+
+  @Test
+  def stateShouldBeChangedWhenQuotaIsUpdated(server: GuiceJamesServer): Unit = {
+    val quotaProbe = server.getProbe(classOf[QuotaProbesImpl])
+    val bobQuotaRoot = quotaProbe.getQuotaRoot(MailboxPath.inbox(BOB))
+    quotaProbe.setMaxMessageCount(bobQuotaRoot, QuotaCountLimit.count(100L))
+    server.getProbe(classOf[MailboxProbeImpl]).createMailbox(MailboxPath.inbox(BOB))
+
+    val newState: String = getLastState()
+
+    // update quota usage
+    server.getProbe(classOf[MailboxProbeImpl])
+      .appendMessage(BOB.asString(), MailboxPath.inbox(BOB), AppendCommand.from(Message.Builder
+        .of
+        .setSubject("test")
+        .setBody("testmail", StandardCharsets.UTF_8)
+        .build))
+      .getMessageId.serialize()
+
+    awaitAtMostTenSeconds.untilAsserted(() => assertThat(getLastState())
+      .isNotEqualTo(newState))
+  }
+
+  private def getLastState(): String = {
+    `given`
+      .body(
+        s"""{
+           |  "using": [
+           |    "urn:ietf:params:jmap:core",
+           |    "urn:ietf:params:jmap:quota"],
+           |  "methodCalls": [[
+           |    "Quota/changes",
+           |    {
+           |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+           |      "sinceState": "${INSTANCE.value}"
+           |    },
+           |    "c1"]]
+           |}""".stripMargin)
+      .when
+      .post
+      .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract()
+      .jsonPath()
+      .get("methodResponses[0][1].newState")
+  }
+
+  @Test
+  def quotaChangesShouldFailWhenWrongAccountId(): Unit = {
+    val response = `given`
+      .body(
+        s"""{
+           |  "using": [
+           |    "urn:ietf:params:jmap:core",
+           |    "urn:ietf:params:jmap:quota"],
+           |  "methodCalls": [[
+           |    "Quota/changes",
+           |    {
+           |      "accountId": "unknownAccountId",
+           |      "sinceState": "${INSTANCE.value}"
+           |    },
+           |    "c1"]]
+           |}""".stripMargin)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |  "sessionState": "${SESSION_STATE.value}",
+         |  "methodResponses": [
+         |    ["error", {
+         |      "type": "accountNotFound"
+         |    }, "c1"]
+         |  ]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def quotaChangesShouldFailWhenMissSinceState(): Unit = {
+    val response = `given`
+      .body(
+        s"""{
+           |  "using": [
+           |    "urn:ietf:params:jmap:core",
+           |    "urn:ietf:params:jmap:quota"],
+           |  "methodCalls": [[
+           |    "Quota/changes",
+           |    {
+           |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6"
+           |    },
+           |    "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.sinceState\\",\\"messages\\":[\\"error.path.missing\\"]}]}"
+         |            },
+         |            "c1"
+         |        ]
+         |    ]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def quotaChangesShouldFailWhenSinceStateIsInvalid(): Unit = {
+    val response = `given`
+      .body(
+        s"""{
+           |  "using": [
+           |    "urn:ietf:params:jmap:core",
+           |    "urn:ietf:params:jmap:quota"],
+           |  "methodCalls": [[
+           |    "Quota/changes",
+           |    {
+           |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+           |      "sinceState": "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.sinceState\\",\\"messages\\":[\\"error.expected.uuid\\"]}]}"
+         |            },
+         |            "c1"
+         |        ]
+         |    ]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def quotaChangeShouldFailWhenOmittingOneCapability(): Unit = {
+    val response = `given`
+      .body(
+        s"""{
+           |  "using": [
+           |    "urn:ietf:params:jmap:core"],
+           |  "methodCalls": [[
+           |    "Quota/changes",
+           |    {
+           |      "accountId": "unknownAccountId",
+           |      "sinceState": "${INSTANCE.value}"
+           |    },
+           |    "c1"]]
+           |}""".stripMargin)
+    .when
+      .post
+    .`then`
+      .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 quotaChangesShouldFailWhenOmittingAllCapability(): Unit = {
+    val response = `given`
+      .body(
+        s"""{
+           |  "using": [],
+           |  "methodCalls": [[
+           |    "Quota/changes",
+           |    {
+           |      "accountId": "unknownAccountId",
+           |      "sinceState": "${INSTANCE.value}"
+           |    },
+           |    "c1"]]
+           |}""".stripMargin)
+    .when
+      .post
+    .`then`
+      .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 quotaChangesShouldReturnDifferenceStateWhenProvideDelegatedMailbox(server: GuiceJamesServer): Unit = {
+    val quotaProbe = server.getProbe(classOf[QuotaProbesImpl])
+    val bobQuotaRoot = quotaProbe.getQuotaRoot(MailboxPath.inbox(BOB))
+    quotaProbe.setMaxMessageCount(bobQuotaRoot, QuotaCountLimit.count(100L))
+
+    // setup delegated Mailbox
+    val andreMailbox = MailboxPath.forUser(ANDRE, "mailbox")
+    val mailboxId = 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 stateWithOutShareCapability: String = `given`
+      .body(
+        s"""{
+           |  "using": [
+           |    "urn:ietf:params:jmap:core",
+           |    "urn:ietf:params:jmap:quota"],
+           |  "methodCalls": [[
+           |    "Quota/changes",
+           |    {
+           |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+           |      "sinceState": "${INSTANCE.value}"
+           |    },
+           |    "c1"]]
+           |}""".stripMargin)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract()
+      .jsonPath()
+      .get("methodResponses[0][1].newState")
+
+    val stateWithShareCapability: String = `given`
+      .body(
+        s"""{
+           |  "using": [
+           |    "urn:ietf:params:jmap:core",
+           |    "urn:ietf:params:jmap:quota",
+           |    "urn:apache:james:params:jmap:mail:shares"],
+           |  "methodCalls": [[
+           |    "Quota/changes",
+           |    {
+           |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+           |      "sinceState": "${INSTANCE.value}"
+           |    },
+           |    "c1"]]
+           |}""".stripMargin)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract()
+      .jsonPath()
+      .get("methodResponses[0][1].newState")
+
+    assertThat(stateWithShareCapability).isNotEqualTo(stateWithOutShareCapability)
+  }
+}
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/QuotaGetMethodContract.scala b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/QuotaGetMethodContract.scala
index bd8f00ca02..3d0687a5f8 100644
--- a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/QuotaGetMethodContract.scala
+++ b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/QuotaGetMethodContract.scala
@@ -43,9 +43,18 @@ import org.junit.jupiter.api.{BeforeEach, Test}
 import java.nio.charset.StandardCharsets
 import org.apache.james.mailbox.model.MailboxACL.Right.Read
 import org.apache.james.mailbox.model.MailboxACL.Right.Lookup
+import org.awaitility.Awaitility
+
+import java.time.Duration
+import java.util.concurrent.TimeUnit
 
 trait QuotaGetMethodContract {
 
+  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])
@@ -92,7 +101,7 @@ trait QuotaGetMethodContract {
          |    "Quota/get",
          |    {
          |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
-         |      "state": "${INSTANCE.value}",
+         |      "state": "1a9d5db2-2c73-3993-bf0b-42f64b396873",
          |      "list": [],
          |      "notFound": []
          |    },
@@ -141,7 +150,7 @@ trait QuotaGetMethodContract {
          |            {
          |                "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
          |                "notFound": [],
-         |                "state": "${INSTANCE.value}",
+         |                "state": "6d7199ed-f1ce-31f3-8f02-c2e824004e55",
          |                "list": [
          |                    {
          |                        "used": 0,
@@ -214,7 +223,7 @@ trait QuotaGetMethodContract {
          |            {
          |                "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
          |                "notFound": [],
-         |                "state": "${INSTANCE.value}",
+         |                "state": "84c40a2e-76a1-3f84-a1e8-862104c7a697",
          |                "list": []
          |            },
          |            "c1"
@@ -262,7 +271,7 @@ trait QuotaGetMethodContract {
          |            {
          |                "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
          |                "notFound": [ "notfound123" ],
-         |                "state": "${INSTANCE.value}",
+         |                "state": "84c40a2e-76a1-3f84-a1e8-862104c7a697",
          |                "list": []
          |            },
          |            "c1"
@@ -313,7 +322,7 @@ trait QuotaGetMethodContract {
          |            {
          |                "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
          |                "notFound": [ "notfound123" ],
-         |                "state": "${INSTANCE.value}",
+         |                "state": "461cef39-0c47-352b-a9e9-052093c20d5d",
          |                "list": [
          |                    {
          |                        "used": 0,
@@ -386,7 +395,7 @@ trait QuotaGetMethodContract {
          |            {
          |                "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
          |                "notFound": [ ],
-         |                "state": "${INSTANCE.value}",
+         |                "state": "3c51d50a-d766-38b7-9fa4-c9ff12de87a4",
          |                "list": [
          |                    {
          |                        "used": 1,
@@ -576,7 +585,7 @@ trait QuotaGetMethodContract {
          |    "Quota/get",
          |    {
          |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
-         |      "state": "${INSTANCE.value}",
+         |      "state": "1a9d5db2-2c73-3993-bf0b-42f64b396873",
          |      "list": [],
          |      "notFound": []
          |    },
@@ -622,7 +631,7 @@ trait QuotaGetMethodContract {
          |    "Quota/get",
          |    {
          |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
-         |      "state": "${INSTANCE.value}",
+         |      "state": "1a9d5db2-2c73-3993-bf0b-42f64b396873",
          |      "list": [],
          |      "notFound": [ "${quotaId}" ]
          |    },
@@ -667,7 +676,7 @@ trait QuotaGetMethodContract {
          |    "Quota/get",
          |    {
          |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
-         |      "state": "${INSTANCE.value}",
+         |      "state": "84c40a2e-76a1-3f84-a1e8-862104c7a697",
          |      "list": [
          |        {
          |          "id": "08417be420b6dd6fa77d48fb2438e0d19108cd29424844bb109b52d356fab528"
@@ -716,7 +725,7 @@ trait QuotaGetMethodContract {
          |    "Quota/get",
          |    {
          |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
-         |      "state": "${INSTANCE.value}",
+         |      "state": "84c40a2e-76a1-3f84-a1e8-862104c7a697",
          |      "list": [
          |        {
          |          "id": "08417be420b6dd6fa77d48fb2438e0d19108cd29424844bb109b52d356fab528",
@@ -844,73 +853,75 @@ trait QuotaGetMethodContract {
         .build))
       .getMessageId.serialize()
 
-    val response = `given`
-      .body(
-        s"""{
-           |  "using": [
-           |    "urn:ietf:params:jmap:core",
-           |    "urn:ietf:params:jmap:quota"],
-           |  "methodCalls": [[
-           |    "Quota/get",
-           |    {
-           |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
-           |      "ids": null
-           |    },
-           |    "c1"]]
-           |}""".stripMargin)
-    .when
-      .post
-    .`then`
-      .statusCode(SC_OK)
-      .contentType(JSON)
-      .extract
-      .body
-      .asString
-
-    assertThatJson(response)
-      .withOptions(new Options(IGNORING_ARRAY_ORDER))
-      .isEqualTo(
-      s"""{
-         |    "sessionState": "${SESSION_STATE.value}",
-         |    "methodResponses": [
-         |        [
-         |            "Quota/get",
-         |            {
-         |                "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
-         |                "notFound": [],
-         |                "state": "${INSTANCE.value}",
-         |                "list": [
-         |                    {
-         |                        "used": 1,
-         |                        "name": "#private&bob@domain.tld@domain.tld:account:count:Mail",
-         |                        "id": "08417be420b6dd6fa77d48fb2438e0d19108cd29424844bb109b52d356fab528",
-         |                        "dataTypes": [
-         |                            "Mail"
-         |                        ],
-         |                        "limit": 100,
-         |                        "warnLimit": 90,
-         |                        "resourceType": "count",
-         |                        "scope": "account"
-         |                    },
-         |                    {
-         |                        "used": 85,
-         |                        "name": "#private&bob@domain.tld@domain.tld:account:octets:Mail",
-         |                        "id": "eab6ce8ac5d9730a959e614854410cf39df98ff3760a623b8e540f36f5184947",
-         |                        "dataTypes": [
-         |                            "Mail"
-         |                        ],
-         |                        "limit": 101,
-         |                        "warnLimit": 90,
-         |                        "resourceType": "octets",
-         |                        "scope": "account"
-         |                    }
-         |                ]
-         |            },
-         |            "c1"
-         |        ]
-         |    ]
-         |}
-         |""".stripMargin)
+    awaitAtMostTenSeconds.untilAsserted(() => {
+      val response = `given`
+        .body(
+          s"""{
+             |  "using": [
+             |    "urn:ietf:params:jmap:core",
+             |    "urn:ietf:params:jmap:quota"],
+             |  "methodCalls": [[
+             |    "Quota/get",
+             |    {
+             |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+             |      "ids": null
+             |    },
+             |    "c1"]]
+             |}""".stripMargin)
+      .when
+        .post
+      .`then`
+        .statusCode(SC_OK)
+        .contentType(JSON)
+        .extract
+        .body
+        .asString
+
+      assertThatJson(response)
+        .withOptions(new Options(IGNORING_ARRAY_ORDER))
+        .isEqualTo(
+          s"""{
+             |    "sessionState": "${SESSION_STATE.value}",
+             |    "methodResponses": [
+             |        [
+             |            "Quota/get",
+             |            {
+             |                "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+             |                "notFound": [],
+             |                "state": "7d53031b-2819-3584-9e9d-e10ac1067906",
+             |                "list": [
+             |                    {
+             |                        "used": 1,
+             |                        "name": "#private&bob@domain.tld@domain.tld:account:count:Mail",
+             |                        "id": "08417be420b6dd6fa77d48fb2438e0d19108cd29424844bb109b52d356fab528",
+             |                        "dataTypes": [
+             |                            "Mail"
+             |                        ],
+             |                        "limit": 100,
+             |                        "warnLimit": 90,
+             |                        "resourceType": "count",
+             |                        "scope": "account"
+             |                    },
+             |                    {
+             |                        "used": 85,
+             |                        "name": "#private&bob@domain.tld@domain.tld:account:octets:Mail",
+             |                        "id": "eab6ce8ac5d9730a959e614854410cf39df98ff3760a623b8e540f36f5184947",
+             |                        "dataTypes": [
+             |                            "Mail"
+             |                        ],
+             |                        "limit": 101,
+             |                        "warnLimit": 90,
+             |                        "resourceType": "octets",
+             |                        "scope": "account"
+             |                    }
+             |                ]
+             |            },
+             |            "c1"
+             |        ]
+             |    ]
+             |}
+             |""".stripMargin)
+    })
   }
 
   @Test
@@ -960,7 +971,7 @@ trait QuotaGetMethodContract {
          |            {
          |                "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
          |                "notFound": [],
-         |                "state": "${INSTANCE.value}",
+         |                "state": "84c40a2e-76a1-3f84-a1e8-862104c7a697",
          |                "list": [
          |                    {
          |                        "used": 0,
@@ -1032,7 +1043,7 @@ trait QuotaGetMethodContract {
          |            {
          |                "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
          |                "notFound": [],
-         |                "state": "${INSTANCE.value}",
+         |                "state": "5dc809dd-d059-3fab-bc7e-c0f1fcacf2f2",
          |                "list": [
          |                    {
          |                        "used": 0,
@@ -1114,7 +1125,7 @@ trait QuotaGetMethodContract {
          |            {
          |                "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
          |                "notFound": [],
-         |                "state": "${INSTANCE.value}",
+         |                "state": "84c40a2e-76a1-3f84-a1e8-862104c7a697",
          |                "list": [
          |                    {
          |                        "used": 0,
@@ -1186,7 +1197,7 @@ trait QuotaGetMethodContract {
          |            {
          |                "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
          |                "notFound": [],
-         |                "state": "${INSTANCE.value}",
+         |                "state": "5dc809dd-d059-3fab-bc7e-c0f1fcacf2f2",
          |                "list": [
          |                    {
          |                        "used": 0,
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/MemoryQuotaChangesMethodTest.java b/server/protocols/jmap-rfc-8621-integration-tests/memory-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/memory/MemoryQuotaChangesMethodTest.java
new file mode 100644
index 0000000000..c2606b3d9d
--- /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/MemoryQuotaChangesMethodTest.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.QuotaChangesMethodContract;
+import org.apache.james.modules.TestJMAPServerModule;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+public class MemoryQuotaChangesMethodTest implements QuotaChangesMethodContract {
+
+    @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 1fef9d046d..e9215e303c 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
@@ -46,8 +46,8 @@ Standard “/get” method as described in [@!RFC8620] section 5.1. The ids argu
 
 ## Quota/changes
 
-> :warning:
-> Not implemented
+> :information_source:
+> Implemented
 
 Standard “/changes” method as described in [@!RFC8620] section 5.2 but with one extra argument to the response:
 
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 2ca89be9ab..bcd56b62a4 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
@@ -22,7 +22,7 @@ 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, QuotaDescription, QuotaGetRequest, QuotaGetResponse, QuotaIds, QuotaName, QuotaNotFound, ResourceType, Scope, UnparsedQuotaId}
+import org.apache.james.jmap.mail.{DataType, JmapQuota, QuotaChangesRequest, QuotaChangesResponse, QuotaDescription, QuotaGetRequest, QuotaGetResponse, QuotaIds, QuotaName, QuotaNotFound, ResourceType, Scope, UnparsedQuotaId}
 import play.api.libs.json._
 
 object QuotaSerializer {
@@ -53,10 +53,25 @@ object QuotaSerializer {
     notFound => JsArray(notFound.value.toList.map(id => JsString(id.id.value)))
   private implicit val quotaGetResponseWrites: Writes[QuotaGetResponse] = Json.writes[QuotaGetResponse]
 
+  private implicit val quotaChangesRequestReads: Reads[QuotaChangesRequest] = Json.reads[QuotaChangesRequest]
+
+  private implicit val quotaChangesResponseWrites: Writes[QuotaChangesResponse] = response =>
+    Json.obj(
+      "accountId" -> response.accountId,
+      "oldState" -> response.oldState,
+      "newState" -> response.newState,
+      "hasMoreChanges" -> response.hasMoreChanges,
+      "updatedProperties" -> response.updatedProperties,
+      "created" -> response.created,
+      "updated" -> response.updated,
+      "destroyed" -> response.destroyed)
+
   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 serialize(quotaGetResponse: QuotaGetResponse, properties: Properties): JsValue =
     Json.toJson(quotaGetResponse)
       .transform((__ \ "list").json.update {
@@ -70,4 +85,6 @@ object QuotaSerializer {
 
   def serialize(response: QuotaGetResponse): JsValue = Json.toJson(response)
 
+  def serializeChanges(changesResponse: QuotaChangesResponse): JsObject = Json.toJson(changesResponse).as[JsObject]
+
 }
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 1c4545a5d9..d24076b1ce 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,14 +23,15 @@ 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.UnsignedInt.UnsignedInt
-import org.apache.james.jmap.core.UuidState.INSTANCE
 import org.apache.james.jmap.core.{AccountId, Id, Properties, UnsignedInt, UuidState}
 import org.apache.james.jmap.method.WithAccountId
 import org.apache.james.mailbox.model.{Quota => ModelQuota, QuotaRoot => ModelQuotaRoot}
 
 import java.nio.charset.StandardCharsets
+import java.util.UUID
 import scala.compat.java8.OptionConverters._
 
 object QuotaRoot {
@@ -109,6 +110,10 @@ object JmapQuota {
         name = QuotaName.from(quotaRoot, AccountScope, OctetsResourceType, List(MailDataType)),
         dataTypes = List(MailDataType),
         warnLimit = Some(UnsignedInt.liftOrThrow((limit.asLong() * WARN_LIMIT_PERCENTAGE).toLong))))
+
+  def correspondingState(quotas: Seq[JmapQuota]): UuidState =
+    UuidState(UUID.nameUUIDFromBytes(s"${quotas.sortBy(_.name.string).map(_.name.string).mkString("_")}:${quotas.map(_.used.value).sum + quotas.map(_.limit.value).sum}"
+      .getBytes(StandardCharsets.UTF_8)))
 }
 
 case class JmapQuota(id: Id,
@@ -174,19 +179,49 @@ object QuotaIdFactory {
 }
 
 object QuotaResponseGetResult {
-  def empty: QuotaResponseGetResult = QuotaResponseGetResult()
-
-  def merge(result1: QuotaResponseGetResult, result2: QuotaResponseGetResult): QuotaResponseGetResult = result1.merge(result2)
-}
-
-case class QuotaResponseGetResult(jmapQuotaSet: Set[JmapQuota] = Set(), notFound: QuotaNotFound = QuotaNotFound(Set())) {
-  def merge(other: QuotaResponseGetResult): QuotaResponseGetResult =
-    QuotaResponseGetResult(this.jmapQuotaSet ++ other.jmapQuotaSet,
-      this.notFound.merge(other.notFound))
-
+  def from(quotas: Seq[JmapQuota], requestIds: Option[Set[Id]]): QuotaResponseGetResult =
+    requestIds match {
+      case None => QuotaResponseGetResult(quotas.toSet, state = JmapQuota.correspondingState(quotas))
+      case Some(value) => QuotaResponseGetResult(
+        jmapQuotaSet = quotas.filter(quota => value.contains(quota.id)).toSet,
+        notFound = QuotaNotFound(value.diff(quotas.map(_.id).toSet).map(UnparsedQuotaId)),
+        state = JmapQuota.correspondingState(quotas))
+    }
+}
+
+case class QuotaResponseGetResult(jmapQuotaSet: Set[JmapQuota] = Set(),
+                                  notFound: QuotaNotFound = QuotaNotFound(Set()),
+                                  state: UuidState) {
   def asResponse(accountId: AccountId): QuotaGetResponse =
     QuotaGetResponse(accountId = accountId,
-      state = INSTANCE,
+      state = state,
       list = jmapQuotaSet.toList,
       notFound = notFound)
-}
\ No newline at end of file
+}
+
+case class QuotaChangesRequest(accountId: AccountId,
+                               sinceState: UuidState,
+                               maxChanges: Option[Limit]) extends WithAccountId
+
+object QuotaChangesResponse {
+  def from(oldState: UuidState, newState: (UuidState, Seq[Id]), accountId: AccountId): QuotaChangesResponse =
+    QuotaChangesResponse(
+      accountId = accountId,
+      oldState = oldState,
+      newState = newState._1,
+      hasMoreChanges = HasMoreChanges(false),
+      updated = if (oldState.value.equals(newState._1.value)) {
+        Set()
+      } else {
+        newState._2.toSet
+      })
+}
+
+case class QuotaChangesResponse(accountId: AccountId,
+                                oldState: UuidState,
+                                newState: UuidState,
+                                hasMoreChanges: HasMoreChanges,
+                                updatedProperties: Option[Properties] = None,
+                                created: Set[Id] = Set(),
+                                updated: Set[Id] = Set(),
+                                destroyed: Set[Id] = Set())
\ No newline at end of file
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/QuotaChangesMethod.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/QuotaChangesMethod.scala
new file mode 100644
index 0000000000..f451495402
--- /dev/null
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/QuotaChangesMethod.scala
@@ -0,0 +1,79 @@
+/****************************************************************
+ * 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.core.Username
+import org.apache.james.jmap.api.change.CanNotCalculateChangesException
+import org.apache.james.jmap.core.CapabilityIdentifier.{CapabilityIdentifier, JMAP_CORE, JMAP_QUOTA}
+import org.apache.james.jmap.core.Id.Id
+import org.apache.james.jmap.core.Invocation.{Arguments, MethodName}
+import org.apache.james.jmap.core.{ErrorCode, Invocation, UuidState}
+import org.apache.james.jmap.json.{QuotaSerializer, ResponseSerializer}
+import org.apache.james.jmap.mail.{JmapQuota, QuotaChangesRequest, QuotaChangesResponse}
+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 reactor.core.scala.publisher.SMono
+
+import javax.inject.Inject
+
+class QuotaChangesMethod @Inject()(val metricFactory: MetricFactory,
+                                   val sessionSupplier: SessionSupplier,
+                                   val quotaManager: QuotaManager,
+                                   val quotaRootResolver: UserQuotaRootResolver) extends MethodRequiringAccountId[QuotaChangesRequest] {
+
+  override val methodName: Invocation.MethodName = MethodName("Quota/changes")
+
+  override val requiredCapabilities: Set[CapabilityIdentifier] = Set(JMAP_QUOTA, JMAP_CORE)
+
+  val quotaChangesResolver: QuotaChangesResolver = QuotaChangesResolver(quotaManager, quotaRootResolver)
+
+  override def doProcess(capabilities: Set[CapabilityIdentifier], invocation: InvocationWithContext, mailboxSession: MailboxSession, request: QuotaChangesRequest): Publisher[InvocationWithContext] =
+    quotaChangesResolver.getLatestState(mailboxSession.getUser, capabilities)
+      .map(newState => QuotaChangesResponse.from(request.sinceState, newState, request.accountId))
+      .map(response => InvocationWithContext(
+        invocation = Invocation(
+          methodName = methodName,
+          arguments = Arguments(QuotaSerializer.serializeChanges(response)),
+          methodCallId = invocation.invocation.methodCallId),
+        processingContext = invocation.processingContext))
+      .onErrorResume {
+        case e: CanNotCalculateChangesException => SMono.just(InvocationWithContext(Invocation.error(ErrorCode.CannotCalculateChanges, e.getMessage, invocation.invocation.methodCallId), invocation.processingContext))
+        case e => SMono.error(e)
+      }
+
+  override def getRequest(mailboxSession: MailboxSession, invocation: Invocation): Either[Exception, QuotaChangesRequest] =
+    QuotaSerializer.deserializeQuotaChangesRequest(invocation.arguments.value)
+      .asEither.left.map(ResponseSerializer.asException)
+}
+
+case class QuotaChangesResolver(private val quotaManager: QuotaManager,
+                                private val quotaRootResolver: UserQuotaRootResolver) {
+  private val jmapQuotaManagerWrapper: JmapQuotaManagerWrapper = JmapQuotaManagerWrapper(quotaManager, quotaRootResolver)
+
+  def getLatestState(username: Username, capabilities: Set[CapabilityIdentifier]): SMono[(UuidState, Seq[Id])] =
+    jmapQuotaManagerWrapper.list(username, capabilities)
+      .collectSeq()
+      .map(quotas => (JmapQuota.correspondingState(quotas), quotas.map(_.id)))
+}
\ No newline at end of file
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/QuotaGetMethod.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/QuotaGetMethod.scala
index 4c2d07dc7b..fb339699a8 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/QuotaGetMethod.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/QuotaGetMethod.scala
@@ -26,7 +26,7 @@ import org.apache.james.jmap.core.Id.Id
 import org.apache.james.jmap.core.Invocation.{Arguments, MethodCallId, MethodName}
 import org.apache.james.jmap.core.{ErrorCode, Invocation, MissingCapabilityException, Properties}
 import org.apache.james.jmap.json.{QuotaSerializer, ResponseSerializer}
-import org.apache.james.jmap.mail.{CountResourceType, JmapQuota, OctetsResourceType, QuotaGetRequest, QuotaIdFactory, QuotaNotFound, QuotaResponseGetResult}
+import org.apache.james.jmap.mail.{CountResourceType, JmapQuota, OctetsResourceType, QuotaGetRequest, QuotaIdFactory, QuotaNotFound, QuotaResponseGetResult, UnparsedQuotaId}
 import org.apache.james.jmap.routes.SessionSupplier
 import org.apache.james.lifecycle.api.Startable
 import org.apache.james.mailbox.MailboxSession
@@ -54,7 +54,6 @@ class QuotaGetMethod @Inject()(val metricFactory: MetricFactory,
 
     (requestedProperties -- JmapQuota.allProperties match {
       case invalidProperties if invalidProperties.isEmpty() => getQuotaGetResponse(request, mailboxSession.getUser, capabilities)
-        .reduce(QuotaResponseGetResult.empty)(QuotaResponseGetResult.merge)
         .map(result => result.asResponse(accountId = request.accountId))
         .map(response => Invocation(
           methodName = methodName,
@@ -75,18 +74,10 @@ class QuotaGetMethod @Inject()(val metricFactory: MetricFactory,
     QuotaSerializer.deserializeQuotaGetRequest(invocation.arguments.value)
       .asEither.left.map(ResponseSerializer.asException)
 
-  private def getQuotaGetResponse(quotaGetRequest: QuotaGetRequest, username: Username, capabilities: Set[CapabilityIdentifier]): SFlux[QuotaResponseGetResult] =
-    quotaGetRequest.ids match {
-      case None =>
-        jmapQuotaManagerWrapper.list(username, capabilities)
-          .collectSeq()
-          .map(listJmapQuota => QuotaResponseGetResult(jmapQuotaSet = listJmapQuota.toSet))
-          .flatMapMany(result => SFlux.just(result))
-      case Some(ids) => SFlux.fromIterable(ids.value)
-        .flatMap(id => jmapQuotaManagerWrapper.get(username, id.id, capabilities)
-          .map(jmapQuota => QuotaResponseGetResult(jmapQuotaSet = Set(jmapQuota)))
-          .switchIfEmpty(SMono.just(QuotaResponseGetResult(notFound = QuotaNotFound(Set(id))))))
-    }
+  private def getQuotaGetResponse(request: QuotaGetRequest, username: Username, capabilities: Set[CapabilityIdentifier]): SMono[QuotaResponseGetResult] =
+    jmapQuotaManagerWrapper.list(username, capabilities)
+      .collectSeq()
+      .map(quotas => QuotaResponseGetResult.from(quotas, request.ids.map(_.value.map(_.id).toSet)))
 
   private def handleRequestValidationErrors(exception: Exception, methodCallId: MethodCallId): SMono[Invocation] = exception match {
     case _: MissingCapabilityException => SMono.just(Invocation.error(ErrorCode.UnknownMethod, methodCallId))
@@ -94,15 +85,11 @@ class QuotaGetMethod @Inject()(val metricFactory: MetricFactory,
   }
 }
 
-case class JmapQuotaManagerWrapper(private var quotaManager: QuotaManager,
-                                   private var quotaRootResolver: UserQuotaRootResolver) {
-  def get(username: Username, quotaId: Id, capabilities: Set[CapabilityIdentifier]): SFlux[JmapQuota] =
-    retrieveQuotaRoot(username, capabilities)
-      .flatMap(quotaRoot => getJmapQuota(quotaRoot, Some(quotaId)))
-
+case class JmapQuotaManagerWrapper(private val quotaManager: QuotaManager,
+                                   private val quotaRootResolver: UserQuotaRootResolver) {
   def list(username: Username, capabilities: Set[CapabilityIdentifier]): SFlux[JmapQuota] =
     retrieveQuotaRoot(username, capabilities)
-      .flatMap(quotaRoot => getJmapQuota(quotaRoot))
+      .flatMap(quotaRoot => getJmapQuota(quotaRoot), ReactorUtils.DEFAULT_CONCURRENCY)
 
   def retrieveQuotaRoot(username: Username, capabilities: Set[CapabilityIdentifier]): SFlux[QuotaRoot] =
     SMono.just(capabilities)
@@ -111,26 +98,10 @@ case class JmapQuotaManagerWrapper(private var quotaManager: QuotaManager,
       .flatMapMany(_ => SFlux(quotaRootResolver.listAllAccessibleQuotaRoots(username)))
       .switchIfEmpty(SFlux.just(quotaRootResolver.forUser(username)))
 
-  private def getJmapQuota(quotaRoot: QuotaRoot, quotaId: Option[Id] = None): SFlux[JmapQuota] =
-    (quotaId match {
-      case None => SMono(quotaManager.getQuotasReactive(quotaRoot))
-        .flatMapMany(quotas => SMono.fromCallable(() => JmapQuota.extractUserMessageCountQuota(quotas.getMessageQuota, QuotaIdFactory.from(quotaRoot, CountResourceType), quotaRoot))
-          .mergeWith(SMono.fromCallable(() => JmapQuota.extractUserMessageSizeQuota(quotas.getStorageQuota, QuotaIdFactory.from(quotaRoot, OctetsResourceType), quotaRoot))))
-
-      case Some(quotaIdValue) =>
-        val quotaCountPublisher = SMono.fromCallable(() => QuotaIdFactory.from(quotaRoot, CountResourceType))
-          .filter(countQuotaId => countQuotaId.value.equals(quotaIdValue.value))
-          .flatMap(_ => SMono.fromCallable(() => quotaManager.getMessageQuota(quotaRoot))
-            .subscribeOn(ReactorUtils.BLOCKING_CALL_WRAPPER))
-          .map(quota => JmapQuota.extractUserMessageCountQuota(quota, quotaIdValue, quotaRoot))
-
-        val quotaSizePublisher = SMono.fromCallable(() => QuotaIdFactory.from(quotaRoot, OctetsResourceType))
-          .filter(sizeQuotaId => sizeQuotaId.value.equals(quotaIdValue.value))
-          .flatMap(_ => SMono.fromCallable(() => quotaManager.getStorageQuota(quotaRoot))
-            .subscribeOn(ReactorUtils.BLOCKING_CALL_WRAPPER))
-          .map(quota => JmapQuota.extractUserMessageSizeQuota(quota, quotaIdValue, quotaRoot))
-
-        quotaCountPublisher.mergeWith(quotaSizePublisher)
-    }).flatMap(SMono.justOrEmpty)
+  private def getJmapQuota(quotaRoot: QuotaRoot): SFlux[JmapQuota] =
+    SMono(quotaManager.getQuotasReactive(quotaRoot))
+      .flatMapMany(quotas => SMono.fromCallable(() => JmapQuota.extractUserMessageCountQuota(quotas.getMessageQuota, QuotaIdFactory.from(quotaRoot, CountResourceType), quotaRoot))
+        .mergeWith(SMono.fromCallable(() => JmapQuota.extractUserMessageSizeQuota(quotas.getStorageQuota, QuotaIdFactory.from(quotaRoot, OctetsResourceType), quotaRoot))))
+      .flatMap(SMono.justOrEmpty)
 
 }
\ No newline at end of file


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