You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@james.apache.org by bt...@apache.org on 2020/12/05 07:10:13 UTC

[james-project] 06/17: JAMES-2884 Text body support for Email/set create

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

commit 1cf50f65903d2d640fd846e61f7ec6ef4a369c57
Author: Benoit Tellier <bt...@linagora.com>
AuthorDate: Thu Nov 26 13:09:55 2020 +0700

    JAMES-2884 Text body support for Email/set create
    
    This is required for text/plain clients
---
 .../rfc8621/contract/EmailSetMethodContract.scala  | 152 ++++++++++++++++++++-
 .../james/jmap/json/EmailSetSerializer.scala       |  40 +++---
 .../org/apache/james/jmap/mail/EmailSet.scala      | 110 ++++++++-------
 3 files changed, 231 insertions(+), 71 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 b843fed..724455a 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
@@ -2008,8 +2008,8 @@ trait EmailSetMethodContract {
            |  "subject": "World domination",
            |  "attachments": [
            |    {
-           |      "partId": "5",
-           |      "blobId": "${messageId}_5",
+           |      "partId": "4",
+           |      "blobId": "${messageId}_4",
            |      "size": 11,
            |      "type": "text/plain",
            |      "charset": "UTF-8",
@@ -2024,7 +2024,7 @@ trait EmailSetMethodContract {
       .basePath("")
       .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
     .when
-      .get(s"/download/$accountId/${messageId}_5")
+      .get(s"/download/$accountId/${messageId}_4")
     .`then`
       .statusCode(SC_OK)
       .contentType("text/plain")
@@ -2179,6 +2179,148 @@ trait EmailSetMethodContract {
   }
 
   @Test
+  def createShouldSupportAttachmentAndTextBody(server: GuiceJamesServer): Unit = {
+    val bobPath = MailboxPath.inbox(BOB)
+    val mailboxId = server.getProbe(classOf[MailboxProbeImpl]).createMailbox(bobPath)
+    val payload = "123456789\r\n".getBytes(StandardCharsets.UTF_8)
+    val textBody: String = "Let me tell you all about it."
+
+    val uploadResponse: String = `given`
+      .basePath("")
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .contentType("text/plain")
+      .body(payload)
+    .when
+      .post(s"/upload/$ACCOUNT_ID/")
+    .`then`
+      .statusCode(SC_CREATED)
+      .extract
+      .body
+      .asString
+
+    val blobId: String = Json.parse(uploadResponse).\("blobId").get.asInstanceOf[JsString].value
+
+    val request =
+      s"""{
+         |  "using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
+         |  "methodCalls": [
+         |    ["Email/set", {
+         |      "accountId": "$ACCOUNT_ID",
+         |      "create": {
+         |        "aaaaaa": {
+         |          "mailboxIds": {
+         |             "${mailboxId.serialize}": true
+         |          },
+         |          "subject": "World domination",
+         |          "attachments": [
+         |            {
+         |              "blobId": "$blobId",
+         |              "type":"text/plain",
+         |              "charset":"UTF-8",
+         |              "disposition": "attachment"
+         |            }
+         |          ],
+         |          "textBody": [
+         |            {
+         |              "partId": "a49d",
+         |              "type": "text/plain"
+         |            }
+         |          ],
+         |          "bodyValues": {
+         |            "a49d": {
+         |              "value": "$textBody",
+         |              "isTruncated": false,
+         |              "isEncodingProblem": false
+         |            }
+         |          }
+         |        }
+         |      }
+         |    }, "c1"],
+         |    ["Email/get",
+         |      {
+         |        "accountId": "$ACCOUNT_ID",
+         |        "ids": ["#aaaaaa"],
+         |        "properties": ["mailboxIds", "subject", "attachments", "textBody", "bodyValues"],
+         |        "fetchTextBodyValues": true
+         |      },
+         |    "c2"]
+         |  ]
+         |}""".stripMargin
+
+    val response = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    val responseAsJson = Json.parse(response)
+      .\("methodResponses")
+      .\(0).\(1)
+      .\("created")
+      .\("aaaaaa")
+
+    val messageId = responseAsJson
+      .\("id")
+      .get.asInstanceOf[JsString].value
+    val size = responseAsJson
+      .\("size")
+      .get.asInstanceOf[JsNumber].value
+
+    assertThatJson(response)
+      .inPath("methodResponses[0][1].created.aaaaaa")
+      .isEqualTo(
+        s"""{
+           | "id": "$messageId",
+           | "blobId": "$messageId",
+           | "threadId": "$messageId",
+           | "size": $size
+           |}""".stripMargin)
+
+    assertThatJson(response)
+      .whenIgnoringPaths("methodResponses[1][1].list[0].id")
+      .inPath(s"methodResponses[1][1].list")
+      .isEqualTo(
+        s"""[{
+           |  "mailboxIds": {
+           |    "${mailboxId.serialize}": true
+           |  },
+           |  "subject": "World domination",
+           |  "attachments": [
+           |    {
+           |      "partId": "4",
+           |      "blobId": "${messageId}_4",
+           |      "size": 11,
+           |      "type": "text/plain",
+           |      "charset": "UTF-8",
+           |      "disposition": "attachment"
+           |    }
+           |  ],
+           |  "textBody": [
+           |    {
+           |      "partId": "3",
+           |      "blobId": "${messageId}_3",
+           |      "size": 29,
+           |      "type": "text/plain",
+           |      "charset": "UTF-8"
+           |    }
+           |  ],
+           |  "bodyValues": {
+           |    "3": {
+           |      "value": "$textBody",
+           |      "isEncodingProblem": false,
+           |      "isTruncated": false
+           |    }
+           |  }
+           |}]""".stripMargin)
+  }
+
+  @Test
   def createShouldSupportInlinedAttachmentsMixedWithRegularAttachmentsAndHtmlBody(server: GuiceJamesServer): Unit = {
     val bobPath = MailboxPath.inbox(BOB)
     val mailboxId = server.getProbe(classOf[MailboxProbeImpl]).createMailbox(bobPath)
@@ -3311,8 +3453,8 @@ trait EmailSetMethodContract {
            |  "attachments": [
            |    {
            |      "name": "myAttachment",
-           |      "partId": "5",
-           |      "blobId": "${messageId}_5",
+           |      "partId": "4",
+           |      "blobId": "${messageId}_4",
            |      "size": 11,
            |      "type": "text/plain",
            |      "charset": "UTF-8",
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/EmailSetSerializer.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/EmailSetSerializer.scala
index accb3fb..12baa8d 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/EmailSetSerializer.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/EmailSetSerializer.scala
@@ -28,7 +28,7 @@ import org.apache.james.jmap.core.Id.IdConstraint
 import org.apache.james.jmap.core.{Id, SetError, UTCDate}
 import org.apache.james.jmap.mail.EmailSet.{EmailCreationId, UnparsedMessageId, UnparsedMessageIdConstraint}
 import org.apache.james.jmap.mail.KeywordsFactory.STRICT_KEYWORDS_FACTORY
-import org.apache.james.jmap.mail.{AddressesHeaderValue, AsAddresses, AsDate, AsGroupedAddresses, AsMessageIds, AsRaw, AsText, AsURLs, Attachment, BlobId, Charset, ClientCid, ClientEmailBodyValue, ClientHtmlBody, ClientPartId, DateHeaderValue, DestroyIds, Disposition, EmailAddress, EmailAddressGroup, EmailCreationRequest, EmailCreationResponse, EmailHeader, EmailHeaderName, EmailHeaderValue, EmailSetRequest, EmailSetResponse, EmailSetUpdate, EmailerName, GroupName, GroupedAddressesHeader [...]
+import org.apache.james.jmap.mail.{AddressesHeaderValue, AsAddresses, AsDate, AsGroupedAddresses, AsMessageIds, AsRaw, AsText, AsURLs, Attachment, BlobId, Charset, ClientBody, ClientCid, ClientEmailBodyValue, ClientPartId, DateHeaderValue, DestroyIds, Disposition, EmailAddress, EmailAddressGroup, EmailCreationRequest, EmailCreationResponse, EmailHeader, EmailHeaderName, EmailHeaderValue, EmailSetRequest, EmailSetResponse, EmailSetUpdate, EmailerName, GroupName, GroupedAddressesHeaderValu [...]
 import org.apache.james.mailbox.model.{MailboxId, MessageId}
 import play.api.libs.json.{Format, JsArray, JsBoolean, JsError, JsNull, JsObject, JsResult, JsString, JsSuccess, JsValue, Json, OWrites, Reads, Writes}
 
@@ -235,8 +235,8 @@ class EmailSetSerializer @Inject()(messageIdFactory: MessageId.Factory, mailboxI
   private implicit val clientEmailBodyValueReads: Reads[ClientEmailBodyValue] = Json.reads[ClientEmailBodyValue]
   private implicit val typeReads: Reads[Type] = Json.valueReads[Type]
   private implicit val clientPartIdReads: Reads[ClientPartId] = Json.valueReads[ClientPartId]
-  private val rawHTMLReads: Reads[ClientHtmlBody] = Json.reads[ClientHtmlBody]
-  private implicit val clientHtmlBodyReads: Reads[ClientHtmlBody] = {
+  private val rawHTMLReads: Reads[ClientBody] = Json.reads[ClientBody]
+  private implicit val clientHtmlBodyReads: Reads[ClientBody] = {
     case JsObject(underlying) if underlying.contains("charset") => JsError("charset must not be specified in htmlBody")
     case JsObject(underlying) if underlying.contains("size") => JsError("size must not be specified in htmlBody")
     case JsObject(underlying) if underlying.contains("header:Content-Transfer-Encoding:asText") => JsError("Content-Transfer-Encoding must not be specified in htmlBody")
@@ -252,22 +252,23 @@ class EmailSetSerializer @Inject()(messageIdFactory: MessageId.Factory, mailboxI
     }
 
   case class EmailCreationRequestWithoutHeaders(mailboxIds: MailboxIds,
-                                  messageId: Option[MessageIdsHeaderValue],
-                                  references: Option[MessageIdsHeaderValue],
-                                  inReplyTo: Option[MessageIdsHeaderValue],
-                                  from: Option[AddressesHeaderValue],
-                                  to: Option[AddressesHeaderValue],
-                                  cc: Option[AddressesHeaderValue],
-                                  bcc: Option[AddressesHeaderValue],
-                                  sender: Option[AddressesHeaderValue],
-                                  replyTo: Option[AddressesHeaderValue],
-                                  subject: Option[Subject],
-                                  sentAt: Option[UTCDate],
-                                  keywords: Option[Keywords],
-                                  receivedAt: Option[UTCDate],
-                                  htmlBody: Option[List[ClientHtmlBody]],
-                                  bodyValues: Option[Map[ClientPartId, ClientEmailBodyValue]],
-                                  attachments: Option[List[Attachment]]) {
+                                                messageId: Option[MessageIdsHeaderValue],
+                                                references: Option[MessageIdsHeaderValue],
+                                                inReplyTo: Option[MessageIdsHeaderValue],
+                                                from: Option[AddressesHeaderValue],
+                                                to: Option[AddressesHeaderValue],
+                                                cc: Option[AddressesHeaderValue],
+                                                bcc: Option[AddressesHeaderValue],
+                                                sender: Option[AddressesHeaderValue],
+                                                replyTo: Option[AddressesHeaderValue],
+                                                subject: Option[Subject],
+                                                sentAt: Option[UTCDate],
+                                                keywords: Option[Keywords],
+                                                receivedAt: Option[UTCDate],
+                                                htmlBody: Option[List[ClientBody]],
+                                                textBody: Option[List[ClientBody]],
+                                                bodyValues: Option[Map[ClientPartId, ClientEmailBodyValue]],
+                                                attachments: Option[List[Attachment]]) {
     def toCreationRequest(specificHeaders: List[EmailHeader]): EmailCreationRequest = EmailCreationRequest(
       mailboxIds = mailboxIds,
       messageId = messageId,
@@ -286,6 +287,7 @@ class EmailSetSerializer @Inject()(messageIdFactory: MessageId.Factory, mailboxI
       specificHeaders = specificHeaders,
       bodyValues = bodyValues,
       htmlBody = htmlBody,
+      textBody = textBody,
       attachments = attachments)
   }
 
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 dab3d2c..91a2fda 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
@@ -75,7 +75,7 @@ object SubType {
 
 case class ClientPartId(id: Id)
 
-case class ClientHtmlBody(partId: ClientPartId, `type`: Type)
+case class ClientBody(partId: ClientPartId, `type`: Type)
 
 case class ClientEmailBodyValue(value: String,
                                 isEncodingProblem: Option[IsEncodingProblem],
@@ -120,7 +120,8 @@ case class EmailCreationRequest(mailboxIds: MailboxIds,
                                 sentAt: Option[UTCDate],
                                 keywords: Option[Keywords],
                                 receivedAt: Option[UTCDate],
-                                htmlBody: Option[List[ClientHtmlBody]],
+                                htmlBody: Option[List[ClientBody]],
+                                textBody: Option[List[ClientBody]],
                                 bodyValues: Option[Map[ClientPartId, ClientEmailBodyValue]],
                                 specificHeaders: List[EmailHeader],
                                 attachments: Option[List[Attachment]]) {
@@ -129,39 +130,41 @@ case class EmailCreationRequest(mailboxIds: MailboxIds,
                       htmlTextExtractor: HtmlTextExtractor,
                       mailboxSession: MailboxSession): Either[Exception, Message] =
     validateHtmlBody
-      .flatMap(maybeHtmlBody => {
-        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)
-        messageId.flatMap(_.asString).map(new RawField(FieldName.MESSAGE_ID, _)).foreach(builder.setField)
-        subject.foreach(value => builder.setSubject(value.value))
-        from.flatMap(_.asMime4JMailboxList).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)
-        sentAt.map(_.asUTC).map(_.toInstant).map(Date.from).foreach(builder.setDate)
-        validateSpecificHeaders(builder)
-          .flatMap(_ => {
-            specificHeaders.map(_.asField).foreach(builder.addField)
-            attachments.filter(_.nonEmpty).map(attachments =>
-              createMultipartWithAttachments(maybeHtmlBody, attachments, attachmentManager, attachmentContentLoader, htmlTextExtractor, mailboxSession)
-                .map(multipartBuilder => {
-                  builder.setBody(multipartBuilder)
-                  builder.build
-                }))
-              .getOrElse({
-                builder.setBody(createAlternativeBody(maybeHtmlBody, htmlTextExtractor))
-                Right(builder.build)
-              })
-          })
-      })
-
-  private def createAlternativeBody(htmlBody: Option[String], htmlTextExtractor: HtmlTextExtractor): MultipartBuilder = {
+      .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)
+          messageId.flatMap(_.asString).map(new RawField(FieldName.MESSAGE_ID, _)).foreach(builder.setField)
+          subject.foreach(value => builder.setSubject(value.value))
+          from.flatMap(_.asMime4JMailboxList).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)
+          sentAt.map(_.asUTC).map(_.toInstant).map(Date.from).foreach(builder.setDate)
+          validateSpecificHeaders(builder)
+            .flatMap(_ => {
+              specificHeaders.map(_.asField).foreach(builder.addField)
+              attachments.filter(_.nonEmpty).map(attachments =>
+                createMultipartWithAttachments(maybeHtmlBody, maybeTextBody, attachments, attachmentManager, attachmentContentLoader, htmlTextExtractor, mailboxSession)
+                  .map(multipartBuilder => {
+                    builder.setBody(multipartBuilder)
+                    builder.build
+                  }))
+                .getOrElse({
+                  builder.setBody(createAlternativeBody(maybeHtmlBody, maybeTextBody, htmlTextExtractor))
+                  Right(builder.build)
+                })
+            })
+      }
+
+  private def createAlternativeBody(htmlBody: Option[String], textBody: Option[String], htmlTextExtractor: HtmlTextExtractor): MultipartBuilder = {
     val alternativeBuilder: MultipartBuilder = MultipartBuilder.create(SubType.ALTERNATIVE_SUBTYPE)
-    addBodypart(alternativeBuilder, htmlBody.getOrElse(""), HTML_UTF_8, StandardCharsets.UTF_8)
-    addBodypart(alternativeBuilder, htmlTextExtractor.toPlainText(htmlBody.getOrElse("")), PLAIN_TEXT_UTF_8, StandardCharsets.UTF_8)
+    htmlBody.foreach(text => addBodypart(alternativeBuilder, text, HTML_UTF_8, StandardCharsets.UTF_8))
+    addBodypart(alternativeBuilder, textBody.getOrElse(htmlTextExtractor.toPlainText(htmlBody.getOrElse(""))), PLAIN_TEXT_UTF_8, StandardCharsets.UTF_8)
 
     alternativeBuilder
   }
@@ -173,6 +176,7 @@ case class EmailCreationRequest(mailboxIds: MailboxIds,
       .setContentTransferEncoding("quoted-printable"))
 
   private def createMultipartWithAttachments(maybeHtmlBody: Option[String],
+                                             maybeTextBody: Option[String],
                                              attachments: List[Attachment],
                                              attachmentManager: AttachmentManager,
                                              attachmentContentLoader: AttachmentContentLoader,
@@ -188,20 +192,21 @@ case class EmailCreationRequest(mailboxIds: MailboxIds,
     maybeAttachments.map(list => {
 
       (list.filter(_._1.isInline), list.filter(!_._1.isInline)) match {
-        case (Nil, normalAttachments) => createMixedBody(maybeHtmlBody, normalAttachments, htmlTextExtractor)
-        case (inlineAttachments, Nil) => createRelatedBody(maybeHtmlBody, inlineAttachments, htmlTextExtractor)
-        case (inlineAttachments, normalAttachments) => createMixedRelatedBody(maybeHtmlBody, inlineAttachments, normalAttachments, htmlTextExtractor)
+        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)
       }
     })
   }
 
   private def createMixedRelatedBody(maybeHtmlBody: Option[String],
+                                     maybeTextBody: Option[String],
                                      inlineAttachments: List[(Attachment, AttachmentMetadata, Array[Byte])],
                                      normalAttachments: List[(Attachment, AttachmentMetadata, Array[Byte])],
                                      htmlTextExtractor: HtmlTextExtractor) = {
     val mixedMultipartBuilder = MultipartBuilder.create(SubType.MIXED_SUBTYPE)
     val relatedMultipartBuilder = MultipartBuilder.create(SubType.RELATED_SUBTYPE)
-    relatedMultipartBuilder.addBodyPart(BodyPartBuilder.create().setBody(createAlternativeBody(maybeHtmlBody, htmlTextExtractor).build))
+    relatedMultipartBuilder.addBodyPart(BodyPartBuilder.create().setBody(createAlternativeBody(maybeHtmlBody, maybeTextBody, htmlTextExtractor).build))
     inlineAttachments.foldLeft(relatedMultipartBuilder) {
       case (acc, (attachment, storedMetadata, content)) =>
         acc.addBodyPart(toBodypartBuilder(attachment, storedMetadata, content))
@@ -217,9 +222,9 @@ case class EmailCreationRequest(mailboxIds: MailboxIds,
     }
   }
 
-  private def createMixedBody(maybeHtmlBody: Option[String], normalAttachments: List[(Attachment, AttachmentMetadata, Array[Byte])], htmlTextExtractor: HtmlTextExtractor) = {
+  private def createMixedBody(maybeHtmlBody: Option[String], maybeTextBody: Option[String], normalAttachments: List[(Attachment, AttachmentMetadata, Array[Byte])], htmlTextExtractor: HtmlTextExtractor) = {
     val mixedMultipartBuilder = MultipartBuilder.create(SubType.MIXED_SUBTYPE)
-    mixedMultipartBuilder.addBodyPart(BodyPartBuilder.create().setBody(createAlternativeBody(maybeHtmlBody, htmlTextExtractor).build))
+    mixedMultipartBuilder.addBodyPart(BodyPartBuilder.create().setBody(createAlternativeBody(maybeHtmlBody, maybeTextBody, htmlTextExtractor).build))
     normalAttachments.foldLeft(mixedMultipartBuilder) {
       case (acc, (attachment, storedMetadata, content)) =>
         acc.addBodyPart(toBodypartBuilder(attachment, storedMetadata, content))
@@ -227,9 +232,9 @@ case class EmailCreationRequest(mailboxIds: MailboxIds,
     }
   }
 
-  private def createRelatedBody(maybeHtmlBody: Option[String], inlineAttachments: List[(Attachment, AttachmentMetadata, Array[Byte])], htmlTextExtractor: HtmlTextExtractor) = {
+  private def createRelatedBody(maybeHtmlBody: Option[String], maybeTextBody: Option[String], inlineAttachments: List[(Attachment, AttachmentMetadata, Array[Byte])], htmlTextExtractor: HtmlTextExtractor) = {
     val relatedMultipartBuilder = MultipartBuilder.create(SubType.RELATED_SUBTYPE)
-    relatedMultipartBuilder.addBodyPart(BodyPartBuilder.create().setBody(createAlternativeBody(maybeHtmlBody, htmlTextExtractor).build))
+    relatedMultipartBuilder.addBodyPart(BodyPartBuilder.create().setBody(createAlternativeBody(maybeHtmlBody, maybeTextBody, htmlTextExtractor).build))
     inlineAttachments.foldLeft(relatedMultipartBuilder) {
       case (acc, (attachment, storedMetadata, content)) =>
         acc.addBodyPart(toBodypartBuilder(attachment, storedMetadata, content))
@@ -291,16 +296,27 @@ case class EmailCreationRequest(mailboxIds: MailboxIds,
   def validateHtmlBody: Either[IllegalArgumentException, Option[String]] = 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) => bodyValues.getOrElse(Map())
-      .get(html.partId)
+    case Some(html :: Nil) => retrieveCorrespondingBody(html.partId)
+      .getOrElse(Left(new IllegalArgumentException("Expecting bodyValues to contain the part specified in htmlBody")))
+    case _ => Left(new IllegalArgumentException("Expecting htmlBody to contains only 1 part"))
+  }
+
+  def validateTextBody: Either[IllegalArgumentException, Option[String]] = textBody match {
+    case None => Right(None)
+    case Some(text :: Nil) if !text.`type`.value.equals("text/plain") => Left(new IllegalArgumentException("Expecting htmlBody type to be text/html"))
+    case Some(text :: Nil) => retrieveCorrespondingBody(text.partId)
+      .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 retrieveCorrespondingBody(partId: ClientPartId): Option[Either[IllegalArgumentException, Some[String]]] =
+    bodyValues.getOrElse(Map())
+      .get(partId)
       .map {
         case part if part.isTruncated.isDefined && part.isTruncated.get.value.equals(true) => Left(new IllegalArgumentException("Expecting isTruncated to be false"))
         case part if part.isEncodingProblem.isDefined && part.isEncodingProblem.get.value.equals(true) => Left(new IllegalArgumentException("Expecting isEncodingProblem to be false"))
         case part => Right(Some(part.value))
       }
-      .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 validateSpecificHeaders(message: Message.Builder): Either[IllegalArgumentException, Unit] = {
     specificHeaders.map(header => {


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@james.apache.org
For additional commands, e-mail: notifications-help@james.apache.org