You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@james.apache.org by bt...@apache.org on 2023/01/04 02:31:21 UTC

[james-project] 02/03: JAMES-3872 Add a JMAP read level that get preview of mail with attachments' metadata without getting body content

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 <hq...@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 <bo...@domain.tld>
+To: Alice <al...@domain.tld>
+Subject: My subject
+Message-ID: <20...@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": "alice@domain.tld"
+         |					}],
+         |					"id": "${messageId.serialize}",
+         |					"mailboxIds": {
+         |						"${mailboxId.serialize}": true
+         |					},
+         |					"from": [{
+         |						"name": "Bob",
+         |						"email": "bob@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": "alice@domain.tld"
+         |					}],
+         |					"id": "${messageId.serialize}",
+         |					"mailboxIds": {
+         |						"${mailboxId.serialize}": true
+         |					},
+         |					"from": [{
+         |						"name": "Bob",
+         |						"email": "bob@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-unsubscribe@james.apache.org
For additional commands, e-mail: notifications-help@james.apache.org