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