JAMES-4016 JMAP email keywords in mixed JMAP/IMAP usage
diff --git a/server/container/guice/mailbox/src/main/java/org/apache/james/modules/MailboxProbeImpl.java b/server/container/guice/mailbox/src/main/java/org/apache/james/modules/MailboxProbeImpl.java
index 645298c..b01c6ea 100644
--- a/server/container/guice/mailbox/src/main/java/org/apache/james/modules/MailboxProbeImpl.java
+++ b/server/container/guice/mailbox/src/main/java/org/apache/james/modules/MailboxProbeImpl.java
@@ -35,6 +35,7 @@
 import org.apache.james.mailbox.MailboxManager;
 import org.apache.james.mailbox.MailboxSession;
 import org.apache.james.mailbox.MessageManager;
+import org.apache.james.mailbox.MessageUid;
 import org.apache.james.mailbox.SubscriptionManager;
 import org.apache.james.mailbox.exception.MailboxException;
 import org.apache.james.mailbox.model.ByteSourceContent;
@@ -43,6 +44,7 @@
 import org.apache.james.mailbox.model.MailboxMetaData;
 import org.apache.james.mailbox.model.MailboxPath;
 import org.apache.james.mailbox.model.MessageId;
+import org.apache.james.mailbox.model.MessageRange;
 import org.apache.james.mailbox.model.MultimailboxesSearchQuery;
 import org.apache.james.mailbox.model.search.MailboxQuery;
 import org.apache.james.mailbox.model.search.Wildcard;
@@ -166,6 +168,17 @@
         }
     }
 
+    public void copy(Username username, MailboxPath source, MailboxPath destination, MessageUid uid) throws MailboxException {
+        MailboxSession mailboxSession = mailboxManager.createSystemSession(username);
+        mailboxManager.copyMessages(MessageRange.one(uid), source, destination, mailboxSession);
+    }
+
+    public void setFlags(Username username, MailboxPath mailboxPath, MessageUid uid, Flags flags) throws MailboxException {
+        MailboxSession mailboxSession = mailboxManager.createSystemSession(username);
+        MessageManager messageManager = mailboxManager.getMailbox(mailboxPath, mailboxSession);
+        messageManager.setFlags(flags, MessageManager.FlagsUpdateMode.REPLACE, MessageRange.one(uid), mailboxSession);
+    }
+
     public ComposedMessageId appendMessage(String username, MailboxPath mailboxPath, MessageManager.AppendCommand appendCommand)
             throws MailboxException {
 
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/EmailGetMethodContract.scala b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailGetMethodContract.scala
index ddd8ba8..397e759 100644
--- a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailGetMethodContract.scala
+++ b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailGetMethodContract.scala
@@ -39,14 +39,14 @@
 import org.apache.james.jmap.api.model.AccountId
 import org.apache.james.jmap.core.ResponseObject.SESSION_STATE
 import org.apache.james.jmap.core.UuidState.INSTANCE
-import org.apache.james.jmap.draft.JmapGuiceProbe
+import org.apache.james.jmap.draft.{JmapGuiceProbe}
 import org.apache.james.jmap.http.UserCredential
 import org.apache.james.jmap.rfc8621.contract.EmailGetMethodContract.createTestMessage
 import org.apache.james.jmap.rfc8621.contract.Fixture.{ACCEPT_RFC8621_VERSION_HEADER, ALICE, ANDRE, ANDRE_ACCOUNT_ID, ANDRE_PASSWORD, BOB, BOB_PASSWORD, DOMAIN, authScheme, baseRequestSpecBuilder}
 import org.apache.james.jmap.rfc8621.contract.probe.DelegationProbe
 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, MailboxId, MailboxPath, MessageId}
 import org.apache.james.mime4j.dom.Message
 import org.apache.james.mime4j.message.MultipartBuilder
 import org.apache.james.mime4j.stream.RawField
@@ -6766,6 +6766,72 @@
   }
 
   @Test
+  def shouldAggregateKeywordsAccrossMailbox(server: GuiceJamesServer): Unit = {
+    val message: Message = createTestMessage
+
+    val flags1: Flags = new Flags(Flags.Flag.ANSWERED)
+    flags1.add(Flags.Flag.FLAGGED)
+    flags1.add("f1")
+    flags1.add("f2")
+
+    val flags2: Flags = new Flags(Flags.Flag.SEEN)
+    flags2.add(Flags.Flag.FLAGGED)
+    flags2.add("f3")
+    flags2.add("f2")
+
+    val path1 = MailboxPath.inbox(BOB)
+    val path2 = MailboxPath.forUser(BOB, "box2")
+    val mailboxProbe = server.getProbe(classOf[MailboxProbeImpl])
+    mailboxProbe.createMailbox(path1)
+    mailboxProbe.createMailbox(path2)
+    val messageId: ComposedMessageId = mailboxProbe.appendMessage(BOB.asString(), path1, AppendCommand.builder()
+      .withFlags(flags1)
+      .build(message))
+    mailboxProbe.copy(BOB, path1, path2, messageId.getUid)
+    mailboxProbe.setFlags(BOB, path1, messageId.getUid, flags2)
+
+    val response = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(
+        s"""{
+           |  "using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
+           |  "methodCalls": [[
+           |     "Email/get",
+           |     {
+           |       "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+           |       "ids": ["${messageId.getMessageId.serialize}"],
+           |       "properties": ["keywords"]
+           |     },
+           |     "c1"]]
+           |}""".stripMargin)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .inPath("methodResponses[0][1].list[0]")
+      .isEqualTo(
+        s"""
+           |  {
+           |     "id":"${messageId.getMessageId.serialize}",
+           |     "keywords": {
+           |       "$$flagged": true,
+           |       "f1": true,
+           |       "f2": true,
+           |       "f3": true,
+           |       "$$seen": true,
+           |       "$$answered": true
+           |     }
+           |  }
+      """.stripMargin)
+  }
+
+  @Test
   def emailGetShouldReturnSpecificHeadersAsRaw(server: GuiceJamesServer): Unit = {
     val bobPath = MailboxPath.inbox(BOB)
     server.getProbe(classOf[MailboxProbeImpl]).createMailbox(bobPath)
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 40b2b62..179e627 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
@@ -68,6 +68,13 @@
 object Email {
   private val logger: Logger = LoggerFactory.getLogger(classOf[EmailView])
 
+  def mergeKeywords(messages: Seq[MessageResult]): Try[Keywords] = {
+    messages.map(_.getFlags)
+      .map(LENIENT_KEYWORDS_FACTORY.fromFlags)
+      .sequence
+      .map(list => list.reduce(_ ++ _))
+  }
+
   val defaultCharset = Option(System.getenv("james.jmap.default.charset"))
     .map(value => java.nio.charset.Charset.forName(value))
     .getOrElse(StandardCharsets.US_ASCII)
@@ -498,7 +505,7 @@
         .map(Success(_))
         .getOrElse(Failure(new IllegalArgumentException("No message supplied")))
       blobId <- BlobId.of(messageId)
-      keywords <- LENIENT_KEYWORDS_FACTORY.fromFlags(firstMessage.getFlags)
+      keywords <- Email.mergeKeywords(message._2)
     } yield {
       EmailMetadataView(
         metadata = EmailMetadata(
@@ -528,7 +535,7 @@
         .getOrElse(Failure(new IllegalArgumentException("No message supplied")))
       mime4JMessage <- Email.parseAsMime4JMessage(firstMessage)
       blobId <- BlobId.of(messageId)
-      keywords <- LENIENT_KEYWORDS_FACTORY.fromFlags(firstMessage.getFlags)
+      keywords <- Email.mergeKeywords(message._2)
     } yield {
       EmailHeaderView(
         metadata = EmailMetadata(
@@ -605,7 +612,7 @@
       bodyStructure <- EmailBodyPart.of(request.bodyProperties, zoneIdProvider.get(), blobId, mime4JMessage)
       bodyValues <- extractBodyValues(htmlTextExtractor)(bodyStructure, request)
       preview <- Try(previewFactory.fromMime4JMessage(mime4JMessage))
-      keywords <- LENIENT_KEYWORDS_FACTORY.fromFlags(firstMessage.getFlags)
+      keywords <- Email.mergeKeywords(message._2)
     } yield {
       EmailFullView(
         metadata = EmailMetadata(
@@ -753,7 +760,7 @@
         .getOrElse(Failure(new IllegalArgumentException("No message supplied")))
       mime4JMessage <- Email.parseAsMime4JMessage(firstMessage)
       blobId <- BlobId.of(messageId)
-      keywords <- LENIENT_KEYWORDS_FACTORY.fromFlags(firstMessage.getFlags)
+      keywords <- Email.mergeKeywords(message._2)
     } yield {
       EmailFastView(
         metadata = EmailMetadata(
@@ -844,7 +851,7 @@
         .getOrElse(Failure(new IllegalArgumentException("No message supplied")))
       mime4JMessage <- Email.parseAsMime4JMessage(firstMessage)
       blobId <- BlobId.of(messageId)
-      keywords <- LENIENT_KEYWORDS_FACTORY.fromFlags(firstMessage.getFlags)
+      keywords <- Email.mergeKeywords(message._2)
     } yield {
       EmailFastViewWithAttachments(
         metadata = EmailMetadata(