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 11ae5320f1ddb9fcfdc7cb1029ddf6758ad21d45
Author: Benoit Tellier <[email protected]>
AuthorDate: Fri Oct 30 14:10:01 2020 +0700

    JAMES-3436 Email/set create should support specific headers
    
    This enables for instance to request read receipts
    
    The following restrictions applies:
    
     - There MUST NOT be two properties that represent the same header field
        (e.g., header:from and from) within the Email or particular 
EmailBodyPart.
     - Header fields MUST NOT be specified in parsed forms that are forbidden 
for that particular field.
     - Header fields beginning with Content- MUST NOT be specified on the Email 
object, only on EmailBodyPart objects.
---
 .../rfc8621/contract/EmailSetMethodContract.scala  | 250 +++++++++++++++++++++
 .../james/jmap/json/EmailSetSerializer.scala       | 118 +++++++++-
 .../scala/org/apache/james/jmap/mail/Email.scala   |  13 ++
 .../apache/james/jmap/mail/EmailAddressGroup.scala |  11 +-
 .../org/apache/james/jmap/mail/EmailGet.scala      |  15 +-
 .../org/apache/james/jmap/mail/EmailHeader.scala   |  71 ++++--
 .../org/apache/james/jmap/mail/EmailSet.scala      |  53 +++--
 7 files changed, 492 insertions(+), 39 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 4fa93d0..f800f45 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
@@ -128,6 +128,256 @@ trait EmailSetMethodContract {
       """.stripMargin, messageId.serialize))
   }
 
+  @ParameterizedTest
+  @ValueSource(strings = Array(
+    """"header:aheader": " a value"""",
+    """"header:aheader:asRaw": " a value"""",
+    """"header:aheader:asText": "a value"""",
+    """"header:aheader:asDate": "2020-10-29T06:39:04Z"""",
+    """"header:aheader:asAddresses": [{"email": "[email protected]"}, {"email": 
"[email protected]"}]""",
+    """"header:aheader:asURLs": ["url1", "url2"]""",
+    """"header:aheader:asMessageIds": ["[email protected]", 
"[email protected]"]""",
+    """"header:aheader:asGroupedAddresses": [{"name": null,"addresses": 
[{"name": "user1","email": "[email protected]" },{"name": "user2", "email": 
"[email protected]"}]},{"name": "Friends","addresses": [{"email": 
"[email protected]"},{"name": "user4","email": "[email protected]"}]}]"""
+  ))
+  def createShouldPositionSpecificHeaders(specificHeader: String, server: 
GuiceJamesServer): Unit = {
+    val bobPath = MailboxPath.inbox(BOB)
+    val mailboxId = 
server.getProbe(classOf[MailboxProbeImpl]).createMailbox(bobPath)
+    val propertyEnd = specificHeader.substring(1).indexOf('"')
+    val property = specificHeader.substring(1, propertyEnd + 1)
+
+    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
+         |          },
+         |          $specificHeader
+         |        }
+         |      }
+         |    }, "c1"],
+         |    ["Email/get",
+         |     {
+         |       "accountId": "$ACCOUNT_ID",
+         |       "ids": ["#aaaaaa"],
+         |       "properties": ["mailboxIds", "$property"]
+         |     },
+         |     "c2"]]
+         |}""".stripMargin
+
+    val response = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .whenIgnoringPaths("methodResponses[0][1].created.aaaaaa.id")
+      .inPath("methodResponses[0][1].created.aaaaaa")
+      .isEqualTo("{}".stripMargin)
+
+    assertThatJson(response)
+      .whenIgnoringPaths("methodResponses[1][1].list[0].id")
+      .inPath(s"methodResponses[1][1].list")
+      .isEqualTo(s"""[{
+                    |  "mailboxIds": {
+                    |    "${mailboxId.serialize}": true
+                    |  },
+                    |  $specificHeader
+                    |}]""".stripMargin)
+  }
+
+  @Test
+  def specificHeaderShouldMatchSupportedType(server: GuiceJamesServer): Unit = 
{
+    val bobPath = MailboxPath.inbox(BOB)
+    val mailboxId = 
server.getProbe(classOf[MailboxProbeImpl]).createMailbox(bobPath)
+
+    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
+         |          },
+         |          "header:To:asMessageId": ["[email protected]"]
+         |        }
+         |      }
+         |    }, "c1"]]
+         |}""".stripMargin
+
+    val response = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .inPath("methodResponses[0][1].notCreated")
+      .isEqualTo(
+        s"""{
+           |  "aaaaaa": {
+           |    "type": "invalidArguments",
+           |    "description": 
"List((,List(JsonValidationError(List(header:To:asMessageId is an invalid 
specific header),ArraySeq()))))"
+           |  }
+           |}""".stripMargin)
+  }
+
+  @Test
+  def specificHeadersCannotOverrideConvenienceHeader(server: 
GuiceJamesServer): Unit = {
+    val bobPath = MailboxPath.inbox(BOB)
+    val mailboxId = 
server.getProbe(classOf[MailboxProbeImpl]).createMailbox(bobPath)
+
+    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
+         |          },
+         |          "header:To:asAddresses": [{"email": "[email protected]"}, 
{"email": "[email protected]"}],
+         |          "to": [{"email": "[email protected]"}, {"email": 
"[email protected]"}]
+         |        }
+         |      }
+         |    }, "c1"]]
+         |}""".stripMargin
+
+    val response = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .inPath("methodResponses[0][1].notCreated")
+      .isEqualTo(
+        s"""{
+           |  "aaaaaa": {
+           |    "type": "invalidArguments",
+           |    "description": "To was already defined by convenience headers"
+           |  }
+           |}""".stripMargin)
+  }
+
+  @Test
+  def createShouldFailWhenBadJsonPayloadForSpecificHeader(server: 
GuiceJamesServer): Unit = {
+    val bobPath = MailboxPath.inbox(BOB)
+    val mailboxId = 
server.getProbe(classOf[MailboxProbeImpl]).createMailbox(bobPath)
+
+    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
+         |          },
+         |          "header:To:asAddresses": "invalid"
+         |        }
+         |      }
+         |    }, "c1"]]
+         |}""".stripMargin
+
+    val response = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .inPath("methodResponses[0][1].notCreated")
+      .isEqualTo(
+        s"""{
+           |  "aaaaaa": {
+           |    "type": "invalidArguments",
+           |    "description": 
"List((,List(JsonValidationError(List(List((,List(JsonValidationError(List(error.expected.jsarray),ArraySeq()))))),ArraySeq()))))"
+           |  }
+           |}""".stripMargin)
+  }
+
+  @Test
+  def specificContentHeadersShouldBeRejected(server: GuiceJamesServer): Unit = 
{
+    val bobPath = MailboxPath.inbox(BOB)
+    val mailboxId = 
server.getProbe(classOf[MailboxProbeImpl]).createMailbox(bobPath)
+
+    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
+         |          },
+         |          "header:Content-Type:asText": "text/plain"
+         |        }
+         |      }
+         |    }, "c1"]]
+         |}""".stripMargin
+
+    val response = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .inPath("methodResponses[0][1].notCreated")
+      .isEqualTo(
+        s"""{
+           |  "aaaaaa": {
+           |    "type": "invalidArguments",
+           |    "description": "Header fields beginning with `Content-` MUST 
NOT be specified on the Email object, only on EmailBodyPart objects."
+           |  }
+           |}""".stripMargin)
+  }
+
   @Test
   def shouldNotResetKeywordWhenFalseValue(server: GuiceJamesServer): Unit = {
     val message: Message = Fixture.createTestMessage
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 3113b86..3d455b5 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
@@ -20,13 +20,15 @@
 package org.apache.james.jmap.json
 
 import cats.implicits._
+import eu.timepit.refined.collection.NonEmpty
 import eu.timepit.refined.refineV
+import eu.timepit.refined.types.string.NonEmptyString
 import javax.inject.Inject
 import org.apache.james.jmap.core.Id.IdConstraint
-import org.apache.james.jmap.core.{Id, SetError}
+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, ClientEmailBodyValue, 
ClientHtmlBody, ClientPartId, DestroyIds, EmailAddress, EmailCreationRequest, 
EmailCreationResponse, EmailSetRequest, EmailSetResponse, EmailSetUpdate, 
EmailerName, HeaderMessageId, IsEncodingProblem, IsTruncated, Keyword, 
Keywords, MailboxIds, MessageIdsHeaderValue, Subject, Type}
+import org.apache.james.jmap.mail.{AddressesHeaderValue, AsAddresses, AsDate, 
AsGroupedAddresses, AsMessageIds, AsRaw, AsText, AsURLs, ClientEmailBodyValue, 
ClientHtmlBody, ClientPartId, DateHeaderValue, DestroyIds, EmailAddress, 
EmailAddressGroup, EmailCreationRequest, EmailCreationResponse, EmailHeader, 
EmailHeaderName, EmailHeaderValue, EmailSetRequest, EmailSetResponse, 
EmailSetUpdate, EmailerName, GroupName, GroupedAddressesHeaderValue, 
HeaderMessageId, HeaderURL, IsEncodingProblem, [...]
 import org.apache.james.mailbox.model.{MailboxId, MessageId}
 import play.api.libs.json.{JsArray, JsBoolean, JsError, JsNull, JsObject, 
JsResult, JsString, JsSuccess, JsValue, Json, OWrites, Reads, Writes}
 
@@ -260,7 +262,117 @@ class EmailSetSerializer @Inject()(messageIdFactory: 
MessageId.Factory, mailboxI
     readMapEntry[ClientPartId, ClientEmailBodyValue](s => 
Id.validate(s).fold(e => Left(e.getMessage), partId => 
Right(ClientPartId(partId))),
       clientEmailBodyValueReads)
 
-  private implicit val emailCreationRequestReads: Reads[EmailCreationRequest] 
= Json.reads[EmailCreationRequest]
+  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]]) {
+    def toCreationRequest(specificHeaders: List[EmailHeader]): 
EmailCreationRequest = EmailCreationRequest(
+      mailboxIds = mailboxIds,
+      messageId = messageId,
+      references = references,
+      inReplyTo = inReplyTo,
+      from = from,
+      to = to,
+      cc = cc,
+      bcc = bcc,
+      sender = sender,
+      replyTo = replyTo,
+      subject = subject,
+      sentAt = sentAt,
+      keywords = keywords,
+      receivedAt = receivedAt,
+      specificHeaders = specificHeaders,
+      bodyValues = bodyValues,
+      htmlBody = htmlBody)
+  }
+
+  private implicit val headerUrlReads: Reads[HeaderURL] = 
Json.valueReads[HeaderURL]
+  private implicit val groupNameReads: Reads[GroupName] = 
Json.valueReads[GroupName]
+  private implicit val groupReads: Reads[EmailAddressGroup] = 
Json.reads[EmailAddressGroup]
+
+  private implicit val dateReads: Reads[DateHeaderValue] = {
+    case JsNull => JsSuccess(DateHeaderValue(None))
+    case json: JsValue => UTCDateReads.reads(json).map(date => 
DateHeaderValue(Some(date)))
+  }
+
+  sealed trait HeaderValueReads extends Reads[EmailHeaderValue]
+  case object RawReads extends HeaderValueReads {
+    val rawReads: Reads[RawHeaderValue] = Json.valueReads[RawHeaderValue]
+    override def reads(json: JsValue): JsResult[EmailHeaderValue] = 
rawReads.reads(json)
+  }
+  case object TextReads extends HeaderValueReads {
+    val textReads: Reads[TextHeaderValue] = Json.valueReads[TextHeaderValue]
+    override def reads(json: JsValue): JsResult[TextHeaderValue] = 
textReads.reads(json)
+  }
+  case object AddressesReads extends HeaderValueReads {
+    override def reads(json: JsValue): JsResult[AddressesHeaderValue] = 
addressesHeaderValueReads.reads(json)
+  }
+  case object DateReads extends HeaderValueReads {
+    override def reads(json: JsValue): JsResult[DateHeaderValue] = 
dateReads.reads(json)
+  }
+  case object MessageIdReads extends HeaderValueReads {
+    override def reads(json: JsValue): JsResult[MessageIdsHeaderValue] = 
messageIdsHeaderValueReads.reads(json)
+  }
+  case object URLReads extends HeaderValueReads {
+    val urlsReads: Reads[URLsHeaderValue] = {
+      case JsNull => JsSuccess(URLsHeaderValue(None))
+      case JsArray(value) => 
value.map(headerUrlReads.reads).map(_.asEither).toList.sequence
+        .fold(e => JsError(e), urls => JsSuccess(URLsHeaderValue(Some(urls))))
+      case _ => JsError("Expecting a JsArray")
+    }
+    override def reads(json: JsValue): JsResult[URLsHeaderValue] = 
urlsReads.reads(json)
+  }
+  case object GroupedAddressReads extends HeaderValueReads {
+    val groupsReads: Reads[GroupedAddressesHeaderValue] = 
Json.valueReads[GroupedAddressesHeaderValue]
+    override def reads(json: JsValue): JsResult[GroupedAddressesHeaderValue] = 
groupsReads.reads(json)
+  }
+
+  def asReads(parseOption: ParseOption): Reads[EmailHeaderValue] = parseOption 
match {
+      case AsRaw => RawReads
+      case AsText => TextReads
+      case AsAddresses => AddressesReads
+      case AsDate => DateReads
+      case AsMessageIds => MessageIdReads
+      case AsURLs => URLReads
+      case AsGroupedAddresses => GroupedAddressReads
+    }
+
+  private implicit val emailCreationRequestWithoutHeadersReads: 
Reads[EmailCreationRequestWithoutHeaders] = 
Json.reads[EmailCreationRequestWithoutHeaders]
+  private implicit val emailCreationRequestReads: Reads[EmailCreationRequest] 
= {
+    case o: JsObject =>
+      val withoutHeader = emailCreationRequestWithoutHeadersReads.reads(o)
+
+      val specificHeadersEither: Either[IllegalArgumentException, 
List[EmailHeader]] = o.value.toList
+        .filter {
+          case (name, _) => name.startsWith("header:")
+        }.map {
+          case (name, value) =>
+            val refinedName: Either[String, NonEmptyString] = 
refineV[NonEmpty](name)
+            refinedName.left.map(e => new IllegalArgumentException(e))
+              .flatMap(property => SpecificHeaderRequest.from(property)
+                .left.map(_ => new IllegalArgumentException(s"$name is an 
invalid specific header")))
+              .flatMap(_.validate)
+              .flatMap(specificHeaderRequest => 
asReads(specificHeaderRequest.parseOption.getOrElse(AsRaw))
+                .reads(value).asEither.left.map(e => new 
IllegalArgumentException(e.toString()))
+                .map(headerValue => 
EmailHeader(EmailHeaderName(specificHeaderRequest.headerName), headerValue)))
+        }.sequence
+
+      specificHeadersEither.fold(e => JsError(e.getMessage),
+        specificHeaders => 
withoutHeader.map(_.toCreationRequest(specificHeaders)))
+    case _ => JsError("Expecting a JsObject to represent a creation request")
+  }
 
   def deserialize(input: JsValue): JsResult[EmailSetRequest] = 
Json.fromJson[EmailSetRequest](input)
 
diff --git 
a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/Email.scala
 
b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/Email.scala
index bf91c0f..e978537 100644
--- 
a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/Email.scala
+++ 
b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/Email.scala
@@ -37,6 +37,7 @@ import 
org.apache.james.jmap.api.projections.{MessageFastViewPrecomputedProperti
 import org.apache.james.jmap.core.{Properties, UTCDate}
 import org.apache.james.jmap.mail.BracketHeader.sanitize
 import org.apache.james.jmap.mail.Email.{Size, sanitizeSize}
+import org.apache.james.jmap.mail.EmailHeaderName.{ADDRESSES_NAMES, DATE, 
MESSAGE_ID_NAMES}
 import org.apache.james.jmap.mail.KeywordsFactory.LENIENT_KEYWORDS_FACTORY
 import org.apache.james.jmap.method.ZoneIdProvider
 import org.apache.james.mailbox.model.FetchGroup.{FULL_CONTENT, HEADERS, 
MINIMAL}
@@ -184,6 +185,8 @@ object ParseOptions {
 
 sealed trait ParseOption {
   def extractHeaderValue(field: Field): EmailHeaderValue
+
+  def forbiddenHeaderNames: Set[EmailHeaderName] = Set()
 }
 case object AsRaw extends ParseOption {
   override def extractHeaderValue(field: Field): EmailHeaderValue = 
RawHeaderValue.from(field)
@@ -193,20 +196,30 @@ case object AsText extends ParseOption {
 }
 case object AsAddresses extends ParseOption {
   override def extractHeaderValue(field: Field): EmailHeaderValue = 
AddressesHeaderValue.from(field)
+
+  override def forbiddenHeaderNames: Set[EmailHeaderName] = MESSAGE_ID_NAMES + 
DATE
 }
 case object AsGroupedAddresses extends ParseOption {
   override def extractHeaderValue(field: Field): EmailHeaderValue = 
GroupedAddressesHeaderValue.from(field)
+
+  override def forbiddenHeaderNames: Set[EmailHeaderName] = MESSAGE_ID_NAMES + 
DATE
 }
 case object AsMessageIds extends ParseOption {
   override def extractHeaderValue(field: Field): EmailHeaderValue = 
MessageIdsHeaderValue.from(field)
+
+  override def forbiddenHeaderNames: Set[EmailHeaderName] = ADDRESSES_NAMES + 
DATE
 }
 case object AsDate extends ParseOption {
   override def extractHeaderValue(field: Field): EmailHeaderValue = 
DateHeaderValue.from(field, ZoneId.systemDefault())
 
   def extractHeaderValue(field: Field, zoneId: ZoneId): EmailHeaderValue = 
DateHeaderValue.from(field, zoneId)
+
+  override def forbiddenHeaderNames: Set[EmailHeaderName] = ADDRESSES_NAMES ++ 
MESSAGE_ID_NAMES
 }
 case object AsURLs extends ParseOption {
   override def extractHeaderValue(field: Field): EmailHeaderValue = 
URLsHeaderValue.from(field)
+
+  override def forbiddenHeaderNames: Set[EmailHeaderName] = ADDRESSES_NAMES ++ 
MESSAGE_ID_NAMES + DATE
 }
 
 case class HeaderMessageId(value: String) extends AnyVal
diff --git 
a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailAddressGroup.scala
 
b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailAddressGroup.scala
index b423996..e363bd6 100644
--- 
a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailAddressGroup.scala
+++ 
b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailAddressGroup.scala
@@ -19,6 +19,15 @@
 
 package org.apache.james.jmap.mail
 
+import org.apache.james.mime4j.dom.address.{Address, Group, MailboxList}
+
+import scala.jdk.CollectionConverters._
+
 case class GroupName(value: String) extends AnyVal
 
-case class EmailAddressGroup(name: Option[GroupName], addresses: 
List[EmailAddress])
+case class EmailAddressGroup(name: Option[GroupName], addresses: 
List[EmailAddress]) {
+  val asAddress: List[Address] = name.map(aName => new Group(aName.value,
+    new MailboxList(addresses.map(_.asMime4JMailbox).asJava)))
+    .map(List(_))
+    .getOrElse(addresses.map(_.asMime4JMailbox))
+}
diff --git 
a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailGet.scala
 
b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailGet.scala
index fce39f5..ae59d9e 100644
--- 
a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailGet.scala
+++ 
b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailGet.scala
@@ -86,13 +86,13 @@ case class EmailGetResponse(accountId: AccountId,
                             list: List[EmailView],
                             notFound: EmailNotFound)
 
-case class SpecificHeaderRequest(headerName: NonEmptyString, property: String, 
parseOption: Option[ParseOption]) {
+case class SpecificHeaderRequest(property: NonEmptyString, headerName: String, 
parseOption: Option[ParseOption]) {
   def retrieveHeader(zoneId: ZoneId, message: Message): (String, 
Option[EmailHeaderValue]) = {
-    val field: Option[Field] = Option(message.getHeader.getFields(property))
+    val field: Option[Field] = Option(message.getHeader.getFields(headerName))
       .map(_.asScala)
       .flatMap(fields => fields.reverse.headOption)
 
-    (headerName, field.map({
+    (property.value, field.map({
       val option = parseOption.getOrElse(AsRaw)
         option match {
           case AsDate => AsDate.extractHeaderValue(_, zoneId)
@@ -100,4 +100,13 @@ case class SpecificHeaderRequest(headerName: 
NonEmptyString, property: String, p
         }
     }))
   }
+
+  def validate: Either[IllegalArgumentException, SpecificHeaderRequest] = {
+    val forbiddenNames = 
parseOption.map(_.forbiddenHeaderNames).getOrElse(Set())
+    if (forbiddenNames.contains(EmailHeaderName(headerName))) {
+      Left(new IllegalArgumentException(s"$property is forbidden with 
$parseOption"))
+    } else {
+      scala.Right(this)
+    }
+  }
 }
diff --git 
a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailHeader.scala
 
b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailHeader.scala
index 7330fc7..a1d3ade 100644
--- 
a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailHeader.scala
+++ 
b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailHeader.scala
@@ -21,13 +21,14 @@ package org.apache.james.jmap.mail
 
 import java.nio.charset.StandardCharsets.US_ASCII
 import java.time.ZoneId
+import java.util.Date
 
 import org.apache.commons.lang3.StringUtils
 import org.apache.james.jmap.core.UTCDate
 import org.apache.james.mime4j.codec.{DecodeMonitor, DecoderUtil}
 import org.apache.james.mime4j.dom.address.{AddressList, Group, Address => 
Mime4jAddress, Mailbox => Mime4jMailbox}
-import org.apache.james.mime4j.field.{AddressListFieldImpl, 
ContentLocationFieldImpl, DateTimeFieldImpl}
-import org.apache.james.mime4j.stream.Field
+import org.apache.james.mime4j.field.{AddressListFieldImpl, 
ContentLocationFieldImpl, DateTimeFieldImpl, Fields}
+import org.apache.james.mime4j.stream.{Field, RawField}
 import org.apache.james.mime4j.util.MimeUtil
 
 import scala.jdk.CollectionConverters._
@@ -36,19 +37,19 @@ object EmailHeader {
   def apply(field: Field): EmailHeader = 
EmailHeader(EmailHeaderName(field.getName), RawHeaderValue.from(field))
 }
 
-object RawHeaderValue extends EmailHeaderValue {
+object RawHeaderValue {
   def from(field: Field): RawHeaderValue = RawHeaderValue(new 
String(field.getRaw.toByteArray, US_ASCII).substring(field.getName.length + 1))
 }
 
-object TextHeaderValue extends EmailHeaderValue {
+object TextHeaderValue {
   def from(field: Field): TextHeaderValue = 
TextHeaderValue(MimeUtil.unfold(DecoderUtil.decodeEncodedWords(field.getBody, 
DecodeMonitor.SILENT)).stripLeading())
 }
 
-object AddressesHeaderValue extends EmailHeaderValue {
+object AddressesHeaderValue {
   def from(field: Field): AddressesHeaderValue = 
AddressesHeaderValue(EmailAddress.from(AddressListFieldImpl.PARSER.parse(field, 
DecodeMonitor.SILENT).getAddressList))
 }
 
-object GroupedAddressesHeaderValue extends EmailHeaderValue {
+object GroupedAddressesHeaderValue {
   def from(field: Field): GroupedAddressesHeaderValue = {
     val addresses: List[Mime4jAddress] =
       Option(AddressListFieldImpl.PARSER.parse(field, 
DecodeMonitor.SILENT).getAddressList)
@@ -95,14 +96,14 @@ object MessageIdsHeaderValue {
   }
 }
 
-object DateHeaderValue extends EmailHeaderValue {
+object DateHeaderValue {
   def from(field: Field, zoneId: ZoneId): DateHeaderValue =
     Option(DateTimeFieldImpl.PARSER.parse(field, DecodeMonitor.SILENT).getDate)
       .map(date => DateHeaderValue(Some(UTCDate.from(date, zoneId))))
       .getOrElse(DateHeaderValue(None))
 }
 
-object URLsHeaderValue extends EmailHeaderValue {
+object URLsHeaderValue {
   def from(field: Field): URLsHeaderValue = {
     val url: Option[List[HeaderURL]] = 
Option(ContentLocationFieldImpl.PARSER.parse(field, 
DecodeMonitor.SILENT).getLocation)
       .map(urls => urls.split(',')
@@ -119,22 +120,62 @@ object URLsHeaderValue extends EmailHeaderValue {
   }
 }
 
+object EmailHeaderName {
+  val DATE: EmailHeaderName = EmailHeaderName("Date")
+  val TO: EmailHeaderName = EmailHeaderName("To")
+  val FROM: EmailHeaderName = EmailHeaderName("From")
+  val CC: EmailHeaderName = EmailHeaderName("Cc")
+  val BCC: EmailHeaderName = EmailHeaderName("Bcc")
+  val SENDER: EmailHeaderName = EmailHeaderName("Sender")
+  val REPLY_TO: EmailHeaderName = EmailHeaderName("Reply-To")
+  val REFERENCES: EmailHeaderName = EmailHeaderName("References")
+  val MESSAGE_ID: EmailHeaderName = EmailHeaderName("Message-Id")
+  val IN_REPLY_TO: EmailHeaderName = EmailHeaderName("In-Reply-To")
+
+  val MESSAGE_ID_NAMES = Set(REFERENCES, MESSAGE_ID, IN_REPLY_TO)
+  val ADDRESSES_NAMES = Set(SENDER, FROM, TO, CC, BCC, REPLY_TO)
+}
+
 case class EmailHeaderName(value: String) extends AnyVal
 
-sealed trait EmailHeaderValue
-case class RawHeaderValue(value: String) extends EmailHeaderValue
-case class TextHeaderValue(value: String) extends EmailHeaderValue
+sealed trait EmailHeaderValue {
+  def asField(name: EmailHeaderName): Field
+}
+case class RawHeaderValue(value: String) extends EmailHeaderValue {
+  override def asField(name: EmailHeaderName): Field = new 
RawField(name.value, value.substring(1))
+}
+case class TextHeaderValue(value: String) extends EmailHeaderValue {
+  override def asField(name: EmailHeaderName): Field = new 
RawField(name.value, value)
+}
 case class AddressesHeaderValue(value: List[EmailAddress]) extends 
EmailHeaderValue {
   def asMime4JMailboxList: Option[List[Mime4jMailbox]] = 
Some(value.map(_.asMime4JMailbox)).filter(_.nonEmpty)
+
+  override def asField(name: EmailHeaderName): Field = 
Fields.addressList(name.value, asMime4JMailboxList.getOrElse(List()).asJava)
+}
+case class GroupedAddressesHeaderValue(value: List[EmailAddressGroup]) extends 
EmailHeaderValue {
+  override def asField(name: EmailHeaderName): Field = 
Fields.addressList(name.value, value.flatMap(_.asAddress).asJava)
 }
-case class GroupedAddressesHeaderValue(value: List[EmailAddressGroup]) extends 
EmailHeaderValue
 case class MessageIdsHeaderValue(value: Option[List[HeaderMessageId]]) extends 
EmailHeaderValue {
   def asString: Option[String] = value.map(messageIds => messageIds
     .map(_.value)
     .map(messageId => s"<${messageId}>")
     .mkString(" "))
+
+  override def asField(name: EmailHeaderName): Field = new 
RawField(name.value, asString.getOrElse(""))
+}
+case class DateHeaderValue(value: Option[UTCDate]) extends EmailHeaderValue {
+  override def asField(name: EmailHeaderName): Field = Fields.date(name.value,
+    value.map(_.asUTC.toInstant)
+      .map(Date.from)
+      .orNull)
+}
+case class URLsHeaderValue(value: Option[List[HeaderURL]]) extends 
EmailHeaderValue {
+  override def asField(name: EmailHeaderName): Field = new RawField(name.value,
+    value
+      .map(list => list.map("<" + _.value + ">").mkString(", "))
+      .getOrElse(""))
 }
-case class DateHeaderValue(value: Option[UTCDate]) extends EmailHeaderValue
-case class URLsHeaderValue(value: Option[List[HeaderURL]]) extends 
EmailHeaderValue
 
-case class EmailHeader(name: EmailHeaderName, value: EmailHeaderValue)
+case class EmailHeader(name: EmailHeaderName, value: EmailHeaderValue) {
+  def asField: Field = value.asField(name)
+}
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 2176302..dacc307 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
@@ -21,6 +21,7 @@ package org.apache.james.jmap.mail
 import java.nio.charset.StandardCharsets
 import java.util.Date
 
+import cats.implicits._
 import eu.timepit.refined
 import eu.timepit.refined.api.Refined
 import eu.timepit.refined.collection.NonEmpty
@@ -76,24 +77,30 @@ case class EmailCreationRequest(mailboxIds: MailboxIds,
                                 keywords: Option[Keywords],
                                 receivedAt: Option[UTCDate],
                                 htmlBody: Option[List[ClientHtmlBody]],
-                                bodyValues: Option[Map[ClientPartId, 
ClientEmailBodyValue]]) {
+                                bodyValues: Option[Map[ClientPartId, 
ClientEmailBodyValue]],
+                                specificHeaders: List[EmailHeader]) {
   def toMime4JMessage: Either[IllegalArgumentException, Message] =
-    validateHtmlBody.map(maybeHtmlBody => {
-      val builder = Message.Builder.of
-      val htmlSubType = "html"
-      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)
-      builder.setBody(maybeHtmlBody.getOrElse(""), htmlSubType, 
StandardCharsets.UTF_8)
-      builder.build()
+    validateHtmlBody
+      .flatMap(maybeHtmlBody => {
+        val builder = Message.Builder.of
+        val htmlSubType = "html"
+        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)
+          .map(_ => {
+            specificHeaders.map(_.asField).foreach(builder.addField)
+            builder.setBody(maybeHtmlBody.getOrElse(""), htmlSubType, 
StandardCharsets.UTF_8)
+            builder.build()
+          })
     })
 
   def validateHtmlBody: Either[IllegalArgumentException, Option[String]] = 
htmlBody match {
@@ -109,6 +116,18 @@ case class EmailCreationRequest(mailboxIds: MailboxIds,
       .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 => {
+      if (Option(message.getField(header.name.value)).isDefined) {
+        Left(new IllegalArgumentException(s"${header.name.value} was already 
defined by convenience headers"))
+      } else if (header.name.value.startsWith("Content-")) {
+        Left(new IllegalArgumentException(s"Header fields beginning with 
`Content-` MUST NOT be specified on the Email object, only on EmailBodyPart 
objects."))
+      } else {
+        scala.Right(())
+      }
+    }).sequence.map(_ => ())
+  }
 }
 
 case class DestroyIds(value: Seq[UnparsedMessageId])


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to