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:49 UTC

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

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