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 f7aa673640acdee20d3f538fc7a379735cd1b241
Author: duc91 <[email protected]>
AuthorDate: Tue Oct 13 17:26:03 2020 +0700

    JAMES-3411 Email/set update keywords
---
 .../rfc8621/contract/EmailSetMethodContract.scala  | 550 ++++++++++++++++++++-
 .../james/jmap/json/EmailSetSerializer.scala       |  34 +-
 .../org/apache/james/jmap/mail/EmailSet.scala      |  27 +-
 .../apache/james/jmap/method/EmailSetMethod.scala  |  30 +-
 4 files changed, 626 insertions(+), 15 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 758d6ac..3267358 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
@@ -18,25 +18,34 @@
  ****************************************************************/
 package org.apache.james.jmap.rfc8621.contract
 
+import java.io.ByteArrayInputStream
 import java.nio.charset.StandardCharsets
+import java.util.Date
 
 import io.netty.handler.codec.http.HttpHeaderNames.ACCEPT
 import io.restassured.RestAssured.{`given`, requestSpecification}
 import io.restassured.http.ContentType.JSON
+import javax.mail.Flags
 import net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson
 import org.apache.http.HttpStatus.SC_OK
 import org.apache.james.GuiceJamesServer
 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.jmap.rfc8621.contract.Fixture.{ACCEPT_RFC8621_VERSION_HEADER, 
ACCOUNT_ID, ANDRE, ANDRE_PASSWORD, BOB, BOB_PASSWORD, DOMAIN, authScheme, 
baseRequestSpecBuilder}
+import org.apache.james.mailbox.FlagsBuilder
 import org.apache.james.mailbox.MessageManager.AppendCommand
 import org.apache.james.mailbox.model.MailboxACL.Right
-import org.apache.james.mailbox.model.{MailboxACL, MailboxId, MailboxPath, 
MessageId}
+import org.apache.james.mailbox.model.{ComposedMessageId, MailboxACL, 
MailboxConstants, MailboxId, MailboxPath, MessageId}
+import org.apache.james.mailbox.probe.MailboxProbe
 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}
+import org.junit.jupiter.params.ParameterizedTest
+import org.junit.jupiter.params.provider.ValueSource
+
+import scala.jdk.CollectionConverters._
 
 trait EmailSetMethodContract {
   @BeforeEach
@@ -55,6 +64,543 @@ trait EmailSetMethodContract {
   def randomMessageId: MessageId
 
   @Test
+  def shouldResetKeywords(server: GuiceJamesServer): Unit = {
+    val message: Message = Fixture.createTestMessage
+
+    val flags: Flags = new Flags(Flags.Flag.ANSWERED)
+
+    val bobPath = MailboxPath.inbox(BOB)
+    server.getProbe(classOf[MailboxProbeImpl]).createMailbox(bobPath)
+    val messageId: MessageId = 
server.getProbe(classOf[MailboxProbeImpl]).appendMessage(BOB.asString(), 
bobPath, AppendCommand.builder()
+      .withFlags(flags)
+      .build(message))
+      .getMessageId
+
+    val request =
+      s"""{
+         |  "using": ["urn:ietf:params:jmap:core", 
"urn:ietf:params:jmap:mail"],
+         |  "methodCalls": [
+         |    ["Email/set", {
+         |      "accountId": "$ACCOUNT_ID",
+         |      "update": {
+         |        "${messageId.serialize}":{
+         |          "keywords": {
+         |             "music": true
+         |          }
+         |        }
+         |      }
+         |    }, "c1"],
+         |    ["Email/get",
+         |     {
+         |       "accountId": "$ACCOUNT_ID",
+         |       "ids": ["${messageId.serialize}"],
+         |       "properties": ["keywords"]
+         |     },
+         |     "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)
+      .inPath("methodResponses[1][1].list[0]")
+      .isEqualTo(String.format(
+        """{
+          |   "id":"%s",
+          |   "keywords": {
+          |     "music": true
+          |   }
+          |}
+      """.stripMargin, messageId.serialize))
+  }
+
+  @Test
+  def shouldNotResetKeywordWhenFalseValue(server: GuiceJamesServer): Unit = {
+    val message: Message = Fixture.createTestMessage
+
+    val flags: Flags = new Flags(Flags.Flag.ANSWERED)
+
+    val bobPath = MailboxPath.inbox(BOB)
+    server.getProbe(classOf[MailboxProbeImpl]).createMailbox(bobPath)
+    val messageId: MessageId = 
server.getProbe(classOf[MailboxProbeImpl]).appendMessage(BOB.asString(), 
bobPath, AppendCommand.builder()
+      .withFlags(flags)
+      .build(message))
+      .getMessageId
+
+    val request =
+      s"""{
+         |  "using": ["urn:ietf:params:jmap:core", 
"urn:ietf:params:jmap:mail"],
+         |  "methodCalls": [
+         |    ["Email/set", {
+         |      "accountId": "$ACCOUNT_ID",
+         |      "update": {
+         |        "${messageId.serialize}":{
+         |          "keywords": {
+         |             "music": true,
+         |             "movie": 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)
+      .inPath(s"methodResponses[0][1].notUpdated.${messageId.serialize}")
+      .isEqualTo(
+        """|{
+          |   "type":"invalidPatch",
+          |   "description": "Message 1 update is invalid: 
List((,List(JsonValidationError(List(Value associated with keywords is invalid: 
List((,List(JsonValidationError(List(keyword value can only be 
true),ArraySeq()))))),ArraySeq()))))"
+          |}""".stripMargin)
+  }
+
+  @Test
+  def shouldNotResetKeywordWhenInvalidKeyword(server: GuiceJamesServer): Unit 
= {
+    val message: Message = Fixture.createTestMessage
+
+    val flags: Flags = new Flags(Flags.Flag.ANSWERED)
+
+    val bobPath = MailboxPath.inbox(BOB)
+    server.getProbe(classOf[MailboxProbeImpl]).createMailbox(bobPath)
+    val messageId: MessageId = 
server.getProbe(classOf[MailboxProbeImpl]).appendMessage(BOB.asString(), 
bobPath, AppendCommand.builder()
+      .withFlags(flags)
+      .build(message))
+      .getMessageId
+
+    val request =
+      s"""{
+         |  "using": ["urn:ietf:params:jmap:core", 
"urn:ietf:params:jmap:mail"],
+         |  "methodCalls": [
+         |    ["Email/set", {
+         |      "accountId": "$ACCOUNT_ID",
+         |      "update": {
+         |        "${messageId.serialize}":{
+         |          "keywords": {
+         |             "mus*c": 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)
+      .inPath(s"methodResponses[0][1].notUpdated.${messageId.serialize}")
+      .isEqualTo(
+        """|{
+           |   "type":"invalidPatch",
+           |   "description": "Message 1 update is invalid: 
List((,List(JsonValidationError(List(Value associated with keywords is invalid: 
List((,List(JsonValidationError(List(FlagName must not be null or empty, must 
have length form 1-255,must not contain characters with hex from '\\u0000' to 
'\\u00019' or {'(' ')' '{' ']' '%' '*' '\"' '\\'} 
),ArraySeq()))))),ArraySeq()))))"
+           |}""".stripMargin)
+  }
+
+  @ParameterizedTest
+  @ValueSource(strings = Array(
+    "$Recent",
+    "$Deleted"
+  ))
+  def shouldNotResetNonExposedKeyword(unexposedKeyword: String, server: 
GuiceJamesServer): Unit = {
+    val message: Message = Fixture.createTestMessage
+
+    val flags: Flags = new Flags(Flags.Flag.ANSWERED)
+
+    val bobPath = MailboxPath.inbox(BOB)
+    server.getProbe(classOf[MailboxProbeImpl]).createMailbox(bobPath)
+    val messageId: MessageId = 
server.getProbe(classOf[MailboxProbeImpl]).appendMessage(BOB.asString(), 
bobPath, AppendCommand.builder()
+      .withFlags(flags)
+      .build(message))
+      .getMessageId
+
+    val request = String.format(
+      s"""{
+         |  "using": ["urn:ietf:params:jmap:core", 
"urn:ietf:params:jmap:mail"],
+         |  "methodCalls": [
+         |    ["Email/set", {
+         |      "accountId": "$ACCOUNT_ID",
+         |      "update": {
+         |        "${messageId.serialize}":{
+         |          "keywords": {
+         |             "music": true,
+         |             "$unexposedKeyword": 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)
+      .inPath("methodResponses[0][1].notUpdated")
+      .isEqualTo(
+        s"""{
+           |  "${messageId.serialize}":{
+           |      "type":"invalidPatch",
+           |      "description":"Message 1 update is invalid: Does not allow 
to update 'Deleted' or 'Recent' flag"}
+           |  }
+           |}"""
+          .stripMargin)
+  }
+
+  @Test
+  def shouldKeepUnexposedKeywordWhenResetKeywords(server: GuiceJamesServer): 
Unit = {
+    val mailboxProbe: MailboxProbe = server.getProbe(classOf[MailboxProbeImpl])
+    mailboxProbe.createMailbox(MailboxConstants.USER_NAMESPACE, 
BOB.asString(), "mailbox");
+
+    val bobPath = MailboxPath.forUser(BOB, "mailbox")
+    val message: ComposedMessageId = mailboxProbe.appendMessage(BOB.asString, 
bobPath,
+      new ByteArrayInputStream("Subject: 
test\r\n\r\ntestmail".getBytes(StandardCharsets.UTF_8)),
+      new Date, false, new Flags(Flags.Flag.DELETED))
+
+    val messageId: String = message.getMessageId.serialize
+
+    val request = String.format(s"""{
+         |  "using": ["urn:ietf:params:jmap:core", 
"urn:ietf:params:jmap:mail"],
+         |  "methodCalls": [
+         |    ["Email/set", {
+         |      "accountId": "$ACCOUNT_ID",
+         |      "update": {
+         |        "$messageId":{
+         |          "keywords": {
+         |             "music": true
+         |          }
+         |        }
+         |      }
+         |    }, "c1"]]
+         |}""".stripMargin)
+
+    `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+
+    val flags: List[Flags] = 
server.getProbe(classOf[MessageIdProbe]).getMessages(message.getMessageId, 
BOB).asScala.map(m => m.getFlags).toList
+    val expectedFlags: Flags  = 
FlagsBuilder.builder.add("music").add(Flags.Flag.DELETED).build
+
+    assertThat(flags.asJava)
+      .containsExactly(expectedFlags)
+  }
+
+  @Test
+  def shouldResetKeywordsWhenNotDefault(server: GuiceJamesServer): Unit = {
+    val message: Message = Fixture.createTestMessage
+
+    val flags: Flags = new Flags(Flags.Flag.ANSWERED)
+
+    val bobPath = MailboxPath.inbox(BOB)
+    server.getProbe(classOf[MailboxProbeImpl]).createMailbox(bobPath)
+    val messageId: MessageId = 
server.getProbe(classOf[MailboxProbeImpl]).appendMessage(BOB.asString(), 
bobPath, AppendCommand.builder()
+      .withFlags(flags)
+      .build(message))
+      .getMessageId
+
+    val request =
+      s"""{
+         |  "using": ["urn:ietf:params:jmap:core", 
"urn:ietf:params:jmap:mail"],
+         |  "methodCalls": [
+         |    ["Email/set", {
+         |      "accountId": "$ACCOUNT_ID",
+         |      "update": {
+         |        "${messageId.serialize}":{
+         |          "keywords": {
+         |             "music": true
+         |          }
+         |        }
+         |      }
+         |    }, "c1"],
+         |    ["Email/get",
+         |     {
+         |       "accountId": "$ACCOUNT_ID",
+         |       "ids": ["${messageId.serialize}"],
+         |       "properties": ["keywords"]
+         |     },
+         |     "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)
+      .inPath("methodResponses[1][1].list[0]")
+      .isEqualTo(String.format(
+        """{
+          |   "id":"%s",
+          |   "keywords": {
+          |             "music": true
+          |    }
+          |}
+      """.stripMargin, messageId.serialize))
+  }
+
+  @Test
+  def shouldNotResetKeywordWhenInvalidMessageId(server: GuiceJamesServer): 
Unit = {
+    val bobPath = MailboxPath.inbox(BOB)
+    server.getProbe(classOf[MailboxProbeImpl]).createMailbox(bobPath)
+
+    val request =
+      s"""{
+         |  "using": ["urn:ietf:params:jmap:core", 
"urn:ietf:params:jmap:mail"],
+         |  "methodCalls": [
+         |    ["Email/set", {
+         |      "accountId": "$ACCOUNT_ID",
+         |      "update": {
+         |        "invalid":{
+         |          "keywords": {
+         |             "music": 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)
+     .inPath("methodResponses[0][1].notUpdated")
+     .isEqualTo("""{
+        | "invalid": {
+        |     "type":"invalidPatch",
+        |     "description":"Message invalid update is invalid: For input 
string: \"invalid\""
+        | }
+        |}""".stripMargin)
+  }
+
+  @Test
+  def shouldNotResetKeywordWhenMessageIdNonExisted(server: GuiceJamesServer): 
Unit = {
+    val invalidMessageId: MessageId = randomMessageId
+
+    val bobPath = MailboxPath.inbox(BOB)
+    server.getProbe(classOf[MailboxProbeImpl]).createMailbox(bobPath)
+
+    val request = s"""{
+         |  "using": ["urn:ietf:params:jmap:core", 
"urn:ietf:params:jmap:mail"],
+         |  "methodCalls": [
+         |    ["Email/set", {
+         |      "accountId": "$ACCOUNT_ID",
+         |      "update": {
+         |        "${invalidMessageId.serialize}":{
+         |          "keywords": {
+         |             "music": 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)
+      .inPath("methodResponses[0][1].notUpdated")
+      .isEqualTo(s"""{
+        | "${invalidMessageId.serialize}": {
+        |     "type":"notFound",
+        |     "description":"Cannot find message with messageId: 
${invalidMessageId.serialize}"
+        | }
+        |}""".stripMargin)
+  }
+
+  @Test
+  def shouldNotUpdateInDelegatedMailboxesWhenReadOnly(server: 
GuiceJamesServer): Unit = {
+    val andreMailbox: String = "andrecustom"
+    val andrePath = MailboxPath.forUser(ANDRE, andreMailbox)
+    server.getProbe(classOf[MailboxProbeImpl]).createMailbox(andrePath)
+    val message: Message = Message.Builder
+      .of
+      .setSender(BOB.asString())
+      .setFrom(ANDRE.asString())
+      .setSubject("test")
+      .setBody("testmail", StandardCharsets.UTF_8)
+      .build
+    val messageId: MessageId = server.getProbe(classOf[MailboxProbeImpl])
+      .appendMessage(ANDRE.asString, andrePath, AppendCommand.from(message))
+      .getMessageId
+    server.getProbe(classOf[ACLProbeImpl])
+      .replaceRights(andrePath, BOB.asString, 
MailboxACL.Rfc4314Rights.of(Set(Right.Read, Right.Lookup).asJava))
+
+    val request =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail"],
+         |  "methodCalls": [
+         |  ["Email/set",
+         |    {
+         |      "accountId": "$ACCOUNT_ID",
+         |      "update": {
+         |        "${messageId.serialize}":{
+         |          "keywords": {
+         |             "music": 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)
+      .inPath("methodResponses[0][1].notUpdated")
+      .isEqualTo(
+        s"""{
+           |  "${messageId.serialize}":{
+           |     "type": "notFound",
+           |     "description": "Mailbox not found"
+           |  }
+           |}""".stripMargin)
+  }
+
+  @Test
+  def shouldResetFlagsInDelegatedMailboxesWhenHadAtLeastWriteRight(server: 
GuiceJamesServer): Unit = {
+    val andreMailbox: String = "andrecustom"
+    val andrePath = MailboxPath.forUser(ANDRE, andreMailbox)
+    server.getProbe(classOf[MailboxProbeImpl]).createMailbox(andrePath)
+    val message: Message = Message.Builder
+      .of
+      .setSender(BOB.asString())
+      .setFrom(ANDRE.asString())
+      .setSubject("test")
+      .setBody("testmail", StandardCharsets.UTF_8)
+      .build
+    val messageId: MessageId = server.getProbe(classOf[MailboxProbeImpl])
+      .appendMessage(ANDRE.asString, andrePath, AppendCommand.from(message))
+      .getMessageId
+    server.getProbe(classOf[ACLProbeImpl])
+      .replaceRights(andrePath, BOB.asString, 
MailboxACL.Rfc4314Rights.of(Set(Right.Write, Right.Read).asJava))
+
+    val request =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail"],
+         |  "methodCalls": [
+         |  ["Email/set",
+         |    {
+         |      "accountId": "$ACCOUNT_ID",
+         |      "ids": ["${messageId.serialize}"],
+         |      "update": {
+         |        "${messageId.serialize}":{
+         |          "keywords": {
+         |             "music": true
+         |          }
+         |        }
+         |      }
+         |    },
+         |    "c1"],
+         |    ["Email/get",
+         |     {
+         |       "accountId": "$ACCOUNT_ID",
+         |       "ids": ["${messageId.serialize}"],
+         |       "properties": ["keywords"]
+         |     },
+         |     "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)
+      .inPath("methodResponses[1][1].list[0]")
+      .isEqualTo(String.format(
+        """{
+          |   "id":"%s",
+          |   "keywords": {
+          |     "music":true
+          |   }
+          |}
+      """.stripMargin, messageId.serialize))
+  }
+
+  @Test
   def emailSetShouldDestroyEmail(server: GuiceJamesServer): Unit = {
     val mailboxProbe = server.getProbe(classOf[MailboxProbeImpl])
     mailboxProbe.createMailbox(MailboxPath.inbox(BOB))
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 556f194..28e79c2 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
@@ -23,7 +23,7 @@ import eu.timepit.refined.refineV
 import javax.inject.Inject
 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.jmap.model.{Keyword, Keywords, SetError}
 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}
 
@@ -46,6 +46,12 @@ class EmailSetSerializer @Inject()(messageIdFactory: 
MessageId.Factory, mailboxI
           }.headOption
             .map(_.ids)
 
+          val keywordsReset: Option[Keywords] = entries.flatMap {
+            case update: KeywordsReset => Some(update)
+            case _ => None
+          }.headOption
+            .map(_.keywords)
+
           val mailboxesToAdd: Option[MailboxIds] = Some(entries
             .flatMap {
               case update: MailboxAddition => Some(update)
@@ -62,7 +68,8 @@ class EmailSetSerializer @Inject()(messageIdFactory: 
MessageId.Factory, mailboxI
             .filter(_.nonEmpty)
             .map(MailboxIds)
 
-          JsSuccess(EmailSetUpdate(mailboxIds = mailboxReset,
+          JsSuccess(EmailSetUpdate(keywords = keywordsReset,
+            mailboxIds = mailboxReset,
             mailboxIdsToAdd = mailboxesToAdd,
             mailboxIdsToRemove = mailboxesToRemove))
         })
@@ -75,6 +82,10 @@ class EmailSetSerializer @Inject()(messageIdFactory: 
MessageId.Factory, mailboxI
           .fold(
             e => InvalidPatchEntryValue(property, e.toString()),
             MailboxReset)
+        case "keywords" => keywordsReads.reads(value)
+          .fold(
+            e => InvalidPatchEntryValue(property, e.toString()),
+            KeywordsReset)
         case name if name.startsWith(mailboxIdPrefix) => 
Try(mailboxIdFactory.fromString(name.substring(mailboxIdPrefix.length)))
           .fold(e => InvalidPatchEntryNameWithDetails(property, e.getMessage),
             id => value match {
@@ -108,6 +119,8 @@ class EmailSetSerializer @Inject()(messageIdFactory: 
MessageId.Factory, mailboxI
 
     private case class MailboxReset(ids: MailboxIds) extends EntryValidation
 
+    private case class KeywordsReset(keywords: Keywords) extends 
EntryValidation
+
   }
 
   private implicit val messageIdWrites: Writes[MessageId] = messageId => 
JsString(messageId.serialize)
@@ -140,6 +153,23 @@ class EmailSetSerializer @Inject()(messageIdFactory: 
MessageId.Factory, mailboxI
         case _ => JsError("Expecting a JsObject as an update entry")
       })
 
+  private implicit val keywordReads: Reads[Keyword] = {
+    case jsString: JsString => Keyword.parse(jsString.value)
+      .fold(JsError(_),
+        JsSuccess(_))
+    case _ => JsError("Expecting a string as a keyword")
+  }
+
+  private implicit val keywordsMapReads: Reads[Map[Keyword, Boolean]] =
+    readMapEntry[Keyword, Boolean](s => Keyword.parse(s),
+      {
+        case JsBoolean(true) => JsSuccess(true)
+        case JsBoolean(false) => JsError("keyword value can only be true")
+        case _ => JsError("Expecting keyword value to be a boolean")
+      })
+  private implicit val keywordsReads: Reads[Keywords] = jsValue => 
keywordsMapReads.reads(jsValue).map(
+    keywordsMap => Keywords(keywordsMap.keys.toSet))
+
   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)
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 ab692e5..c2ea230 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
@@ -23,12 +23,13 @@ import eu.timepit.refined.api.Refined
 import eu.timepit.refined.collection.NonEmpty
 import org.apache.james.jmap.mail.EmailSet.UnparsedMessageId
 import org.apache.james.jmap.method.WithAccountId
+import org.apache.james.jmap.model.KeywordsFactory.STRICT_KEYWORDS_FACTORY
 import org.apache.james.jmap.model.State.State
-import org.apache.james.jmap.model.{AccountId, SetError}
+import org.apache.james.jmap.model.{AccountId, Keywords, SetError}
 import org.apache.james.mailbox.model.MessageId
 import play.api.libs.json.JsObject
 
-import scala.util.Try
+import scala.util.{Failure, Right, Success, Try}
 
 object EmailSet {
   type UnparsedMessageIdConstraint = NonEmpty
@@ -56,7 +57,8 @@ case class EmailSetResponse(accountId: AccountId,
                             destroyed: Option[DestroyIds],
                             notDestroyed: Option[Map[UnparsedMessageId, 
SetError]])
 
-case class EmailSetUpdate(mailboxIds: Option[MailboxIds],
+case class EmailSetUpdate(keywords: Option[Keywords],
+                          mailboxIds: Option[MailboxIds],
                           mailboxIdsToAdd: Option[MailboxIds],
                           mailboxIdsToRemove: Option[MailboxIds]) {
   def validate: Either[IllegalArgumentException, ValidatedEmailSetUpdate] = if 
(mailboxIds.isDefined && (mailboxIdsToAdd.isDefined || 
mailboxIdsToRemove.isDefined)) {
@@ -75,15 +77,26 @@ case class EmailSetUpdate(mailboxIds: Option[MailboxIds],
     val mailboxIdsTransformation: Function[MailboxIds, MailboxIds] = 
mailboxIdsAddition
       .compose(mailboxIdsRemoval)
       .compose(mailboxIdsReset)
-    scala.Right(ValidatedEmailSetUpdate(mailboxIdsTransformation))
+    Right(mailboxIdsTransformation)
+      .flatMap(mailboxIdsTransformation => validateKeywords
+        .map(validatedKeywords => ValidatedEmailSetUpdate(validatedKeywords, 
mailboxIdsTransformation)))
+  }
+
+  private def validateKeywords: Either[IllegalArgumentException, 
Option[Keywords]] = {
+    keywords.map(_.getKeywords)
+      .map(STRICT_KEYWORDS_FACTORY.fromSet)
+      .map {
+        case Success(validatedKeywords: Keywords) => 
Right(Some(validatedKeywords))
+        case Failure(throwable: IllegalArgumentException) => Left(throwable)
+      }
+      .getOrElse(Right(None))
   }
 }
 
-case class ValidatedEmailSetUpdate private (mailboxIdsTransformation: 
Function[MailboxIds, MailboxIds])
+case class ValidatedEmailSetUpdate private (keywords: Option[Keywords],
+                                            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 c7022f8..74d72b3 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
@@ -18,8 +18,10 @@
  ****************************************************************/
 package org.apache.james.jmap.method
 
+import com.google.common.collect.ImmutableList
 import eu.timepit.refined.auto._
 import javax.inject.Inject
+import javax.mail.Flags
 import org.apache.james.jmap.http.SessionSupplier
 import org.apache.james.jmap.json.{EmailSetSerializer, ResponseSerializer}
 import org.apache.james.jmap.mail.EmailSet.UnparsedMessageId
@@ -29,6 +31,7 @@ import 
org.apache.james.jmap.model.DefaultCapabilities.{CORE_CAPABILITY, MAIL_CA
 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.MessageManager.FlagsUpdateMode
 import org.apache.james.mailbox.exception.MailboxNotFoundException
 import org.apache.james.mailbox.model.{ComposedMessageIdWithMetaData, 
DeleteResult, MessageId}
 import org.apache.james.mailbox.{MailboxSession, MessageIdManager}
@@ -195,7 +198,7 @@ class EmailSetMethod @Inject()(serializer: 
EmailSetSerializer,
         .collectMultimap(metaData => 
metaData.getComposedMessageId.getMessageId)
         .flatMap(metaData => {
           SFlux.fromIterable(validUpdates)
-            .flatMap[UpdateResult]({
+            .concatMap[UpdateResult]({
               case (messageId, updatePatch) =>
                 doUpdate(messageId, updatePatch, 
metaData.get(messageId).toList.flatten, session)
             })
@@ -208,6 +211,11 @@ class EmailSetMethod @Inject()(serializer: 
EmailSetSerializer,
 
   private def doUpdate(messageId: MessageId, update: EmailSetUpdate, 
storedMetaData: List[ComposedMessageIdWithMetaData], session: MailboxSession): 
SMono[UpdateResult] = {
     val mailboxIds: MailboxIds = MailboxIds(storedMetaData.map(metaData => 
metaData.getComposedMessageId.getMailboxId))
+    val originFlags: Flags = storedMetaData
+      .foldLeft[Flags](new Flags())((flags: Flags, m: 
ComposedMessageIdWithMetaData) => {
+        flags.add(m.getFlags)
+        flags
+      })
 
     if (mailboxIds.value.isEmpty) {
       SMono.just[UpdateResult](UpdateFailure(EmailSet.asUnparsed(messageId), 
MessageNotFoundExeception(messageId)))
@@ -215,9 +223,14 @@ class EmailSetMethod @Inject()(serializer: 
EmailSetSerializer,
       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))))
+          validatedUpdate =>
+            resetFlags(messageId, validatedUpdate, mailboxIds, originFlags, 
session)
+              .flatMap {
+                case failure: UpdateFailure => 
SMono.just[UpdateResult](failure)
+                case _: UpdateSuccess => updateMailboxIds(messageId, 
validatedUpdate, mailboxIds, session)
+              }
+              .onErrorResume(e => 
SMono.just[UpdateResult](UpdateFailure(EmailSet.asUnparsed(messageId), e)))
+              
.switchIfEmpty(SMono.just[UpdateResult](UpdateSuccess(messageId))))
     }
   }
 
@@ -234,6 +247,15 @@ class EmailSetMethod @Inject()(serializer: 
EmailSetSerializer,
     }
   }
 
+  private def resetFlags(messageId: MessageId, update: 
ValidatedEmailSetUpdate, mailboxIds: MailboxIds, originalFlags: Flags, session: 
MailboxSession): SMono[UpdateResult] =
+    update.keywords
+      .map(keywords => keywords.asFlagsWithRecentAndDeletedFrom(originalFlags))
+      .map(flags => SMono.fromCallable(() =>
+        messageIdManager.setFlags(flags, FlagsUpdateMode.REPLACE, messageId, 
ImmutableList.copyOf(mailboxIds.value.asJavaCollection), session))
+        .subscribeOn(Schedulers.elastic())
+        .`then`(SMono.just[UpdateResult](UpdateSuccess(messageId))))
+      .getOrElse(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)),


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to