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 de44d8b5abffff1e4260799bfe2c0ac4fc110ac6 Author: Quan Tran <hqt...@linagora.com> AuthorDate: Wed Dec 28 18:02:08 2022 +0700 JAMES-3872 Add a JMAP read level that get preview of mail with attachments' metadata without getting body content fixup! Add a JMAP read level that get preview of mail with attachments' metadata without getting body content --- .../resources/eml/inlined-single-attachment.eml | 30 ++ .../rfc8621/contract/EmailGetMethodContract.scala | 353 ++++++++++++++++++++- .../james/jmap/json/EmailGetSerializer.scala | 9 +- .../scala/org/apache/james/jmap/mail/Email.scala | 130 +++++++- .../org/apache/james/jmap/mail/EmailBodyPart.scala | 30 +- 5 files changed, 540 insertions(+), 12 deletions(-) diff --git a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/resources/eml/inlined-single-attachment.eml b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/resources/eml/inlined-single-attachment.eml new file mode 100644 index 0000000000..82684c56b2 --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/resources/eml/inlined-single-attachment.eml @@ -0,0 +1,30 @@ +Date: Wed, 26 Jan 2022 12:21:37 +0100 +From: Bob <b...@domain.tld> +To: Alice <al...@domain.tld> +Subject: My subject +Message-ID: <20220126112137.wookj26xellphlam@W0248292> +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="7f4cfz6rtfqdbqxn" +Content-Disposition: inline +Content-Transfer-Encoding: 8bit + +--7f4cfz6rtfqdbqxn +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: 8bit + +Main test message... + +--7f4cfz6rtfqdbqxn +Content-Type: application/json; charset=us-ascii +Content-Disposition: attachment; filename="yyy.txt" +Content-Transfer-Encoding: quoted-printable + +[ + { + "Id": "2xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + } +] + +--7f4cfz6rtfqdbqxn + + 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 2da1b0acd9..54191b1891 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 @@ -19,9 +19,16 @@ package org.apache.james.jmap.rfc8621.contract +import java.io.ByteArrayInputStream +import java.nio.charset.StandardCharsets +import java.time.{Duration, ZonedDateTime} +import java.util.Date +import java.util.concurrent.TimeUnit + import io.netty.handler.codec.http.HttpHeaderNames.ACCEPT import io.restassured.RestAssured.{`given`, requestSpecification} import io.restassured.http.ContentType.JSON +import javax.mail.Flags import net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson import net.javacrumbs.jsonunit.core.Option import net.javacrumbs.jsonunit.core.Option.IGNORING_ARRAY_ORDER @@ -49,12 +56,6 @@ import org.assertj.core.api.Assertions.assertThat import org.awaitility.Awaitility import org.junit.jupiter.api.{BeforeEach, Test} -import java.nio.charset.StandardCharsets -import java.time.{Duration, ZonedDateTime} -import java.util.Date -import java.util.concurrent.TimeUnit -import javax.mail.Flags - object EmailGetMethodContract { private def createTestMessage: Message = Message.Builder .of @@ -4488,6 +4489,346 @@ trait EmailGetMethodContract { |}""".stripMargin) } + @Test + def shouldUseFullViewReaderWhenFetchAllBodyProperties(server: GuiceJamesServer): Unit = { + val path = MailboxPath.inbox(BOB) + val mailboxId = server.getProbe(classOf[MailboxProbeImpl]).createMailbox(path) + val messageId: MessageId = server.getProbe(classOf[MailboxProbeImpl]) + .appendMessage(BOB.asString, path, AppendCommand.from( + ClassLoaderUtils.getSystemResourceAsSharedStream("eml/inlined-mixed.eml"))) + .getMessageId + + val request = + s"""{ + | "using": [ + | "urn:ietf:params:jmap:core", + | "urn:ietf:params:jmap:mail" + | ], + | "methodCalls": [ + | [ + | "Email/get", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "ids": ["${messageId.serialize}"], + | "properties": [ + | "id", + | "subject", + | "from", + | "to", + | "cc", + | "bcc", + | "keywords", + | "size", + | "receivedAt", + | "sentAt", + | "preview", + | "hasAttachment", + | "attachments", + | "replyTo", + | "mailboxIds" + | ], + | "fetchTextBodyValues": true + | }, + | "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") + .isEqualTo( + s"""{ + | "sessionState": "${SESSION_STATE.value}", + | "methodResponses": [ + | [ + | "Email/get", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "notFound": [], + | "list": [{ + | "preview": "Main test message...", + | "to": [{ + | "name": "Alice", + | "email": "al...@domain.tld" + | }], + | "id": "${messageId.serialize}", + | "mailboxIds": { + | "${mailboxId.serialize}": true + | }, + | "from": [{ + | "name": "Bob", + | "email": "b...@domain.tld" + | }], + | "keywords": { + | + | }, + | "receivedAt": "$${json-unit.ignore}", + | "sentAt": "$${json-unit.ignore}", + | "hasAttachment": true, + | "attachments": [{ + | "charset": "us-ascii", + | "disposition": "attachment", + | "size": 102, + | "partId": "3", + | "blobId": "${messageId.serialize}_3", + | "name": "yyy.txt", + | "type": "application/json" + | }, + | { + | "charset": "us-ascii", + | "disposition": "attachment", + | "size": 102, + | "partId": "4", + | "blobId": "${messageId.serialize}_4", + | "name": "xxx.txt", + | "type": "application/json" + | } + | ], + | "subject": "My subject", + | "size": 970 + | }] + | }, + | "c1" + | ] + | ] + |}""".stripMargin) + } + + @Test + def shouldUseFastViewWithAttachmentMetadataWhenSupportedBodyProperties(server: GuiceJamesServer): Unit = { + val path = MailboxPath.inbox(BOB) + val mailboxId = server.getProbe(classOf[MailboxProbeImpl]).createMailbox(path) + val messageId: MessageId = server.getProbe(classOf[MailboxProbeImpl]) + .appendMessage(BOB.asString, path, AppendCommand.from( + ClassLoaderUtils.getSystemResourceAsSharedStream("eml/inlined-mixed.eml"))) + .getMessageId + + val request = + s"""{ + | "using": [ + | "urn:ietf:params:jmap:core", + | "urn:ietf:params:jmap:mail" + | ], + | "methodCalls": [ + | [ + | "Email/get", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "ids": ["${messageId.serialize}"], + | "properties": [ + | "id", + | "subject", + | "from", + | "to", + | "cc", + | "bcc", + | "keywords", + | "size", + | "receivedAt", + | "sentAt", + | "preview", + | "hasAttachment", + | "attachments", + | "replyTo", + | "mailboxIds" + | ], + | "fetchTextBodyValues": true, + | "bodyProperties": ["partId", "blobId", "size", "name", "type", "charset", "disposition", "cid", "headers"] + | }, + | "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") + .isEqualTo( + s"""{ + | "sessionState": "${SESSION_STATE.value}", + | "methodResponses": [ + | [ + | "Email/get", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "notFound": [], + | "list": [{ + | "preview": "Main test message...", + | "to": [{ + | "name": "Alice", + | "email": "al...@domain.tld" + | }], + | "id": "${messageId.serialize}", + | "mailboxIds": { + | "${mailboxId.serialize}": true + | }, + | "from": [{ + | "name": "Bob", + | "email": "b...@domain.tld" + | }], + | "keywords": { + | + | }, + | "receivedAt": "$${json-unit.ignore}", + | "sentAt": "$${json-unit.ignore}", + | "hasAttachment": true, + | "attachments": [{ + | "charset": "us-ascii", + | "headers": [{ + | "name": "Content-Type", + | "value": " application/json; charset=us-ascii" + | }, + | { + | "name": "Content-Disposition", + | "value": "$${json-unit.ignore}" + | }, + | { + | "name": "Content-Transfer-Encoding", + | "value": " quoted-printable" + | } + | ], + | "disposition": "attachment", + | "size": 102, + | "partId": "3", + | "blobId": "${messageId.serialize}_3", + | "name": "yyy.txt", + | "type": "application/json" + | }, + | { + | "charset": "us-ascii", + | "headers": [{ + | "name": "Content-Type", + | "value": " application/json; charset=us-ascii" + | }, + | { + | "name": "Content-Disposition", + | "value": "$${json-unit.ignore}" + | }, + | { + | "name": "Content-Transfer-Encoding", + | "value": " quoted-printable" + | } + | ], + | "disposition": "attachment", + | "size": 102, + | "partId": "4", + | "blobId": "${messageId.serialize}_4", + | "name": "xxx.txt", + | "type": "application/json" + | } + | ], + | "subject": "My subject", + | "size": 970 + | }] + | }, + | "c1" + | ] + | ] + |}""".stripMargin) + } + + @Test + def shouldBeAbleToDownloadAttachmentBaseOnFastViewWithAttachmentsMetadataResult(server: GuiceJamesServer): Unit = { + val path = MailboxPath.inbox(BOB) + server.getProbe(classOf[MailboxProbeImpl]).createMailbox(path) + val messageId: MessageId = server.getProbe(classOf[MailboxProbeImpl]) + .appendMessage(BOB.asString, path, AppendCommand.from( + ClassLoaderUtils.getSystemResourceAsSharedStream("eml/inlined-single-attachment.eml"))) + .getMessageId + + val request = + s"""{ + | "using": [ + | "urn:ietf:params:jmap:core", + | "urn:ietf:params:jmap:mail" + | ], + | "methodCalls": [ + | [ + | "Email/get", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "ids": ["${messageId.serialize}"], + | "properties": [ + | "id", + | "subject", + | "from", + | "to", + | "cc", + | "bcc", + | "keywords", + | "size", + | "receivedAt", + | "sentAt", + | "preview", + | "hasAttachment", + | "attachments", + | "replyTo", + | "mailboxIds" + | ], + | "fetchTextBodyValues": true, + | "bodyProperties": ["blobId", "size", "name", "type", "charset", "disposition", "cid"] + | }, + | "c1" + | ] + | ] + |}""".stripMargin + + val blobId = `given` + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .body(request) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .jsonPath() + .getString("methodResponses[0][1].list[0].attachments[0].blobId") + + val blob = `given` + .basePath("") + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .when + .get(s"/download/29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6/$blobId") + .`then` + .statusCode(SC_OK) + .contentType("application/json") + .extract + .body + .asString + + val expectedBlob: String = + """[ + | { + | "Id": "2xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + | } + |]""".stripMargin + + assertThat(new ByteArrayInputStream(blob.getBytes(StandardCharsets.UTF_8))) + .hasContent(expectedBlob) + } + @Test def textBodyValuesForComplexMultipart(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 a247daa1e2..4bff99d640 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 @@ -25,7 +25,7 @@ import org.apache.james.jmap.api.model.Size.Size import org.apache.james.jmap.api.model.{EmailAddress, EmailerName, Preview} import org.apache.james.jmap.core.Id.IdConstraint import org.apache.james.jmap.core.{Properties, UuidState} -import org.apache.james.jmap.mail.{AddressesHeaderValue, BlobId, Charset, DateHeaderValue, Disposition, EmailAddressGroup, EmailBody, EmailBodyMetadata, EmailBodyPart, EmailBodyValue, EmailChangesRequest, EmailChangesResponse, EmailFastView, EmailFullView, EmailGetRequest, EmailGetResponse, EmailHeader, EmailHeaderName, EmailHeaderValue, EmailHeaderView, EmailHeaders, EmailIds, EmailMetadata, EmailMetadataView, EmailNotFound, EmailView, FetchAllBodyValues, FetchHTMLBodyValues, FetchTextB [...] +import org.apache.james.jmap.mail._ import org.apache.james.mailbox.model.{Cid, MailboxId, MessageId} import play.api.libs.functional.syntax._ import play.api.libs.json._ @@ -152,12 +152,18 @@ object EmailGetSerializer { private implicit val emailMetadataWrites: OWrites[EmailMetadata] = Json.writes[EmailMetadata] private implicit val emailHeadersWrites: Writes[EmailHeaders] = Json.writes[EmailHeaders] + private implicit val attachmentsMetadataWrites: Writes[AttachmentsMetadata] = Json.writes[AttachmentsMetadata] private implicit val emailBodyMetadataWrites: Writes[EmailBodyMetadata] = Json.writes[EmailBodyMetadata] private val emailFastViewWrites: OWrites[EmailFastView] = (JsPath.write[EmailMetadata] and JsPath.write[EmailHeaders] and JsPath.write[EmailBodyMetadata] and JsPath.write[Map[String, Option[EmailHeaderValue]]]) (unlift(EmailFastView.unapply)) + private val emailFastViewWithAttachmentsWrites: OWrites[EmailFastViewWithAttachments] = (JsPath.write[EmailMetadata] and + JsPath.write[EmailHeaders] and + JsPath.write[AttachmentsMetadata] and + JsPath.write[EmailBodyMetadata] and + JsPath.write[Map[String, Option[EmailHeaderValue]]]) (unlift(EmailFastViewWithAttachments.unapply)) private val emailHeaderViewWrites: OWrites[EmailHeaderView] = (JsPath.write[EmailMetadata] and JsPath.write[EmailHeaders] and JsPath.write[Map[String, Option[EmailHeaderValue]]]) (unlift(EmailHeaderView.unapply)) @@ -172,6 +178,7 @@ object EmailGetSerializer { case view: EmailMetadataView => emailMetadataViewWrites.writes(view) case view: EmailHeaderView => emailHeaderViewWrites.writes(view) case view: EmailFastView => emailFastViewWrites.writes(view) + case view: EmailFastViewWithAttachments => emailFastViewWithAttachmentsWrites.writes(view) case view: EmailFullView => emailFullViewWrites.writes(view) } 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 6650dce10d..bc6c039658 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 @@ -35,9 +35,10 @@ import org.apache.james.jmap.core.Id.{Id, IdConstraint} import org.apache.james.jmap.core.{Properties, UTCDate} import org.apache.james.jmap.mail.BracketHeader.sanitize import org.apache.james.jmap.mail.EmailHeaderName.{ADDRESSES_NAMES, DATE, MESSAGE_ID_NAMES} +import org.apache.james.jmap.mail.FastViewWithAttachmentsMetadataReadLevel.supportedByFastViewWithAttachments 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} +import org.apache.james.mailbox.model.FetchGroup.{FULL_CONTENT, HEADERS, HEADERS_WITH_ATTACHMENTS_METADATA, MINIMAL} import org.apache.james.mailbox.model.{FetchGroup, MailboxId, MessageId, MessageResult, ThreadId => JavaThreadId} import org.apache.james.mailbox.{MailboxSession, MessageIdManager} import org.apache.james.mime4j.codec.DecodeMonitor @@ -104,14 +105,16 @@ object ReadLevel { private val metadataProperty: Seq[NonEmptyString] = Seq("id", "size", "mailboxIds", "mailboxIds", "blobId", "threadId", "receivedAt") private val fastViewProperty: Seq[NonEmptyString] = Seq("preview", "hasAttachment") - private val fullProperty: Seq[NonEmptyString] = Seq("bodyStructure", "textBody", "htmlBody", - "attachments", "bodyValues") + private val attachmentsMetadataViewProperty: Seq[NonEmptyString] = Seq("attachments") + private val fullProperty: Seq[NonEmptyString] = Seq("bodyStructure", "textBody", "htmlBody", "bodyValues") def of(property: NonEmptyString): ReadLevel = if (metadataProperty.contains(property)) { MetadataReadLevel } else if (fastViewProperty.contains(property)) { FastViewReadLevel - } else if (fullProperty.contains(property)) { + } else if (attachmentsMetadataViewProperty.contains(property)) { + FastViewWithAttachmentsMetadataReadLevel + } else if (fullProperty.contains(property)) { FullReadLevel } else { HeaderReadLevel @@ -122,11 +125,13 @@ object ReadLevel { case FullReadLevel => FullReadLevel case HeaderReadLevel => readLevel2 match { case FullReadLevel => FullReadLevel + case FastViewWithAttachmentsMetadataReadLevel => FastViewWithAttachmentsMetadataReadLevel case FastViewReadLevel => FastViewReadLevel case _ => HeaderReadLevel } case FastViewReadLevel => readLevel2 match { case FullReadLevel => FullReadLevel + case FastViewWithAttachmentsMetadataReadLevel => FastViewWithAttachmentsMetadataReadLevel case _ => FastViewReadLevel } } @@ -136,6 +141,17 @@ sealed trait ReadLevel case object MetadataReadLevel extends ReadLevel case object HeaderReadLevel extends ReadLevel case object FastViewReadLevel extends ReadLevel +case object FastViewWithAttachmentsMetadataReadLevel extends ReadLevel { + private val availableFetchingBodyPropertiesForFastViewWithAttachments = Seq("partId", "blobId", "size", "name", "type", "charset", "disposition", "cid", "headers") + + def supportedByFastViewWithAttachments(bodyProperties: Option[Properties]): Boolean = + bodyProperties.exists(supportedByFastViewWithAttachments) + + private def supportedByFastViewWithAttachments(properties: Properties): Boolean = + properties.value + .map(availableFetchingBodyPropertiesForFastViewWithAttachments.contains) + .reduce(_&&_) +} case object FullReadLevel extends ReadLevel object HeaderMessageId { @@ -356,10 +372,18 @@ case class EmailFastView(metadata: EmailMetadata, bodyMetadata: EmailBodyMetadata, specificHeaders: Map[String, Option[EmailHeaderValue]]) extends EmailView +case class EmailFastViewWithAttachments(metadata: EmailMetadata, + header: EmailHeaders, + attachments: AttachmentsMetadata, + bodyMetadata: EmailBodyMetadata, + specificHeaders: Map[String, Option[EmailHeaderValue]]) extends EmailView + +case class AttachmentsMetadata(attachments: List[EmailBodyPart]) class EmailViewReaderFactory @Inject() (metadataReader: EmailMetadataViewReader, headerReader: EmailHeaderViewReader, fastViewReader: EmailFastViewReader, + fastViewWithAttachmentsMetadataReader: EmailFastViewWithAttachmentsMetadataReader, fullReader: EmailFullViewReader) { def selectReader(request: EmailGetRequest): EmailViewReader[EmailView] = { val readLevel: ReadLevel = request.properties @@ -373,6 +397,12 @@ class EmailViewReaderFactory @Inject() (metadataReader: EmailMetadataViewReader, case MetadataReadLevel => metadataReader case HeaderReadLevel => headerReader case FastViewReadLevel => fastViewReader + case FastViewWithAttachmentsMetadataReadLevel => + if (supportedByFastViewWithAttachments(request.bodyProperties)) { + fastViewWithAttachmentsMetadataReader + } else { + fullReader + } case FullReadLevel => fullReader } } @@ -667,3 +697,95 @@ private class EmailFastViewReader @Inject()(messageIdManager: MessageIdManager, } } } + +private class EmailFastViewWithAttachmentsMetadataReader @Inject()(messageIdManager: MessageIdManager, + messageFastViewProjection: MessageFastViewProjection, + htmlTextExtractor: HtmlTextExtractor, + zoneIdProvider: ZoneIdProvider, + fullViewFactory: EmailFullViewFactory) extends EmailViewReader[EmailView] { + private val fullReader: GenericEmailViewReader[EmailFullView] = new GenericEmailViewReader[EmailFullView](messageIdManager, FULL_CONTENT, htmlTextExtractor, fullViewFactory) + + override def read[T >: EmailView](ids: Seq[MessageId], request: EmailGetRequest, mailboxSession: MailboxSession): SFlux[T] = + SMono.fromPublisher(messageFastViewProjection.retrieve(ids.asJava)) + .map(_.asScala.toMap) + .map(fastViews => ids.map(id => fastViews.get(id) + .map(FastViewAvailable(id, _)) + .getOrElse(FastViewUnavailable(id)))) + .flatMapMany(results => toEmailViews(results, request, mailboxSession)) + + private def toEmailViews[T >: EmailView](results: Seq[FastViewResult], request: EmailGetRequest, mailboxSession: MailboxSession): SFlux[T] = { + val availables: Seq[FastViewAvailable] = results.flatMap { + case available: FastViewAvailable => Some(available) + case _ => None + } + val unavailables: Seq[FastViewUnavailable] = results.flatMap { + case unavailable: FastViewUnavailable => Some(unavailable) + case _ => None + } + + SFlux.merge(Seq( + toFastViews(availables, request, mailboxSession), + fullReader.read(unavailables.map(_.id), request, mailboxSession) + .doOnNext(storeOnCacheMisses))) + } + + private def storeOnCacheMisses(fullView: EmailFullView) = { + SMono.fromPublisher(messageFastViewProjection.store( + fullView.metadata.id, + MessageFastViewPrecomputedProperties.builder() + .preview(fullView.bodyMetadata.preview) + .hasAttachment(fullView.bodyMetadata.hasAttachment.value) + .build())) + .doOnError(e => EmailFastViewReader.logger.error(s"Cannot store the projection to MessageFastViewProjection for ${fullView.metadata.id}", e)) + .subscribeOn(Schedulers.parallel()) + .subscribe() + } + + private def toFastViews(fastViews: Seq[FastViewAvailable], request: EmailGetRequest, mailboxSession: MailboxSession): SFlux[EmailView] ={ + val fastViewsAsMap: Map[MessageId, MessageFastViewPrecomputedProperties] = fastViews.map(e => (e.id, e.fastView)).toMap + val ids: Seq[MessageId] = fastViews.map(_.id) + + SFlux.fromPublisher(messageIdManager.getMessagesReactive(ids.asJava, HEADERS_WITH_ATTACHMENTS_METADATA, mailboxSession)) + .collectSeq() + .flatMapIterable(messages => messages.groupBy(_.getMessageId).toSet) + .map(x => toEmail(request)(x, fastViewsAsMap(x._1))) + .handle[EmailView]((aTry, sink) => aTry match { + case Success(value) => sink.next(value) + case Failure(e) => sink.error(e) + }) + } + + private def toEmail(request: EmailGetRequest)(message: (MessageId, Seq[MessageResult]), fastView: MessageFastViewPrecomputedProperties): Try[EmailView] = { + val messageId: MessageId = message._1 + val mailboxIds: MailboxIds = MailboxIds(message._2 + .map(_.getMailboxId) + .toList) + val threadId: ThreadId = ThreadId(message._2.head.getThreadId.serialize()) + + for { + firstMessage <- message._2 + .headOption + .map(Success(_)) + .getOrElse(Failure(new IllegalArgumentException("No message supplied"))) + mime4JMessage <- Email.parseAsMime4JMessage(firstMessage) + blobId <- BlobId.of(messageId) + keywords <- LENIENT_KEYWORDS_FACTORY.fromFlags(firstMessage.getFlags) + } yield { + EmailFastViewWithAttachments( + metadata = EmailMetadata( + id = messageId, + blobId = blobId, + threadId = threadId, + mailboxIds = mailboxIds, + receivedAt = UTCDate.from(firstMessage.getInternalDate, zoneIdProvider.get()), + size = sanitizeSize(firstMessage.getSize), + keywords = keywords), + bodyMetadata = EmailBodyMetadata( + 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)))) + } + } +} 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 0824cf5fbe..f3af9e778f 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 @@ -28,11 +28,12 @@ import eu.timepit.refined.auto._ import eu.timepit.refined.numeric.NonNegative import eu.timepit.refined.refineV import org.apache.commons.io.IOUtils +import org.apache.james.jmap.api.model.Size import org.apache.james.jmap.api.model.Size.Size import org.apache.james.jmap.core.Properties import org.apache.james.jmap.mail.EmailBodyPart.{FILENAME_PREFIX, MULTIPART_ALTERNATIVE, TEXT_HTML, TEXT_PLAIN} import org.apache.james.jmap.mail.PartId.PartIdValue -import org.apache.james.mailbox.model.{Cid, MessageId, MessageResult} +import org.apache.james.mailbox.model.{Cid, MessageAttachmentMetadata, MessageId, MessageResult} import org.apache.james.mime4j.codec.{DecodeMonitor, DecoderUtil} import org.apache.james.mime4j.dom.field.{ContentDispositionField, ContentLanguageField, ContentTypeField, FieldName} import org.apache.james.mime4j.dom.{Entity, Message, Multipart, TextBody => Mime4JTextBody} @@ -41,6 +42,7 @@ import org.apache.james.mime4j.stream.{Field, MimeConfig, RawField} import org.apache.james.util.html.HtmlTextExtractor import scala.jdk.CollectionConverters._ +import scala.jdk.OptionConverters._ import scala.util.{Failure, Success, Try} object PartId { @@ -80,6 +82,32 @@ object EmailBodyPart { mime4JMessage.flatMap(of(messageId, _)) } + def fromAttachment(attachment: MessageAttachmentMetadata, entity: Message): EmailBodyPart = { + def parseDisposition(attachment: MessageAttachmentMetadata): Option[Disposition] = + if (attachment.isInline) { + Option(Disposition.INLINE) + } else { + Option(Disposition.ATTACHMENT) + } + + def parsePartIdFromBlobId(blobId: String): PartId = + PartId(blobId.substring(blobId.lastIndexOf("_") + 1).asInstanceOf[PartIdValue]) + + EmailBodyPart(partId = parsePartIdFromBlobId(attachment.getAttachmentId.getId), + blobId = BlobId.of(attachment.getAttachmentId.getId).toOption, + headers = entity.getHeader.getFields.asScala.toList.map(EmailHeader(_)), + size = Size.sanitizeSize(attachment.getAttachment.getSize), + name = attachment.getName.map(Name(_)).toScala, + `type` = Type(attachment.getAttachment.getType.mimeType().asString()), + charset = attachment.getAttachment.getType.charset().map(charset => Charset(charset.name())).toScala, + disposition = parseDisposition(attachment), + cid = attachment.getCid.toScala, + language = Option.empty, + location = Option.empty, + subParts = Option.empty, + entity = entity) + } + def of(messageId: MessageId, message: Message): Try[EmailBodyPart] = of(messageId, PartId(1), message).map(_._1) --------------------------------------------------------------------- To unsubscribe, e-mail: notifications-unsubscr...@james.apache.org For additional commands, e-mail: notifications-h...@james.apache.org