This is an automated email from the ASF dual-hosted git repository. btellier pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/james-project.git
The following commit(s) were added to refs/heads/master by this push: new 3192f76b48 JAMES-3419 JMAP EmailBodyPart individual headers (#1433) 3192f76b48 is described below commit 3192f76b486f37be6f417103dfe7ab199dc13e4d Author: Benoit TELLIER <btell...@linagora.com> AuthorDate: Mon Feb 13 08:43:06 2023 +0700 JAMES-3419 JMAP EmailBodyPart individual headers (#1433) --- .../rfc8621/contract/EmailGetMethodContract.scala | 57 ++++++++++++++++++++++ .../james/jmap/json/EmailGetSerializer.scala | 9 ++-- .../scala/org/apache/james/jmap/mail/Email.scala | 16 +++--- .../org/apache/james/jmap/mail/EmailBodyPart.scala | 36 ++++++++------ .../org/apache/james/jmap/mail/EmailGet.scala | 14 +++--- .../apache/james/jmap/method/EmailGetMethod.scala | 14 ++++-- .../apache/james/jmap/routes/DownloadRoutes.scala | 10 ++-- 7 files changed, 115 insertions(+), 41 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/EmailGetMethodContract.scala b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailGetMethodContract.scala index bce366b985..e3d77f3573 100644 --- a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailGetMethodContract.scala +++ b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailGetMethodContract.scala @@ -3615,6 +3615,63 @@ trait EmailGetMethodContract { |}""".stripMargin) } + @Test + def bodyStructureShouldSupportSpecificHeaders(server: GuiceJamesServer): Unit = { + val path = MailboxPath.inbox(BOB) + server.getProbe(classOf[MailboxProbeImpl]).createMailbox(path) + val message: Message = Message.Builder + .of + .setSubject("test") + .setBody("testmail", StandardCharsets.UTF_8) + .build + val messageId: MessageId = server.getProbe(classOf[MailboxProbeImpl]) + .appendMessage(BOB.asString, path, AppendCommand.from(message)) + .getMessageId + + val request = + s"""{ + | "using": [ + | "urn:ietf:params:jmap:core", + | "urn:ietf:params:jmap:mail"], + | "methodCalls": [[ + | "Email/get", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "ids": ["${messageId.serialize}"], + | "properties":["bodyStructure"], + | "bodyProperties":["partId", "blobId", "size", "name", "type", "charset", "disposition", "cid", "header:Subject:asText"] + | }, + | "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) + .whenIgnoringPaths("methodResponses[0][1].state") + .inPath("methodResponses[0][1].list[0]") + .isEqualTo( + s"""{ + | "id": "${messageId.serialize}", + | "bodyStructure": { + | "header:Subject:asText": "test", + | "charset": "UTF-8", + | "size": 8, + | "partId": "1", + | "blobId": "1_1", + | "type": "text/plain" + | } + |}""".stripMargin) + } + @Test def bodyStructureForSimpleMultipart(server: GuiceJamesServer): Unit = { val path = MailboxPath.inbox(BOB) diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/EmailGetSerializer.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/EmailGetSerializer.scala index a07953d846..bb8f29ac81 100644 --- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/EmailGetSerializer.scala +++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/EmailGetSerializer.scala @@ -44,7 +44,8 @@ object EmailBodyPartToSerialize { language = part.language, location = part.location, name = part.name, - subParts = part.subParts.map(list => list.map(EmailBodyPartToSerialize.from))) + subParts = part.subParts.map(list => list.map(EmailBodyPartToSerialize.from)), + specificHeaders = part.specificHeaders) } case class EmailBodyPartToSerialize(partId: PartId, @@ -58,7 +59,8 @@ case class EmailBodyPartToSerialize(partId: PartId, cid: Option[Cid], language: Option[Languages], location: Option[Location], - subParts: Option[List[EmailBodyPartToSerialize]]) + subParts: Option[List[EmailBodyPartToSerialize]], + specificHeaders: Map[String, Option[EmailHeaderValue]]) object EmailGetSerializer { private implicit val mailboxIdWrites: Writes[MailboxId] = mailboxId => JsString(mailboxId.serialize) @@ -147,7 +149,8 @@ object EmailGetSerializer { (__ \ "cid").writeNullable[Cid] and (__ \ "language").writeNullable[Languages] and (__ \ "location").writeNullable[Location] and - (__ \ "subParts").lazyWriteNullable(implicitly[Writes[List[EmailBodyPartToSerialize]]]) + (__ \ "subParts").lazyWriteNullable(implicitly[Writes[List[EmailBodyPartToSerialize]]]) and + JsPath.write[Map[String, Option[EmailHeaderValue]]] )(unlift(EmailBodyPartToSerialize.unapply)) private implicit val bodyPartWrites: Writes[EmailBodyPart] = part => bodyPartWritesToSerializeWrites.writes(EmailBodyPartToSerialize.from(part)) 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 bc6c039658..decafec662 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 @@ -272,10 +272,10 @@ object EmailHeaders { sentAt = extractDate(mime4JMessage, "Date").map(date => UTCDate.from(date, zoneId))) } - def extractSpecificHeaders(properties: Option[Properties])(zoneId: ZoneId, mime4JMessage: Message) = { + def extractSpecificHeaders(properties: Option[Properties])(zoneId: ZoneId, header: org.apache.james.mime4j.dom.Header) = { properties.getOrElse(Properties.empty()).value .flatMap(property => SpecificHeaderRequest.from(property).toOption) - .map(_.retrieveHeader(zoneId, mime4JMessage)) + .map(_.retrieveHeader(zoneId, header)) .toMap } @@ -490,7 +490,7 @@ private class EmailHeaderViewFactory @Inject()(zoneIdProvider: ZoneIdProvider) e size = sanitizeSize(firstMessage.getSize), keywords = keywords), header = EmailHeaders.from(zoneIdProvider.get())(mime4JMessage), - specificHeaders = EmailHeaders.extractSpecificHeaders(request.properties)(zoneIdProvider.get(), mime4JMessage)) + specificHeaders = EmailHeaders.extractSpecificHeaders(request.properties)(zoneIdProvider.get(), mime4JMessage.getHeader)) } } } @@ -509,7 +509,7 @@ private class EmailFullViewFactory @Inject()(zoneIdProvider: ZoneIdProvider, pre .map(Success(_)) .getOrElse(Failure(new IllegalArgumentException("No message supplied"))) mime4JMessage <- Email.parseAsMime4JMessage(firstMessage) - bodyStructure <- EmailBodyPart.of(messageId, mime4JMessage) + bodyStructure <- EmailBodyPart.of(request.bodyProperties, zoneIdProvider.get(), messageId, mime4JMessage) bodyValues <- extractBodyValues(htmlTextExtractor)(bodyStructure, request) blobId <- BlobId.of(messageId) preview <- Try(previewFactory.fromMime4JMessage(mime4JMessage)) @@ -534,7 +534,7 @@ private class EmailFullViewFactory @Inject()(zoneIdProvider: ZoneIdProvider, pre htmlBody = bodyStructure.htmlBody, attachments = bodyStructure.attachments, bodyValues = bodyValues), - specificHeaders = EmailHeaders.extractSpecificHeaders(request.properties)(zoneIdProvider.get(), mime4JMessage)) + specificHeaders = EmailHeaders.extractSpecificHeaders(request.properties)(zoneIdProvider.get(), mime4JMessage.getHeader)) } } @@ -693,7 +693,7 @@ private class EmailFastViewReader @Inject()(messageIdManager: MessageIdManager, hasAttachment = HasAttachment(fastView.hasAttachment), preview = fastView.getPreview), header = EmailHeaders.from(zoneIdProvider.get())(mime4JMessage), - specificHeaders = EmailHeaders.extractSpecificHeaders(request.properties)(zoneIdProvider.get(), mime4JMessage)) + specificHeaders = EmailHeaders.extractSpecificHeaders(request.properties)(zoneIdProvider.get(), mime4JMessage.getHeader)) } } } @@ -784,8 +784,8 @@ private class EmailFastViewWithAttachmentsMetadataReader @Inject()(messageIdMana hasAttachment = HasAttachment(fastView.hasAttachment), preview = fastView.getPreview), header = EmailHeaders.from(zoneIdProvider.get())(mime4JMessage), - specificHeaders = EmailHeaders.extractSpecificHeaders(request.properties)(zoneIdProvider.get(), mime4JMessage), - attachments = AttachmentsMetadata(firstMessage.getLoadedAttachments.asScala.toList.map(EmailBodyPart.fromAttachment(_, mime4JMessage)))) + specificHeaders = EmailHeaders.extractSpecificHeaders(request.properties)(zoneIdProvider.get(), mime4JMessage.getHeader), + attachments = AttachmentsMetadata(firstMessage.getLoadedAttachments.asScala.toList.map(EmailBodyPart.fromAttachment(request.bodyProperties, zoneIdProvider.get(), _, mime4JMessage)))) } } } diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailBodyPart.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailBodyPart.scala index f3af9e778f..142c244bb8 100644 --- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailBodyPart.scala +++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailBodyPart.scala @@ -20,6 +20,7 @@ package org.apache.james.jmap.mail import java.io.OutputStream +import java.time.ZoneId import cats.implicits._ import com.google.common.io.CountingOutputStream @@ -73,16 +74,16 @@ object EmailBodyPart { val defaultProperties: Properties = Properties("partId", "blobId", "size", "name", "type", "charset", "disposition", "cid", "language", "location") val allowedProperties: Properties = defaultProperties ++ Properties("subParts", "headers") - def of(messageId: MessageId, message: MessageResult): Try[EmailBodyPart] = { + def of(properties: Option[Properties], zoneId: ZoneId, messageId: MessageId, message: MessageResult): Try[EmailBodyPart] = { val defaultMessageBuilder = new DefaultMessageBuilder defaultMessageBuilder.setMimeEntityConfig(MimeConfig.PERMISSIVE) defaultMessageBuilder.setDecodeMonitor(DecodeMonitor.SILENT) val mime4JMessage = Try(defaultMessageBuilder.parseMessage(message.getFullContent.getInputStream)) - mime4JMessage.flatMap(of(messageId, _)) + mime4JMessage.flatMap(of(properties, zoneId, messageId, _)) } - def fromAttachment(attachment: MessageAttachmentMetadata, entity: Message): EmailBodyPart = { + def fromAttachment(properties: Option[Properties], zoneId: ZoneId, attachment: MessageAttachmentMetadata, entity: Message): EmailBodyPart = { def parseDisposition(attachment: MessageAttachmentMetadata): Option[Disposition] = if (attachment.isInline) { Option(Disposition.INLINE) @@ -105,45 +106,48 @@ object EmailBodyPart { language = Option.empty, location = Option.empty, subParts = Option.empty, - entity = entity) + entity = entity, + specificHeaders = EmailHeaders.extractSpecificHeaders(properties)(zoneId, entity.getHeader)) } - def of(messageId: MessageId, message: Message): Try[EmailBodyPart] = - of(messageId, PartId(1), message).map(_._1) + def of(properties: Option[Properties], zoneId: ZoneId, messageId: MessageId, message: Message): Try[EmailBodyPart] = + of(properties, zoneId, messageId, PartId(1), message).map(_._1) - private def of(messageId: MessageId, partId: PartId, entity: Entity): Try[(EmailBodyPart, PartId)] = + private def of(properties: Option[Properties], zoneId: ZoneId, messageId: MessageId, partId: PartId, entity: Entity): Try[(EmailBodyPart, PartId)] = entity.getBody match { case multipart: Multipart => val scanResults: Try[List[(Option[EmailBodyPart], PartId)]] = multipart.getBodyParts .asScala.toList - .scanLeft[Try[(Option[EmailBodyPart], PartId)]](Success((None, partId)))(traverse(messageId)) + .scanLeft[Try[(Option[EmailBodyPart], PartId)]](Success((None, partId)))(traverse(properties, zoneId, messageId)) .sequence val highestPartIdValidation: Try[PartId] = scanResults.map(list => list.map(_._2).reverse.headOption.getOrElse(partId)) val childrenValidation: Try[List[EmailBodyPart]] = scanResults.map(list => list.flatMap(_._1)) zip(childrenValidation, highestPartIdValidation) .flatMap { - case (children, highestPartId) => of(None, partId, entity, Some(children)) + case (children, highestPartId) => of(properties, zoneId, None, partId, entity, Some(children)) .map(part => (part, highestPartId)) } case _ => BlobId.of(messageId, partId) - .flatMap(blobId => of(Some(blobId), partId, entity, None)) + .flatMap(blobId => of(properties, zoneId, Some(blobId), partId, entity, None)) .map(part => (part, partId)) } - private def traverse(messageId: MessageId)(acc: Try[(Option[EmailBodyPart], PartId)], entity: Entity): Try[(Option[EmailBodyPart], PartId)] = { + private def traverse(properties: Option[Properties], zoneId: ZoneId, messageId: MessageId)(acc: Try[(Option[EmailBodyPart], PartId)], entity: Entity): Try[(Option[EmailBodyPart], PartId)] = { acc.flatMap { case (_, previousPartId) => val partId = previousPartId.next - of(messageId, partId, entity) + of(properties, zoneId, messageId, partId, entity) .map({ case (part, partId) => (Some(part), partId) }) } } - private def of(blobId: Option[BlobId], + private def of(properties: Option[Properties], + zoneId: ZoneId, + blobId: Option[BlobId], partId: PartId, entity: Entity, subParts: Option[List[EmailBodyPart]]): Try[EmailBodyPart] = @@ -162,7 +166,8 @@ object EmailBodyPart { location = headerValue(entity, "Content-Location") .map(Location), subParts = subParts, - entity = entity)) + entity = entity, + specificHeaders = EmailHeaders.extractSpecificHeaders(properties)(zoneId, entity.getHeader))) private def headerValue(entity: Entity, headerName: String): Option[String] = entity.getHeader .getFields(headerName) @@ -259,7 +264,8 @@ case class EmailBodyPart(partId: PartId, language: Option[Languages], location: Option[Location], subParts: Option[List[EmailBodyPart]], - entity: Entity) { + entity: Entity, + specificHeaders: Map[String, Option[EmailHeaderValue]]) { def bodyContent: Try[Option[EmailBodyValue]] = entity.getBody match { case textBody: Mime4JTextBody => 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 60d0dc9cab..a479e1f8f8 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 @@ -94,15 +94,15 @@ case class EmailGetResponse(accountId: AccountId, notFound: EmailNotFound) case class SpecificHeaderRequest(property: NonEmptyString, headerName: String, parseOption: Option[ParseOption], isAll: Boolean = false) { - def retrieveHeader(zoneId: ZoneId, message: Message): (String, Option[EmailHeaderValue]) = + def retrieveHeader(zoneId: ZoneId, header: org.apache.james.mime4j.dom.Header): (String, Option[EmailHeaderValue]) = if (isAll) { - extractAllHeaders(zoneId, message) + extractAllHeaders(zoneId, header) } else { - extractLastHeader(zoneId, message) + extractLastHeader(zoneId, header) } - private def extractAllHeaders(zoneId: ZoneId, message: Message) = { - val fields: List[Field] = Option(message.getHeader.getFields(headerName)) + private def extractAllHeaders(zoneId: ZoneId, header: org.apache.james.mime4j.dom.Header) = { + val fields: List[Field] = Option(header.getFields(headerName)) .map(_.asScala.toList) .getOrElse(List()) @@ -110,8 +110,8 @@ case class SpecificHeaderRequest(property: NonEmptyString, headerName: String, p (property.value, Some(AllHeaderValues(fields.map(toHeader(zoneId, option))))) } - private def extractLastHeader(zoneId: ZoneId, message: Message) = { - val field: Option[Field] = Option(message.getHeader.getFields(headerName)) + private def extractLastHeader(zoneId: ZoneId, header: org.apache.james.mime4j.dom.Header) = { + val field: Option[Field] = Option(header.getFields(headerName)) .map(_.asScala) .flatMap(fields => fields.reverse.headOption) diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailGetMethod.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailGetMethod.scala index a9cc31b665..568b661e9f 100644 --- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailGetMethod.scala +++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailGetMethod.scala @@ -130,11 +130,17 @@ class EmailGetMethod @Inject() (readerFactory: EmailViewReaderFactory, request.bodyProperties match { case None => Right(EmailBodyPart.defaultProperties) case Some(properties) => - val invalidProperties = properties -- EmailBodyPart.allowedProperties - if (invalidProperties.isEmpty()) { - Right(properties) + val invalidProperties: Set[NonEmptyString] = properties.value + .flatMap(property => SpecificHeaderRequest.from(property) + .fold( + invalidProperty => Some(invalidProperty), + _ => None + )) -- EmailBodyPart.allowedProperties.value + + if (invalidProperties.nonEmpty) { + Left(new IllegalArgumentException(s"The following bodyProperties [${invalidProperties.map(p => p.value).mkString(", ")}] do not exist.")) } else { - Left(new IllegalArgumentException(s"The following bodyProperties [${invalidProperties.format()}] do not exist.")) + Right(properties) } } diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/routes/DownloadRoutes.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/routes/DownloadRoutes.scala index 0b54980f25..63317105da 100644 --- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/routes/DownloadRoutes.scala +++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/routes/DownloadRoutes.scala @@ -37,7 +37,7 @@ import org.apache.james.jmap.http.Authenticator import org.apache.james.jmap.http.rfc8621.InjectionKeys import org.apache.james.jmap.json.ResponseSerializer import org.apache.james.jmap.mail.{BlobId, EmailBodyPart, PartId} -import org.apache.james.jmap.method.AccountNotFoundException +import org.apache.james.jmap.method.{AccountNotFoundException, ZoneIdProvider} import org.apache.james.jmap.routes.DownloadRoutes.{BUFFER_SIZE, LOGGER} import org.apache.james.jmap.{Endpoint, JMAPRoute, JMAPRoutes} import org.apache.james.mailbox.model.ContentType.{MediaType, MimeType, SubType} @@ -54,12 +54,13 @@ import reactor.core.publisher.Mono import reactor.core.scala.publisher.SMono import reactor.core.scheduler.Schedulers import reactor.netty.http.server.{HttpServerRequest, HttpServerResponse} - import java.io.InputStream import java.nio.charset.StandardCharsets import java.util.stream import java.util.stream.Stream + import javax.inject.{Inject, Named} + import scala.compat.java8.FunctionConverters._ import scala.jdk.CollectionConverters._ import scala.util.{Failure, Success, Try} @@ -186,7 +187,8 @@ class AttachmentBlobResolver @Inject()(val attachmentManager: AttachmentManager) } class MessagePartBlobResolver @Inject()(val messageIdFactory: MessageId.Factory, - val messageIdManager: MessageIdManager) extends BlobResolver { + val messageIdManager: MessageIdManager, + val zoneIdSupplier: ZoneIdProvider) extends BlobResolver { private def asMessageAndPartId(blobId: BlobId): Try[(MessageId, PartId)] = { blobId.value.value.split('_').toList match { case List(messageIdString, partIdString) => for { @@ -206,7 +208,7 @@ class MessagePartBlobResolver @Inject()(val messageIdFactory: MessageId.Factory, Applicable(SMono.fromPublisher( messageIdManager.getMessagesReactive(List(messageId).asJava, FetchGroup.FULL_CONTENT, mailboxSession)) .handle[EmailBodyPart] { - case (message, sink) => EmailBodyPart.of(messageId, message) + case (message, sink) => EmailBodyPart.of(None, zoneIdSupplier.get(), messageId, message) .fold(sink.error, sink.next) } .handle[EmailBodyPart] { --------------------------------------------------------------------- To unsubscribe, e-mail: notifications-unsubscr...@james.apache.org For additional commands, e-mail: notifications-h...@james.apache.org