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 2020/12/05 07:10:10 UTC

[james-project] 03/17: JAMES-2884 Implement */changes support

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 2d4e077907e22870a4b05f283d2cc7ed232e7765
Author: Benoit Tellier <bt...@linagora.com>
AuthorDate: Wed Nov 25 18:51:56 2020 +0700

    JAMES-2884 Implement */changes support
    
    LTT.rs app requires it, stick to naive implementation: never calculate changes...
---
 .../james/jmap/rfc8621/RFC8621MethodsModule.java   |   6 +
 .../rfc8621/contract/EmailChangesContract.scala    | 171 ++++++++++++++++++++
 .../rfc8621/contract/MailboxChangesContract.scala  | 172 +++++++++++++++++++++
 .../rfc8621/contract/ThreadChangesContract.scala   | 171 ++++++++++++++++++++
 .../memory/MemoryEmailChangesMethodTest.java}      |  30 ++--
 .../memory/MemoryMailboxChangesMethodTest.java}    |  30 ++--
 .../memory/MemoryThreadChangesMethodTest.java}     |  30 ++--
 .../org/apache/james/jmap/core/Invocation.scala    |   4 +
 .../james/jmap/json/EmailGetSerializer.scala       |   8 +-
 .../apache/james/jmap/json/MailboxSerializer.scala |   8 +-
 .../apache/james/jmap/json/ThreadSerializer.scala  |   6 +-
 .../scala/org/apache/james/jmap/json/package.scala |   2 +
 .../org/apache/james/jmap/mail/EmailGet.scala      |  15 ++
 .../org/apache/james/jmap/mail/MailboxGet.scala    |  15 ++
 .../scala/org/apache/james/jmap/mail/Thread.scala  |  15 ++
 .../james/jmap/method/EmailChangesMethod.scala     |  67 ++++++++
 .../james/jmap/method/MailboxChangesMethod.scala   |  69 +++++++++
 .../james/jmap/method/ThreadChangesMethod.scala    |  67 ++++++++
 18 files changed, 844 insertions(+), 42 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 270e56d..4de781d 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
@@ -33,16 +33,19 @@ import org.apache.james.jmap.http.BasicAuthenticationStrategy;
 import org.apache.james.jmap.http.rfc8621.InjectionKeys;
 import org.apache.james.jmap.jwt.JWTAuthenticationStrategy;
 import org.apache.james.jmap.method.CoreEchoMethod;
+import org.apache.james.jmap.method.EmailChangesMethod;
 import org.apache.james.jmap.method.EmailGetMethod;
 import org.apache.james.jmap.method.EmailQueryMethod;
 import org.apache.james.jmap.method.EmailSetMethod;
 import org.apache.james.jmap.method.EmailSubmissionSetMethod;
 import org.apache.james.jmap.method.IdentityGetMethod;
+import org.apache.james.jmap.method.MailboxChangesMethod;
 import org.apache.james.jmap.method.MailboxGetMethod;
 import org.apache.james.jmap.method.MailboxQueryMethod;
 import org.apache.james.jmap.method.MailboxSetMethod;
 import org.apache.james.jmap.method.Method;
 import org.apache.james.jmap.method.SystemZoneIdProvider;
+import org.apache.james.jmap.method.ThreadChangesMethod;
 import org.apache.james.jmap.method.ThreadGetMethod;
 import org.apache.james.jmap.method.VacationResponseGetMethod;
 import org.apache.james.jmap.method.VacationResponseSetMethod;
@@ -80,13 +83,16 @@ public class RFC8621MethodsModule extends AbstractModule {
         methods.addBinding().to(MailboxGetMethod.class);
         methods.addBinding().to(MailboxQueryMethod.class);
         methods.addBinding().to(MailboxSetMethod.class);
+        methods.addBinding().to(MailboxChangesMethod.class);
         methods.addBinding().to(EmailGetMethod.class);
         methods.addBinding().to(EmailSetMethod.class);
         methods.addBinding().to(EmailSubmissionSetMethod.class);
         methods.addBinding().to(EmailQueryMethod.class);
+        methods.addBinding().to(EmailChangesMethod.class);
         methods.addBinding().to(VacationResponseGetMethod.class);
         methods.addBinding().to(VacationResponseSetMethod.class);
         methods.addBinding().to(IdentityGetMethod.class);
+        methods.addBinding().to(ThreadChangesMethod.class);
         methods.addBinding().to(ThreadGetMethod.class);
     }
 
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/EmailChangesContract.scala b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailChangesContract.scala
new file mode 100644
index 0000000..d5ea35b
--- /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/EmailChangesContract.scala
@@ -0,0 +1,171 @@
+/****************************************************************
+ * 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.jmap.http.UserCredential
+import org.apache.james.jmap.rfc8621.contract.Fixture.{ACCEPT_RFC8621_VERSION_HEADER, BOB, BOB_PASSWORD, DOMAIN, authScheme, baseRequestSpecBuilder}
+import org.apache.james.utils.DataProbeImpl
+import org.junit.jupiter.api.{BeforeEach, Test}
+
+trait EmailChangesContract {
+  @BeforeEach
+  def setUp(server: GuiceJamesServer): Unit = {
+    server.getProbe(classOf[DataProbeImpl])
+      .fluent
+      .addDomain(DOMAIN.asString)
+      .addDomain("domain-alias.tld")
+      .addUser(BOB.asString, BOB_PASSWORD)
+
+    requestSpecification = baseRequestSpecBuilder(server)
+      .setAuth(authScheme(UserCredential(BOB, BOB_PASSWORD)))
+      .build
+  }
+
+  @Test
+  def shouldReturnCannotCalculateChanges(): Unit = {
+    val request =
+      s"""{
+         |  "using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
+         |  "methodCalls": [[
+         |    "Email/changes",
+         |    {
+         |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |      "sinceState": "any-state"
+         |    },
+         |    "c1"]]
+         |}""".stripMargin
+
+    val response =  `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .isEqualTo(
+        """{
+          |    "sessionState": "75128aab4b1b",
+          |    "methodResponses": [
+          |        [
+          |            "error",
+          |            {
+          |                "type": "cannotCalculateChanges",
+          |                "description": "Naive implementation for Email/changes"
+          |            },
+          |            "c1"
+          |        ]
+          |    ]
+          |}""".stripMargin)
+  }
+
+  @Test
+  def badAccountIdShouldBeRejected(): Unit = {
+    val request =
+      s"""{
+         |  "using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
+         |  "methodCalls": [[
+         |    "Email/changes",
+         |    {
+         |      "accountId": "bad",
+         |      "sinceState": "any-state"
+         |    },
+         |    "c1"]]
+         |}""".stripMargin
+
+    val response =  `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .isEqualTo(
+        """{
+          |    "sessionState": "75128aab4b1b",
+          |    "methodResponses": [
+          |        [
+          |            "error",
+          |            {
+          |                "type": "accountNotFound"
+          |            },
+          |            "c1"
+          |        ]
+          |    ]
+          |}""".stripMargin)
+  }
+
+  @Test
+  def shouldReturnEmptyWhenKnownState(): Unit = {
+    val request =
+      s"""{
+         |  "using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
+         |  "methodCalls": [[
+         |    "Thread/changes",
+         |    {
+         |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |      "sinceState": "000001"
+         |    },
+         |    "c1"]]
+         |}""".stripMargin
+
+    val response =  `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .inPath("methodResponses[0][1]")
+      .isEqualTo(
+        """{
+          |  "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+          |  "oldState": "000001",
+          |  "newState": "000001",
+          |  "hasMoreChanges": false,
+          |  "created": [],
+          |  "updated": [],
+          |  "destroyed": []
+          |}""".stripMargin)
+  }
+}
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/MailboxChangesContract.scala b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/MailboxChangesContract.scala
new file mode 100644
index 0000000..b38a3ce
--- /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/MailboxChangesContract.scala
@@ -0,0 +1,172 @@
+/****************************************************************
+ * 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.jmap.http.UserCredential
+import org.apache.james.jmap.rfc8621.contract.Fixture.{ACCEPT_RFC8621_VERSION_HEADER, BOB, BOB_PASSWORD, DOMAIN, authScheme, baseRequestSpecBuilder}
+import org.apache.james.utils.DataProbeImpl
+import org.junit.jupiter.api.{BeforeEach, Test}
+
+trait MailboxChangesContract {
+  @BeforeEach
+  def setUp(server: GuiceJamesServer): Unit = {
+    server.getProbe(classOf[DataProbeImpl])
+      .fluent
+      .addDomain(DOMAIN.asString)
+      .addDomain("domain-alias.tld")
+      .addUser(BOB.asString, BOB_PASSWORD)
+
+    requestSpecification = baseRequestSpecBuilder(server)
+      .setAuth(authScheme(UserCredential(BOB, BOB_PASSWORD)))
+      .build
+  }
+
+  @Test
+  def shouldReturnCannotCalculateChanges(): Unit = {
+    val request =
+      s"""{
+         |  "using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
+         |  "methodCalls": [[
+         |    "Mailbox/changes",
+         |    {
+         |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |      "sinceState": "any-state"
+         |    },
+         |    "c1"]]
+         |}""".stripMargin
+
+    val response =  `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .isEqualTo(
+        """{
+          |    "sessionState": "75128aab4b1b",
+          |    "methodResponses": [
+          |        [
+          |            "error",
+          |            {
+          |                "type": "cannotCalculateChanges",
+          |                "description": "Naive implementation for Mailbox/changes"
+          |            },
+          |            "c1"
+          |        ]
+          |    ]
+          |}""".stripMargin)
+  }
+
+  @Test
+  def badAccountIdShouldBeRejected(): Unit = {
+    val request =
+      s"""{
+         |  "using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
+         |  "methodCalls": [[
+         |    "Mailbox/changes",
+         |    {
+         |      "accountId": "bad",
+         |      "sinceState": "any-state"
+         |    },
+         |    "c1"]]
+         |}""".stripMargin
+
+    val response =  `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .isEqualTo(
+        """{
+          |    "sessionState": "75128aab4b1b",
+          |    "methodResponses": [
+          |        [
+          |            "error",
+          |            {
+          |                "type": "accountNotFound"
+          |            },
+          |            "c1"
+          |        ]
+          |    ]
+          |}""".stripMargin)
+  }
+
+  @Test
+  def shouldReturnEmptyWhenKnownState(): Unit = {
+    val request =
+      s"""{
+         |  "using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
+         |  "methodCalls": [[
+         |    "Mailbox/changes",
+         |    {
+         |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |      "sinceState": "000001"
+         |    },
+         |    "c1"]]
+         |}""".stripMargin
+
+    val response =  `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .inPath("methodResponses[0][1]")
+      .isEqualTo(
+        """{
+          |  "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+          |  "oldState": "000001",
+          |  "newState": "000001",
+          |  "hasMoreChanges": false,
+          |  "updatedProperties": [],
+          |  "created": [],
+          |  "updated": [],
+          |  "destroyed": []
+          |}""".stripMargin)
+  }
+}
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/ThreadChangesContract.scala b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/ThreadChangesContract.scala
new file mode 100644
index 0000000..6695084
--- /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/ThreadChangesContract.scala
@@ -0,0 +1,171 @@
+/****************************************************************
+ * 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.jmap.http.UserCredential
+import org.apache.james.jmap.rfc8621.contract.Fixture.{ACCEPT_RFC8621_VERSION_HEADER, BOB, BOB_PASSWORD, DOMAIN, authScheme, baseRequestSpecBuilder}
+import org.apache.james.utils.DataProbeImpl
+import org.junit.jupiter.api.{BeforeEach, Test}
+
+trait ThreadChangesContract {
+  @BeforeEach
+  def setUp(server: GuiceJamesServer): Unit = {
+    server.getProbe(classOf[DataProbeImpl])
+      .fluent
+      .addDomain(DOMAIN.asString)
+      .addDomain("domain-alias.tld")
+      .addUser(BOB.asString, BOB_PASSWORD)
+
+    requestSpecification = baseRequestSpecBuilder(server)
+      .setAuth(authScheme(UserCredential(BOB, BOB_PASSWORD)))
+      .build
+  }
+
+  @Test
+  def shouldReturnCannotCalculateChanges(): Unit = {
+    val request =
+      s"""{
+         |  "using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
+         |  "methodCalls": [[
+         |    "Thread/changes",
+         |    {
+         |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |      "sinceState": "any-state"
+         |    },
+         |    "c1"]]
+         |}""".stripMargin
+
+    val response =  `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .isEqualTo(
+        """{
+          |    "sessionState": "75128aab4b1b",
+          |    "methodResponses": [
+          |        [
+          |            "error",
+          |            {
+          |                "type": "cannotCalculateChanges",
+          |                "description": "Naive implementation for Thread/changes"
+          |            },
+          |            "c1"
+          |        ]
+          |    ]
+          |}""".stripMargin)
+  }
+
+  @Test
+  def shouldReturnEmptyWhenKnownState(): Unit = {
+    val request =
+      s"""{
+         |  "using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
+         |  "methodCalls": [[
+         |    "Thread/changes",
+         |    {
+         |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |      "sinceState": "000001"
+         |    },
+         |    "c1"]]
+         |}""".stripMargin
+
+    val response =  `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .inPath("methodResponses[0][1]")
+      .isEqualTo(
+        """{
+          |  "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+          |  "oldState": "000001",
+          |  "newState": "000001",
+          |  "hasMoreChanges": false,
+          |  "created": [],
+          |  "updated": [],
+          |  "destroyed": []
+          |}""".stripMargin)
+  }
+
+  @Test
+  def badAccountIdShouldBeRejected(): Unit = {
+    val request =
+      s"""{
+         |  "using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
+         |  "methodCalls": [[
+         |    "Thread/changes",
+         |    {
+         |      "accountId": "bad",
+         |      "sinceState": "any-state"
+         |    },
+         |    "c1"]]
+         |}""".stripMargin
+
+    val response =  `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .isEqualTo(
+        """{
+          |    "sessionState": "75128aab4b1b",
+          |    "methodResponses": [
+          |        [
+          |            "error",
+          |            {
+          |                "type": "accountNotFound"
+          |            },
+          |            "c1"
+          |        ]
+          |    ]
+          |}""".stripMargin)
+  }
+}
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/ThreadSerializer.scala b/server/protocols/jmap-rfc-8621-integration-tests/memory-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/memory/MemoryEmailChangesMethodTest.java
similarity index 55%
copy from server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/ThreadSerializer.scala
copy to server/protocols/jmap-rfc-8621-integration-tests/memory-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/memory/MemoryEmailChangesMethodTest.java
index 2c6fba9..e6887ed 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/ThreadSerializer.scala
+++ b/server/protocols/jmap-rfc-8621-integration-tests/memory-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/memory/MemoryEmailChangesMethodTest.java
@@ -17,19 +17,23 @@
  * under the License.                                           *
  ****************************************************************/
 
-package org.apache.james.jmap.json
+package org.apache.james.jmap.rfc8621.memory;
 
-import org.apache.james.jmap.mail.{ThreadGetRequest, ThreadGetResponse, Thread}
-import play.api.libs.json.{JsObject, JsResult, JsValue, Json, OWrites, Reads}
+import static org.apache.james.MemoryJamesServerMain.IN_MEMORY_SERVER_AGGREGATE_MODULE;
 
-import scala.language.implicitConversions
+import org.apache.james.GuiceJamesServer;
+import org.apache.james.JamesServerBuilder;
+import org.apache.james.JamesServerExtension;
+import org.apache.james.jmap.rfc8621.contract.EmailChangesContract;
+import org.apache.james.jmap.rfc8621.contract.ThreadChangesContract;
+import org.apache.james.modules.TestJMAPServerModule;
+import org.junit.jupiter.api.extension.RegisterExtension;
 
-object ThreadSerializer {
-  private implicit val threadGetReads: Reads[ThreadGetRequest] = Json.reads[ThreadGetRequest]
-  private implicit val threadWrites: OWrites[Thread] = Json.writes[Thread]
-  private implicit val threadGetWrites: OWrites[ThreadGetResponse] = Json.writes[ThreadGetResponse]
-
-  def serialize(threadGetResponse: ThreadGetResponse): JsObject = Json.toJson(threadGetResponse).as[JsObject]
-
-  def deserialize(input: JsValue): JsResult[ThreadGetRequest] = Json.fromJson[ThreadGetRequest](input)
-}
\ No newline at end of file
+public class MemoryEmailChangesMethodTest implements EmailChangesContract {
+    @RegisterExtension
+    static JamesServerExtension testExtension = new JamesServerBuilder<>(JamesServerBuilder.defaultConfigurationProvider())
+        .server(configuration -> GuiceJamesServer.forConfiguration(configuration)
+            .combineWith(IN_MEMORY_SERVER_AGGREGATE_MODULE)
+            .overrideWith(new TestJMAPServerModule()))
+        .build();
+}
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/ThreadSerializer.scala b/server/protocols/jmap-rfc-8621-integration-tests/memory-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/memory/MemoryMailboxChangesMethodTest.java
similarity index 55%
copy from server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/ThreadSerializer.scala
copy to server/protocols/jmap-rfc-8621-integration-tests/memory-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/memory/MemoryMailboxChangesMethodTest.java
index 2c6fba9..9b8cd71 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/ThreadSerializer.scala
+++ b/server/protocols/jmap-rfc-8621-integration-tests/memory-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/memory/MemoryMailboxChangesMethodTest.java
@@ -17,19 +17,23 @@
  * under the License.                                           *
  ****************************************************************/
 
-package org.apache.james.jmap.json
+package org.apache.james.jmap.rfc8621.memory;
 
-import org.apache.james.jmap.mail.{ThreadGetRequest, ThreadGetResponse, Thread}
-import play.api.libs.json.{JsObject, JsResult, JsValue, Json, OWrites, Reads}
+import static org.apache.james.MemoryJamesServerMain.IN_MEMORY_SERVER_AGGREGATE_MODULE;
 
-import scala.language.implicitConversions
+import org.apache.james.GuiceJamesServer;
+import org.apache.james.JamesServerBuilder;
+import org.apache.james.JamesServerExtension;
+import org.apache.james.jmap.rfc8621.contract.EmailChangesContract;
+import org.apache.james.jmap.rfc8621.contract.MailboxChangesContract;
+import org.apache.james.modules.TestJMAPServerModule;
+import org.junit.jupiter.api.extension.RegisterExtension;
 
-object ThreadSerializer {
-  private implicit val threadGetReads: Reads[ThreadGetRequest] = Json.reads[ThreadGetRequest]
-  private implicit val threadWrites: OWrites[Thread] = Json.writes[Thread]
-  private implicit val threadGetWrites: OWrites[ThreadGetResponse] = Json.writes[ThreadGetResponse]
-
-  def serialize(threadGetResponse: ThreadGetResponse): JsObject = Json.toJson(threadGetResponse).as[JsObject]
-
-  def deserialize(input: JsValue): JsResult[ThreadGetRequest] = Json.fromJson[ThreadGetRequest](input)
-}
\ No newline at end of file
+public class MemoryMailboxChangesMethodTest implements MailboxChangesContract {
+    @RegisterExtension
+    static JamesServerExtension testExtension = new JamesServerBuilder<>(JamesServerBuilder.defaultConfigurationProvider())
+        .server(configuration -> GuiceJamesServer.forConfiguration(configuration)
+            .combineWith(IN_MEMORY_SERVER_AGGREGATE_MODULE)
+            .overrideWith(new TestJMAPServerModule()))
+        .build();
+}
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/ThreadSerializer.scala b/server/protocols/jmap-rfc-8621-integration-tests/memory-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/memory/MemoryThreadChangesMethodTest.java
similarity index 55%
copy from server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/ThreadSerializer.scala
copy to server/protocols/jmap-rfc-8621-integration-tests/memory-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/memory/MemoryThreadChangesMethodTest.java
index 2c6fba9..9829f28 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/ThreadSerializer.scala
+++ b/server/protocols/jmap-rfc-8621-integration-tests/memory-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/memory/MemoryThreadChangesMethodTest.java
@@ -17,19 +17,23 @@
  * under the License.                                           *
  ****************************************************************/
 
-package org.apache.james.jmap.json
+package org.apache.james.jmap.rfc8621.memory;
 
-import org.apache.james.jmap.mail.{ThreadGetRequest, ThreadGetResponse, Thread}
-import play.api.libs.json.{JsObject, JsResult, JsValue, Json, OWrites, Reads}
+import static org.apache.james.MemoryJamesServerMain.IN_MEMORY_SERVER_AGGREGATE_MODULE;
 
-import scala.language.implicitConversions
+import org.apache.james.GuiceJamesServer;
+import org.apache.james.JamesServerBuilder;
+import org.apache.james.JamesServerExtension;
+import org.apache.james.jmap.rfc8621.contract.ThreadChangesContract;
+import org.apache.james.jmap.rfc8621.contract.ThreadGetContract;
+import org.apache.james.modules.TestJMAPServerModule;
+import org.junit.jupiter.api.extension.RegisterExtension;
 
-object ThreadSerializer {
-  private implicit val threadGetReads: Reads[ThreadGetRequest] = Json.reads[ThreadGetRequest]
-  private implicit val threadWrites: OWrites[Thread] = Json.writes[Thread]
-  private implicit val threadGetWrites: OWrites[ThreadGetResponse] = Json.writes[ThreadGetResponse]
-
-  def serialize(threadGetResponse: ThreadGetResponse): JsObject = Json.toJson(threadGetResponse).as[JsObject]
-
-  def deserialize(input: JsValue): JsResult[ThreadGetRequest] = Json.fromJson[ThreadGetRequest](input)
-}
\ No newline at end of file
+public class MemoryThreadChangesMethodTest implements ThreadChangesContract {
+    @RegisterExtension
+    static JamesServerExtension testExtension = new JamesServerBuilder<>(JamesServerBuilder.defaultConfigurationProvider())
+        .server(configuration -> GuiceJamesServer.forConfiguration(configuration)
+            .combineWith(IN_MEMORY_SERVER_AGGREGATE_MODULE)
+            .overrideWith(new TestJMAPServerModule()))
+        .build();
+}
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/Invocation.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/Invocation.scala
index 9d2f631..ad2ced8 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/Invocation.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/Invocation.scala
@@ -58,6 +58,10 @@ object ErrorCode {
     override def code: String = "serverFail"
   }
 
+  case object CannotCalculateChanges extends ErrorCode {
+    override def code: String = "cannotCalculateChanges"
+  }
+
   case object UnknownMethod extends ErrorCode {
     override def code: String = "unknownMethod"
   }
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/EmailGetSerializer.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/EmailGetSerializer.scala
index 0eafd02..dba6258 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/EmailGetSerializer.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/EmailGetSerializer.scala
@@ -22,7 +22,7 @@ package org.apache.james.jmap.json
 import org.apache.james.jmap.api.model.Preview
 import org.apache.james.jmap.core.Properties
 import org.apache.james.jmap.mail.Email.Size
-import org.apache.james.jmap.mail.{AddressesHeaderValue, BlobId, Charset, DateHeaderValue, Disposition, EmailAddress, EmailAddressGroup, EmailBody, EmailBodyMetadata, EmailBodyPart, EmailBodyValue, EmailFastView, EmailFullView, EmailGetRequest, EmailGetResponse, EmailHeader, EmailHeaderName, EmailHeaderValue, EmailHeaderView, EmailHeaders, EmailIds, EmailMetadata, EmailMetadataView, EmailNotFound, EmailView, EmailerName, FetchAllBodyValues, FetchHTMLBodyValues, FetchTextBodyValues, Group [...]
+import org.apache.james.jmap.mail.{AddressesHeaderValue, BlobId, Charset, DateHeaderValue, Disposition, EmailAddress, EmailAddressGroup, EmailBody, EmailBodyMetadata, EmailBodyPart, EmailBodyValue, EmailChangesRequest, EmailChangesResponse, EmailFastView, EmailFullView, EmailGetRequest, EmailGetResponse, EmailHeader, EmailHeaderName, EmailHeaderValue, EmailHeaderView, EmailHeaders, EmailIds, EmailMetadata, EmailMetadataView, EmailNotFound, EmailView, EmailerName, FetchAllBodyValues, Fetc [...]
 import org.apache.james.mailbox.model.{Cid, MailboxId, MessageId}
 import play.api.libs.functional.syntax._
 import play.api.libs.json._
@@ -110,6 +110,7 @@ object EmailGetSerializer {
   private implicit val bodyValueWrites: Writes[EmailBodyValue] = Json.writes[EmailBodyValue]
   private implicit val emailIdsReads: Reads[EmailIds] = Json.valueReads[EmailIds]
   private implicit val emailGetRequestReads: Reads[EmailGetRequest] = Json.reads[EmailGetRequest]
+  private implicit val emailChangesRequestReads: Reads[EmailChangesRequest] = Json.reads[EmailChangesRequest]
   private implicit val subjectWrites: Writes[Subject] = Json.valueWrites[Subject]
   private implicit val emailNotFoundWrites: Writes[EmailNotFound] = Json.valueWrites[EmailNotFound]
   private implicit val messageIdWrites: Writes[MessageId] = id => JsString(id.serialize())
@@ -163,6 +164,9 @@ object EmailGetSerializer {
     case view: EmailFullView => emailFullViewWrites.writes(view)
   }
   private implicit val emailGetResponseWrites: Writes[EmailGetResponse] = Json.writes[EmailGetResponse]
+  private implicit val changesResponseWrites: OWrites[EmailChangesResponse] = Json.writes[EmailChangesResponse]
+
+  def serializeChanges(changesResponse: EmailChangesResponse): JsObject = Json.toJson(changesResponse).as[JsObject]
 
   def serialize(emailGetResponse: EmailGetResponse, properties: Properties, bodyProperties: Properties): JsValue =
     Json.toJson(emailGetResponse)
@@ -211,4 +215,6 @@ object EmailGetSerializer {
   }
 
   def deserializeEmailGetRequest(input: JsValue): JsResult[EmailGetRequest] = Json.fromJson[EmailGetRequest](input)
+
+  def deserializeEmailChangesRequest(input: JsValue): JsResult[EmailChangesRequest] = Json.fromJson[EmailChangesRequest](input)
 }
\ No newline at end of file
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/MailboxSerializer.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/MailboxSerializer.scala
index 20697ef..fdc1ea5 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/MailboxSerializer.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/MailboxSerializer.scala
@@ -27,7 +27,7 @@ import org.apache.james.jmap.core.CapabilityIdentifier.CapabilityIdentifier
 import org.apache.james.jmap.core.{ClientId, Properties, SetError}
 import org.apache.james.jmap.mail.MailboxGet.{UnparsedMailboxId, UnparsedMailboxIdConstraint}
 import org.apache.james.jmap.mail.MailboxSetRequest.MailboxCreationId
-import org.apache.james.jmap.mail.{DelegatedNamespace, Ids, IsSubscribed, Mailbox, MailboxCreationRequest, MailboxCreationResponse, MailboxGetRequest, MailboxGetResponse, MailboxNamespace, MailboxPatchObject, MailboxRights, MailboxSetRequest, MailboxSetResponse, MailboxUpdateResponse, MayAddItems, MayCreateChild, MayDelete, MayReadItems, MayRemoveItems, MayRename, MaySetKeywords, MaySetSeen, MaySubmit, NotFound, PersonalNamespace, Quota, QuotaId, QuotaRoot, Quotas, RemoveEmailsOnDestroy, [...]
+import org.apache.james.jmap.mail.{DelegatedNamespace, Ids, IsSubscribed, Mailbox, MailboxChangesRequest, MailboxChangesResponse, MailboxCreationRequest, MailboxCreationResponse, MailboxGetRequest, MailboxGetResponse, MailboxNamespace, MailboxPatchObject, MailboxRights, MailboxSetRequest, MailboxSetResponse, MailboxUpdateResponse, MayAddItems, MayCreateChild, MayDelete, MayReadItems, MayRemoveItems, MayRename, MaySetKeywords, MaySetSeen, MaySubmit, NotFound, PersonalNamespace, Quota, Quo [...]
 import org.apache.james.mailbox.Role
 import org.apache.james.mailbox.model.MailboxACL.{Right => JavaRight}
 import org.apache.james.mailbox.model.{MailboxACL, MailboxId}
@@ -112,6 +112,7 @@ class MailboxSerializer @Inject()(mailboxIdFactory: MailboxId.Factory) {
   private implicit val idsRead: Reads[Ids] = Json.valueReads[Ids]
 
   private implicit val mailboxGetRequest: Reads[MailboxGetRequest] = Json.reads[MailboxGetRequest]
+  private implicit val mailboxChangesRequest: Reads[MailboxChangesRequest] = Json.reads[MailboxChangesRequest]
 
   private implicit val mailboxRemoveEmailsOnDestroy: Reads[RemoveEmailsOnDestroy] = Json.valueFormat[RemoveEmailsOnDestroy]
   implicit val mailboxCreationRequest: Reads[MailboxCreationRequest] = Json.reads[MailboxCreationRequest]
@@ -144,6 +145,9 @@ class MailboxSerializer @Inject()(mailboxIdFactory: MailboxId.Factory) {
     mapWrites[MailboxId, MailboxUpdateResponse](_.serialize(), mailboxSetUpdateResponseWrites)
 
   private implicit val mailboxSetResponseWrites: Writes[MailboxSetResponse] = Json.writes[MailboxSetResponse]
+  private implicit val changesResponseWrites: OWrites[MailboxChangesResponse] = Json.writes[MailboxChangesResponse]
+
+  def serializeChanges(changesResponse: MailboxChangesResponse): JsObject = Json.toJson(changesResponse).as[JsObject]
 
   def serialize(mailbox: Mailbox): JsValue = Json.toJson(mailbox)
 
@@ -179,6 +183,8 @@ class MailboxSerializer @Inject()(mailboxIdFactory: MailboxId.Factory) {
 
   def deserializeMailboxGetRequest(input: JsValue): JsResult[MailboxGetRequest] = Json.fromJson[MailboxGetRequest](input)
 
+  def deserializeMailboxChangesRequest(input: JsValue): JsResult[MailboxChangesRequest] = Json.fromJson[MailboxChangesRequest](input)
+
   def deserializeMailboxSetRequest(input: JsValue): JsResult[MailboxSetRequest] = Json.fromJson[MailboxSetRequest](input)
 
   def deserializeRights(input: JsValue): JsResult[Rights] = Json.fromJson[Rights](input)
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/ThreadSerializer.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/ThreadSerializer.scala
index 2c6fba9..235a817 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/ThreadSerializer.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/ThreadSerializer.scala
@@ -19,17 +19,21 @@
 
 package org.apache.james.jmap.json
 
-import org.apache.james.jmap.mail.{ThreadGetRequest, ThreadGetResponse, Thread}
+import org.apache.james.jmap.mail.{Thread, ThreadChangesRequest, ThreadChangesResponse, ThreadGetRequest, ThreadGetResponse}
 import play.api.libs.json.{JsObject, JsResult, JsValue, Json, OWrites, Reads}
 
 import scala.language.implicitConversions
 
 object ThreadSerializer {
   private implicit val threadGetReads: Reads[ThreadGetRequest] = Json.reads[ThreadGetRequest]
+  private implicit val threadChangesReads: Reads[ThreadChangesRequest] = Json.reads[ThreadChangesRequest]
   private implicit val threadWrites: OWrites[Thread] = Json.writes[Thread]
   private implicit val threadGetWrites: OWrites[ThreadGetResponse] = Json.writes[ThreadGetResponse]
+  private implicit val changesResponseWrites: OWrites[ThreadChangesResponse] = Json.writes[ThreadChangesResponse]
 
+  def serializeChanges(threadChangesResponse: ThreadChangesResponse): JsObject = Json.toJson(threadChangesResponse).as[JsObject]
   def serialize(threadGetResponse: ThreadGetResponse): JsObject = Json.toJson(threadGetResponse).as[JsObject]
 
   def deserialize(input: JsValue): JsResult[ThreadGetRequest] = Json.fromJson[ThreadGetRequest](input)
+  def deserializeChanges(input: JsValue): JsResult[ThreadChangesRequest] = Json.fromJson[ThreadChangesRequest](input)
 }
\ No newline at end of file
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/package.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/package.scala
index 536a620..3a8d44f 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/package.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/package.scala
@@ -26,6 +26,7 @@ import eu.timepit.refined.api.{RefType, Validate}
 import org.apache.james.core.MailAddress
 import org.apache.james.jmap.core.SetError.SetErrorDescription
 import org.apache.james.jmap.core.{AccountId, Properties, SetError, UTCDate}
+import org.apache.james.jmap.mail.HasMoreChanges
 import play.api.libs.json._
 
 import scala.util.{Failure, Success, Try}
@@ -91,4 +92,5 @@ package object json {
   }
   private[json] implicit val utcDateWrites: Writes[UTCDate] =
     utcDate => JsString(utcDate.asUTC.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssX")))
+  private[json] implicit val hasMoreChangesWrites: Writes[HasMoreChanges] = Json.valueWrites[HasMoreChanges]
 }
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailGet.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailGet.scala
index ae59d9e..0582a08 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailGet.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailGet.scala
@@ -25,7 +25,9 @@ import eu.timepit.refined.api.Refined
 import eu.timepit.refined.auto._
 import eu.timepit.refined.numeric.NonNegative
 import eu.timepit.refined.types.string.NonEmptyString
+import org.apache.james.jmap.core.Id.Id
 import org.apache.james.jmap.core.State.State
+import org.apache.james.jmap.core.UnsignedInt.UnsignedInt
 import org.apache.james.jmap.core.{AccountId, Properties}
 import org.apache.james.jmap.mail.Email.UnparsedEmailId
 import org.apache.james.jmap.mail.EmailGetRequest.MaxBodyValueBytes
@@ -110,3 +112,16 @@ case class SpecificHeaderRequest(property: NonEmptyString, headerName: String, p
     }
   }
 }
+
+case class EmailChangesRequest(accountId: AccountId,
+                                sinceState: State,
+                                maxChanged: Option[UnsignedInt]) extends WithAccountId
+
+
+case class EmailChangesResponse(accountId: AccountId,
+                                oldState: State,
+                                newState: State,
+                                hasMoreChanges: HasMoreChanges,
+                                created: List[Id],
+                                updated: List[Id],
+                                destroyed: List[Id])
\ No newline at end of file
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/MailboxGet.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/MailboxGet.scala
index 971cfcb..509b2c4 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/MailboxGet.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/MailboxGet.scala
@@ -22,7 +22,9 @@ package org.apache.james.jmap.mail
 import eu.timepit.refined
 import eu.timepit.refined.api.Refined
 import eu.timepit.refined.collection.NonEmpty
+import org.apache.james.jmap.core.Id.Id
 import org.apache.james.jmap.core.State.State
+import org.apache.james.jmap.core.UnsignedInt.UnsignedInt
 import org.apache.james.jmap.core.{AccountId, Properties}
 import org.apache.james.jmap.mail.MailboxGet.UnparsedMailboxId
 import org.apache.james.jmap.method.WithAccountId
@@ -64,3 +66,16 @@ case class MailboxGetResponse(accountId: AccountId,
                               state: State,
                               list: List[Mailbox],
                               notFound: NotFound)
+
+case class MailboxChangesRequest(accountId: AccountId,
+                                sinceState: State,
+                                maxChanged: Option[UnsignedInt]) extends WithAccountId
+
+case class MailboxChangesResponse(accountId: AccountId,
+                                  oldState: State,
+                                  newState: State,
+                                  hasMoreChanges: HasMoreChanges,
+                                  updatedProperties: Option[Properties],
+                                  created: List[Id],
+                                  updated: List[Id],
+                                  destroyed: List[Id])
\ No newline at end of file
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/Thread.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/Thread.scala
index 63e0358..b85954c 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/Thread.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/Thread.scala
@@ -22,6 +22,7 @@ package org.apache.james.jmap.mail
 import org.apache.james.jmap.core.AccountId
 import org.apache.james.jmap.core.Id.Id
 import org.apache.james.jmap.core.State.State
+import org.apache.james.jmap.core.UnsignedInt.UnsignedInt
 import org.apache.james.jmap.method.WithAccountId
 
 case class Thread(id: Id, emailIds: List[Id])
@@ -32,3 +33,17 @@ case class ThreadGetRequest(accountId: AccountId,
 case class ThreadGetResponse(accountId: AccountId,
                              state: State,
                              list: List[Thread])
+
+case class ThreadChangesRequest(accountId: AccountId,
+                                sinceState: State,
+                                maxChanged: Option[UnsignedInt]) extends WithAccountId
+
+case class HasMoreChanges(value: Boolean) extends AnyVal
+
+case class ThreadChangesResponse(accountId: AccountId,
+                                 oldState: State,
+                                 newState: State,
+                                 hasMoreChanges: HasMoreChanges,
+                                 created: List[Id],
+                                 updated: List[Id],
+                                 destroyed: List[Id])
\ No newline at end of file
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailChangesMethod.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailChangesMethod.scala
new file mode 100644
index 0000000..821e323
--- /dev/null
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailChangesMethod.scala
@@ -0,0 +1,67 @@
+/****************************************************************
+ * 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 javax.inject.Inject
+import org.apache.james.jmap.core.CapabilityIdentifier.{CapabilityIdentifier, JMAP_MAIL}
+import org.apache.james.jmap.core.Invocation.{Arguments, MethodName}
+import org.apache.james.jmap.core.{ErrorCode, Invocation, State}
+import org.apache.james.jmap.json.{EmailGetSerializer, ResponseSerializer}
+import org.apache.james.jmap.mail.{EmailChangesRequest, EmailChangesResponse, HasMoreChanges}
+import org.apache.james.jmap.routes.SessionSupplier
+import org.apache.james.mailbox.MailboxSession
+import org.apache.james.metrics.api.MetricFactory
+import play.api.libs.json.{JsError, JsSuccess}
+import reactor.core.scala.publisher.SMono
+
+class EmailChangesMethod @Inject()(val metricFactory: MetricFactory,
+                                   val sessionSupplier: SessionSupplier) extends MethodRequiringAccountId[EmailChangesRequest] {
+  override val methodName: MethodName = MethodName("Email/changes")
+  override val requiredCapabilities: Set[CapabilityIdentifier] = Set(JMAP_MAIL)
+
+  override def doProcess(capabilities: Set[CapabilityIdentifier], invocation: InvocationWithContext, mailboxSession: MailboxSession, request: EmailChangesRequest): SMono[InvocationWithContext] =
+    if (request.sinceState.equals(State.INSTANCE)) {
+      val response: EmailChangesResponse = EmailChangesResponse(
+        accountId = request.accountId,
+        oldState = State.INSTANCE,
+        newState = State.INSTANCE,
+        hasMoreChanges = HasMoreChanges(false),
+        created = List(),
+        updated = List(),
+        destroyed = List())
+      SMono.just(InvocationWithContext(invocation = Invocation(
+        methodName = methodName,
+        arguments = Arguments(EmailGetSerializer.serializeChanges(response)),
+        methodCallId = invocation.invocation.methodCallId
+      ), processingContext = invocation.processingContext))
+    } else {
+      SMono.just(InvocationWithContext(invocation = Invocation.error(ErrorCode.CannotCalculateChanges,
+        "Naive implementation for Email/changes",
+        invocation.invocation.methodCallId),
+        processingContext = invocation.processingContext))
+    }
+
+  override def getRequest(mailboxSession: MailboxSession, invocation: Invocation): Either[IllegalArgumentException, EmailChangesRequest] =
+    EmailGetSerializer.deserializeEmailChangesRequest(invocation.arguments.value) match {
+      case JsSuccess(emailGetRequest, _) => Right(emailGetRequest)
+      case errors: JsError => Left(new IllegalArgumentException(ResponseSerializer.serialize(errors).toString))
+    }
+}
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/MailboxChangesMethod.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/MailboxChangesMethod.scala
new file mode 100644
index 0000000..09b5ccb
--- /dev/null
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/MailboxChangesMethod.scala
@@ -0,0 +1,69 @@
+/****************************************************************
+ * 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 javax.inject.Inject
+import org.apache.james.jmap.core.CapabilityIdentifier.{CapabilityIdentifier, JMAP_MAIL}
+import org.apache.james.jmap.core.Invocation.{Arguments, MethodName}
+import org.apache.james.jmap.core.{ErrorCode, Invocation, Properties, State}
+import org.apache.james.jmap.json.{MailboxSerializer, ResponseSerializer}
+import org.apache.james.jmap.mail.{HasMoreChanges, MailboxChangesRequest, MailboxChangesResponse}
+import org.apache.james.jmap.routes.SessionSupplier
+import org.apache.james.mailbox.MailboxSession
+import org.apache.james.metrics.api.MetricFactory
+import play.api.libs.json.{JsError, JsSuccess}
+import reactor.core.scala.publisher.SMono
+
+class MailboxChangesMethod @Inject()(mailboxSerializer: MailboxSerializer,
+                                   val metricFactory: MetricFactory,
+                                   val sessionSupplier: SessionSupplier) extends MethodRequiringAccountId[MailboxChangesRequest] {
+  override val methodName: MethodName = MethodName("Mailbox/changes")
+  override val requiredCapabilities: Set[CapabilityIdentifier] = Set(JMAP_MAIL)
+
+  override def doProcess(capabilities: Set[CapabilityIdentifier], invocation: InvocationWithContext, mailboxSession: MailboxSession, request: MailboxChangesRequest): SMono[InvocationWithContext] =
+    if (request.sinceState.equals(State.INSTANCE)) {
+      val response: MailboxChangesResponse = MailboxChangesResponse(
+        accountId = request.accountId,
+        oldState = State.INSTANCE,
+        newState = State.INSTANCE,
+        hasMoreChanges = HasMoreChanges(false),
+        updatedProperties = Some(Properties()),
+        created = List(),
+        updated = List(),
+        destroyed = List())
+      SMono.just(InvocationWithContext(invocation = Invocation(
+        methodName = methodName,
+        arguments = Arguments(mailboxSerializer.serializeChanges(response)),
+        methodCallId = invocation.invocation.methodCallId
+      ), processingContext = invocation.processingContext))
+    } else {
+      SMono.just(InvocationWithContext(invocation = Invocation.error(ErrorCode.CannotCalculateChanges,
+        "Naive implementation for Mailbox/changes",
+        invocation.invocation.methodCallId),
+        processingContext = invocation.processingContext))
+    }
+
+  override def getRequest(mailboxSession: MailboxSession, invocation: Invocation): Either[IllegalArgumentException, MailboxChangesRequest] =
+    mailboxSerializer.deserializeMailboxChangesRequest(invocation.arguments.value) match {
+      case JsSuccess(mailboxGetRequest, _) => Right(mailboxGetRequest)
+      case errors: JsError => Left(new IllegalArgumentException(ResponseSerializer.serialize(errors).toString))
+    }
+}
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/ThreadChangesMethod.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/ThreadChangesMethod.scala
new file mode 100644
index 0000000..c2d907b
--- /dev/null
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/ThreadChangesMethod.scala
@@ -0,0 +1,67 @@
+/****************************************************************
+ * 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 javax.inject.Inject
+import org.apache.james.jmap.core.CapabilityIdentifier.{CapabilityIdentifier, JMAP_MAIL}
+import org.apache.james.jmap.core.Invocation.{Arguments, MethodName}
+import org.apache.james.jmap.core.{ErrorCode, Invocation, State}
+import org.apache.james.jmap.json.{ResponseSerializer, ThreadSerializer}
+import org.apache.james.jmap.mail.{HasMoreChanges, ThreadChangesRequest, ThreadChangesResponse}
+import org.apache.james.jmap.routes.SessionSupplier
+import org.apache.james.mailbox.MailboxSession
+import org.apache.james.metrics.api.MetricFactory
+import play.api.libs.json.{JsError, JsSuccess}
+import reactor.core.scala.publisher.SMono
+
+class ThreadChangesMethod @Inject()(val metricFactory: MetricFactory,
+                                    val sessionSupplier: SessionSupplier) extends MethodRequiringAccountId[ThreadChangesRequest] {
+  override val methodName: MethodName = MethodName("Thread/changes")
+  override val requiredCapabilities: Set[CapabilityIdentifier] = Set(JMAP_MAIL)
+
+  override def doProcess(capabilities: Set[CapabilityIdentifier], invocation: InvocationWithContext, mailboxSession: MailboxSession, request: ThreadChangesRequest): SMono[InvocationWithContext] =
+    if (request.sinceState.equals(State.INSTANCE)) {
+      val response: ThreadChangesResponse = ThreadChangesResponse(
+        accountId = request.accountId,
+        oldState = State.INSTANCE,
+        newState = State.INSTANCE,
+        hasMoreChanges = HasMoreChanges(false),
+        created = List(),
+        updated = List(),
+        destroyed = List())
+      SMono.just(InvocationWithContext(invocation = Invocation(
+        methodName = methodName,
+        arguments = Arguments(ThreadSerializer.serializeChanges(response)),
+        methodCallId = invocation.invocation.methodCallId
+      ), processingContext = invocation.processingContext))
+    } else {
+      SMono.just(InvocationWithContext(invocation = Invocation.error(ErrorCode.CannotCalculateChanges,
+        "Naive implementation for Thread/changes",
+        invocation.invocation.methodCallId),
+        processingContext = invocation.processingContext))
+    }
+
+  override def getRequest(mailboxSession: MailboxSession, invocation: Invocation): Either[IllegalArgumentException, ThreadChangesRequest] =
+    ThreadSerializer.deserializeChanges(invocation.arguments.value) match {
+      case JsSuccess(threadGetRequest, _) => Right(threadGetRequest)
+      case errors: JsError => Left(new IllegalArgumentException(ResponseSerializer.serialize(errors).toString))
+    }
+}


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