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 ce61177448 JAMES-4117 JMAP - Email/set create - support blobId in
htmlBody + textBody properties (#2661)
ce61177448 is described below
commit ce6117744884c01708d9a46aac0b5e525063a327
Author: vttran <[email protected]>
AuthorDate: Wed Mar 5 17:16:49 2025 +0700
JAMES-4117 JMAP - Email/set create - support blobId in htmlBody + textBody
properties (#2661)
---
.../rfc8621/contract/EmailSetMethodContract.scala | 503 ++++++++++++++++++++-
.../org/apache/james/jmap/mail/EmailSet.scala | 207 +++++----
.../jmap/method/EmailSetCreatePerformer.scala | 21 +-
3 files changed, 632 insertions(+), 99 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 43f7af39f9..9babd04add 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
@@ -19,11 +19,11 @@
package org.apache.james.jmap.rfc8621.contract
import java.io.ByteArrayInputStream
-import java.nio.charset.StandardCharsets
+import java.nio.charset.{Charset, StandardCharsets}
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
-import java.util.Date
import java.util.concurrent.TimeUnit
+import java.util.{Date, UUID}
import io.netty.handler.codec.http.HttpHeaderNames.ACCEPT
import io.restassured.RestAssured.{`given`, `with`, requestSpecification}
@@ -44,6 +44,7 @@ import org.apache.james.jmap.http.UserCredential
import org.apache.james.jmap.rfc8621.contract.DownloadContract.accountId
import
org.apache.james.jmap.rfc8621.contract.Fixture.{ACCEPT_RFC8621_VERSION_HEADER,
ACCOUNT_ID, 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.jmap.rfc8621.contract.tags.CategoryTags
import org.apache.james.jmap.{JmapGuiceProbe, MessageIdProbe}
import org.apache.james.mailbox.MessageManager.AppendCommand
import org.apache.james.mailbox.model.MailboxACL.Right
@@ -58,8 +59,8 @@ import org.assertj.core.api.Assertions.assertThat
import org.awaitility.Awaitility
import org.awaitility.Durations.ONE_HUNDRED_MILLISECONDS
import org.hamcrest.Matchers
-import org.hamcrest.Matchers.{equalTo, not}
-import org.junit.jupiter.api.{BeforeEach, Test}
+import org.hamcrest.Matchers.{equalTo, hasKey, not, notNullValue}
+import org.junit.jupiter.api.{BeforeEach, Tag, Test}
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.ValueSource
import play.api.libs.json.{JsNumber, JsString, Json}
@@ -2847,6 +2848,500 @@ trait EmailSetMethodContract {
|}]""".stripMargin)
}
+ @Test
+ @Tag(CategoryTags.BASIC_FEATURE)
+ def creationShouldSupportTextBodyUsingBlobId(server: GuiceJamesServer): Unit
= {
+ val bobPath = MailboxPath.inbox(BOB)
+ val mailboxId =
server.getProbe(classOf[MailboxProbeImpl]).createMailbox(bobPath)
+ val textBody: String = UUID.randomUUID().toString
+ val payload = textBody.getBytes(StandardCharsets.UTF_8)
+
+ val blobId: 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
+ .path("blobId")
+
+ 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",
+ | "textBody": [
+ | {
+ | "blobId": "$blobId",
+ | "type": "text/plain"
+ | }
+ | ]
+ | }
+ | }
+ | }, "c1"],
+ | ["Email/get",
+ | {
+ | "accountId": "$ACCOUNT_ID",
+ | "ids": ["#aaaaaa"],
+ | "properties": ["mailboxIds", "subject", "preview",
"textBody", "bodyValues"],
+ | "fetchTextBodyValues": true
+ | },
+ | "c2"]
+ | ]
+ |}""".stripMargin
+
+ `given`
+ .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+ .body(request)
+ .when
+ .post
+ .`then`
+ .statusCode(SC_OK)
+ .contentType(JSON)
+ .body("methodResponses[0][1].created.aaaaaa", notNullValue())
+ .body("methodResponses[1][1].list[0].preview", equalTo(textBody))
+ .body("methodResponses[1][1].list[0].textBody",
+ jsonEquals(
+ s"""[
+ | {
+ | "partId": "$${json-unit.ignore}",
+ | "blobId": "$${json-unit.ignore}",
+ | "size": ${payload.size},
+ | "type": "text/plain",
+ | "charset": "UTF-8"
+ | }
+ |]""".stripMargin))
+ }
+
+ @Test
+ def creationShouldSupportHtmlBodyUsingBlobId(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 blobId: String = `given`
+ .basePath("")
+ .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+ .contentType("text/html")
+ .body(htmlBody)
+ .when
+ .post(s"/upload/$ACCOUNT_ID")
+ .`then`
+ .statusCode(SC_CREATED)
+ .extract
+ .path("blobId")
+
+ 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": [
+ | {
+ | "blobId": "$blobId",
+ | "type": "text/html"
+ | }
+ | ]
+ | }
+ | }
+ | }, "c1"],
+ | ["Email/get",
+ | {
+ | "accountId": "$ACCOUNT_ID",
+ | "ids": ["#aaaaaa"],
+ | "properties": ["mailboxIds", "subject", "preview",
"htmlBody", "bodyValues"],
+ | "fetchTextBodyValues": true
+ | },
+ | "c2"]
+ | ]
+ |}""".stripMargin
+
+ `given`
+ .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+ .body(request)
+ .when
+ .post
+ .`then`
+ .statusCode(SC_OK)
+ .contentType(JSON)
+ .body("methodResponses[0][1].created.aaaaaa", notNullValue())
+ .body("methodResponses[1][1].list[0].preview", equalTo("I have the most
brilliant plan. Let me tell you all about it. What we do is, we"))
+ .body("methodResponses[1][1].list[0].htmlBody",
+ jsonEquals(
+ s"""[
+ | {
+ | "partId": "$${json-unit.ignore}",
+ | "blobId": "$${json-unit.ignore}",
+ | "size": 166,
+ | "type": "text/html",
+ | "charset": "UTF-8"
+ | }
+ |]""".stripMargin))
+ }
+
+ @Test
+ def
emailCreationShouldFailWhenHtmlBodyUsesUnsupportedBlobContentType(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 unSupportContentType = "application/javascript"
+
+ val blobId: String = `given`
+ .basePath("")
+ .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+ .contentType(unSupportContentType)
+ .body(htmlBody)
+ .when
+ .post(s"/upload/$ACCOUNT_ID")
+ .`then`
+ .statusCode(SC_CREATED)
+ .extract
+ .path("blobId")
+
+ 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": [
+ | {
+ | "blobId": "$blobId",
+ | "type": "text/html"
+ | }
+ | ]
+ | }
+ | }
+ | }, "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", hasKey("aaaaaa"))
+ .body("methodResponses[0][1].notCreated.aaaaaa",
+ jsonEquals(
+ s"""{
+ | "type": "invalidArguments",
+ | "description": "Blob: Unsupported content type. Expecting
text/plain or text/html"
+ |}""".stripMargin))
+ }
+
+ @Test
+ def emailCreationShouldFailWhenHtmlBodyUsesNotFoundBlobId(server:
GuiceJamesServer): Unit = {
+ val bobPath = MailboxPath.inbox(BOB)
+ val mailboxId =
server.getProbe(classOf[MailboxProbeImpl]).createMailbox(bobPath)
+
+ val blobIdOfAndre: String = given(baseRequestSpecBuilder(server)
+ .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+ .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+ .build)
+ .basePath("")
+ .contentType("text/plain")
+ .body(UUID.randomUUID().toString)
+ .when
+ .post(s"/upload/$ANDRE_ACCOUNT_ID")
+ .`then`
+ .statusCode(SC_CREATED)
+ .extract
+ .path("blobId")
+
+ `given`
+ .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+ .body(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": [
+ | {
+ | "blobId": "$blobIdOfAndre",
+ | "type": "text/html"
+ | }
+ | ]
+ | }
+ | }
+ | }, "c1"]
+ | ]
+ |}""".stripMargin)
+ .when
+ .post
+ .`then`
+ .statusCode(SC_OK)
+ .contentType(JSON)
+ .body("methodResponses[0][1].notCreated", hasKey("aaaaaa"))
+ .body("methodResponses[0][1].notCreated.aaaaaa",
+ jsonEquals(
+ s"""{
+ | "type": "invalidArguments",
+ | "description": "Blob not found: $blobIdOfAndre",
+ | "properties": [
+ | "blobId"
+ | ]
+ |}""".stripMargin))
+ }
+
+ @Test
+ def emailCreationShouldFailWhenHtmlBodyUsesNotUploadBlobId(server:
GuiceJamesServer): Unit = {
+ val bobPath = MailboxPath.inbox(BOB)
+ val mailboxId =
server.getProbe(classOf[MailboxProbeImpl]).createMailbox(bobPath)
+ val attachedMessageId: MessageId =
server.getProbe(classOf[MailboxProbeImpl])
+ .appendMessage(BOB.asString, bobPath, AppendCommand.from(Message.Builder
+ .of
+ .setSubject("test")
+ .setSender(ANDRE.asString())
+ .setFrom(ANDRE.asString())
+ .setSubject("I'm happy to be attached")
+ .setBody("testmail", StandardCharsets.UTF_8)
+ .build))
+ .getMessageId
+
+ val notUploadBlobId: String = attachedMessageId.serialize()
+
+ `given`
+ .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+ .body(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": [
+ | {
+ | "blobId": "$notUploadBlobId",
+ | "type": "text/html"
+ | }
+ | ]
+ | }
+ | }
+ | }, "c1"]
+ | ]
+ |}""".stripMargin)
+ .when
+ .post
+ .`then`
+ .statusCode(SC_OK)
+ .contentType(JSON)
+ .body("methodResponses[0][1].notCreated", hasKey("aaaaaa"))
+ .body("methodResponses[0][1].notCreated.aaaaaa",
+ jsonEquals(
+ s"""{
+ | "type": "invalidArguments",
+ | "description": "Blob resolution failed or blob type is
invalid"
+ |}""".stripMargin))
+ }
+
+ @Test
+ def emailCreationShouldFailWhenHtmlBodyPresentBothBlobIdAndPartId(server:
GuiceJamesServer): Unit = {
+ val bobPath = MailboxPath.inbox(BOB)
+ val mailboxId =
server.getProbe(classOf[MailboxProbeImpl]).createMailbox(bobPath)
+ val textBody: String = UUID.randomUUID().toString
+ val payload = textBody.getBytes(StandardCharsets.UTF_8)
+
+ val blobId: 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
+ .path("blobId")
+
+ `given`
+ .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+ .body(
+ 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",
+ | "textBody": [
+ | {
+ | "blobId": "$blobId",
+ | "partId": "a49d",
+ | "type": "text/plain"
+ | }
+ | ],
+ | "bodyValues": {
+ | "a49d": {
+ | "value": "$textBody",
+ | "isTruncated": false,
+ | "isEncodingProblem": false
+ | }
+ | }
+ | }
+ | }
+ | }, "c1"]
+ | ]
+ |}""".stripMargin)
+ .when
+ .post
+ .`then`
+ .statusCode(SC_OK)
+ .contentType(JSON)
+ .body("methodResponses[0][1].notCreated", hasKey("aaaaaa"))
+ .body("methodResponses[0][1].notCreated.aaaaaa",
+ jsonEquals(
+ s"""{
+ | "type": "invalidArguments",
+ | "description": "Expecting only one of partId or blobId to be
defined"
+ |}""".stripMargin))
+ }
+
+ @Test
+ def emailCreationShouldFailWhenHtmlBodyAbsentBothBlobIdAndPartId(server:
GuiceJamesServer): Unit = {
+ val bobPath = MailboxPath.inbox(BOB)
+ val mailboxId =
server.getProbe(classOf[MailboxProbeImpl]).createMailbox(bobPath)
+ `given`
+ .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+ .body(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",
+ | "textBody": [
+ | {
+ | "type": "text/plain"
+ | }
+ | ]
+ | }
+ | }
+ | }, "c1"]
+ | ]
+ |}""".stripMargin)
+ .when
+ .post
+ .`then`
+ .statusCode(SC_OK)
+ .contentType(JSON)
+ .body("methodResponses[0][1].notCreated", hasKey("aaaaaa"))
+ .body("methodResponses[0][1].notCreated.aaaaaa",
+ jsonEquals(
+ s"""{
+ | "type": "invalidArguments",
+ | "description": "Expecting either partId or blobId to be
defined"
+ |}""".stripMargin))
+ }
+
+ @Test
+ def shouldPreserveCharsetOfBlobWhenEmailBodyWithBlobId(server:
GuiceJamesServer): Unit = {
+ val bobPath = MailboxPath.inbox(BOB)
+ val mailboxId =
server.getProbe(classOf[MailboxProbeImpl]).createMailbox(bobPath)
+ val textBody: String = "Café"
+ val payload = textBody.getBytes(Charset.forName("Windows-1252"))
+
+ val blobId: String = `given`
+ .basePath("")
+ .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+ .contentType("text/plain; charset=Windows-1252")
+ .body(payload)
+ .when
+ .post(s"/upload/$ACCOUNT_ID")
+ .`then`
+ .statusCode(SC_CREATED)
+ .extract
+ .path("blobId")
+
+ 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",
+ | "textBody": [
+ | {
+ | "blobId": "$blobId",
+ | "type": "text/plain"
+ | }
+ | ]
+ | }
+ | }
+ | }, "c1"],
+ | ["Email/get",
+ | {
+ | "accountId": "$ACCOUNT_ID",
+ | "ids": ["#aaaaaa"],
+ | "properties": ["mailboxIds", "subject", "preview"],
+ | "fetchTextBodyValues": true
+ | },
+ | "c2"]
+ | ]
+ |}""".stripMargin
+
+ `given`
+ .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+ .body(request)
+ .when
+ .post
+ .`then`
+ .statusCode(SC_OK)
+ .contentType(JSON)
+ .body("methodResponses[0][1].created.aaaaaa", notNullValue())
+ .body("methodResponses[1][1].list[0].preview", equalTo(textBody))
+ }
+
@Test
def textContentTransferEncodingShouldBeRejectedInTextBody(server:
GuiceJamesServer): Unit = {
val bobPath = MailboxPath.inbox(BOB)
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 47f1da96e7..500c5ba0a5 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
@@ -33,7 +33,7 @@ import org.apache.james.jmap.core.{AccountId, SetError,
UTCDate, UuidState}
import org.apache.james.jmap.mail.Disposition.INLINE
import org.apache.james.jmap.mail.EmailCreationRequest.KEYWORD_DRAFT
import org.apache.james.jmap.method.{SetRequest, WithAccountId}
-import org.apache.james.jmap.routes.{Blob, BlobResolvers}
+import org.apache.james.jmap.routes.{Blob, BlobNotFoundException,
BlobResolvers, UploadedBlob}
import org.apache.james.mailbox.MailboxSession
import org.apache.james.mailbox.model.{Cid, MessageId}
import org.apache.james.mime4j.codec.EncoderUtil.Usage
@@ -45,12 +45,16 @@ import org.apache.james.mime4j.field.{ContentIdFieldImpl,
Fields}
import org.apache.james.mime4j.message.{BodyPartBuilder, MultipartBuilder}
import org.apache.james.mime4j.stream.{Field, NameValuePair, RawField}
import org.apache.james.mime4j.util.MimeUtil
+import org.apache.james.util.ReactorUtils
import org.apache.james.util.html.HtmlTextExtractor
import play.api.libs.json.JsObject
+import reactor.core.scala.publisher.{SFlux, SMono}
import scala.jdk.CollectionConverters._
import scala.jdk.OptionConverters._
-import scala.util.{Right, Try, Using}
+import scala.util.{Right, Try}
+
+case class AttachmentNotFoundException(blobId: BlobId) extends RuntimeException
object EmailSet {
def asUnparsed(messageId: MessageId): UnparsedMessageId =
refined.refineV[IdConstraint](messageId.serialize()) match {
@@ -74,12 +78,16 @@ object SubType {
case class ClientPartId(id: Id)
-case class ClientBody(partId: ClientPartId, `type`: Type, specificHeaders:
List[EmailHeader]) {
-}
+case class ClientBody(partId: Option[ClientPartId],
+ blobId: Option[BlobId],
+ `type`: Type,
+ specificHeaders: List[EmailHeader])
-case class ClientBodyWithoutHeaders(partId: ClientPartId, `type`: Type) {
+case class ClientBodyWithoutHeaders(partId: Option[ClientPartId],
+ blobId: Option[BlobId],
+ `type`: Type) {
def withHeaders(specificHeaders: List[EmailHeader]): ClientBody =
- ClientBody(partId, `type`, specificHeaders)
+ ClientBody(partId, blobId, `type`, specificHeaders)
}
case class ClientEmailBodyValueWithoutHeaders(value: String,
@@ -190,46 +198,51 @@ case class EmailCreationRequest(mailboxIds: MailboxIds,
bodyValues: Option[Map[ClientPartId,
ClientEmailBodyValue]],
specificHeaders: List[EmailHeader],
attachments: Option[List[Attachment]]) {
+
def toMime4JMessage(blobResolvers: BlobResolvers,
htmlTextExtractor: HtmlTextExtractor,
- mailboxSession: MailboxSession): Either[Throwable,
Message] =
- validateHtmlBody
- .flatMap(maybeHtmlBody => validateTextBody.map((maybeHtmlBody, _)))
- .flatMap {
- case (maybeHtmlBody, maybeTextBody) =>
- val builder = Message.Builder.of
- references.flatMap(_.asString).map(new RawField("References",
_)).foreach(builder.setField)
- inReplyTo.flatMap(_.asString).map(new RawField("In-Reply-To",
_)).foreach(builder.setField)
- subject.foreach(value => builder.setSubject(value.value))
- val maybeFrom: Option[List[Mime4jMailbox]] =
from.flatMap(_.asMime4JMailboxList)
- maybeFrom.map(_.asJava).foreach(builder.setFrom)
-
to.flatMap(_.asMime4JMailboxList).map(_.asJava).foreach(builder.setTo)
-
cc.flatMap(_.asMime4JMailboxList).map(_.asJava).foreach(builder.setCc)
-
bcc.flatMap(_.asMime4JMailboxList).map(_.asJava).foreach(builder.setBcc)
-
sender.flatMap(_.asMime4JMailboxList).map(_.asJava).map(Fields.addressList(FieldName.SENDER,
_)).foreach(builder.setField)
-
replyTo.flatMap(_.asMime4JMailboxList).map(_.asJava).foreach(builder.setReplyTo)
- builder.setDate(
sentAt.map(_.asUTC).map(_.toInstant).map(Date.from).getOrElse(new Date()))
- builder.setField(new RawField(FieldName.MESSAGE_ID,
messageId.flatMap(_.asString).getOrElse(generateUniqueMessageId(maybeFrom))))
- validateSpecificHeaders(builder)
- .flatMap(_ => {
- specificHeaders.flatMap(_.asFields).foreach(builder.addField)
- attachments.filter(_.nonEmpty).map(attachments =>
- createMultipartWithAttachments(maybeHtmlBody, maybeTextBody,
attachments, blobResolvers, htmlTextExtractor, mailboxSession)
- .map(multipartBuilder => {
- builder.setBody(multipartBuilder)
- builder.build
- }))
- .getOrElse({
- builder.setBody(createAlternativeBody(maybeHtmlBody,
maybeTextBody, htmlTextExtractor))
- Right(builder.build)
- })
- })
+ mailboxSession: MailboxSession): SMono[Message] = {
+
+ val baseMessageBuilderPublisher: SMono[Message.Builder] =
SMono.fromCallable(() => {
+ val builder: Message.Builder = Message.Builder.of
+ references.flatMap(_.asString).map(new RawField("References",
_)).foreach(builder.setField)
+ inReplyTo.flatMap(_.asString).map(new RawField("In-Reply-To",
_)).foreach(builder.setField)
+ subject.foreach(value => builder.setSubject(value.value))
+ val maybeFrom: Option[List[Mime4jMailbox]] =
from.flatMap(_.asMime4JMailboxList)
+ maybeFrom.map(_.asJava).foreach(builder.setFrom)
+ to.flatMap(_.asMime4JMailboxList).map(_.asJava).foreach(builder.setTo)
+ cc.flatMap(_.asMime4JMailboxList).map(_.asJava).foreach(builder.setCc)
+
bcc.flatMap(_.asMime4JMailboxList).map(_.asJava).foreach(builder.setBcc)
+
sender.flatMap(_.asMime4JMailboxList).map(_.asJava).map(Fields.addressList(FieldName.SENDER,
_)).foreach(builder.setField)
+
replyTo.flatMap(_.asMime4JMailboxList).map(_.asJava).foreach(builder.setReplyTo)
+
builder.setDate(sentAt.map(_.asUTC).map(_.toInstant).map(Date.from).getOrElse(new
Date()))
+ builder.setField(new RawField(FieldName.MESSAGE_ID,
messageId.flatMap(_.asString).getOrElse(generateUniqueMessageId(maybeFrom))))
+ validateSpecificHeaders(builder)
+ .map(_ => {
+ specificHeaders.flatMap(_.asFields).foreach(builder.addField)
+ builder
+ })
+ })
+ .flatMap(_.fold(SMono.error, SMono.just))
+
+ for {
+ maybeHtmlBody <- validateHtmlBody(blobResolvers,
mailboxSession).map(Some(_)).switchIfEmpty(SMono.just(None))
+ maybeTextBody <- validateTextBody(blobResolvers,
mailboxSession).map(Some(_)).switchIfEmpty(SMono.just(None))
+ messageBuilder <- baseMessageBuilderPublisher
+ multipartBody <- attachments match {
+ case None | Some(Nil) =>
SMono.just(createAlternativeBody(maybeHtmlBody, maybeTextBody,
htmlTextExtractor))
+ case Some(attachmentList) =>
createMultipartWithAttachments(maybeHtmlBody, maybeTextBody, attachmentList,
blobResolvers, htmlTextExtractor, mailboxSession)
}
+ } yield {
+ messageBuilder.setBody(multipartBody)
+ messageBuilder.build()
+ }
+ }
private def generateUniqueMessageId(fromAddress:
Option[List[Mime4jMailbox]]): String =
MimeUtil.createUniqueMessageId(fromAddress.flatMap(_.headOption).map(_.getDomain).orNull)
- private def createAlternativeBody(htmlBody: Option[ClientBodyPart],
textBody: Option[ClientBodyPart], htmlTextExtractor: HtmlTextExtractor) = {
+ private def createAlternativeBody(htmlBody: Option[ClientBodyPart],
textBody: Option[ClientBodyPart], htmlTextExtractor: HtmlTextExtractor):
MultipartBuilder = {
val alternativeBuilder =
MultipartBuilder.create(SubType.ALTERNATIVE_SUBTYPE)
val replacement: ClientBodyPart = textBody.getOrElse(ClientBodyPart(
htmlTextExtractor.toPlainText(htmlBody.map(_.value).getOrElse("")),
@@ -257,29 +270,25 @@ case class EmailCreationRequest(mailboxIds: MailboxIds,
attachments: List[Attachment],
blobResolvers: BlobResolvers,
htmlTextExtractor:
HtmlTextExtractor,
- mailboxSession: MailboxSession):
Either[Throwable, MultipartBuilder] = {
- val maybeAttachments: Either[Throwable, List[LoadedAttachment]] =
- attachments
- .map(loadWithMetadata(blobResolvers, mailboxSession))
- .sequence
-
- maybeAttachments.map(list => {
- (list.filter(_.isInline), list.filter(!_.isInline)) match {
- case (Nil, normalAttachments) => createMixedBody(maybeHtmlBody,
maybeTextBody, normalAttachments, htmlTextExtractor)
- case (inlineAttachments, Nil) => createRelatedBody(maybeHtmlBody,
maybeTextBody, inlineAttachments, htmlTextExtractor)
- case (inlineAttachments, normalAttachments) =>
createMixedRelatedBody(maybeHtmlBody, maybeTextBody, inlineAttachments,
normalAttachments, htmlTextExtractor)
+ mailboxSession: MailboxSession):
SMono[MultipartBuilder] =
+ SFlux.fromIterable(attachments)
+ .concatMap(loadWithMetadata(blobResolvers, mailboxSession),
ReactorUtils.LOW_CONCURRENCY)
+ .collectSeq()
+ .map(list => {
+ (list.filter(_.isInline), list.filter(!_.isInline)) match {
+ case (Nil, normalAttachments) => createMixedBody(maybeHtmlBody,
maybeTextBody, normalAttachments.toList, htmlTextExtractor)
+ case (inlineAttachments, Nil) => createRelatedBody(maybeHtmlBody,
maybeTextBody, inlineAttachments.toList, htmlTextExtractor)
+ case (inlineAttachments, normalAttachments) =>
createMixedRelatedBody(maybeHtmlBody, maybeTextBody, inlineAttachments.toList,
normalAttachments.toList, htmlTextExtractor)
+ }
+ })
+
+ private def loadWithMetadata(blobResolvers: BlobResolvers, mailboxSession:
MailboxSession)(attachment: Attachment): SMono[LoadedAttachment] =
+ blobResolvers.resolve(attachment.blobId, mailboxSession)
+ .onErrorMap {
+ case notFoundException: BlobNotFoundException =>
AttachmentNotFoundException(notFoundException.blobId)
+ case e => e
}
- })
- }
-
- private def loadWithMetadata(blobResolvers: BlobResolvers, mailboxSession:
MailboxSession)(attachment: Attachment): Either[Throwable, LoadedAttachment] =
- Try(blobResolvers.resolve(attachment.blobId, mailboxSession).block())
- .toEither.flatMap(blob => load(blob).map(content =>
LoadedAttachment(attachment, blob, content)))
-
- private def load(blob: Blob): Either[Throwable, Array[Byte]] =
- Using(blob.content) {
- _.readAllBytes()
- }.toEither
+ .map(blob => LoadedAttachment(attachment, blob,
blob.content.readAllBytes()))
private def createMixedRelatedBody(maybeHtmlBody: Option[ClientBodyPart],
maybeTextBody: Option[ClientBodyPart],
@@ -359,21 +368,23 @@ case class EmailCreationRequest(mailboxIds: MailboxIds,
.filter(!_._1.equalsIgnoreCase("charset"))
.toMap
- 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)
- .getOrElse(Left(new IllegalArgumentException("Expecting bodyValues to
contain the part specified in htmlBody")))
- case _ => Left(new IllegalArgumentException("Expecting htmlBody to
contains only 1 part"))
- }
+ private def validateHtmlBody(blobResolvers: BlobResolvers, mailboxSession:
MailboxSession): SMono[ClientBodyPart] =
+ htmlBody match {
+ case None => SMono.empty
+ case Some(html :: Nil) if !html.`type`.value.equals("text/html") =>
SMono.error(new IllegalArgumentException("Expecting htmlBody type to be
text/html"))
+ case Some(html :: Nil) => retrieveCorrespondingBody(html, blobResolvers,
mailboxSession)
+ .switchIfEmpty(SMono.error(new IllegalArgumentException("Expecting
bodyValues to contain the part specified in htmlBody")))
+ case _ => SMono.error(new IllegalArgumentException("Expecting htmlBody
to contains only 1 part"))
+ }
- 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)
- .getOrElse(Left(new IllegalArgumentException("Expecting bodyValues to
contain the part specified in textBody")))
- case _ => Left(new IllegalArgumentException("Expecting textBody to
contains only 1 part"))
- }
+ private def validateTextBody(blobResolvers: BlobResolvers, mailboxSession:
MailboxSession): SMono[ClientBodyPart] =
+ textBody match {
+ case None => SMono.empty
+ case Some(text :: Nil) if !text.`type`.value.equals("text/plain") =>
SMono.error(new IllegalArgumentException("Expecting htmlBody type to be
text/html"))
+ case Some(text :: Nil) => retrieveCorrespondingBody(text, blobResolvers,
mailboxSession)
+ .switchIfEmpty(SMono.error(new IllegalArgumentException("Expecting
bodyValues to contain the part specified in textBody")))
+ case _ => SMono.error(new IllegalArgumentException("Expecting textBody
to contains only 1 part"))
+ }
def validateRequest: Either[IllegalArgumentException, EmailCreationRequest]
= validateEmailAddressHeader
@@ -395,16 +406,44 @@ case class EmailCreationRequest(mailboxIds: MailboxIds,
}
}
- private def retrieveCorrespondingBody(clientBody: ClientBody):
Option[Either[IllegalArgumentException, Some[ClientBodyPart]]] =
- bodyValues.getOrElse(Map())
- .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,
Option(clientBody.specificHeaders).filter(_.nonEmpty).getOrElse(part.specificHeaders))))
+ private def retrieveCorrespondingBody(clientBody: ClientBody,
+ blobResolvers: BlobResolvers,
+ mailboxSession: MailboxSession):
SMono[ClientBodyPart] =
+ (clientBody.partId, clientBody.blobId) match {
+ case (None, None) => SMono.error(new IllegalArgumentException("Expecting
either partId or blobId to be defined"))
+ case (Some(_), Some(_)) => SMono.error(new
IllegalArgumentException("Expecting only one of partId or blobId to be
defined"))
+ case (Some(_), None) => retrieveCorrespondingBodyFromPartId(clientBody)
+ case (None, Some(_)) => retrieveCorrespondingBodyFromBlobId(clientBody,
blobResolvers, mailboxSession)
+ }
+
+ private def retrieveCorrespondingBodyFromBlobId(clientBody: ClientBody,
+ blobResolvers: BlobResolvers,
+ mailboxSession:
MailboxSession): SMono[ClientBodyPart] = {
+ SMono.justOrEmpty(clientBody.blobId)
+ .flatMap(blobResolvers.resolve(_, mailboxSession))
+ .flatMap {
+ case uploadedBlob: UploadedBlob =>
+ val mimeType: String = uploadedBlob.contentType.mimeType().asString()
+ if (mimeType == "text/plain" || mimeType == "text/html") {
+ val charset =
uploadedBlob.contentType.charset().orElse(StandardCharsets.UTF_8)
+ val content = new String(uploadedBlob.content.readAllBytes(),
charset)
+ SMono.just(ClientBodyPart(content, clientBody.specificHeaders))
+ } else {
+ SMono.error(new IllegalArgumentException("Blob: Unsupported
content type. Expecting text/plain or text/html"))
+ }
+ case _ => SMono.error(new IllegalArgumentException("Blob resolution
failed or blob type is invalid"))
}
+ }
+
+ private def retrieveCorrespondingBodyFromPartId(clientBody: ClientBody):
SMono[ClientBodyPart] =
+ bodyValues.getOrElse(Map())
+ .get(clientBody.partId.get) match {
+ case Some(part) if part.isTruncated.exists(_.value) => SMono.error(new
IllegalArgumentException("Expecting isTruncated to be false"))
+ case Some(part) if part.isEncodingProblem.exists(_.value) =>
SMono.error(new IllegalArgumentException("Expecting isEncodingProblem to be
false"))
+ case Some(part) if part.specificHeaders.nonEmpty &&
clientBody.specificHeaders.nonEmpty => SMono.error(new
IllegalArgumentException("Could not set specific headers on both EmailBodyPart
and EmailBodyValue"))
+ case Some(part) => SMono.just(ClientBodyPart(part.value,
Option(clientBody.specificHeaders).filter(_.nonEmpty).getOrElse(part.specificHeaders)))
+ case None => SMono.empty
+ }
private def validateSpecificHeaders(message: Message.Builder):
Either[IllegalArgumentException, Unit] = {
specificHeaders.map(header => {
diff --git
a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailSetCreatePerformer.scala
b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailSetCreatePerformer.scala
index 69649c4412..62ebe5dcfe 100644
---
a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailSetCreatePerformer.scala
+++
b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailSetCreatePerformer.scala
@@ -30,7 +30,7 @@ import org.apache.james.jmap.api.model.Size.sanitizeSize
import org.apache.james.jmap.core.SetError.SetErrorDescription
import org.apache.james.jmap.core.{Properties, SetError, UTCDate}
import org.apache.james.jmap.json.EmailSetSerializer
-import org.apache.james.jmap.mail.{BlobId, EmailCreationId,
EmailCreationRequest, EmailCreationResponse, EmailSetRequest, ThreadId}
+import org.apache.james.jmap.mail.{AttachmentNotFoundException, BlobId,
EmailCreationId, EmailCreationRequest, EmailCreationResponse, EmailSetRequest,
ThreadId}
import org.apache.james.jmap.method.EmailSetCreatePerformer.{CreationFailure,
CreationResult, CreationResults, CreationSuccess}
import org.apache.james.jmap.routes.{BlobNotFoundException, BlobResolvers}
import org.apache.james.mailbox.MessageManager.AppendCommand
@@ -38,7 +38,6 @@ import
org.apache.james.mailbox.exception.{MailboxNotFoundException, OverQuotaEx
import org.apache.james.mailbox.model.MailboxId
import org.apache.james.mailbox.{MailboxManager, MailboxSession}
import org.apache.james.mime4j.dom.Message
-import org.apache.james.util.ReactorUtils
import org.apache.james.util.html.HtmlTextExtractor
import org.slf4j.LoggerFactory
import reactor.core.scala.publisher.{SFlux, SMono}
@@ -72,9 +71,12 @@ object EmailSetCreatePerformer {
case e: MailboxNotFoundException =>
LOGGER.info(s"Mailbox ${e.getMessage}")
SetError.notFound(SetErrorDescription("Mailbox " + e.getMessage))
- case e: BlobNotFoundException =>
+ case e: AttachmentNotFoundException =>
LOGGER.info(s"Attachment not found: ${e.blobId.value}")
SetError.invalidArguments(SetErrorDescription(s"Attachment not found:
${e.blobId.value}"), Some(Properties("attachments")))
+ case e: BlobNotFoundException =>
+ LOGGER.info(s"Blob not found: ${e.blobId.value}")
+ SetError.invalidArguments(SetErrorDescription(s"Blob not found:
${e.blobId.value}"), Some(Properties("blobId")))
case e: SizeExceededException =>
LOGGER.info("Attempt to create too big of a message")
SetError.tooLarge(SetErrorDescription(e.getMessage))
@@ -115,14 +117,11 @@ class EmailSetCreatePerformer @Inject()(serializer:
EmailSetSerializer,
if (mailboxIds.size != 1) {
SMono.just(CreationFailure(clientId, new
IllegalArgumentException("mailboxIds need to have size 1")))
} else {
- SMono.fromCallable(() => request.toMime4JMessage(blobResolvers,
htmlTextExtractor, mailboxSession))
- .flatMap(either => either.fold(e =>
SMono.just(CreationFailure(clientId, e)),
- message =>
- asAppendCommand(request, message)
- .fold(e => SMono.error(e),
- appendCommand => append(clientId, appendCommand, mailboxSession,
mailboxIds))))
- .onErrorResume(e =>
SMono.just[CreationResult](CreationFailure(clientId, e)))
- .subscribeOn(ReactorUtils.BLOCKING_CALL_WRAPPER)
+ request.toMime4JMessage(blobResolvers, htmlTextExtractor, mailboxSession)
+ .flatMap(message => asAppendCommand(request, message)
+ .fold(e => SMono.error(e),
+ appendCommand => append(clientId, appendCommand, mailboxSession,
mailboxIds)))
+ .onErrorResume(error =>
SMono.just[CreationResult](CreationFailure(clientId, error)))
}
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]