JAMES-4025 ImapKeywordsConsistency cucumber test for JMAP RFC-8621 in plain scala
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/ImapKeywordsConsistencyContract.scala b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/ImapKeywordsConsistencyContract.scala
new file mode 100644
index 0000000..b91808c
--- /dev/null
+++ b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/ImapKeywordsConsistencyContract.scala
@@ -0,0 +1,614 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ * http://www.apache.org/licenses/LICENSE-2.0                   *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.jmap.rfc8621.contract
+
+import java.nio.charset.StandardCharsets
+import java.util
+import java.util.concurrent.TimeUnit
+
+import io.netty.handler.codec.http.HttpHeaderNames.ACCEPT
+import io.restassured.RestAssured
+import io.restassured.RestAssured.`given`
+import io.restassured.http.ContentType.JSON
+import net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson
+import org.apache.http.HttpStatus.SC_OK
+import org.apache.james.GuiceJamesServer
+import org.apache.james.jmap.JMAPTestingConstants.{DOMAIN, LOCALHOST_IP}
+import org.apache.james.jmap.http.UserCredential
+import org.apache.james.jmap.rfc8621.contract.Fixture.{ACCEPT_RFC8621_VERSION_HEADER, ACCOUNT_ID, ANDRE, BOB, BOB_PASSWORD, authScheme, baseRequestSpecBuilder}
+import org.apache.james.jmap.rfc8621.contract.ImapKeywordsConsistencyContract.bobInboxPath
+import org.apache.james.mailbox.DefaultMailboxes
+import org.apache.james.mailbox.MessageManager.AppendCommand
+import org.apache.james.mailbox.model.{MailboxConstants, MailboxPath, MessageId}
+import org.apache.james.mime4j.dom.Message
+import org.apache.james.modules.MailboxProbeImpl
+import org.apache.james.modules.protocols.ImapGuiceProbe
+import org.apache.james.utils.{DataProbeImpl, TestIMAPClient}
+import org.assertj.core.api.Assertions.assertThat
+import org.awaitility.Awaitility
+import org.awaitility.Durations.ONE_HUNDRED_MILLISECONDS
+import org.junit.jupiter.api.{BeforeEach, Disabled, Test}
+import org.junit.jupiter.params.ParameterizedTest
+import org.junit.jupiter.params.provider.ValueSource
+
+import scala.jdk.CollectionConverters._
+
+object ImapKeywordsConsistencyContract {
+  private val bobInboxPath = MailboxPath.forUser(BOB, DefaultMailboxes.INBOX)
+}
+
+trait ImapKeywordsConsistencyContract {
+  private lazy val slowPacedPollInterval = ONE_HUNDRED_MILLISECONDS
+  private lazy val calmlyAwait = Awaitility.`with`
+    .pollInterval(slowPacedPollInterval)
+    .and.`with`.pollDelay(slowPacedPollInterval)
+    .await
+  private lazy val awaitAtMostOneMinute = calmlyAwait.atMost(1, TimeUnit.MINUTES)
+
+  def imapClient: TestIMAPClient
+
+  @BeforeEach
+  def setUp(server: GuiceJamesServer): Unit = {
+    server.getProbe(classOf[DataProbeImpl])
+      .fluent
+      .addDomain(DOMAIN)
+      .addUser(BOB.asString, BOB_PASSWORD)
+
+    val mailboxProbe = server.getProbe(classOf[MailboxProbeImpl])
+    mailboxProbe.createMailbox(MailboxConstants.USER_NAMESPACE, BOB.asString, DefaultMailboxes.INBOX)
+    mailboxProbe.createMailbox(MailboxConstants.USER_NAMESPACE, BOB.asString, DefaultMailboxes.ARCHIVE)
+    mailboxProbe.createMailbox(MailboxConstants.USER_NAMESPACE, BOB.asString, DefaultMailboxes.TRASH)
+
+    RestAssured.requestSpecification = baseRequestSpecBuilder(server)
+      .setAuth(authScheme(UserCredential(BOB, BOB_PASSWORD)))
+      .build
+  }
+
+  @ParameterizedTest
+  @ValueSource(strings = Array(
+    DefaultMailboxes.INBOX,
+    DefaultMailboxes.ARCHIVE
+  ))
+  def emailGetShouldUnionKeywordsWhenInconsistencyCreatedViaImap(mailbox: String, server: GuiceJamesServer): Unit = {
+    // Given the user has a message "m1" in "inbox" mailbox with subject "My awesome subject", content "This is the content"
+    val messageId = appendMessageToInbox(server)
+
+    // And bob copies "m1" from mailbox "inbox" to mailbox "archive"
+    copyMessageFromInboxToArchive(server, messageId)
+    awaitAtMostOneMinute.until(() => listMessageIdsArchive(server).size == 1)
+
+    // And the user has an open IMAP connection with mailbox "<mailbox>" selected
+    imapClient.connect(LOCALHOST_IP, server.getProbe(classOf[ImapGuiceProbe]).getImapPort)
+      .login(BOB, BOB_PASSWORD)
+      .select(mailbox)
+
+    // And the user set flags via IMAP to "(\Flagged)" for all messages in mailbox "<mailbox>"
+    imapClient.setFlagsForAllMessagesInMailbox("\\Flagged")
+
+    // When the user ask for message "m1"
+    // Then no error is returned
+    // And the list should contain 1 message
+    // And the id of the message is "m1"
+    // And the keywords of the message is "(\Flagged)"
+    val ids = listMessageIds().asScala.toList
+
+    assertThat(ids.size).isEqualTo(1)
+    assertThat(ids).isEqualTo(List(messageId.serialize))
+
+    val idString = concatMessageIds(ids)
+    val response = getMessagesByIds(idString)
+
+    assertThatJson(response)
+      .inPath("methodResponses[0][1].list[0].keywords")
+      .isEqualTo("{\"$flagged\": true}")
+  }
+
+  @Disabled("No intersection on JMAP RFC, all keywords are just unioned")
+  @ParameterizedTest
+  @ValueSource(strings = Array(
+    DefaultMailboxes.INBOX,
+    DefaultMailboxes.ARCHIVE
+  ))
+  def emailGetShouldIntersectDraftWhenInconsistencyCreatedViaImap(mailbox: String, server: GuiceJamesServer): Unit = {
+    // Given the user has a message "m1" in "inbox" mailbox with subject "My awesome subject", content "This is the content"
+    val messageId = appendMessageToInbox(server)
+
+    // And user copies "m1" from mailbox "inbox" to mailbox "archive"
+    copyMessageFromInboxToArchive(server, messageId)
+    awaitAtMostOneMinute.until(() => listMessageIdsArchive(server).size == 1)
+
+    // And the user has an open IMAP connection with mailbox "<mailbox>" selected
+    imapClient.connect(LOCALHOST_IP, server.getProbe(classOf[ImapGuiceProbe]).getImapPort)
+      .login(BOB, BOB_PASSWORD)
+      .select(mailbox)
+
+    // And the user set flags via IMAP to "(\Draft)" for all messages in mailbox "<mailbox>"
+    imapClient.setFlagsForAllMessagesInMailbox("\\Draft")
+
+    // When the user ask for message "m1"
+    // Then no error is returned
+    // And the list should contain 1 message
+    // And the id of the message is "m1"
+    // And the keywords of the message is <keyword>
+    val ids = listMessageIds().asScala.toList
+
+    assertThat(ids.size).isEqualTo(1)
+    assertThat(ids).isEqualTo(List(messageId.serialize))
+
+    val idString = concatMessageIds(ids)
+    val response = getMessagesByIds(idString)
+
+    assertThatJson(response)
+      .inPath("methodResponses[0][1].list[0].keywords")
+      .isEqualTo("")
+  }
+
+  @Test
+  def emailQueryShouldReturnMatchingMessageIdWhenMatchingInAtLeastOneMailbox(server: GuiceJamesServer): Unit = {
+    // Given the user has a message "m1" in "inbox" mailbox with subject "My awesome subject", content "This is the content"
+    val messageId = appendMessageToInbox(server)
+
+    // And user copies "m1" from mailbox "inbox" to mailbox "archive"
+    copyMessageFromInboxToArchive(server, messageId)
+    awaitAtMostOneMinute.until(() => listMessageIdsArchive(server).size == 1)
+
+    // And the user has an open IMAP connection with mailbox "archive" selected
+    imapClient.connect(LOCALHOST_IP, server.getProbe(classOf[ImapGuiceProbe]).getImapPort)
+      .login(BOB, BOB_PASSWORD)
+      .select(DefaultMailboxes.ARCHIVE)
+
+    // And the user set flags via IMAP to "(\Flagged)" for all messages in mailbox "archive"
+    imapClient.setFlagsForAllMessagesInMailbox("\\Flagged")
+
+    // When the user asks for message list with flag "$Flagged"
+    val ids = listMessageIdsBykeyword("$Flagged").asScala.toList
+
+    // Then the message list has size 1
+    // And the message list contains "m1"
+    assertThat(ids.size).isEqualTo(1)
+    assertThat(ids).isEqualTo(List(messageId.serialize))
+  }
+
+  @Test
+  def emailQueryInSpecificMailboxShouldReturnMessageIdWhenMatching(server: GuiceJamesServer): Unit = {
+    // Given the user has a message "m1" in "inbox" mailbox with subject "My awesome subject", content "This is the content"
+    val messageId = appendMessageToInbox(server)
+
+    // And user copies "m1" from mailbox "inbox" to mailbox "archive"
+    copyMessageFromInboxToArchive(server, messageId)
+    awaitAtMostOneMinute.until(() => listMessageIdsArchive(server).size == 1)
+
+    // And the user has an open IMAP connection with mailbox "archive" selected
+    imapClient.connect(LOCALHOST_IP, server.getProbe(classOf[ImapGuiceProbe]).getImapPort)
+      .login(BOB, BOB_PASSWORD)
+      .select(DefaultMailboxes.ARCHIVE)
+
+    // And the user set flags via IMAP to "(\Flagged)" for all messages in mailbox "archive"
+    imapClient.setFlagsForAllMessagesInMailbox("\\Flagged")
+
+    // When user asks for message list in mailbox "archive" with flag "$Flagged"
+    val ids = listMessageIdsByMailboxAndKeyword(server, DefaultMailboxes.ARCHIVE, "$Flagged").asScala.toList
+
+    // Then the message list has size 1
+    // And the message list contains "m1"
+    assertThat(ids.size).isEqualTo(1)
+    assertThat(ids).isEqualTo(List(messageId.serialize))
+  }
+
+  @Test
+  def emailQueryInSpecificMailboxShouldSkipMessageIdWhenNotMatching(server: GuiceJamesServer): Unit = {
+    // Given the user has a message "m1" in "inbox" mailbox with subject "My awesome subject", content "This is the content"
+    val messageId = appendMessageToInbox(server)
+
+    // And user copies "m1" from mailbox "inbox" to mailbox "archive"
+    copyMessageFromInboxToArchive(server, messageId)
+    awaitAtMostOneMinute.until(() => listMessageIdsArchive(server).size == 1)
+
+    // And the user has an open IMAP connection with mailbox "archive" selected
+    imapClient.connect(LOCALHOST_IP, server.getProbe(classOf[ImapGuiceProbe]).getImapPort)
+      .login(BOB, BOB_PASSWORD)
+      .select(DefaultMailboxes.ARCHIVE)
+
+    // And the user set flags via IMAP to "(\Flagged)" for all messages in mailbox "archive"
+    imapClient.setFlagsForAllMessagesInMailbox("\\Flagged")
+
+    // When user asks for message list in mailbox "inbox" with flag "$Flagged"
+    val ids = listMessageIdsByMailboxAndKeyword(server, DefaultMailboxes.INBOX, "$Flagged").asScala.toList
+
+    // Then the message list is empty
+    assertThat(ids.size).isEqualTo(0)
+  }
+
+  @Disabled("JAMES-4026: Issue with solving inconsistency created from IMAP via Email/set update JMAP RFC-8621 request")
+  @Test
+  def emailSetShouldSucceedToSolveKeywordsConflictsIntroducedViaImapUponFlagsAddition(server: GuiceJamesServer): Unit = {
+    // Given the user has a message "m1" in "inbox" mailbox with subject "My awesome subject", content "This is the content"
+    val messageId = appendMessageToInbox(server)
+
+    // And user copies "m1" from mailbox "inbox" to mailbox "archive"
+    copyMessageFromInboxToArchive(server, messageId)
+    awaitAtMostOneMinute.until(() => listMessageIdsArchive(server).size == 1)
+
+    // And the user has an open IMAP connection with mailbox "archive" selected
+    imapClient.connect(LOCALHOST_IP, server.getProbe(classOf[ImapGuiceProbe]).getImapPort)
+      .login(BOB, BOB_PASSWORD)
+      .select(DefaultMailboxes.ARCHIVE)
+
+    // And the user set flags via IMAP to "(\Flagged)" for all messages in mailbox "archive"
+    imapClient.setFlagsForAllMessagesInMailbox("\\Flagged")
+
+    // When user sets flags "$Flagged" on message "m1"
+    emailSetFlags(messageId, "$Flagged")
+
+    // Then user asks for message list in mailbox "archive" with flag "$Flagged"
+    val idsArchive = listMessageIdsByMailboxAndKeyword(server, DefaultMailboxes.ARCHIVE, "$Flagged").asScala.toList
+
+    // And the message list has size 1
+    assertThat(idsArchive.size).isEqualTo(1)
+
+    // And the message list contains "m1"
+    assertThat(idsArchive).isEqualTo(List(messageId.serialize))
+
+    // And user asks for message list in mailbox "inbox" with flag "$Flagged"
+    val idsInbox = listMessageIdsByMailboxAndKeyword(server, DefaultMailboxes.INBOX, "$Flagged").asScala.toList
+
+    // And the message list has size 1
+    assertThat(idsInbox.size).isEqualTo(1)
+
+    // And the message list contains "m1"
+    assertThat(idsInbox).isEqualTo(List(messageId.serialize))
+  }
+
+  @Test
+  def emailSetShouldIgnoreKeywordsConflictIntroducedViaImapUponFlagsDeletionWithEmailQuery(server: GuiceJamesServer): Unit = {
+    // Given the user has a message "m1" in "inbox" mailbox with subject "My awesome subject", content "This is the content"
+    val messageId = appendMessageToInbox(server)
+
+    // And user copies "m1" from mailbox "inbox" to mailbox "archive"
+    copyMessageFromInboxToArchive(server, messageId)
+    awaitAtMostOneMinute.until(() => listMessageIdsArchive(server).size == 1)
+
+    // And the user has an open IMAP connection with mailbox "archive" selected
+    imapClient.connect(LOCALHOST_IP, server.getProbe(classOf[ImapGuiceProbe]).getImapPort)
+      .login(BOB, BOB_PASSWORD)
+      .select(DefaultMailboxes.ARCHIVE)
+
+    // And the user set flags via IMAP to "(\Flagged)" for all messages in mailbox "archive"
+    imapClient.setFlagsForAllMessagesInMailbox("\\Flagged")
+
+    // When user sets flags "$Answered" on message "m1"
+    emailSetFlags(messageId, "$Answered")
+
+    // Then user asks for message list in mailbox "archive" with flag "$Flagged"
+    val idsArchive = listMessageIdsByMailboxAndKeyword(server, DefaultMailboxes.ARCHIVE, "$Flagged").asScala.toList
+
+    // And the message list is empty
+    assertThat(idsArchive.size).isEqualTo(0)
+
+    // And user asks for message list in mailbox "inbox" with flag "$Flagged"
+    val idsInbox = listMessageIdsByMailboxAndKeyword(server, DefaultMailboxes.INBOX, "$Flagged").asScala.toList
+
+    // And the message list is empty
+    assertThat(idsInbox.size).isEqualTo(0)
+
+    // Then user asks for message list in mailbox "archive" with flag "$Answered"
+    val idsAnsweredArchive = listMessageIdsByMailboxAndKeyword(server, DefaultMailboxes.ARCHIVE, "$Answered").asScala.toList
+
+    // And the message list has size 1
+    assertThat(idsAnsweredArchive.size).isEqualTo(1)
+
+    // And the message list contains "m1"
+    assertThat(idsAnsweredArchive).isEqualTo(List(messageId.serialize))
+
+    // And user asks for message list in mailbox "inbox" with flag "$Answered"
+    val idsAnsweredInbox = listMessageIdsByMailboxAndKeyword(server, DefaultMailboxes.INBOX, "$Answered").asScala.toList
+
+    // And the message list has size 1
+    assertThat(idsAnsweredInbox.size).isEqualTo(1)
+
+    // And the message list contains "m1"
+    assertThat(idsAnsweredInbox).isEqualTo(List(messageId.serialize))
+  }
+
+  @Test
+  def emailSetShouldIgnoreKeywordsConflictIntroducedViaImapUponFlagsDeletionWithEmailGet(server: GuiceJamesServer): Unit = {
+    // Given the user has a message "m1" in "inbox" mailbox with subject "My awesome subject", content "This is the content"
+    val messageId = appendMessageToInbox(server)
+
+    // And user copies "m1" from mailbox "inbox" to mailbox "archive"
+    copyMessageFromInboxToArchive(server, messageId)
+    awaitAtMostOneMinute.until(() => listMessageIdsArchive(server).size == 1)
+
+    // And the user has an open IMAP connection with mailbox "archive" selected
+    imapClient.connect(LOCALHOST_IP, server.getProbe(classOf[ImapGuiceProbe]).getImapPort)
+      .login(BOB, BOB_PASSWORD)
+      .select(DefaultMailboxes.ARCHIVE)
+
+    // And the user set flags via IMAP to "(\Flagged)" for all messages in mailbox "archive"
+    imapClient.setFlagsForAllMessagesInMailbox("\\Flagged")
+
+    // When user sets flags "$Answered" on message "m1"
+    emailSetFlags(messageId, "$Answered")
+
+    // Then the user ask for message "m1"
+    // And no error is returned
+    // And the list should contain 1 message
+    // And the id of the message is "m1"
+    // And the keywords of the message is $Answered
+    val ids = listMessageIds().asScala.toList
+
+    assertThat(ids.size).isEqualTo(1)
+    assertThat(ids).isEqualTo(List(messageId.serialize))
+
+    val idString = concatMessageIds(ids)
+    val response = getMessagesByIds(idString)
+
+    assertThatJson(response)
+      .inPath("methodResponses[0][1].list[0].keywords")
+      .isEqualTo("{\"$answered\": true}")
+  }
+
+  private def appendMessageToInbox(server: GuiceJamesServer): MessageId = {
+    val message = Message.Builder
+      .of
+      .setSubject("test")
+      .setSender(ANDRE.asString)
+      .setFrom("ANDRE <" + ANDRE.asString + ">")
+      .setTo(BOB.asString)
+      .setSubject("My awesome subject")
+      .setBody("This is the content", StandardCharsets.UTF_8)
+      .build
+
+    server.getProbe(classOf[MailboxProbeImpl])
+      .appendMessage(BOB.asString(), bobInboxPath, AppendCommand.builder().build(message))
+      .getMessageId
+  }
+
+  def moveMessageFromInboxToArchive(server: GuiceJamesServer, messageId: MessageId): Unit = {
+    val archiveMailboxId = server.getProbe(classOf[MailboxProbeImpl])
+      .getMailboxId(MailboxConstants.USER_NAMESPACE, BOB.asString, DefaultMailboxes.ARCHIVE)
+
+    val request =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail"],
+         |  "methodCalls": [
+         |    ["Email/set", {
+         |      "accountId": "$ACCOUNT_ID",
+         |      "update": {
+         |        "${messageId.serialize}": {
+         |          "mailboxIds": {
+         |            "${archiveMailboxId.serialize}": true
+         |          }
+         |        }
+         |      }
+         |    }, "c1"]]
+         |}""".stripMargin
+
+    `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+  }
+
+  def copyMessageFromInboxToArchive(server: GuiceJamesServer, messageId: MessageId): Unit = {
+    val inboxId = server.getProbe(classOf[MailboxProbeImpl])
+      .getMailboxId(MailboxConstants.USER_NAMESPACE, BOB.asString, DefaultMailboxes.INBOX)
+    val archiveMailboxId = server.getProbe(classOf[MailboxProbeImpl])
+      .getMailboxId(MailboxConstants.USER_NAMESPACE, BOB.asString, DefaultMailboxes.ARCHIVE)
+
+    val request =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail"],
+         |  "methodCalls": [
+         |    ["Email/set", {
+         |      "accountId": "$ACCOUNT_ID",
+         |      "update": {
+         |        "${messageId.serialize}": {
+         |          "mailboxIds": {
+         |            "${inboxId.serialize}": true,
+         |            "${archiveMailboxId.serialize}": true
+         |          }
+         |        }
+         |      }
+         |    }, "c1"]]
+         |}""".stripMargin
+
+    `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+  }
+
+  def listMessageIds(): util.ArrayList[String] = {
+    val request =
+      s"""{
+         |  "using": ["urn:ietf:params:jmap:core","urn:ietf:params:jmap:mail"],
+         |  "methodCalls": [[
+         |    "Email/query",
+         |    {
+         |      "accountId": "$ACCOUNT_ID"
+         |    },
+         |    "c1"]]
+         |}""".stripMargin
+
+    `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post()
+    .`then`
+      .statusCode(SC_OK)
+      .extract
+      .body
+      .path("methodResponses[0][1].ids")
+  }
+
+  def listMessageIdsBykeyword(keyword: String): util.ArrayList[String] = {
+    val request =
+      s"""{
+         |  "using": ["urn:ietf:params:jmap:core","urn:ietf:params:jmap:mail"],
+         |  "methodCalls": [[
+         |    "Email/query",
+         |    {
+         |      "accountId": "$ACCOUNT_ID",
+         |      "filter" : {
+         |        "hasKeyword": "$keyword"
+         |      }
+         |    },
+         |    "c1"]]
+         |}""".stripMargin
+
+    `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post()
+    .`then`
+      .statusCode(SC_OK)
+      .extract
+      .body
+      .path("methodResponses[0][1].ids")
+  }
+
+  def listMessageIdsArchive(server: GuiceJamesServer): util.ArrayList[String] = {
+    val archiveMailboxId = server.getProbe(classOf[MailboxProbeImpl])
+      .getMailboxId(MailboxConstants.USER_NAMESPACE, BOB.asString, DefaultMailboxes.ARCHIVE)
+
+    val request =
+      s"""{
+         |  "using": ["urn:ietf:params:jmap:core","urn:ietf:params:jmap:mail"],
+         |  "methodCalls": [[
+         |    "Email/query",
+         |    {
+         |      "accountId": "$ACCOUNT_ID",
+         |      "filter": {"inMailbox": "${archiveMailboxId.serialize}"}
+         |    },
+         |    "c1"]]
+         |}""".stripMargin
+
+    `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post()
+    .`then`
+      .statusCode(SC_OK)
+      .extract
+      .body
+      .path("methodResponses[0][1].ids")
+  }
+
+  def listMessageIdsByMailboxAndKeyword(server: GuiceJamesServer, mailbox: String, keyword: String): util.ArrayList[String] = {
+    val mailboxId = server.getProbe(classOf[MailboxProbeImpl])
+      .getMailboxId(MailboxConstants.USER_NAMESPACE, BOB.asString, mailbox)
+
+    val request =
+      s"""{
+         |  "using": ["urn:ietf:params:jmap:core","urn:ietf:params:jmap:mail"],
+         |  "methodCalls": [[
+         |    "Email/query",
+         |    {
+         |      "accountId": "$ACCOUNT_ID",
+         |      "filter": {
+         |        "inMailbox": "${mailboxId.serialize}",
+         |        "hasKeyword": "$keyword"
+         |      }
+         |    },
+         |    "c1"]]
+         |}""".stripMargin
+
+    `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post()
+    .`then`
+      .statusCode(SC_OK)
+      .extract
+      .body
+      .path("methodResponses[0][1].ids")
+  }
+
+  def getMessagesByIds(idString: String): String = {
+    val request =
+      s"""{
+         |  "using": ["urn:ietf:params:jmap:core","urn:ietf:params:jmap:mail", "urn:ietf:params:jmap:submission"],
+         |  "methodCalls": [[
+         |     "Email/get",
+         |     {
+         |       "accountId": "$ACCOUNT_ID",
+         |       "ids": [$idString]
+         |     },
+         |     "c1"]]
+         |}""".stripMargin
+
+    `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post()
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+  }
+
+  private def emailSetFlags(messageId: MessageId, flag: String): Unit = {
+    val request =
+      s"""{
+         |  "using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
+         |  "methodCalls": [
+         |    ["Email/set", {
+         |      "accountId": "$ACCOUNT_ID",
+         |      "update": {
+         |        "${messageId.serialize}":{
+         |          "keywords": {
+         |            "$flag": true
+         |          }
+         |        }
+         |      }
+         |    }, "c1"]]
+         |}""".stripMargin
+
+    `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+  }
+
+  private def concatMessageIds(ids: List[String]): String =
+    ids.map(id => "\"" + id + "\"")
+      .mkString(",")
+}
diff --git a/server/protocols/jmap-rfc-8621-integration-tests/memory-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/memory/MemoryIMAPKeywordsInconsistenciesTest.java b/server/protocols/jmap-rfc-8621-integration-tests/memory-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/memory/MemoryIMAPKeywordsInconsistenciesTest.java
new file mode 100644
index 0000000..d83eba1
--- /dev/null
+++ b/server/protocols/jmap-rfc-8621-integration-tests/memory-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/memory/MemoryIMAPKeywordsInconsistenciesTest.java
@@ -0,0 +1,34 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ * http://www.apache.org/licenses/LICENSE-2.0                   *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.jmap.rfc8621.memory;
+
+import org.apache.james.jmap.rfc8621.contract.ImapKeywordsConsistencyContract;
+import org.apache.james.utils.TestIMAPClient;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+public class MemoryIMAPKeywordsInconsistenciesTest extends MemoryBase implements ImapKeywordsConsistencyContract {
+    @RegisterExtension
+    TestIMAPClient testIMAPClient = new TestIMAPClient();
+
+    @Override
+    public TestIMAPClient imapClient() {
+        return testIMAPClient;
+    }
+}