This is an automated email from the ASF dual-hosted git repository. rcordier pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/james-project.git
commit 4ebd13dba7ad1773f76f06275f529cdf179f4756 Author: LanKhuat <[email protected]> AuthorDate: Tue Nov 10 12:02:22 2020 +0700 JAMES-3439 Email/set create: Simplify mime structure when possible --- .../rfc8621/contract/EmailSetMethodContract.scala | 446 ++++++++++++++++++++- .../org/apache/james/jmap/mail/EmailSet.scala | 63 ++- 2 files changed, 480 insertions(+), 29 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 248680f..bdf41cc 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 @@ -1818,7 +1818,7 @@ trait EmailSetMethodContract { | "subject": "World domination", | "attachments": [ | { - | "partId": "4", + | "partId": "3", | "blobId": "$blobIdToDownload", | "size": 11, | "type": "text/plain", @@ -1951,8 +1951,8 @@ trait EmailSetMethodContract { | "subject": "World domination", | "attachments": [ | { - | "partId": "4", - | "blobId": "${messageId}_4", + | "partId": "3", + | "blobId": "${messageId}_3", | "size": 11, | "type": "text/plain", | "charset": "UTF-8", @@ -1961,15 +1961,15 @@ trait EmailSetMethodContract { | ], | "htmlBody": [ | { - | "partId": "3", - | "blobId": "${messageId}_3", + | "partId": "2", + | "blobId": "${messageId}_2", | "size": 166, | "type": "text/html", | "charset": "UTF-8" | } | ], | "bodyValues": { - | "3": { + | "2": { | "value": "$htmlBody", | "isEncodingProblem": false, | "isTruncated": false @@ -2154,9 +2154,9 @@ trait EmailSetMethodContract { .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) .contentType("text/plain") .body(payload) - .when + .when .post(s"/upload/$ACCOUNT_ID/") - .`then` + .`then` .statusCode(SC_CREATED) .extract .body @@ -2228,9 +2228,9 @@ trait EmailSetMethodContract { val response = `given` .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) .body(request) - .when + .when .post - .`then` + .`then` .statusCode(SC_OK) .contentType(JSON) .extract @@ -2314,6 +2314,430 @@ trait EmailSetMethodContract { } @Test + def htmlBodyPartWithOnlyNormalAttachmentsShouldNotBeWrappedInARelatedMultipart(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) + 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 uploadResponse: String = `given` + .basePath("") + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .contentType("text/plain") + .body(payload) + .when + .post(s"/upload/$ACCOUNT_ID/") + .`then` + .statusCode(SC_CREATED) + .extract + .body + .asString + + val blobId: String = Json.parse(uploadResponse).\("blobId").get.asInstanceOf[JsString].value + + 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", + | "attachments": [ + | { + | "blobId": "$blobId", + | "type":"text/plain", + | "charset":"UTF-8", + | "disposition": "attachment" + | } + | ], + | "htmlBody": [ + | { + | "partId": "a49d", + | "type": "text/html" + | } + | ], + | "bodyValues": { + | "a49d": { + | "value": "$htmlBody", + | "isTruncated": false, + | "isEncodingProblem": false + | } + | } + | } + | } + | }, "c1"], + | ["Email/get", + | { + | "accountId": "$ACCOUNT_ID", + | "ids": ["#aaaaaa"], + | "properties": ["bodyStructure"], + | "bodyProperties": ["type", "disposition", "cid", "subParts"] + | }, + | "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 + + assertThatJson(response) + .whenIgnoringPaths("methodResponses[0][1].created.aaaaaa.id") + .inPath("methodResponses[0][1].created.aaaaaa") + .isEqualTo("{}".stripMargin) + + val messageId = Json.parse(response) + .\("methodResponses") + .\(1).\(1) + .\("list") + .\(0) + .\("id") + .get.asInstanceOf[JsString].value + + assertThatJson(response) + .isEqualTo( + s"""{ + | "sessionState": "75128aab4b1b", + | "methodResponses": [ + | [ + | "Email/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "000001", + | "created": { + | "aaaaaa": { + | "id": "$messageId" + | } + | } + | }, + | "c1" + | ], + | [ + | "Email/get", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "state": "000001", + | "list": [ + | { + | "id": "$messageId", + | "bodyStructure": { + | "type": "multipart/mixed", + | "subParts": [ + | { + | "type": "text/html" + | }, + | { + | "type": "text/plain", + | "disposition": "attachment" + | } + | ] + | } + | } + | ], + | "notFound": [] + | }, + | "c2" + | ] + | ] + |}""".stripMargin) + } + + @Test + def inlinedAttachmentsOnlyShouldNotBeWrappedInAMixedMultipart(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) + 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 uploadResponse: String = `given` + .basePath("") + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .contentType("text/plain") + .body(payload) + .when + .post(s"/upload/$ACCOUNT_ID/") + .`then` + .statusCode(SC_CREATED) + .extract + .body + .asString + + val blobId: String = Json.parse(uploadResponse).\("blobId").get.asInstanceOf[JsString].value + + 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", + | "attachments": [ + | { + | "blobId": "$blobId", + | "type":"text/plain", + | "charset":"UTF-8", + | "disposition": "inline", + | "cid": "abc" + | }, + | { + | "blobId": "$blobId", + | "type":"text/plain", + | "charset":"UTF-8", + | "disposition": "inline", + | "cid": "def" + | } + | ], + | "htmlBody": [ + | { + | "partId": "a49d", + | "type": "text/html" + | } + | ], + | "bodyValues": { + | "a49d": { + | "value": "$htmlBody", + | "isTruncated": false, + | "isEncodingProblem": false + | } + | } + | } + | } + | }, "c1"], + | ["Email/get", + | { + | "accountId": "$ACCOUNT_ID", + | "ids": ["#aaaaaa"], + | "properties": ["bodyStructure"], + | "bodyProperties": ["type", "disposition", "cid", "subParts"] + | }, + | "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 + + assertThatJson(response) + .whenIgnoringPaths("methodResponses[0][1].created.aaaaaa.id") + .inPath("methodResponses[0][1].created.aaaaaa") + .isEqualTo("{}".stripMargin) + + val messageId = Json.parse(response) + .\("methodResponses") + .\(1).\(1) + .\("list") + .\(0) + .\("id") + .get.asInstanceOf[JsString].value + + assertThatJson(response) + .isEqualTo( + s"""{ + | "sessionState": "75128aab4b1b", + | "methodResponses": [ + | [ + | "Email/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "000001", + | "created": { + | "aaaaaa": { + | "id": "$messageId" + | } + | } + | }, + | "c1" + | ], + | [ + | "Email/get", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "state": "000001", + | "list": [ + | { + | "id": "$messageId", + | "bodyStructure": { + | "type": "multipart/related", + | "subParts": [ + | { + | "type": "text/html" + | }, + | { + | "type": "text/plain", + | "disposition": "inline", + | "cid": "abc" + | }, + | { + | "type": "text/plain", + | "disposition": "inline", + | "cid": "def" + | } + | ] + | } + | } + | ], + | "notFound": [] + | }, + | "c2" + | ] + | ] + |}""".stripMargin) + } + + @Test + def htmlBodyOnlyShouldNotBeWrappedInMultiparts(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) + 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 uploadResponse: String = `given` + .basePath("") + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .contentType("text/plain") + .body(payload) + .when + .post(s"/upload/$ACCOUNT_ID/") + .`then` + .statusCode(SC_CREATED) + .extract + .body + .asString + + val blobId: String = Json.parse(uploadResponse).\("blobId").get.asInstanceOf[JsString].value + + 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", + | "attachments": [], + | "htmlBody": [ + | { + | "partId": "a49d", + | "type": "text/html" + | } + | ], + | "bodyValues": { + | "a49d": { + | "value": "$htmlBody", + | "isTruncated": false, + | "isEncodingProblem": false + | } + | } + | } + | } + | }, "c1"], + | ["Email/get", + | { + | "accountId": "$ACCOUNT_ID", + | "ids": ["#aaaaaa"], + | "properties": ["bodyStructure"], + | "bodyProperties": ["type", "disposition", "cid", "subParts"] + | }, + | "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 + + assertThatJson(response) + .whenIgnoringPaths("methodResponses[0][1].created.aaaaaa.id") + .inPath("methodResponses[0][1].created.aaaaaa") + .isEqualTo("{}".stripMargin) + + val messageId = Json.parse(response) + .\("methodResponses") + .\(1).\(1) + .\("list") + .\(0) + .\("id") + .get.asInstanceOf[JsString].value + + assertThatJson(response) + .isEqualTo( + s"""{ + | "sessionState": "75128aab4b1b", + | "methodResponses": [ + | [ + | "Email/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "000001", + | "created": { + | "aaaaaa": { + | "id": "$messageId" + | } + | } + | }, + | "c1" + | ], + | [ + | "Email/get", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "state": "000001", + | "list": [ + | { + | "id": "$messageId", + | "bodyStructure": { + | "type": "text/html" + | } + | } + | ], + | "notFound": [] + | }, + | "c2" + | ] + | ] + |}""".stripMargin) + } + + @Test def createShouldSupportAttachmentWithName(server: GuiceJamesServer): Unit = { val bobPath = MailboxPath.inbox(BOB) val mailboxId = server.getProbe(classOf[MailboxProbeImpl]).createMailbox(bobPath) @@ -2410,7 +2834,7 @@ trait EmailSetMethodContract { | "attachments": [ | { | "name": "myAttachment", - | "partId": "4", + | "partId": "3", | "blobId": "$blobIdToDownload", | "size": 11, | "type": "text/plain", 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 c607f8b..a1a72a8 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 @@ -137,7 +137,7 @@ case class EmailCreationRequest(mailboxIds: MailboxIds, validateSpecificHeaders(builder) .flatMap(_ => { specificHeaders.map(_.asField).foreach(builder.addField) - attachments.map(attachments => + attachments.filter(_.nonEmpty).map(attachments => createMultipartWithAttachments(maybeHtmlBody, attachments, attachmentManager, attachmentContentLoader, mailboxSession) .map(multipartBuilder => { builder.setBody(multipartBuilder) @@ -160,28 +160,55 @@ case class EmailCreationRequest(mailboxIds: MailboxIds, .sequence maybeAttachments.map(list => { - val inlineAttachments = list.filter(_._1.isInline) - val normalAttachments = list.filter(!_._1.isInline) - - val mixedMultipartBuilder = MultipartBuilder.create(SubType.MIXED_SUBTYPE) - val relatedMultipartBuilder = MultipartBuilder.create(SubType.RELATED_SUBTYPE) - relatedMultipartBuilder.addBodyPart(BodyPartBuilder.create().setBody(maybeHtmlBody.getOrElse(""), SubType.HTML_SUBTYPE, StandardCharsets.UTF_8).build) - inlineAttachments.foldLeft(relatedMultipartBuilder) { - case (acc, (attachment, storedMetadata, content)) => - acc.addBodyPart(toBodypartBuilder(attachment, storedMetadata, content)) - acc - } - - mixedMultipartBuilder.addBodyPart(BodyPartBuilder.create().setBody(relatedMultipartBuilder.build)) - normalAttachments.foldLeft(mixedMultipartBuilder) { - case (acc, (attachment, storedMetadata, content)) => - acc.addBodyPart(toBodypartBuilder(attachment, storedMetadata, content)) - acc + (list.filter(_._1.isInline), list.filter(!_._1.isInline)) match { + case (Nil, normalAttachments) => createMixedBody(maybeHtmlBody, normalAttachments) + case (inlineAttachments, Nil) => createRelatedBody(maybeHtmlBody, inlineAttachments) + case (inlineAttachments, normalAttachments) => createMixedRelatedBody(maybeHtmlBody, inlineAttachments, normalAttachments) } }) } + private def createMixedRelatedBody(maybeHtmlBody: Option[String], inlineAttachments: List[(Attachment, AttachmentMetadata, Array[Byte])], normalAttachments: List[(Attachment, AttachmentMetadata, Array[Byte])]) = { + val mixedMultipartBuilder = MultipartBuilder.create(SubType.MIXED_SUBTYPE) + val relatedMultipartBuilder = MultipartBuilder.create(SubType.RELATED_SUBTYPE) + relatedMultipartBuilder.addBodyPart(BodyPartBuilder.create().setBody(maybeHtmlBody.getOrElse(""), SubType.HTML_SUBTYPE, StandardCharsets.UTF_8).build) + inlineAttachments.foldLeft(relatedMultipartBuilder) { + case (acc, (attachment, storedMetadata, content)) => + acc.addBodyPart(toBodypartBuilder(attachment, storedMetadata, content)) + acc + } + + mixedMultipartBuilder.addBodyPart(BodyPartBuilder.create().setBody(relatedMultipartBuilder.build)) + + normalAttachments.foldLeft(mixedMultipartBuilder) { + case (acc, (attachment, storedMetadata, content)) => + acc.addBodyPart(toBodypartBuilder(attachment, storedMetadata, content)) + acc + } + } + + private def createMixedBody(maybeHtmlBody: Option[String], normalAttachments: List[(Attachment, AttachmentMetadata, Array[Byte])]) = { + val mixedMultipartBuilder = MultipartBuilder.create(SubType.MIXED_SUBTYPE) + mixedMultipartBuilder.addBodyPart(BodyPartBuilder.create().setBody(maybeHtmlBody.getOrElse(""), SubType.HTML_SUBTYPE, StandardCharsets.UTF_8).build) + normalAttachments.foldLeft(mixedMultipartBuilder) { + case (acc, (attachment, storedMetadata, content)) => + acc.addBodyPart(toBodypartBuilder(attachment, storedMetadata, content)) + acc + } + } + + private def createRelatedBody(maybeHtmlBody: Option[String], inlineAttachments: List[(Attachment, AttachmentMetadata, Array[Byte])]) = { + val relatedMultipartBuilder = MultipartBuilder.create(SubType.RELATED_SUBTYPE) + relatedMultipartBuilder.addBodyPart(BodyPartBuilder.create().setBody(maybeHtmlBody.getOrElse(""), SubType.HTML_SUBTYPE, StandardCharsets.UTF_8).build) + inlineAttachments.foldLeft(relatedMultipartBuilder) { + case (acc, (attachment, storedMetadata, content)) => + acc.addBodyPart(toBodypartBuilder(attachment, storedMetadata, content)) + acc + } + relatedMultipartBuilder + } + private def toBodypartBuilder(attachment: Attachment, storedMetadata: AttachmentMetadata, content: Array[Byte]) = { val bodypartBuilder = BodyPartBuilder.create() bodypartBuilder.setBody(content, attachment.`type`.value) --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
