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