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 <[email protected]>
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: [email protected]
For additional commands, e-mail: [email protected]