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

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


The following commit(s) were added to refs/heads/master by this push:
     new bd6072c53a JAMES-3962 JMAP Email/set: move `EmailHeader[]` from 
`bodyValues` to `htmlBody`/`textBody` (#2659)
bd6072c53a is described below

commit bd6072c53ae3054bee68a7743e21b123253d47bc
Author: vttran <vtt...@linagora.com>
AuthorDate: Tue Mar 4 14:24:46 2025 +0700

    JAMES-3962 JMAP Email/set: move `EmailHeader[]` from `bodyValues` to 
`htmlBody`/`textBody` (#2659)
---
 .../rfc8621/contract/EmailSetMethodContract.scala  | 164 ++++++++++++++++++++-
 .../james/jmap/json/EmailSetSerializer.scala       |   9 +-
 .../org/apache/james/jmap/mail/EmailSet.scala      |  25 ++--
 3 files changed, 185 insertions(+), 13 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 0163bd0a48..43f7af39f9 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
@@ -30,6 +30,7 @@ import io.restassured.RestAssured.{`given`, `with`, 
requestSpecification}
 import io.restassured.builder.ResponseSpecBuilder
 import io.restassured.http.ContentType.JSON
 import jakarta.mail.Flags
+import net.javacrumbs.jsonunit.JsonMatchers.jsonEquals
 import net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson
 import net.javacrumbs.jsonunit.core.Option
 import org.apache.http.HttpStatus.{SC_CREATED, SC_OK}
@@ -3451,8 +3452,9 @@ trait EmailSetMethodContract {
            |}""".stripMargin)
   }
 
+  @deprecated("specificHeaders should be set on EmailBodyPart as RFC8621")
   @Test
-  def bodyPartShouldSupportSpecificHeaders(server: GuiceJamesServer): Unit = {
+  def emailBodyValueShouldSupportSpecificHeaders(server: GuiceJamesServer): 
Unit = {
     val bobPath = MailboxPath.inbox(BOB)
     val mailboxId = 
server.getProbe(classOf[MailboxProbeImpl]).createMailbox(bobPath)
     val payload = "123456789\r\n".getBytes(StandardCharsets.UTF_8)
@@ -3555,6 +3557,166 @@ trait EmailSetMethodContract {
            |}""".stripMargin)
   }
 
+  @Test
+  def shouldSupportSpecificHeadersInEmailBodyPart(server: GuiceJamesServer): 
Unit = {
+    val bobPath = MailboxPath.inbox(BOB)
+    val mailboxId = 
server.getProbe(classOf[MailboxProbeImpl]).createMailbox(bobPath)
+    val htmlBody: String = "<!DOCTYPE 
html><html><head><title></title></head><body><div>I have the most 
<b>brilliant</b> plan. Let me tell you all about it. What we do is, 
we</div></body></html>"
+
+    val request =
+      s"""{
+         |  "using": ["urn:ietf:params:jmap:core", 
"urn:ietf:params:jmap:mail"],
+         |  "methodCalls": [
+         |    ["Email/set", {
+         |      "accountId": "$ACCOUNT_ID",
+         |      "create": {
+         |        "aaaaaa": {
+         |          "mailboxIds": {
+         |             "${mailboxId.serialize}": true
+         |          },
+         |          "subject": "World domination",
+         |          "htmlBody": [
+         |            {
+         |              "partId": "a49d",
+         |              "type": "text/html",
+         |              "header:Specific:asText": "MATCHME"
+         |            }
+         |          ],
+         |          "bodyValues": {
+         |            "a49d": {
+         |              "value": "$htmlBody",
+         |              "isTruncated": false,
+         |              "isEncodingProblem": false
+         |            }
+         |          }
+         |        }
+         |      }
+         |    }, "c1"],
+         |    ["Email/get",
+         |      {
+         |        "accountId": "$ACCOUNT_ID",
+         |        "ids": ["#aaaaaa"],
+         |        "properties": ["bodyStructure"],
+         |        "bodyProperties": ["type", "disposition", "cid", "subParts", 
"header:Specific:asText"]
+         |      },
+         |    "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
+
+    val responseAsJson = Json.parse(response)
+      .\("methodResponses")
+      .\(0).\(1)
+      .\("created")
+      .\("aaaaaa")
+
+    val messageId = responseAsJson
+      .\("id")
+      .get.asInstanceOf[JsString].value
+    val size = responseAsJson
+      .\("size")
+      .get.asInstanceOf[JsNumber].value
+
+    assertThatJson(response)
+      .inPath("methodResponses[0][1].created.aaaaaa")
+      .isEqualTo(
+        s"""{
+           | "id": "$messageId",
+           | "blobId": "$messageId",
+           | "threadId": "$messageId",
+           | "size": $size
+           |}""".stripMargin)
+
+    assertThatJson(response)
+      .inPath(s"methodResponses[1][1].list[0]")
+      .isEqualTo(
+        s"""{
+           |  "id": "$messageId",
+           |  "bodyStructure": {
+           |    "subParts": [
+           |      {
+           |        "header:Specific:asText": "MATCHME",
+           |        "type": "text/plain"
+           |      },
+           |      {
+           |        "header:Specific:asText": "MATCHME",
+           |        "type": "text/html"
+           |      }
+           |    ],
+           |    "header:Specific:asText": null,
+           |    "type": "multipart/alternative"
+           |  }
+           |}""".stripMargin)
+  }
+
+  @Test
+  def 
shouldFailIfSpecificHeadersSetInBothEmailBodyPartAndEmailBodyValue(server: 
GuiceJamesServer): Unit = {
+    val bobPath = MailboxPath.inbox(BOB)
+    val mailboxId = 
server.getProbe(classOf[MailboxProbeImpl]).createMailbox(bobPath)
+    val htmlBody: String = "<!DOCTYPE 
html><html><head><title></title></head><body><div>I have the most 
<b>brilliant</b> plan. Let me tell you all about it. What we do is, 
we</div></body></html>"
+
+    val request =
+      s"""{
+         |  "using": ["urn:ietf:params:jmap:core", 
"urn:ietf:params:jmap:mail"],
+         |  "methodCalls": [
+         |    ["Email/set", {
+         |      "accountId": "$ACCOUNT_ID",
+         |      "create": {
+         |        "aaaaaa": {
+         |          "mailboxIds": {
+         |             "${mailboxId.serialize}": true
+         |          },
+         |          "subject": "World domination",
+         |          "htmlBody": [
+         |            {
+         |              "partId": "a49d",
+         |              "type": "text/html",
+         |              "header:Specific:asText": "MATCHME"
+         |            }
+         |          ],
+         |          "bodyValues": {
+         |            "a49d": {
+         |              "value": "$htmlBody",
+         |              "isTruncated": false,
+         |              "isEncodingProblem": false,
+         |              "header:Specific:asText": "MATCHME2"
+         |            }
+         |          }
+         |        }
+         |      }
+         |    }, "c1"]
+         |  ]
+         |}""".stripMargin
+
+    `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .body("methodResponses[0][1].notCreated",
+        jsonEquals(
+          """{
+            |    "aaaaaa": {
+            |        "type": "invalidArguments",
+            |        "description": "Could not set specific headers on both 
EmailBodyPart and EmailBodyValue"
+            |    }
+            |}""".stripMargin))
+  }
+
   @Test
   def inlinedAttachmentsOnlyShouldNotBeWrappedInAMixedMultipart(server: 
GuiceJamesServer): Unit = {
     val bobPath = 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 5c15ec297b..0a83fb7b11 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
@@ -29,7 +29,7 @@ import org.apache.james.jmap.api.model.{EmailAddress, 
EmailerName}
 import org.apache.james.jmap.core.Id.IdConstraint
 import org.apache.james.jmap.core.{Id, SetError, UTCDate, UuidState}
 import org.apache.james.jmap.mail.KeywordsFactory.STRICT_KEYWORDS_FACTORY
-import org.apache.james.jmap.mail.{AddressesHeaderValue, AllHeaderValues, 
AsAddresses, AsDate, AsGroupedAddresses, AsMessageIds, AsRaw, AsText, AsURLs, 
Attachment, BlobId, Charset, ClientBody, ClientCid, ClientEmailBodyValue, 
ClientEmailBodyValueWithoutHeaders, ClientPartId, DateHeaderValue, DestroyIds, 
Disposition, EmailAddressGroup, EmailCreationId, EmailCreationRequest, 
EmailCreationResponse, EmailHeader, EmailHeaderName, EmailHeaderValue, 
EmailImport, EmailImportRequest, EmailImportR [...]
+import org.apache.james.jmap.mail.{AddressesHeaderValue, AllHeaderValues, 
AsAddresses, AsDate, AsGroupedAddresses, AsMessageIds, AsRaw, AsText, AsURLs, 
Attachment, BlobId, Charset, ClientBody, ClientBodyWithoutHeaders, ClientCid, 
ClientEmailBodyValue, ClientEmailBodyValueWithoutHeaders, ClientPartId, 
DateHeaderValue, DestroyIds, Disposition, EmailAddressGroup, EmailCreationId, 
EmailCreationRequest, EmailCreationResponse, EmailHeader, EmailHeaderName, 
EmailHeaderValue, EmailImport, EmailI [...]
 import org.apache.james.mailbox.model.{MailboxId, MessageId}
 import play.api.libs.json.{Format, JsArray, JsBoolean, JsError, JsNull, 
JsObject, JsResult, JsString, JsSuccess, JsValue, Json, OWrites, Reads, Writes}
 
@@ -291,13 +291,16 @@ class EmailSetSerializer @Inject()(messageIdFactory: 
MessageId.Factory, mailboxI
 
   private implicit val typeReads: Reads[Type] = Json.valueReads[Type]
   private implicit val clientPartIdReads: Reads[ClientPartId] = 
Json.valueReads[ClientPartId]
-  private val rawClientBodyReads: Reads[ClientBody] = Json.reads[ClientBody]
+  private val rawClientBodyWithoutHeaderReads: Reads[ClientBodyWithoutHeaders] 
= Json.reads[ClientBodyWithoutHeaders]
+
   private implicit val clientBodyReads: Reads[ClientBody] = {
     case JsObject(underlying) if underlying.contains("charset") => 
JsError("charset must not be specified in htmlBody")
     case JsObject(underlying) if underlying.contains("size") => JsError("size 
must not be specified in htmlBody")
     case JsObject(underlying) if 
underlying.contains("header:Content-Transfer-Encoding") => 
JsError("Content-Transfer-Encoding must not be specified in htmlBody or 
textBody")
     case JsObject(underlying) if underlying.keySet.exists(s => 
s.startsWith("header:Content-Transfer-Encoding:asText")) => 
JsError("Content-Transfer-Encoding must not be specified in htmlBody or 
textBody")
-    case o: JsObject => rawClientBodyReads.reads(o)
+    case o: JsObject if o.value.contains("headers") => JsError("'headers' is 
not allowed")
+    case o: JsObject => extractSpecificHeaders(o).fold(e => 
JsError(e.getMessage),
+      specificHeaders => 
rawClientBodyWithoutHeaderReads.reads(o).map(_.withHeaders(specificHeaders)))
     case _ => JsError("Expecting a JsObject to represent an ClientHtmlBody")
   }
 
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 c820e075b8..47f1da96e7 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
@@ -74,11 +74,17 @@ object SubType {
 
 case class ClientPartId(id: Id)
 
-case class ClientBody(partId: ClientPartId, `type`: Type)
+case class ClientBody(partId: ClientPartId, `type`: Type, specificHeaders: 
List[EmailHeader]) {
+}
+
+case class ClientBodyWithoutHeaders(partId: ClientPartId, `type`: Type) {
+  def withHeaders(specificHeaders: List[EmailHeader]): ClientBody =
+    ClientBody(partId, `type`, specificHeaders)
+}
 
 case class ClientEmailBodyValueWithoutHeaders(value: String,
-                                isEncodingProblem: Option[IsEncodingProblem],
-                                isTruncated: Option[IsTruncated]) {
+                                              isEncodingProblem: 
Option[IsEncodingProblem],
+                                              isTruncated: 
Option[IsTruncated]) {
   def withHeaders(specificHeaders: List[EmailHeader]): ClientEmailBodyValue =
     ClientEmailBodyValue(value, isEncodingProblem, isTruncated, 
specificHeaders)
 }
@@ -86,7 +92,7 @@ case class ClientEmailBodyValueWithoutHeaders(value: String,
 case class ClientEmailBodyValue(value: String,
                                 isEncodingProblem: Option[IsEncodingProblem],
                                 isTruncated: Option[IsTruncated],
-                                specificHeaders: List[EmailHeader])
+                                @deprecated("specificHeaders should be set on 
EmailBodyPart as RFC8621") specificHeaders: List[EmailHeader])
 
 case class ClientBodyPart(value: String, specificHeaders: List[EmailHeader])
 
@@ -356,7 +362,7 @@ case class EmailCreationRequest(mailboxIds: MailboxIds,
   def validateHtmlBody: Either[IllegalArgumentException, 
Option[ClientBodyPart]] = htmlBody match {
     case None => Right(None)
     case Some(html :: Nil) if !html.`type`.value.equals("text/html") => 
Left(new IllegalArgumentException("Expecting htmlBody type to be text/html"))
-    case Some(html :: Nil) => retrieveCorrespondingBody(html.partId)
+    case Some(html :: Nil) => retrieveCorrespondingBody(html)
       .getOrElse(Left(new IllegalArgumentException("Expecting bodyValues to 
contain the part specified in htmlBody")))
     case _ => Left(new IllegalArgumentException("Expecting htmlBody to 
contains only 1 part"))
   }
@@ -364,7 +370,7 @@ case class EmailCreationRequest(mailboxIds: MailboxIds,
   def validateTextBody: Either[IllegalArgumentException, 
Option[ClientBodyPart]] = textBody match {
     case None => Right(None)
     case Some(text :: Nil) if !text.`type`.value.equals("text/plain") => 
Left(new IllegalArgumentException("Expecting htmlBody type to be text/html"))
-    case Some(text :: Nil) => retrieveCorrespondingBody(text.partId)
+    case Some(text :: Nil) => retrieveCorrespondingBody(text)
       .getOrElse(Left(new IllegalArgumentException("Expecting bodyValues to 
contain the part specified in textBody")))
     case _ => Left(new IllegalArgumentException("Expecting textBody to 
contains only 1 part"))
   }
@@ -389,14 +395,15 @@ case class EmailCreationRequest(mailboxIds: MailboxIds,
     }
   }
 
-  private def retrieveCorrespondingBody(partId: ClientPartId): 
Option[Either[IllegalArgumentException, Some[ClientBodyPart]]] =
+  private def retrieveCorrespondingBody(clientBody: ClientBody): 
Option[Either[IllegalArgumentException, Some[ClientBodyPart]]] =
     bodyValues.getOrElse(Map())
-      .get(partId)
+      .get(clientBody.partId)
       .map {
         case part if part.isTruncated.isDefined && part.isTruncated.get.value 
=> Left(new IllegalArgumentException("Expecting isTruncated to be false"))
         case part if part.isEncodingProblem.isDefined && 
part.isEncodingProblem.get.value => Left(new 
IllegalArgumentException("Expecting isEncodingProblem to be false"))
+        case part if part.specificHeaders.nonEmpty && 
clientBody.specificHeaders.nonEmpty => Left(new IllegalArgumentException("Could 
not set specific headers on both EmailBodyPart and EmailBodyValue"))
         case part => Right(Some(
-          ClientBodyPart(part.value, part.specificHeaders)))
+          ClientBodyPart(part.value, 
Option(clientBody.specificHeaders).filter(_.nonEmpty).getOrElse(part.specificHeaders))))
       }
 
   private def validateSpecificHeaders(message: Message.Builder): 
Either[IllegalArgumentException, Unit] = {


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

Reply via email to