You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@james.apache.org by rc...@apache.org on 2020/10/19 03:24:47 UTC

[james-project] branch master updated (f0d359a -> 715dd58)

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

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


    from f0d359a  JAMES-3028 define implementation in blob properties for cassandra docker images
     new 1569e9a  JAMES-3413 Email/set update mailboxIds implementation
     new c4c1030  JAMES-3413 Email/set update mailboxIds error handling
     new 715dd58  JAMES-3413 Revert changes to JMAPApiRoutes

The 3 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 .../rfc8621/contract/EmailSetMethodContract.scala  | 909 ++++++++++++++++++++-
 .../james/jmap/rfc8621/contract/Fixture.scala      |  13 +
 .../james/jmap/json/EmailSetSerializer.scala       | 109 ++-
 .../apache/james/jmap/json/MailboxSerializer.scala |   8 +-
 .../scala/org/apache/james/jmap/json/package.scala |  42 +-
 .../scala/org/apache/james/jmap/mail/Email.scala   |   5 +-
 .../org/apache/james/jmap/mail/EmailSet.scala      |  22 +
 .../apache/james/jmap/method/EmailSetMethod.scala  | 232 ++++--
 .../apache/james/jmap/routes/JMAPApiRoutes.scala   |   3 +-
 9 files changed, 1252 insertions(+), 91 deletions(-)


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


[james-project] 02/03: JAMES-3413 Email/set update mailboxIds error handling

Posted by rc...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

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

commit c4c1030e3d550bf1e2aa3e77bfee0c437d1414d8
Author: LanKhuat <dl...@linagora.com>
AuthorDate: Thu Oct 15 11:29:58 2020 +0700

    JAMES-3413 Email/set update mailboxIds error handling
---
 .../rfc8621/contract/EmailSetMethodContract.scala  | 742 ++++++++++++++++++++-
 .../james/jmap/json/EmailSetSerializer.scala       |  94 ++-
 .../scala/org/apache/james/jmap/mail/Email.scala   |   5 +-
 .../org/apache/james/jmap/mail/EmailSet.scala      |  22 +-
 .../apache/james/jmap/method/EmailSetMethod.scala  | 214 +++---
 5 files changed, 964 insertions(+), 113 deletions(-)

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/EmailSetMethodContract.scala b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailSetMethodContract.scala
index 572f82e..799e907 100644
--- a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailSetMethodContract.scala
+++ b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailSetMethodContract.scala
@@ -24,11 +24,9 @@ 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 net.javacrumbs.jsonunit.core.Option
-import net.javacrumbs.jsonunit.core.internal.Options
 import org.apache.http.HttpStatus.SC_OK
 import org.apache.james.GuiceJamesServer
-import org.apache.james.jmap.draft.JmapGuiceProbe
+import org.apache.james.jmap.draft.{JmapGuiceProbe, MessageIdProbe}
 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
@@ -37,6 +35,7 @@ import org.apache.james.mailbox.model.{MailboxACL, MailboxId, MailboxPath, Messa
 import org.apache.james.mime4j.dom.Message
 import org.apache.james.modules.{ACLProbeImpl, MailboxProbeImpl}
 import org.apache.james.utils.DataProbeImpl
+import org.assertj.core.api.Assertions.assertThat
 import org.junit.jupiter.api.{BeforeEach, Test}
 
 trait EmailSetMethodContract {
@@ -507,7 +506,7 @@ trait EmailSetMethodContract {
   }
 
   @Test
-  def emailSetShouldUpdateMailboxIds(server: GuiceJamesServer): Unit = {
+  def emailSetMailboxIdResetShouldSucceed(server: GuiceJamesServer): Unit = {
     val mailboxProbe = server.getProbe(classOf[MailboxProbeImpl])
     mailboxProbe.createMailbox(MailboxPath.inbox(BOB))
     val mailboxId2: MailboxId = mailboxProbe.createMailbox(MailboxPath.forUser(BOB, "other"))
@@ -586,7 +585,7 @@ trait EmailSetMethodContract {
   }
 
   @Test
-  def emailSetShouldUpdateMailboxIdsForMultipleMessages(server: GuiceJamesServer): Unit = {
+  def emailSetMailboxIdResetShouldSucceedForMultipleMessages(server: GuiceJamesServer): Unit = {
     val mailboxProbe = server.getProbe(classOf[MailboxProbeImpl])
     mailboxProbe.createMailbox(MailboxPath.inbox(BOB))
     val mailboxId2: MailboxId = mailboxProbe.createMailbox(MailboxPath.forUser(BOB, "other"))
@@ -645,7 +644,6 @@ trait EmailSetMethodContract {
       .asString
 
     assertThatJson(response)
-      .withOptions(new Options(Option.IGNORING_ARRAY_ORDER))
       .isEqualTo(
       s"""{
          |    "sessionState": "75128aab4b1b",
@@ -681,6 +679,738 @@ trait EmailSetMethodContract {
          |}""".stripMargin)
   }
 
+  @Test
+  def emailSetMailboxIdsResetShouldFailWhenNoRightsOnSourceMailbox(server: GuiceJamesServer): Unit = {
+    val mailboxProbe = server.getProbe(classOf[MailboxProbeImpl])
+    val mailboxId1: MailboxId = mailboxProbe.createMailbox(MailboxPath.inbox(BOB))
+
+    val path = MailboxPath.forUser(ANDRE, "other")
+    mailboxProbe.createMailbox(path)
+
+    val messageId: MessageId = mailboxProbe
+      .appendMessage(ANDRE.asString, path,
+        AppendCommand.from(buildTestMessage))
+      .getMessageId
+
+    val request =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail"],
+         |  "methodCalls": [
+         |    ["Email/set", {
+         |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |      "update": {
+         |        "${messageId.serialize}": {
+         |          "mailboxIds": {
+         |            "${mailboxId1.serialize}": true
+         |          }
+         |        }
+         |      }
+         |    }, "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(
+        s"""{
+           |    "sessionState": "75128aab4b1b",
+           |    "methodResponses": [
+           |        ["Email/set", {
+           |          "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+           |          "newState": "000001",
+           |          "notUpdated": {
+           |            "1": {
+           |              "type": "notFound",
+           |              "description": "Cannot find message with messageId: 1"
+           |            }
+           |          }
+           |        }, "c1"]
+           |    ]
+           |}""".stripMargin)
+  }
+
+  @Test
+  def emailSetMailboxIdsResetShouldFailWhenForbidden(server: GuiceJamesServer): Unit = {
+    val mailboxProbe = server.getProbe(classOf[MailboxProbeImpl])
+    val mailboxId1: MailboxId = mailboxProbe.createMailbox(MailboxPath.inbox(BOB))
+
+    val messageId: MessageId = mailboxProbe
+      .appendMessage(BOB.asString, MailboxPath.inbox(BOB),
+        AppendCommand.from(
+          buildTestMessage))
+      .getMessageId
+
+    val path = MailboxPath.forUser(ANDRE, "other")
+    val mailboxId2: MailboxId = mailboxProbe.createMailbox(path)
+
+    server.getProbe(classOf[ACLProbeImpl])
+      .replaceRights(path, BOB.asString, new MailboxACL.Rfc4314Rights(Right.Read))
+
+    val request =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail"],
+         |  "methodCalls": [
+         |    ["Email/set", {
+         |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |      "update": {
+         |        "${messageId.serialize}": {
+         |          "mailboxIds": {
+         |            "${mailboxId2.serialize}": true
+         |          }
+         |        }
+         |      }
+         |    }, "c1"],
+         |    ["Email/get", {
+         |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |      "ids": ["${messageId.serialize}"],
+         |      "properties":["mailboxIds"]
+         |    }, "c2"]
+         |  ]
+         |}""".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(
+        s"""{
+           |    "sessionState": "75128aab4b1b",
+           |    "methodResponses": [
+           |      ["Email/set", {
+           |        "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+           |        "newState": "000001",
+           |        "notUpdated": {
+           |          "${messageId.serialize}": {
+           |            "type": "notFound",
+           |            "description": "Mailbox not found"
+           |          }
+           |        }
+           |      }, "c1"],
+           |      ["Email/get", {
+           |        "accountId":"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+           |        "state":"000001",
+           |        "list":[
+           |          {
+           |            "id":"${messageId.serialize}",
+           |            "mailboxIds": {
+           |              "${mailboxId1.serialize}": true
+           |            }
+           |          }
+           |        ],
+           |        "notFound":[]
+           |        },"c2"]
+           |    ]
+           |}""".stripMargin)
+  }
+
+  @Test
+  def emailSetMailboxIdsResetShouldFailWhenRemovingMessageFromSourceMailbox(server: GuiceJamesServer): Unit = {
+    val mailboxProbe = server.getProbe(classOf[MailboxProbeImpl])
+    val mailboxId1: MailboxId = mailboxProbe.createMailbox(MailboxPath.inbox(BOB))
+
+    val path = MailboxPath.forUser(ANDRE, "other")
+    val mailboxId2: MailboxId = mailboxProbe.createMailbox(path)
+
+    val messageId: MessageId = mailboxProbe
+      .appendMessage(ANDRE.asString, path,
+        AppendCommand.from(
+          buildTestMessage))
+      .getMessageId
+
+    server.getProbe(classOf[ACLProbeImpl])
+      .replaceRights(path, BOB.asString, new MailboxACL.Rfc4314Rights(Right.Read))
+
+    val request =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail"],
+         |  "methodCalls": [
+         |    ["Email/set", {
+         |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |      "update": {
+         |        "${messageId.serialize}": {
+         |          "mailboxIds": {
+         |            "${mailboxId1.serialize}": true
+         |          }
+         |        }
+         |      }
+         |    }, "c1"],
+         |    ["Email/get", {
+         |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |      "ids": ["${messageId.serialize}"],
+         |      "properties":["mailboxIds"]
+         |    }, "c2"]
+         |  ]
+         |}""".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(
+        s"""{
+           |    "sessionState": "75128aab4b1b",
+           |    "methodResponses": [
+           |      ["Email/set", {
+           |        "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+           |        "newState": "000001",
+           |        "notUpdated": {
+           |          "1": {
+           |            "type": "notFound",
+           |            "description": "Mailbox not found"
+           |          }
+           |        }
+           |      }, "c1"],
+           |      ["Email/get", {
+           |        "accountId":"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+           |        "state":"000001",
+           |        "list":[
+           |          {
+           |            "id":"${messageId.serialize}",
+           |            "mailboxIds": {
+           |              "${mailboxId2.serialize}": true
+           |            }
+           |          }
+           |        ],
+           |        "notFound":[]
+           |        },"c2"]
+           |    ]
+           |}""".stripMargin)
+  }
+
+  @Test
+  def emailSetMailboxIdsResetShouldSucceedWhenCopyMessage(server: GuiceJamesServer): Unit = {
+    val mailboxProbe = server.getProbe(classOf[MailboxProbeImpl])
+    val mailboxId1: MailboxId = mailboxProbe.createMailbox(MailboxPath.inbox(BOB))
+
+    val path = MailboxPath.forUser(ANDRE, "other")
+    val mailboxId2: MailboxId = mailboxProbe.createMailbox(path)
+
+    val messageId: MessageId = mailboxProbe
+      .appendMessage(ANDRE.asString, path,
+        AppendCommand.from(
+          buildTestMessage))
+      .getMessageId
+
+    server.getProbe(classOf[ACLProbeImpl])
+      .replaceRights(path, BOB.asString, new MailboxACL.Rfc4314Rights(Right.Read))
+
+    val request =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail"],
+         |  "methodCalls": [
+         |    ["Email/set", {
+         |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |      "update": {
+         |        "${messageId.serialize}": {
+         |          "mailboxIds": {
+         |            "${mailboxId1.serialize}": true,
+         |            "${mailboxId2.serialize}": true
+         |          }
+         |        }
+         |      }
+         |    }, "c1"],
+         |    ["Email/get", {
+         |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |      "ids": ["${messageId.serialize}"],
+         |      "properties":["mailboxIds"]
+         |    }, "c2"]
+         |  ]
+         |}""".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(
+        s"""{
+           |    "sessionState": "75128aab4b1b",
+           |    "methodResponses": [
+           |      ["Email/set", {
+           |        "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+           |        "newState": "000001",
+           |        "updated": {
+           |          "${messageId.serialize}": null
+           |        }
+           |      }, "c1"],
+           |      ["Email/get", {
+           |        "accountId":"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+           |        "state":"000001",
+           |        "list":[
+           |          {
+           |            "id":"${messageId.serialize}",
+           |            "mailboxIds": {
+           |              "${mailboxId1.serialize}": true,
+           |              "${mailboxId2.serialize}": true
+           |            }
+           |          }
+           |        ],
+           |        "notFound":[]
+           |        },"c2"]
+           |    ]
+           |}""".stripMargin)
+  }
+
+  @Test
+  def emailSetMailboxIdsResetShouldSucceedWhenShareeHasRightOnTargetMailbox(server: GuiceJamesServer): Unit = {
+    val mailboxProbe = server.getProbe(classOf[MailboxProbeImpl])
+    mailboxProbe.createMailbox(MailboxPath.inbox(BOB))
+
+    val path = MailboxPath.forUser(ANDRE, "other")
+    val mailboxId2: MailboxId = mailboxProbe.createMailbox(path)
+
+    val messageId: MessageId = mailboxProbe
+      .appendMessage(BOB.asString, MailboxPath.inbox(BOB),
+        AppendCommand.from(
+          buildTestMessage))
+      .getMessageId
+
+    server.getProbe(classOf[ACLProbeImpl])
+      .replaceRights(path, BOB.asString, new MailboxACL.Rfc4314Rights(Right.Read, Right.Insert))
+
+    val request =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail"],
+         |  "methodCalls": [
+         |    ["Email/set", {
+         |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |      "update": {
+         |        "${messageId.serialize}": {
+         |          "mailboxIds": {
+         |            "${mailboxId2.serialize}": true
+         |          }
+         |        }
+         |      }
+         |    }, "c1"],
+         |    ["Email/get", {
+         |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |      "ids": ["${messageId.serialize}"],
+         |      "properties":["mailboxIds"]
+         |    }, "c2"]
+         |  ]
+         |}""".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(
+        s"""{
+           |    "sessionState": "75128aab4b1b",
+           |    "methodResponses": [
+           |      ["Email/set", {
+           |        "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+           |        "newState": "000001",
+           |        "updated": {
+           |          "${messageId.serialize}": null
+           |        }
+           |      }, "c1"],
+           |      ["Email/get", {
+           |        "accountId":"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+           |        "state":"000001",
+           |        "list":[
+           |          {
+           |            "id":"${messageId.serialize}",
+           |            "mailboxIds": {
+           |              "${mailboxId2.serialize}": true
+           |            }
+           |          }
+           |        ],
+           |        "notFound":[]
+           |        },"c2"]
+           |    ]
+           |}""".stripMargin)
+  }
+
+  @Test
+  def emailSetMailboxIdsResetShouldNotAffectMailboxIdFilter(server: GuiceJamesServer): Unit = {
+    val mailboxProbe = server.getProbe(classOf[MailboxProbeImpl])
+
+    val mailboxId1: MailboxId = mailboxProbe.createMailbox(MailboxPath.inbox(BOB))
+    val path = MailboxPath.forUser(ANDRE, "other")
+    val mailboxId2: MailboxId = mailboxProbe.createMailbox(path)
+
+    val messageId: MessageId = mailboxProbe
+      .appendMessage(ANDRE.asString, path,
+        AppendCommand.from(buildTestMessage))
+      .getMessageId
+
+    server.getProbe(classOf[ACLProbeImpl])
+      .replaceRights(path, BOB.asString, new MailboxACL.Rfc4314Rights(Right.Read))
+
+    val request =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail"],
+         |  "methodCalls": [
+         |    ["Email/set", {
+         |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |      "update": {
+         |        "${messageId.serialize}": {
+         |          "mailboxIds": {
+         |            "${mailboxId1.serialize}": true,
+         |            "${mailboxId2.serialize}": true
+         |          }
+         |        }
+         |      }
+         |    }, "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(
+        s"""{
+           |    "sessionState": "75128aab4b1b",
+           |    "methodResponses": [
+           |      ["Email/set", {
+           |        "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+           |        "newState": "000001",
+           |        "updated": {
+           |          "${messageId.serialize}": null
+           |        }
+           |      }, "c1"]
+           |    ]
+           |}""".stripMargin)
+
+    assertThat(server.getProbe(classOf[MessageIdProbe]).getMessages(messageId, ANDRE)
+        .stream()
+        .map(message => message.getMailboxId))
+      .containsExactly(mailboxId2)
+  }
+
+  @Test
+  def emailSetMailboxIdsResetShouldFailWhenInvalidKey(server: GuiceJamesServer): Unit = {
+    val mailboxProbe = server.getProbe(classOf[MailboxProbeImpl])
+    mailboxProbe.createMailbox(MailboxPath.inbox(BOB))
+
+    val messageId: MessageId = mailboxProbe
+      .appendMessage(BOB.asString, MailboxPath.inbox(BOB),
+        AppendCommand.from(
+          buildTestMessage))
+      .getMessageId
+
+    val request =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail"],
+         |  "methodCalls": [
+         |    ["Email/set", {
+         |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |      "update": {
+         |        "${messageId.serialize}": {
+         |          "mailboxIds": {
+         |            "invalid": true
+         |          }
+         |        }
+         |      }
+         |    }, "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(
+        s"""{
+           |    "sessionState": "75128aab4b1b",
+           |    "methodResponses": [
+           |      ["Email/set", {
+           |        "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+           |        "newState": "000001",
+           |        "notUpdated": {
+           |          "${messageId.serialize}": {
+           |            "type": "invalidPatch",
+           |            "description": "Message ${messageId.serialize} update is invalid: List((,List(JsonValidationError(List(Value associated with mailboxIds is invalid: List((,List(JsonValidationError(List(For input string: \\"invalid\\"),ArraySeq()))))),ArraySeq()))))"
+           |          }
+           |        }
+           |      }, "c1"]
+           |    ]
+           |}""".stripMargin)
+  }
+
+  @Test
+  def emailSetMailboxIdsResetShouldFailWhenInvalidValue(server: GuiceJamesServer): Unit = {
+    val mailboxProbe = server.getProbe(classOf[MailboxProbeImpl])
+    val mailboxId: MailboxId = mailboxProbe.createMailbox(MailboxPath.inbox(BOB))
+
+    val messageId: MessageId = mailboxProbe
+      .appendMessage(BOB.asString, MailboxPath.inbox(BOB),
+        AppendCommand.from(
+          buildTestMessage))
+      .getMessageId
+
+    val request =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail"],
+         |  "methodCalls": [
+         |    ["Email/set", {
+         |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |      "update": {
+         |        "${messageId.serialize}": {
+         |          "mailboxIds": {
+         |            "${mailboxId.serialize}": "invalid"
+         |          }
+         |        }
+         |      }
+         |    }, "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(
+        s"""{
+           |    "sessionState": "75128aab4b1b",
+           |    "methodResponses": [
+           |      ["Email/set", {
+           |        "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+           |        "newState": "000001",
+           |        "notUpdated": {
+           |          "${messageId.serialize}": {
+           |            "type": "invalidPatch",
+           |            "description": "Message ${messageId.serialize} update is invalid: List((,List(JsonValidationError(List(Value associated with mailboxIds is invalid: List((,List(JsonValidationError(List(Expecting mailboxId value to be a boolean),ArraySeq()))))),ArraySeq()))))"
+           |          }
+           |        }
+           |      }, "c1"]
+           |    ]
+           |}""".stripMargin)
+  }
+
+  @Test
+  def emailSetMailboxIdsResetShouldFailWhenValueIsFalse(server: GuiceJamesServer): Unit = {
+    val mailboxProbe = server.getProbe(classOf[MailboxProbeImpl])
+    val mailboxId: MailboxId = mailboxProbe.createMailbox(MailboxPath.inbox(BOB))
+
+    val messageId: MessageId = mailboxProbe
+      .appendMessage(BOB.asString, MailboxPath.inbox(BOB),
+        AppendCommand.from(
+          buildTestMessage))
+      .getMessageId
+
+    val request =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail"],
+         |  "methodCalls": [
+         |    ["Email/set", {
+         |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |      "update": {
+         |        "${messageId.serialize}": {
+         |          "mailboxIds": {
+         |            "${mailboxId.serialize}": "false"
+         |          }
+         |        }
+         |      }
+         |    }, "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(
+        s"""{
+           |    "sessionState": "75128aab4b1b",
+           |    "methodResponses": [
+           |      ["Email/set", {
+           |        "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+           |        "newState": "000001",
+           |        "notUpdated": {
+           |          "${messageId.serialize}": {
+           |            "type": "invalidPatch",
+           |            "description": "Message ${messageId.serialize} update is invalid: List((,List(JsonValidationError(List(Value associated with mailboxIds is invalid: List((,List(JsonValidationError(List(Expecting mailboxId value to be a boolean),ArraySeq()))))),ArraySeq()))))"
+           |          }
+           |        }
+           |      }, "c1"]
+           |    ]
+           |}""".stripMargin)
+  }
+
+  @Test
+  def emailSetMailboxIdsResetSuccessAndFailureCanBeMixed(server: GuiceJamesServer): Unit = {
+    val mailboxProbe = server.getProbe(classOf[MailboxProbeImpl])
+    mailboxProbe.createMailbox(MailboxPath.inbox(BOB))
+
+    val path = MailboxPath.forUser(ANDRE, "other")
+    val mailboxId2: MailboxId = mailboxProbe.createMailbox(path)
+
+    val messageId1: MessageId = mailboxProbe
+      .appendMessage(BOB.asString, MailboxPath.inbox(BOB),
+        AppendCommand.from(
+          buildTestMessage))
+      .getMessageId
+
+    val messageId2: MessageId = mailboxProbe
+      .appendMessage(BOB.asString, MailboxPath.inbox(BOB),
+        AppendCommand.from(
+          buildTestMessage))
+      .getMessageId
+
+    server.getProbe(classOf[ACLProbeImpl])
+      .replaceRights(path, BOB.asString, new MailboxACL.Rfc4314Rights(Right.Read, Right.Insert))
+
+    val request =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail"],
+         |  "methodCalls": [
+         |    ["Email/set", {
+         |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |      "update": {
+         |        "${messageId1.serialize}": {
+         |          "mailboxIds": {
+         |            "${mailboxId2.serialize}": true
+         |          }
+         |        },
+         |        "${messageId2.serialize}": {
+         |          "mailboxIds": {
+         |            "invalid": true
+         |          }
+         |        }
+         |      }
+         |    }, "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(
+        s"""{
+           |    "sessionState": "75128aab4b1b",
+           |    "methodResponses": [
+           |      ["Email/set", {
+           |        "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+           |        "newState": "000001",
+           |        "updated": {
+           |          "${messageId1.serialize}": null
+           |        },
+           |        "notUpdated": {
+           |          "${messageId2.serialize}": {
+           |            "type": "invalidPatch",
+           |            "description": "Message ${messageId2.serialize} update is invalid: List((,List(JsonValidationError(List(Value associated with mailboxIds is invalid: List((,List(JsonValidationError(List(For input string: \\"invalid\\"),ArraySeq()))))),ArraySeq()))))"
+           |          }
+           |        }
+           |      }, "c1"]
+           |    ]
+           |}""".stripMargin)
+  }
+
   private def buildTestMessage = {
     Message.Builder
       .of
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/EmailSetSerializer.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/EmailSetSerializer.scala
index 396a7f3..2e33585 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/EmailSetSerializer.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/EmailSetSerializer.scala
@@ -1,4 +1,4 @@
-/** **************************************************************
+/****************************************************************
  * 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        *
@@ -6,16 +6,16 @@
  * 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                 *
- * *
+ *                                                              *
+ *   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.json
 
@@ -31,6 +31,75 @@ import scala.util.Try
 
 class EmailSetSerializer @Inject()(messageIdFactory: MessageId.Factory, mailboxIdFactory: MailboxId.Factory) {
 
+  object EmailSetUpdateReads {
+    def reads(jsObject: JsObject): JsResult[EmailSetUpdate] =
+      asEmailSetUpdate(jsObject.value.map {
+        case (property, value) => EntryValidation.from(property, value)
+      }.toSeq)
+
+    private def asEmailSetUpdate(entries: Seq[EntryValidation]): JsResult[EmailSetUpdate] =
+      entries.flatMap(_.asJsError)
+        .headOption
+        .getOrElse({
+          val mailboxReset: Option[MailboxIds] = entries.flatMap {
+            case update: MailboxReset => Some(update)
+            case _ => None
+          }.headOption
+            .map(_.ids)
+
+          val mailboxesToAdd: Option[MailboxIds] = Some(entries
+            .flatMap {
+              case update: MailboxAddition => Some(update)
+              case _ => None
+            }.map(_.id).toList)
+            .filter(_.nonEmpty)
+            .map(MailboxIds)
+
+          val mailboxesToRemove: Option[MailboxIds] = Some(entries
+            .flatMap {
+              case update: MailboxRemoval => Some(update)
+              case _ => None
+            }.map(_.id).toList)
+            .filter(_.nonEmpty)
+            .map(MailboxIds)
+
+          JsSuccess(EmailSetUpdate(mailboxIds = mailboxReset))
+        })
+
+    object EntryValidation {
+      def from(property: String, value: JsValue): EntryValidation = property match {
+        case "mailboxIds" => mailboxIdsReads.reads(value)
+          .fold(
+            e => InvalidPatchEntryValue(property, e.toString()),
+            MailboxReset)
+        case _ => InvalidPatchEntryName(property)
+      }
+    }
+
+    sealed trait EntryValidation {
+      def asJsError: Option[JsError] = None
+    }
+
+    private case class InvalidPatchEntryName(property: String) extends EntryValidation {
+      override def asJsError: Option[JsError] = Some(JsError(s"$property is an invalid entry in an Email/set update patch"))
+    }
+
+    private case class InvalidPatchEntryNameWithDetails(property: String, cause: String) extends EntryValidation {
+      override def asJsError: Option[JsError] = Some(JsError(s"$property is an invalid entry in an Email/set update patch: $cause"))
+    }
+
+    private case class InvalidPatchEntryValue(property: String, cause: String) extends EntryValidation {
+      override def asJsError: Option[JsError] = Some(JsError(s"Value associated with $property is invalid: $cause"))
+    }
+
+    private case class MailboxAddition(id: MailboxId) extends EntryValidation
+
+    private case class MailboxRemoval(id: MailboxId) extends EntryValidation
+
+    private case class MailboxReset(ids: MailboxIds) extends EntryValidation
+
+  }
+
   private implicit val messageIdWrites: Writes[MessageId] = messageId => JsString(messageId.serialize)
   private implicit val messageIdReads: Reads[MessageId] = {
     case JsString(serializedMessageId) => Try(JsSuccess(messageIdFactory.fromString(serializedMessageId)))
@@ -49,10 +118,17 @@ class EmailSetSerializer @Inject()(messageIdFactory: MessageId.Factory, mailboxI
   private implicit val mailboxIdsReads: Reads[MailboxIds] = jsValue => mailboxIdsMapReads.reads(jsValue).map(
     mailboxIdsMap => MailboxIds(mailboxIdsMap.keys.toList))
 
-  private implicit val emailSetUpdateReads: Reads[EmailSetUpdate] = Json.reads[EmailSetUpdate]
+  private implicit val emailSetUpdateReads: Reads[EmailSetUpdate] = {
+    case o: JsObject => EmailSetUpdateReads.reads(o)
+    case _ => JsError("Expecting a JsObject to represent an EmailSetUpdate")
+  }
 
-  private implicit val updatesMapReads: Reads[Map[UnparsedMessageId, EmailSetUpdate]] =
-    readMapEntry[UnparsedMessageId, EmailSetUpdate](s => refineV[UnparsedMessageIdConstraint](s), emailSetUpdateReads)
+  private implicit val updatesMapReads: Reads[Map[UnparsedMessageId, JsObject]] =
+    readMapEntry[UnparsedMessageId, JsObject](s => refineV[UnparsedMessageIdConstraint](s),
+      {
+        case o: JsObject => JsSuccess(o)
+        case _ => JsError("Expecting a JsObject as an update entry")
+      })
 
   private implicit val unitWrites: Writes[Unit] = _ => JsNull
   private implicit val updatedWrites: Writes[Map[MessageId, Unit]] = mapWrites[MessageId, Unit](_.serialize, unitWrites)
@@ -66,5 +142,7 @@ class EmailSetSerializer @Inject()(messageIdFactory: MessageId.Factory, mailboxI
 
   def deserialize(input: JsValue): JsResult[EmailSetRequest] = Json.fromJson[EmailSetRequest](input)
 
+  def deserializeEmailSetUpdate(input: JsValue): JsResult[EmailSetUpdate] = Json.fromJson[EmailSetUpdate](input)
+
   def serialize(response: EmailSetResponse): JsObject = Json.toJsObject(response)
 }
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/Email.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/Email.scala
index 8efb10f..9174ef9 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/Email.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/Email.scala
@@ -212,7 +212,10 @@ case class HeaderMessageId(value: String) extends AnyVal
 
 case class Subject(value: String) extends AnyVal
 
-case class MailboxIds(value: List[MailboxId])
+case class MailboxIds(value: List[MailboxId]) {
+  def ++(ids: MailboxIds) = MailboxIds(value ++ ids.value)
+  def --(ids: MailboxIds) = MailboxIds((value.toSet -- ids.value.toSet).toList)
+}
 
 case class ThreadId(value: String) extends AnyVal
 
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailSet.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailSet.scala
index 85f98ee..ecab680 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailSet.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailSet.scala
@@ -26,6 +26,7 @@ import org.apache.james.jmap.method.WithAccountId
 import org.apache.james.jmap.model.State.State
 import org.apache.james.jmap.model.{AccountId, SetError}
 import org.apache.james.mailbox.model.MessageId
+import play.api.libs.json.JsObject
 
 import scala.util.Try
 
@@ -45,15 +46,32 @@ object EmailSet {
 case class DestroyIds(value: Seq[UnparsedMessageId])
 
 case class EmailSetRequest(accountId: AccountId,
-                           update: Option[Map[UnparsedMessageId, EmailSetUpdate]],
+                           update: Option[Map[UnparsedMessageId, JsObject]],
                            destroy: Option[DestroyIds]) extends WithAccountId
 
 case class EmailSetResponse(accountId: AccountId,
                             newState: State,
                             updated: Option[Map[MessageId, Unit]],
+                            notUpdated: Option[Map[UnparsedMessageId, SetError]],
                             destroyed: Option[DestroyIds],
                             notDestroyed: Option[Map[UnparsedMessageId, SetError]])
 
-case class EmailSetUpdate(mailboxIds: Option[MailboxIds])
+case class EmailSetUpdate(mailboxIds: Option[MailboxIds]) {
+  def validate: Either[IllegalArgumentException, ValidatedEmailSetUpdate] = {
+    val identity: Function[MailboxIds, MailboxIds] = ids => ids
+    val mailboxIdsReset: Function[MailboxIds, MailboxIds] = mailboxIds
+      .map(toReset => (_: MailboxIds) => toReset)
+      .getOrElse(identity)
+    val mailboxIdsTransformation: Function[MailboxIds, MailboxIds] = mailboxIdsReset
+    scala.Right(ValidatedEmailSetUpdate(mailboxIdsTransformation))
+  }
+}
+
+case class ValidatedEmailSetUpdate private (mailboxIdsTransformation: Function[MailboxIds, MailboxIds])
+
+class EmailUpdateValidationException() extends IllegalArgumentException
+case class InvalidEmailPropertyException(property: String, cause: String) extends EmailUpdateValidationException
+case class InvalidEmailUpdateException(property: String, cause: String) extends EmailUpdateValidationException
+
 
 
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailSetMethod.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailSetMethod.scala
index 36979e0..c7022f8 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailSetMethod.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailSetMethod.scala
@@ -23,16 +23,17 @@ import javax.inject.Inject
 import org.apache.james.jmap.http.SessionSupplier
 import org.apache.james.jmap.json.{EmailSetSerializer, ResponseSerializer}
 import org.apache.james.jmap.mail.EmailSet.UnparsedMessageId
-import org.apache.james.jmap.mail.{DestroyIds, EmailSet, EmailSetRequest, EmailSetResponse, EmailSetUpdate}
+import org.apache.james.jmap.mail.{DestroyIds, EmailSet, EmailSetRequest, EmailSetResponse, EmailSetUpdate, MailboxIds, ValidatedEmailSetUpdate}
 import org.apache.james.jmap.model.CapabilityIdentifier.CapabilityIdentifier
 import org.apache.james.jmap.model.DefaultCapabilities.{CORE_CAPABILITY, MAIL_CAPABILITY}
 import org.apache.james.jmap.model.Invocation.{Arguments, MethodName}
 import org.apache.james.jmap.model.SetError.SetErrorDescription
 import org.apache.james.jmap.model.{Capabilities, Invocation, SetError, State}
-import org.apache.james.mailbox.model.{ComposedMessageIdWithMetaData, DeleteResult, MailboxId, MessageId}
+import org.apache.james.mailbox.exception.MailboxNotFoundException
+import org.apache.james.mailbox.model.{ComposedMessageIdWithMetaData, DeleteResult, MessageId}
 import org.apache.james.mailbox.{MailboxSession, MessageIdManager}
 import org.apache.james.metrics.api.MetricFactory
-import play.api.libs.json.{JsError, JsSuccess}
+import play.api.libs.json.{JsError, JsObject, JsSuccess}
 import reactor.core.scala.publisher.{SFlux, SMono}
 import reactor.core.scheduler.Schedulers
 
@@ -47,81 +48,84 @@ class EmailSetMethod @Inject()(serializer: EmailSetSerializer,
                                val sessionSupplier: SessionSupplier) extends MethodRequiringAccountId[EmailSetRequest] {
 
 
-case class DestroyResults(results: Seq[DestroyResult]) {
-  def destroyed: Option[DestroyIds] = {
-    Option(results.flatMap({
-      result => result match {
-        case result: DestroySuccess => Some(result.messageId)
-        case _ => None
-      }
-    }).map(EmailSet.asUnparsed))
-      .filter(_.nonEmpty)
-      .map(DestroyIds)
-  }
+  case class DestroyResults(results: Seq[DestroyResult]) {
+    def destroyed: Option[DestroyIds] = {
+      Option(results.flatMap({
+        result => result match {
+          case result: DestroySuccess => Some(result.messageId)
+          case _ => None
+        }
+      }).map(EmailSet.asUnparsed))
+        .filter(_.nonEmpty)
+        .map(DestroyIds)
+    }
 
-  def notDestroyed: Option[Map[UnparsedMessageId, SetError]] = {
-    Option(results.flatMap({
-      result => result match {
-        case failure: DestroyFailure => Some(failure)
-        case _ => None
-      }
-    })
-      .map(failure => (failure.unparsedMessageId, failure.asMessageSetError))
-      .toMap)
-      .filter(_.nonEmpty)
+    def notDestroyed: Option[Map[UnparsedMessageId, SetError]] = {
+      Option(results.flatMap({
+        result => result match {
+          case failure: DestroyFailure => Some(failure)
+          case _ => None
+        }
+      })
+        .map(failure => (failure.unparsedMessageId, failure.asMessageSetError))
+        .toMap)
+        .filter(_.nonEmpty)
+    }
   }
-}
 
-object DestroyResult {
-  def from(deleteResult: DeleteResult): DestroyResult = {
-    val notFound = deleteResult.getNotFound.asScala
+  object DestroyResult {
+    def from(deleteResult: DeleteResult): DestroyResult = {
+      val notFound = deleteResult.getNotFound.asScala
 
-    deleteResult.getDestroyed.asScala
-      .headOption
-      .map(DestroySuccess)
-      .getOrElse(DestroyFailure(EmailSet.asUnparsed(notFound.head), MessageNotFoundExeception(notFound.head)))
-  }
-}
-
-trait DestroyResult
-case class DestroySuccess(messageId: MessageId) extends DestroyResult
-case class DestroyFailure(unparsedMessageId: UnparsedMessageId, e: Throwable) extends DestroyResult {
-  def asMessageSetError: SetError = e match {
-    case e: IllegalArgumentException => SetError.invalidArguments(SetErrorDescription(s"$unparsedMessageId is not a messageId: ${e.getMessage}"))
-    case e: MessageNotFoundExeception => SetError.notFound(SetErrorDescription(s"Cannot find message with messageId: ${e.messageId.serialize()}"))
-    case _ => SetError.serverFail(SetErrorDescription(e.getMessage))
+      deleteResult.getDestroyed.asScala
+        .headOption
+        .map(DestroySuccess)
+        .getOrElse(DestroyFailure(EmailSet.asUnparsed(notFound.head), MessageNotFoundExeception(notFound.head)))
+    }
   }
-}
 
-trait UpdateResult
-case class UpdateSuccess(messageId: MessageId) extends UpdateResult
-case class UpdateFailure(unparsedMessageId: UnparsedMessageId, e: Throwable) extends UpdateResult {
-  def asMessageSetError: SetError = e match {
-    case _ => SetError.serverFail(SetErrorDescription(e.getMessage))
+  trait DestroyResult
+  case class DestroySuccess(messageId: MessageId) extends DestroyResult
+  case class DestroyFailure(unparsedMessageId: UnparsedMessageId, e: Throwable) extends DestroyResult {
+    def asMessageSetError: SetError = e match {
+      case e: IllegalArgumentException => SetError.invalidArguments(SetErrorDescription(s"$unparsedMessageId is not a messageId: ${e.getMessage}"))
+      case e: MessageNotFoundExeception => SetError.notFound(SetErrorDescription(s"Cannot find message with messageId: ${e.messageId.serialize()}"))
+      case _ => SetError.serverFail(SetErrorDescription(e.getMessage))
+    }
   }
-}
-case class UpdateResults(results: Seq[UpdateResult]) {
-  def updated: Option[Map[MessageId, Unit]] = {
-    Option(results.flatMap({
-      result => result match {
-        case result: UpdateSuccess => Some(result.messageId, ())
-        case _ => None
-      }
-    }).toMap).filter(_.nonEmpty)
+
+  trait UpdateResult
+  case class UpdateSuccess(messageId: MessageId) extends UpdateResult
+  case class UpdateFailure(unparsedMessageId: UnparsedMessageId, e: Throwable) extends UpdateResult {
+    def asMessageSetError: SetError = e match {
+      case e: IllegalArgumentException => SetError.invalidPatch(SetErrorDescription(s"Message $unparsedMessageId update is invalid: ${e.getMessage}"))
+      case _: MailboxNotFoundException => SetError.notFound(SetErrorDescription(s"Mailbox not found"))
+      case e: MessageNotFoundExeception => SetError.notFound(SetErrorDescription(s"Cannot find message with messageId: ${e.messageId.serialize()}"))
+      case _ => SetError.serverFail(SetErrorDescription(e.getMessage))
+    }
   }
+  case class UpdateResults(results: Seq[UpdateResult]) {
+    def updated: Option[Map[MessageId, Unit]] = {
+      Option(results.flatMap({
+        result => result match {
+          case result: UpdateSuccess => Some(result.messageId, ())
+          case _ => None
+        }
+      }).toMap).filter(_.nonEmpty)
+    }
 
-  def notUpdated: Option[Map[UnparsedMessageId, SetError]] = {
-    Option(results.flatMap({
-      result => result match {
-        case failure: UpdateFailure => Some(failure)
-        case _ => None
-      }
-    })
-      .map(failure => (failure.unparsedMessageId, failure.asMessageSetError))
-      .toMap)
-      .filter(_.nonEmpty)
+    def notUpdated: Option[Map[UnparsedMessageId, SetError]] = {
+      Option(results.flatMap({
+        result => result match {
+          case failure: UpdateFailure => Some(failure)
+          case _ => None
+        }
+      })
+        .map(failure => (failure.unparsedMessageId, failure.asMessageSetError))
+        .toMap)
+        .filter(_.nonEmpty)
+    }
   }
-}
 
   override val methodName: MethodName = MethodName("Email/set")
   override val requiredCapabilities: Capabilities = Capabilities(CORE_CAPABILITY, MAIL_CAPABILITY)
@@ -131,16 +135,17 @@ case class UpdateResults(results: Seq[UpdateResult]) {
       destroyResults <- destroy(request, mailboxSession)
       updateResults <- update(request, mailboxSession)
     } yield InvocationWithContext(
-        invocation = Invocation(
-          methodName = invocation.invocation.methodName,
-          arguments = Arguments(serializer.serialize(EmailSetResponse(
-            accountId = request.accountId,
-            newState = State.INSTANCE,
-            updated = updateResults.updated,
-            destroyed = destroyResults.destroyed,
-            notDestroyed = destroyResults.notDestroyed))),
-          methodCallId = invocation.invocation.methodCallId),
-        processingContext = invocation.processingContext)
+      invocation = Invocation(
+        methodName = invocation.invocation.methodName,
+        arguments = Arguments(serializer.serialize(EmailSetResponse(
+          accountId = request.accountId,
+          newState = State.INSTANCE,
+          updated = updateResults.updated,
+          notUpdated = updateResults.notUpdated,
+          destroyed = destroyResults.destroyed,
+          notDestroyed = destroyResults.notDestroyed))),
+        methodCallId = invocation.invocation.methodCallId),
+      processingContext = invocation.processingContext)
   }
 
   override def getRequest(mailboxSession: MailboxSession, invocation: Invocation): SMono[EmailSetRequest] = asEmailSetRequest(invocation.arguments)
@@ -164,13 +169,16 @@ case class UpdateResults(results: Seq[UpdateResult]) {
       .getOrElse(SMono.just(UpdateResults(Seq())))
   }
 
-  private def update(updates: Map[UnparsedMessageId, EmailSetUpdate], session: MailboxSession): SMono[UpdateResults] = {
+  private def update(updates: Map[UnparsedMessageId, JsObject], session: MailboxSession): SMono[UpdateResults] = {
     val validatedUpdates: List[Either[UpdateFailure, (MessageId, EmailSetUpdate)]] = updates
       .map({
-        case (unparsedMessageId, updatePatch) => EmailSet.parse(messageIdFactory)(unparsedMessageId)
-          .map((_, updatePatch))
+        case (unparsedMessageId, json) => EmailSet.parse(messageIdFactory)(unparsedMessageId)
           .toEither
           .left.map(e => UpdateFailure(unparsedMessageId, e))
+          .flatMap(id => serializer.deserializeEmailSetUpdate(json)
+            .asEither
+            .fold(e => Left(UpdateFailure(unparsedMessageId, new IllegalArgumentException(e.toString()))),
+              (emailSetUpdate: EmailSetUpdate) => Right((id, emailSetUpdate))))
       })
       .toList
     val failures: List[UpdateFailure] = validatedUpdates.flatMap({
@@ -187,7 +195,7 @@ case class UpdateResults(results: Seq[UpdateResult]) {
         .collectMultimap(metaData => metaData.getComposedMessageId.getMessageId)
         .flatMap(metaData => {
           SFlux.fromIterable(validUpdates)
-            .concatMap[UpdateResult]({
+            .flatMap[UpdateResult]({
               case (messageId, updatePatch) =>
                 doUpdate(messageId, updatePatch, metaData.get(messageId).toList.flatten, session)
             })
@@ -199,23 +207,37 @@ case class UpdateResults(results: Seq[UpdateResult]) {
   }
 
   private def doUpdate(messageId: MessageId, update: EmailSetUpdate, storedMetaData: List[ComposedMessageIdWithMetaData], session: MailboxSession): SMono[UpdateResult] = {
-    val mailboxIds: List[MailboxId] = storedMetaData.map(metaData => metaData.getComposedMessageId.getMailboxId)
-
-    updateMailboxIds(messageId, update, session)
-      .onErrorResume(e => SMono.just[UpdateResult](UpdateFailure(EmailSet.asUnparsed(messageId), e)))
-      .switchIfEmpty(SMono.just[UpdateResult](UpdateSuccess(messageId)))
+    val mailboxIds: MailboxIds = MailboxIds(storedMetaData.map(metaData => metaData.getComposedMessageId.getMailboxId))
+
+    if (mailboxIds.value.isEmpty) {
+      SMono.just[UpdateResult](UpdateFailure(EmailSet.asUnparsed(messageId), MessageNotFoundExeception(messageId)))
+    } else {
+      update.validate
+        .fold(
+          e => SMono.just(UpdateFailure(EmailSet.asUnparsed(messageId), e)),
+          validatedUpdate => updateMailboxIds(messageId, validatedUpdate, mailboxIds, session)
+            .onErrorResume(e => SMono.just[UpdateResult](UpdateFailure(EmailSet.asUnparsed(messageId), e)))
+            .switchIfEmpty(SMono.just[UpdateResult](UpdateSuccess(messageId))))
+    }
   }
 
-  private def updateMailboxIds(messageId: MessageId, update: EmailSetUpdate, session: MailboxSession): SMono[UpdateResult] =
-    SMono.justOrEmpty(update.mailboxIds)
-      .flatMap(mailboxIds => SMono.fromCallable(() => messageIdManager.setInMailboxes(messageId, mailboxIds.value.asJava, session))
+  private def updateMailboxIds(messageId: MessageId, update: ValidatedEmailSetUpdate, mailboxIds: MailboxIds, session: MailboxSession): SMono[UpdateResult] = {
+    val targetIds = update.mailboxIdsTransformation.apply(mailboxIds)
+    if (targetIds.equals(mailboxIds)) {
+      SMono.just[UpdateResult](UpdateSuccess(messageId))
+    } else {
+      SMono.fromCallable(() => messageIdManager.setInMailboxes(messageId, targetIds.value.asJava, session))
         .subscribeOn(Schedulers.elastic())
-        .`then`(SMono.just[UpdateResult](UpdateSuccess(messageId))))
+        .`then`(SMono.just[UpdateResult](UpdateSuccess(messageId)))
+        .onErrorResume(e => SMono.just[UpdateResult](UpdateFailure(EmailSet.asUnparsed(messageId), e)))
+        .switchIfEmpty(SMono.just[UpdateResult](UpdateSuccess(messageId)))
+    }
+  }
 
   private def deleteMessage(destroyId: UnparsedMessageId, mailboxSession: MailboxSession): SMono[DestroyResult] =
     EmailSet.parse(messageIdFactory)(destroyId)
-        .fold(e => SMono.just(DestroyFailure(destroyId, e)),
-          parsedId => SMono.fromCallable(() => DestroyResult.from(messageIdManager.delete(parsedId, mailboxSession)))
-            .subscribeOn(Schedulers.elastic)
-            .onErrorRecover(e => DestroyFailure(EmailSet.asUnparsed(parsedId), e)))
-}
+      .fold(e => SMono.just(DestroyFailure(destroyId, e)),
+        parsedId => SMono.fromCallable(() => DestroyResult.from(messageIdManager.delete(parsedId, mailboxSession)))
+          .subscribeOn(Schedulers.elastic)
+          .onErrorRecover(e => DestroyFailure(EmailSet.asUnparsed(parsedId), e)))
+}
\ 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


[james-project] 03/03: JAMES-3413 Revert changes to JMAPApiRoutes

Posted by rc...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

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

commit 715dd582cb7291356e6bb37b6813d0c0298c64ec
Author: LanKhuat <dl...@linagora.com>
AuthorDate: Thu Oct 15 17:44:32 2020 +0700

    JAMES-3413 Revert changes to JMAPApiRoutes
---
 .../src/main/scala/org/apache/james/jmap/routes/JMAPApiRoutes.scala  | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/routes/JMAPApiRoutes.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/routes/JMAPApiRoutes.scala
index a945c21..626528f 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/routes/JMAPApiRoutes.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/routes/JMAPApiRoutes.scala
@@ -162,8 +162,9 @@ class JMAPApiRoutes (val authenticator: Authenticator,
 
   private def processMethodWithMatchName(capabilities: Set[CapabilityIdentifier], invocation: InvocationWithContext, mailboxSession: MailboxSession): SMono[InvocationWithContext] =
     SMono.justOrEmpty(methodsByName.get(invocation.invocation.methodName))
-      .filter(method => validateCapabilities(capabilities, method.requiredCapabilities).isRight)
-      .flatMap(method => SMono.fromPublisher(method.process(capabilities, invocation, mailboxSession)))
+      .flatMap(method => validateCapabilities(capabilities, method.requiredCapabilities)
+      .fold(e => SMono.just(InvocationWithContext(Invocation.error(ErrorCode.UnknownMethod, e.description, invocation.invocation.methodCallId), invocation.processingContext)),
+        _ => SMono.fromPublisher(method.process(capabilities, invocation, mailboxSession))))
       .onErrorResume(throwable => SMono.just(InvocationWithContext(Invocation.error(ErrorCode.ServerFail, throwable.getMessage, invocation.invocation.methodCallId), invocation.processingContext)))
       .switchIfEmpty(SMono.just(InvocationWithContext(Invocation.error(ErrorCode.UnknownMethod, invocation.invocation.methodCallId), invocation.processingContext)))
 


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


[james-project] 01/03: JAMES-3413 Email/set update mailboxIds implementation

Posted by rc...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

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

commit 1569e9a1c4f8e8f79085050e849f37a388cc6e6b
Author: LanKhuat <dl...@linagora.com>
AuthorDate: Fri Oct 16 17:29:48 2020 +0700

    JAMES-3413 Email/set update mailboxIds implementation
---
 .../rfc8621/contract/EmailSetMethodContract.scala  | 177 +++++++++++++++++++++
 .../james/jmap/rfc8621/contract/Fixture.scala      |  13 ++
 .../james/jmap/json/EmailSetSerializer.scala       |  41 +++--
 .../apache/james/jmap/json/MailboxSerializer.scala |   8 +-
 .../scala/org/apache/james/jmap/json/package.scala |  42 ++---
 .../org/apache/james/jmap/mail/EmailSet.scala      |   4 +
 .../apache/james/jmap/method/EmailSetMethod.scala  | 102 +++++++++++-
 .../apache/james/jmap/routes/JMAPApiRoutes.scala   |   6 +-
 8 files changed, 351 insertions(+), 42 deletions(-)

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/EmailSetMethodContract.scala b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailSetMethodContract.scala
index 060d22a..572f82e 100644
--- a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailSetMethodContract.scala
+++ b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailSetMethodContract.scala
@@ -24,6 +24,8 @@ 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 net.javacrumbs.jsonunit.core.Option
+import net.javacrumbs.jsonunit.core.internal.Options
 import org.apache.http.HttpStatus.SC_OK
 import org.apache.james.GuiceJamesServer
 import org.apache.james.jmap.draft.JmapGuiceProbe
@@ -504,6 +506,181 @@ trait EmailSetMethodContract {
          |}""".stripMargin)
   }
 
+  @Test
+  def emailSetShouldUpdateMailboxIds(server: GuiceJamesServer): Unit = {
+    val mailboxProbe = server.getProbe(classOf[MailboxProbeImpl])
+    mailboxProbe.createMailbox(MailboxPath.inbox(BOB))
+    val mailboxId2: MailboxId = mailboxProbe.createMailbox(MailboxPath.forUser(BOB, "other"))
+    val mailboxId3: MailboxId = mailboxProbe.createMailbox(MailboxPath.forUser(BOB, "other2"))
+
+    val messageId: MessageId = mailboxProbe
+      .appendMessage(BOB.asString, MailboxPath.inbox(BOB),
+        AppendCommand.from(
+          buildTestMessage))
+      .getMessageId
+
+    val request =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail"],
+         |  "methodCalls": [
+         |    ["Email/set", {
+         |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |      "update": {
+         |        "${messageId.serialize}": {
+         |          "mailboxIds": {
+         |            "${mailboxId2.serialize}": true,
+         |            "${mailboxId3.serialize}": true
+         |          }
+         |        }
+         |      }
+         |    }, "c1"],
+         |    ["Email/get", {
+         |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |      "ids": ["${messageId.serialize}"],
+         |      "properties":["mailboxIds"]
+         |    }, "c2"]
+         |  ]
+         |}""".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(
+      s"""{
+         |    "sessionState": "75128aab4b1b",
+         |    "methodResponses": [
+         |      ["Email/set", {
+         |        "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |        "newState": "000001",
+         |        "updated": {
+         |          "${messageId.serialize}": null
+         |        }
+         |      }, "c1"],
+         |      ["Email/get",{
+         |        "accountId":"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |        "state":"000001",
+         |        "list":[
+         |          {
+         |            "id":"${messageId.serialize}",
+         |            "mailboxIds":{
+         |              "${mailboxId2.serialize}":true,
+         |              "${mailboxId3.serialize}":true
+         |            }
+         |          }
+         |        ],
+         |        "notFound":[]
+         |        },"c2"]
+         |    ]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def emailSetShouldUpdateMailboxIdsForMultipleMessages(server: GuiceJamesServer): Unit = {
+    val mailboxProbe = server.getProbe(classOf[MailboxProbeImpl])
+    mailboxProbe.createMailbox(MailboxPath.inbox(BOB))
+    val mailboxId2: MailboxId = mailboxProbe.createMailbox(MailboxPath.forUser(BOB, "other"))
+
+    val messageId1: MessageId = mailboxProbe
+      .appendMessage(BOB.asString, MailboxPath.inbox(BOB),
+        AppendCommand.from(
+          buildTestMessage))
+      .getMessageId
+
+    val messageId2: MessageId = mailboxProbe
+      .appendMessage(BOB.asString, MailboxPath.inbox(BOB),
+        AppendCommand.from(
+          buildTestMessage))
+      .getMessageId
+
+    val request =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail"],
+         |  "methodCalls": [
+         |    ["Email/set", {
+         |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |      "update": {
+         |        "${messageId1.serialize}": {
+         |          "mailboxIds": {
+         |            "${mailboxId2.serialize}": true
+         |          }
+         |        },
+         |        "${messageId2.serialize}": {
+         |          "mailboxIds": {
+         |            "${mailboxId2.serialize}": true
+         |          }
+         |        }
+         |      }
+         |    }, "c1"],
+         |    ["Email/get", {
+         |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |      "ids": ["${messageId1.serialize}", "${messageId2.serialize}"],
+         |      "properties":["mailboxIds"]
+         |    }, "c2"]
+         |  ]
+         |}""".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)
+      .withOptions(new Options(Option.IGNORING_ARRAY_ORDER))
+      .isEqualTo(
+      s"""{
+         |    "sessionState": "75128aab4b1b",
+         |    "methodResponses": [
+         |      ["Email/set", {
+         |        "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |        "newState": "000001",
+         |        "updated": {
+         |          "${messageId1.serialize}": null,
+         |          "${messageId2.serialize}": null
+         |        }
+         |      }, "c1"],
+         |      ["Email/get", {
+         |        "accountId":"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |        "state":"000001",
+         |        "list":[
+         |          {
+         |            "id":"${messageId1.serialize}",
+         |            "mailboxIds": {
+         |              "${mailboxId2.serialize}":true
+         |            }
+         |          },
+         |          {
+         |            "id":"${messageId2.serialize}",
+         |            "mailboxIds":{
+         |              "${mailboxId2.serialize}":true
+         |            }
+         |          }
+         |        ],
+         |        "notFound":[]
+         |        },"c2"]
+         |    ]
+         |}""".stripMargin)
+  }
+
   private def buildTestMessage = {
     Message.Builder
       .of
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/Fixture.scala b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/Fixture.scala
index d6fcfbb..4b70b04 100644
--- a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/Fixture.scala
+++ b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/Fixture.scala
@@ -34,8 +34,21 @@ import org.apache.james.core.{Domain, Username}
 import org.apache.james.jmap.JMAPUrls.JMAP
 import org.apache.james.jmap.draft.JmapGuiceProbe
 import org.apache.james.jmap.http.UserCredential
+import org.apache.james.mime4j.dom.Message
 
 object Fixture {
+  val ACCOUNT_ID: String = "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6"
+
+  def createTestMessage: Message = Message.Builder
+      .of
+      .setSubject("test")
+      .setSender(ANDRE.asString())
+      .setFrom(ANDRE.asString())
+      .setSubject("World domination \r\n" +
+        " and this is also part of the header")
+      .setBody("testmail", StandardCharsets.UTF_8)
+      .build
+
   def baseRequestSpecBuilder(server: GuiceJamesServer) = new RequestSpecBuilder()
     .setContentType(ContentType.JSON)
     .setAccept(ContentType.JSON)
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/EmailSetSerializer.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/EmailSetSerializer.scala
index 594ceaa..396a7f3 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/EmailSetSerializer.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/EmailSetSerializer.scala
@@ -1,4 +1,4 @@
-/****************************************************************
+/** **************************************************************
  * 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        *
@@ -6,29 +6,30 @@
  * 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                 *
- *                                                              *
+ * *
+ * 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.json
 
+import eu.timepit.refined.refineV
 import javax.inject.Inject
-import org.apache.james.jmap.mail.EmailSet.UnparsedMessageId
-import org.apache.james.jmap.mail.{DestroyIds, EmailSetRequest, EmailSetResponse}
+import org.apache.james.jmap.mail.EmailSet.{UnparsedMessageId, UnparsedMessageIdConstraint}
+import org.apache.james.jmap.mail.{DestroyIds, EmailSetRequest, EmailSetResponse, EmailSetUpdate, MailboxIds}
 import org.apache.james.jmap.model.SetError
-import org.apache.james.mailbox.model.MessageId
-import play.api.libs.json.{Format, JsError, JsObject, JsResult, JsString, JsSuccess, JsValue, Json, OWrites, Reads, Writes}
+import org.apache.james.mailbox.model.{MailboxId, MessageId}
+import play.api.libs.json.{JsBoolean, JsError, JsNull, JsObject, JsResult, JsString, JsSuccess, JsValue, Json, OWrites, Reads, Writes}
 
 import scala.util.Try
 
-class EmailSetSerializer @Inject() (messageIdFactory: MessageId.Factory) {
+class EmailSetSerializer @Inject()(messageIdFactory: MessageId.Factory, mailboxIdFactory: MailboxId.Factory) {
 
   private implicit val messageIdWrites: Writes[MessageId] = messageId => JsString(messageId.serialize)
   private implicit val messageIdReads: Reads[MessageId] = {
@@ -37,12 +38,30 @@ class EmailSetSerializer @Inject() (messageIdFactory: MessageId.Factory) {
     case _ => JsError("Expecting messageId to be represented by a JsString")
   }
 
+  private implicit val mailboxIdsMapReads: Reads[Map[MailboxId, Boolean]] =
+    readMapEntry[MailboxId, Boolean](s => Try(mailboxIdFactory.fromString(s)).toEither.left.map(error => error.getMessage),
+      {
+        case JsBoolean(true) => JsSuccess(true)
+        case JsBoolean(false) => JsError("mailboxId value can only be true")
+        case _ => JsError("Expecting mailboxId value to be a boolean")
+      })
+
+  private implicit val mailboxIdsReads: Reads[MailboxIds] = jsValue => mailboxIdsMapReads.reads(jsValue).map(
+    mailboxIdsMap => MailboxIds(mailboxIdsMap.keys.toList))
+
+  private implicit val emailSetUpdateReads: Reads[EmailSetUpdate] = Json.reads[EmailSetUpdate]
+
+  private implicit val updatesMapReads: Reads[Map[UnparsedMessageId, EmailSetUpdate]] =
+    readMapEntry[UnparsedMessageId, EmailSetUpdate](s => refineV[UnparsedMessageIdConstraint](s), emailSetUpdateReads)
+
+  private implicit val unitWrites: Writes[Unit] = _ => JsNull
+  private implicit val updatedWrites: Writes[Map[MessageId, Unit]] = mapWrites[MessageId, Unit](_.serialize, unitWrites)
   private implicit val notDestroyedWrites: Writes[Map[UnparsedMessageId, SetError]] = mapWrites[UnparsedMessageId, SetError](_.value, setErrorWrites)
   private implicit val destroyIdsReads: Reads[DestroyIds] = {
     Json.valueFormat[DestroyIds]
   }
   private implicit val destroyIdsWrites: Writes[DestroyIds] = Json.valueWrites[DestroyIds]
-  private implicit val emailRequestSetFormat: Format[EmailSetRequest] = Json.format[EmailSetRequest]
+  private implicit val emailRequestSetReads: Reads[EmailSetRequest] = Json.reads[EmailSetRequest]
   private implicit val emailResponseSetWrites: OWrites[EmailSetResponse] = Json.writes[EmailSetResponse]
 
   def deserialize(input: JsValue): JsResult[EmailSetRequest] = Json.fromJson[EmailSetRequest](input)
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 edb6d11..a445741 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
@@ -125,10 +125,14 @@ class MailboxSerializer @Inject()(mailboxIdFactory: MailboxId.Factory) {
 
   private implicit val mapPatchObjectByMailboxIdReads: Reads[Map[UnparsedMailboxId, MailboxPatchObject]] =
     readMapEntry[UnparsedMailboxId, MailboxPatchObject](s => refineV[UnparsedMailboxIdConstraint](s),
-      o => MailboxPatchObject(o.value.toMap))
+      mailboxPatchObject)
 
   private implicit val mapCreationRequestByMailBoxCreationId: Reads[Map[MailboxCreationId, JsObject]] =
-    readMapEntry[MailboxCreationId, JsObject](s => refineV[NonEmpty](s), o => o)
+    readMapEntry[MailboxCreationId, JsObject](s => refineV[NonEmpty](s),
+      {
+        case o: JsObject => JsSuccess(o)
+        case _ => JsError("Expecting a JsObject as a creation entry")
+      })
 
   private implicit val mailboxSetRequestReads: Reads[MailboxSetRequest] = Json.reads[MailboxSetRequest]
 
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 c79e88b..c3dd3d8 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
@@ -39,24 +39,30 @@ package object json {
       })
     }
 
-  def readMapEntry[K, V](keyValidator: String => Either[String, K], valueTransformer: JsObject => V): Reads[Map[K, V]] = _.validate[Map[String, JsObject]]
-    .flatMap(mapWithStringKey =>{
-      mapWithStringKey
-        .foldLeft[Either[JsError, Map[K, V]]](scala.util.Right[JsError, Map[K, V]](Map.empty))((acc: Either[JsError, Map[K, V]], keyValue) => {
-          acc match {
-            case error@Left(_) => error
-            case scala.util.Right(validatedAcc) =>
-              val refinedKey: Either[String, K] = keyValidator.apply(keyValue._1)
-              refinedKey match {
-                case Left(error) => Left(JsError(error))
-                case scala.util.Right(unparsedK) => scala.util.Right(validatedAcc + (unparsedK -> valueTransformer.apply(keyValue._2)))
-              }
-          }
-        }) match {
-        case Left(jsError) => jsError
-        case scala.util.Right(value) => JsSuccess(value)
-      }
-    })
+  def readMapEntry[K, V](keyValidator: String => Either[String, K], valueReads: Reads[V]): Reads[Map[K, V]] =
+    _.validate[Map[String, JsValue]]
+      .flatMap(mapWithStringKey =>{
+        val firstAcc = scala.util.Right[JsError, Map[K, V]](Map.empty)
+        mapWithStringKey
+          .foldLeft[Either[JsError, Map[K, V]]](firstAcc)((acc: Either[JsError, Map[K, V]], keyValue) => {
+            acc match {
+              case error@Left(_) => error
+              case scala.util.Right(validatedAcc) =>
+                val refinedKey: Either[String, K] = keyValidator.apply(keyValue._1)
+                refinedKey match {
+                  case Left(error) => Left(JsError(error))
+                  case scala.util.Right(unparsedK) =>
+                    val transformValue: JsResult[V] = valueReads.reads(keyValue._2)
+                    transformValue.fold(
+                      error => Left(JsError(error)),
+                      v => scala.util.Right(validatedAcc + (unparsedK -> v)))
+                }
+            }
+          }) match {
+          case Left(jsError) => jsError
+          case scala.util.Right(value) => JsSuccess(value)
+        }
+      })
 
   // code copied from https://github.com/avdv/play-json-refined/blob/master/src/main/scala/de.cbley.refined.play.json/package.scala
   implicit def writeRefined[T, P, F[_, _]](
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailSet.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailSet.scala
index 89813bb..85f98ee 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailSet.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailSet.scala
@@ -45,11 +45,15 @@ object EmailSet {
 case class DestroyIds(value: Seq[UnparsedMessageId])
 
 case class EmailSetRequest(accountId: AccountId,
+                           update: Option[Map[UnparsedMessageId, EmailSetUpdate]],
                            destroy: Option[DestroyIds]) extends WithAccountId
 
 case class EmailSetResponse(accountId: AccountId,
                             newState: State,
+                            updated: Option[Map[MessageId, Unit]],
                             destroyed: Option[DestroyIds],
                             notDestroyed: Option[Map[UnparsedMessageId, SetError]])
 
+case class EmailSetUpdate(mailboxIds: Option[MailboxIds])
+
 
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailSetMethod.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailSetMethod.scala
index 304499a..36979e0 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailSetMethod.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailSetMethod.scala
@@ -23,13 +23,13 @@ import javax.inject.Inject
 import org.apache.james.jmap.http.SessionSupplier
 import org.apache.james.jmap.json.{EmailSetSerializer, ResponseSerializer}
 import org.apache.james.jmap.mail.EmailSet.UnparsedMessageId
-import org.apache.james.jmap.mail.{DestroyIds, EmailSet, EmailSetRequest, EmailSetResponse}
+import org.apache.james.jmap.mail.{DestroyIds, EmailSet, EmailSetRequest, EmailSetResponse, EmailSetUpdate}
 import org.apache.james.jmap.model.CapabilityIdentifier.CapabilityIdentifier
 import org.apache.james.jmap.model.DefaultCapabilities.{CORE_CAPABILITY, MAIL_CAPABILITY}
 import org.apache.james.jmap.model.Invocation.{Arguments, MethodName}
 import org.apache.james.jmap.model.SetError.SetErrorDescription
 import org.apache.james.jmap.model.{Capabilities, Invocation, SetError, State}
-import org.apache.james.mailbox.model.{DeleteResult, MessageId}
+import org.apache.james.mailbox.model.{ComposedMessageIdWithMetaData, DeleteResult, MailboxId, MessageId}
 import org.apache.james.mailbox.{MailboxSession, MessageIdManager}
 import org.apache.james.metrics.api.MetricFactory
 import play.api.libs.json.{JsError, JsSuccess}
@@ -40,6 +40,13 @@ import scala.jdk.CollectionConverters._
 
 case class MessageNotFoundExeception(messageId: MessageId) extends Exception
 
+class EmailSetMethod @Inject()(serializer: EmailSetSerializer,
+                               messageIdManager: MessageIdManager,
+                               messageIdFactory: MessageId.Factory,
+                               val metricFactory: MetricFactory,
+                               val sessionSupplier: SessionSupplier) extends MethodRequiringAccountId[EmailSetRequest] {
+
+
 case class DestroyResults(results: Seq[DestroyResult]) {
   def destroyed: Option[DestroyIds] = {
     Option(results.flatMap({
@@ -86,11 +93,35 @@ case class DestroyFailure(unparsedMessageId: UnparsedMessageId, e: Throwable) ex
   }
 }
 
-class EmailSetMethod @Inject()(serializer: EmailSetSerializer,
-                               messageIdManager: MessageIdManager,
-                               messageIdFactory: MessageId.Factory,
-                               val metricFactory: MetricFactory,
-                               val sessionSupplier: SessionSupplier) extends MethodRequiringAccountId[EmailSetRequest] {
+trait UpdateResult
+case class UpdateSuccess(messageId: MessageId) extends UpdateResult
+case class UpdateFailure(unparsedMessageId: UnparsedMessageId, e: Throwable) extends UpdateResult {
+  def asMessageSetError: SetError = e match {
+    case _ => SetError.serverFail(SetErrorDescription(e.getMessage))
+  }
+}
+case class UpdateResults(results: Seq[UpdateResult]) {
+  def updated: Option[Map[MessageId, Unit]] = {
+    Option(results.flatMap({
+      result => result match {
+        case result: UpdateSuccess => Some(result.messageId, ())
+        case _ => None
+      }
+    }).toMap).filter(_.nonEmpty)
+  }
+
+  def notUpdated: Option[Map[UnparsedMessageId, SetError]] = {
+    Option(results.flatMap({
+      result => result match {
+        case failure: UpdateFailure => Some(failure)
+        case _ => None
+      }
+    })
+      .map(failure => (failure.unparsedMessageId, failure.asMessageSetError))
+      .toMap)
+      .filter(_.nonEmpty)
+  }
+}
 
   override val methodName: MethodName = MethodName("Email/set")
   override val requiredCapabilities: Capabilities = Capabilities(CORE_CAPABILITY, MAIL_CAPABILITY)
@@ -98,12 +129,14 @@ class EmailSetMethod @Inject()(serializer: EmailSetSerializer,
   override def doProcess(capabilities: Set[CapabilityIdentifier], invocation: InvocationWithContext, mailboxSession: MailboxSession, request: EmailSetRequest): SMono[InvocationWithContext] = {
     for {
       destroyResults <- destroy(request, mailboxSession)
+      updateResults <- update(request, mailboxSession)
     } yield InvocationWithContext(
         invocation = Invocation(
           methodName = invocation.invocation.methodName,
           arguments = Arguments(serializer.serialize(EmailSetResponse(
             accountId = request.accountId,
             newState = State.INSTANCE,
+            updated = updateResults.updated,
             destroyed = destroyResults.destroyed,
             notDestroyed = destroyResults.notDestroyed))),
           methodCallId = invocation.invocation.methodCallId),
@@ -124,6 +157,61 @@ class EmailSetMethod @Inject()(serializer: EmailSetSerializer,
       .collectSeq()
       .map(DestroyResults)
 
+  private def update(emailSetRequest: EmailSetRequest, mailboxSession: MailboxSession): SMono[UpdateResults] = {
+    emailSetRequest.update
+      .filter(_.nonEmpty)
+      .map(update(_, mailboxSession))
+      .getOrElse(SMono.just(UpdateResults(Seq())))
+  }
+
+  private def update(updates: Map[UnparsedMessageId, EmailSetUpdate], session: MailboxSession): SMono[UpdateResults] = {
+    val validatedUpdates: List[Either[UpdateFailure, (MessageId, EmailSetUpdate)]] = updates
+      .map({
+        case (unparsedMessageId, updatePatch) => EmailSet.parse(messageIdFactory)(unparsedMessageId)
+          .map((_, updatePatch))
+          .toEither
+          .left.map(e => UpdateFailure(unparsedMessageId, e))
+      })
+      .toList
+    val failures: List[UpdateFailure] = validatedUpdates.flatMap({
+      case Left(e) => Some(e)
+      case _ => None
+    })
+    val validUpdates: List[(MessageId, EmailSetUpdate)] = validatedUpdates.flatMap({
+      case Right(pair) => Some(pair)
+      case _ => None
+    })
+
+    for {
+      updates <- SFlux.fromPublisher(messageIdManager.messagesMetadata(validUpdates.map(_._1).asJavaCollection, session))
+        .collectMultimap(metaData => metaData.getComposedMessageId.getMessageId)
+        .flatMap(metaData => {
+          SFlux.fromIterable(validUpdates)
+            .concatMap[UpdateResult]({
+              case (messageId, updatePatch) =>
+                doUpdate(messageId, updatePatch, metaData.get(messageId).toList.flatten, session)
+            })
+            .collectSeq()
+        })
+    } yield {
+      UpdateResults(updates ++ failures)
+    }
+  }
+
+  private def doUpdate(messageId: MessageId, update: EmailSetUpdate, storedMetaData: List[ComposedMessageIdWithMetaData], session: MailboxSession): SMono[UpdateResult] = {
+    val mailboxIds: List[MailboxId] = storedMetaData.map(metaData => metaData.getComposedMessageId.getMailboxId)
+
+    updateMailboxIds(messageId, update, session)
+      .onErrorResume(e => SMono.just[UpdateResult](UpdateFailure(EmailSet.asUnparsed(messageId), e)))
+      .switchIfEmpty(SMono.just[UpdateResult](UpdateSuccess(messageId)))
+  }
+
+  private def updateMailboxIds(messageId: MessageId, update: EmailSetUpdate, session: MailboxSession): SMono[UpdateResult] =
+    SMono.justOrEmpty(update.mailboxIds)
+      .flatMap(mailboxIds => SMono.fromCallable(() => messageIdManager.setInMailboxes(messageId, mailboxIds.value.asJava, session))
+        .subscribeOn(Schedulers.elastic())
+        .`then`(SMono.just[UpdateResult](UpdateSuccess(messageId))))
+
   private def deleteMessage(destroyId: UnparsedMessageId, mailboxSession: MailboxSession): SMono[DestroyResult] =
     EmailSet.parse(messageIdFactory)(destroyId)
         .fold(e => SMono.just(DestroyFailure(destroyId, e)),
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/routes/JMAPApiRoutes.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/routes/JMAPApiRoutes.scala
index 3ddf463..a945c21 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/routes/JMAPApiRoutes.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/routes/JMAPApiRoutes.scala
@@ -162,10 +162,8 @@ class JMAPApiRoutes (val authenticator: Authenticator,
 
   private def processMethodWithMatchName(capabilities: Set[CapabilityIdentifier], invocation: InvocationWithContext, mailboxSession: MailboxSession): SMono[InvocationWithContext] =
     SMono.justOrEmpty(methodsByName.get(invocation.invocation.methodName))
-      .flatMap(method => validateCapabilities(capabilities, method.requiredCapabilities)
-      .fold(e => SMono.just(InvocationWithContext(Invocation.error(ErrorCode.UnknownMethod, e.description, invocation.invocation.methodCallId), invocation.processingContext)),
-        _ => SMono.fromPublisher(method.process(capabilities, invocation, mailboxSession))
-      ))
+      .filter(method => validateCapabilities(capabilities, method.requiredCapabilities).isRight)
+      .flatMap(method => SMono.fromPublisher(method.process(capabilities, invocation, mailboxSession)))
       .onErrorResume(throwable => SMono.just(InvocationWithContext(Invocation.error(ErrorCode.ServerFail, throwable.getMessage, invocation.invocation.methodCallId), invocation.processingContext)))
       .switchIfEmpty(SMono.just(InvocationWithContext(Invocation.error(ErrorCode.UnknownMethod, invocation.invocation.methodCallId), invocation.processingContext)))
 


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