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]
