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 <vtt...@linagora.com>
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: notifications-unsubscr...@james.apache.org
For additional commands, e-mail: notifications-h...@james.apache.org


Reply via email to