This is an automated email from the ASF dual-hosted git repository.
btellier pushed a commit to branch 3.9.x
in repository https://gitbox.apache.org/repos/asf/james-project.git
The following commit(s) were added to refs/heads/3.9.x by this push:
new 0c8be58923 JAMES-3872 JMAP Email/get: Fix and test attachment read
level (backport) (#2869)
0c8be58923 is described below
commit 0c8be58923f8ed102ef8d1ea0be018d3ba6185c4
Author: Benoit TELLIER <[email protected]>
AuthorDate: Sat Nov 29 19:03:20 2025 +0100
JAMES-3872 JMAP Email/get: Fix and test attachment read level (backport)
(#2869)
* JAMES-3872 JMAP Email/get: Fix and test attachment read level
* JAMES-3872 JMAP: Attachment read level cannot serve headers
Previous implementation lied by reusing Emil headers as body part
headers, but not exposed. While functionally correct it resulted
in unnecessary JSON serialization and unneeded computations
---
.../jmap/rfc8621/distributed/ReadLevelTest.java | 34 +++-
.../rfc8621/contract/EmailGetMethodContract.scala | 194 +++++++++++++++++++++
.../rfc8621/contract/EmailSetMethodContract.scala | 2 +-
.../scala/org/apache/james/jmap/mail/Email.scala | 5 +-
.../org/apache/james/jmap/mail/EmailBodyPart.scala | 9 +-
5 files changed, 234 insertions(+), 10 deletions(-)
diff --git
a/server/protocols/jmap-rfc-8621-integration-tests/distributed-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/distributed/ReadLevelTest.java
b/server/protocols/jmap-rfc-8621-integration-tests/distributed-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/distributed/ReadLevelTest.java
index 67cd602b8c..197302f0a2 100644
---
a/server/protocols/jmap-rfc-8621-integration-tests/distributed-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/distributed/ReadLevelTest.java
+++
b/server/protocols/jmap-rfc-8621-integration-tests/distributed-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/distributed/ReadLevelTest.java
@@ -155,7 +155,7 @@ class ReadLevelTest {
" \"accountId\":
\"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6\"," +
" \"ids\": [\"" + messageId.serialize() + "\"]," +
" \"properties\": [\"id\", \"size\", \"mailboxIds\",
\"mailboxIds\", \"blobId\", " +
- " \"threadId\", \"receivedAt\"]" +
+ " \"threadId\", \"receivedAt\",
\"keywords\"]" +
" }," +
" \"c1\"]]" +
"} ";
@@ -230,6 +230,38 @@ class ReadLevelTest {
.hasSize(1);
}
+ @Test
+ void gettingAttachmentDetailsShouldReadBlobOnce(GuiceJamesServer server) {
+ StatementRecorder statementRecorder =
server.getProbe(TestingSessionProbe.class)
+ .getTestingSession()
+ .recordStatements();
+
+ String request = "{" +
+ " \"using\": [\"urn:ietf:params:jmap:core\",
\"urn:ietf:params:jmap:mail\"]," +
+ " \"methodCalls\": [[" +
+ " \"Email/get\"," +
+ " {" +
+ " \"accountId\":
\"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6\"," +
+ " \"ids\": [\"" + messageId.serialize() + "\"]," +
+ " \"bodyProperties\":[\"blobId\", \"name\", \"type\"]," +
+ " \"properties\": [\"id\", \"size\", \"mailboxIds\",
\"mailboxIds\", \"blobId\", " +
+ " \"threadId\", \"receivedAt\",
\"messageId\", \"inReplyTo\", " +
+ " \"references\", \"to\", \"cc\", \"bcc\",
\"from\", \"sender\", " +
+ " \"replyTo\", \"subject\", \"headers\",
\"header:anything\", " +
+ " \"preview\", \"hasAttachment\",
\"attachments\"]" +
+ " }," +
+ " \"c1\"]]" +
+ "} ";
+ with()
+ .header(HttpHeaderNames.ACCEPT.toString(),
Fixture.ACCEPT_RFC8621_VERSION_HEADER())
+ .body(request)
+ .post();
+
+ assertThat(statementRecorder.listExecutedStatements(
+ StatementRecorder.Selector.preparedStatementStartingWith("SELECT *
FROM blobs")))
+ .hasSize(1);
+ }
+
@Test
void
gettingEmailFastViewShouldReadBlobTwiceUponCacheMisses(GuiceJamesServer server)
{
server.getProbe(JmapGuiceProbe.class).clearMessageFastViewProjection();
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 2d98222a51..0503e12a81 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
@@ -5085,6 +5085,119 @@ trait EmailGetMethodContract {
|}""".stripMargin)
}
+ @Test
+ def
shouldUseFastViewWithAttachmentMetadataWhenSupportedBodyPropertiesAtAttachmentReadLevel(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": ["size", "name",
"type", "charset", "disposition", "cid"]
+ | },
+ | "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",
"methodResponses[0][1].list[0].attachments[0].blobId",
"methodResponses[0][1].list[0].attachments[1].blobId")
+ .isEqualTo(
+ s"""{
+ | "sessionState": "${SESSION_STATE.value}",
+ | "methodResponses": [
+ | [
+ | "Email/get",
+ | {
+ | "accountId":
"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "notFound": [],
+ | "list": [{
+ | "preview": "Main test
message...",
+ | "to": [{
+ | "name": "Alice",
+ | "email":
"[email protected]"
+ | }],
+ | "id": "${messageId.serialize}",
+ | "mailboxIds": {
+ |
"${mailboxId.serialize}": true
+ | },
+ | "from": [{
+ | "name": "Bob",
+ | "email":
"[email protected]"
+ | }],
+ | "keywords": {
+ |
+ | },
+ | "receivedAt":
"$${json-unit.ignore}",
+ | "sentAt":
"$${json-unit.ignore}",
+ | "hasAttachment": true,
+ | "attachments": [{
+ | "charset":
"US-ASCII",
+ | "disposition":
"attachment",
+ | "size": 102,
+ | "name":
"yyy.txt",
+ | "type":
"application/json"
+ | },
+ | {
+ | "charset":
"US-ASCII",
+ | "disposition":
"attachment",
+ | "size": 102,
+ | "name":
"xxx.txt",
+ | "type":
"application/json"
+ | }
+ | ],
+ | "subject": "My subject",
+ | "size": 1011
+ | }]
+ | },
+ | "c1"
+ | ]
+ | ]
+ |}""".stripMargin)
+ }
+
@Test
def
shouldBeAbleToDownloadAttachmentBaseOnFastViewWithAttachmentsMetadataResult(server:
GuiceJamesServer): Unit = {
val path = MailboxPath.inbox(BOB)
@@ -5145,7 +5258,88 @@ trait EmailGetMethodContract {
val blob = `given`
.basePath("")
+ .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER).log().all()
+ .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
shouldBeAbleToDownloadAttachmentBaseOnFastViewWithAttachmentsMetadataResultWithReadLevelFll(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", "partId",
"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).log().all()
.when
.get(s"/download/29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6/$blobId")
.`then`
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 fb494af4e8..0a2047e40c 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
@@ -4992,7 +4992,7 @@ trait EmailSetMethodContract {
|}""".stripMargin)
assertThatJson(response)
- .inPath(s"methodResponses[1][1].list[0]")
+ .inPath("methodResponses[1][1].list[0]")
.isEqualTo(
s"""{
| "id": "$messageId",
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 a0de324aea..0a77ab425e 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
@@ -163,7 +163,7 @@ case class UnparsedEmailId(id: Id)
object ReadLevel {
private val metadataProperty: Seq[NonEmptyString] = Seq("id", "size",
"mailboxIds",
- "mailboxIds", "blobId", "threadId", "receivedAt")
+ "mailboxIds", "blobId", "threadId", "receivedAt", "keywords")
private val fastViewProperty: Seq[NonEmptyString] = Seq("preview",
"hasAttachment")
private val attachmentsMetadataViewProperty: Seq[NonEmptyString] =
Seq("attachments")
private val fullProperty: Seq[NonEmptyString] = Seq("bodyStructure",
"textBody", "htmlBody", "bodyValues")
@@ -203,13 +203,14 @@ 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")
+ private val availableFetchingBodyPropertiesForFastViewWithAttachments =
Seq("blobId", "size", "name", "type", "charset", "disposition", "cid")
def supportedByFastViewWithAttachments(bodyProperties: Option[Properties]):
Boolean =
bodyProperties.exists(supportedByFastViewWithAttachments)
private def supportedByFastViewWithAttachments(properties: Properties):
Boolean =
properties.value
+ .map(s => s.value)
.map(availableFetchingBodyPropertiesForFastViewWithAttachments.contains)
.reduce(_&&_)
}
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 b885e079fd..0986a84493 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
@@ -82,12 +82,9 @@ object EmailBodyPart {
Option(Disposition.ATTACHMENT)
}
- def parsePartIdFromBlobId(blobId: String): PartId =
- PartId(blobId.substring(blobId.lastIndexOf("_") +
1).asInstanceOf[PartIdValue])
-
- EmailBodyPart(partId =
parsePartIdFromBlobId(attachment.getAttachmentId.getId),
+ EmailBodyPart(partId = PartId.parse("1").get,
blobId = BlobId.of(attachment.getAttachmentId.getId).toOption,
- headers = entity.getHeader.getFields.asScala.toList.map(EmailHeader(_)),
+ headers = List(),
size = Size.sanitizeSize(attachment.getAttachment.getSize),
name = attachment.getName.map(Name(_)).toScala,
`type` = Type(attachment.getAttachment.getType.mimeType().asString()),
@@ -98,7 +95,7 @@ object EmailBodyPart {
location = Option.empty,
subParts = Option.empty,
entity = entity,
- specificHeaders =
EmailHeaders.extractSpecificHeaders(properties)(zoneId, entity.getHeader))
+ specificHeaders = Map())
}
def of(properties: Option[Properties], zoneId: ZoneId, blobId: BlobId,
message: Message): Try[EmailBodyPart] =
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]