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 73d549cd5a JAMES-4196 Allow CRUD operation on shared folder for JMAP
(#2988)
73d549cd5a is described below
commit 73d549cd5ab2365a6c400a88dda33a06b206961d
Author: Benoit TELLIER <[email protected]>
AuthorDate: Fri Apr 3 14:01:01 2026 +0700
JAMES-4196 Allow CRUD operation on shared folder for JMAP (#2988)
---
.../contract/MailboxGetMethodContract.scala | 108 ++++++++++
.../contract/MailboxSetMethodContract.scala | 224 ++++++++++++++++++++-
.../apache/james/jmap/mail/MailboxFactory.scala | 4 +-
.../scala/org/apache/james/jmap/mail/Rights.scala | 3 +-
.../org/apache/james/jmap/mail/RightsTest.scala | 4 +-
5 files changed, 335 insertions(+), 8 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/MailboxGetMethodContract.scala
b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/MailboxGetMethodContract.scala
index f378f52c7b..9d5d142ee2 100644
---
a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/MailboxGetMethodContract.scala
+++
b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/MailboxGetMethodContract.scala
@@ -1115,6 +1115,114 @@ trait MailboxGetMethodContract {
.body(s"$FIRST_MAILBOX.myRights.maySetKeywords", equalTo(false))
}
+ @Test
+ def
getMailboxesShouldReturnMayCreateChildTrueWhenDelegatedWithCreateMailboxRight(server:
GuiceJamesServer): Unit = {
+ val sharedMailboxName: String = "AndreShared"
+ val andreMailboxPath: MailboxPath = MailboxPath.forUser(ANDRE,
sharedMailboxName)
+ val mailboxId: String = server.getProbe(classOf[MailboxProbeImpl])
+ .createMailbox(andreMailboxPath)
+ .serialize
+
+ server.getProbe(classOf[ACLProbeImpl])
+ .replaceRights(andreMailboxPath, BOB.asString, new
MailboxACL.Rfc4314Rights(Right.Lookup, Right.CreateMailbox))
+
+ `given`
+ .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+ .body(s"""{
+ | "using": [
+ | "urn:ietf:params:jmap:core",
+ | "urn:ietf:params:jmap:mail",
+ | "urn:apache:james:params:jmap:mail:shares"],
+ | "methodCalls": [[
+ | "Mailbox/get",
+ | {
+ | "accountId":
"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "ids": ["${mailboxId}"]
+ | },
+ | "c1"]]
+ |}""".stripMargin)
+ .when
+ .post
+ .`then`
+ .statusCode(SC_OK)
+ .body(s"$ARGUMENTS.list", hasSize(1))
+ .body(s"$FIRST_MAILBOX.myRights.mayCreateChild", equalTo(true))
+ .body(s"$FIRST_MAILBOX.myRights.maySubmit", equalTo(false))
+ }
+
+ @Test
+ def getMailboxesShouldReturnMaySubmitTrueWhenDelegatedWithPostRight(server:
GuiceJamesServer): Unit = {
+ val sharedMailboxName: String = "AndreShared"
+ val andreMailboxPath: MailboxPath = MailboxPath.forUser(ANDRE,
sharedMailboxName)
+ val mailboxId: String = server.getProbe(classOf[MailboxProbeImpl])
+ .createMailbox(andreMailboxPath)
+ .serialize
+
+ server.getProbe(classOf[ACLProbeImpl])
+ .replaceRights(andreMailboxPath, BOB.asString, new
MailboxACL.Rfc4314Rights(Right.Lookup, Right.Post))
+
+ `given`
+ .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+ .body(s"""{
+ | "using": [
+ | "urn:ietf:params:jmap:core",
+ | "urn:ietf:params:jmap:mail",
+ | "urn:apache:james:params:jmap:mail:shares"],
+ | "methodCalls": [[
+ | "Mailbox/get",
+ | {
+ | "accountId":
"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "ids": ["${mailboxId}"]
+ | },
+ | "c1"]]
+ |}""".stripMargin)
+ .when
+ .post
+ .`then`
+ .statusCode(SC_OK)
+ .body(s"$ARGUMENTS.list", hasSize(1))
+ .body(s"$FIRST_MAILBOX.myRights.mayCreateChild", equalTo(false))
+ .body(s"$FIRST_MAILBOX.myRights.maySubmit", equalTo(true))
+ }
+
+ @Test
+ def getMailboxesShouldIncludeCreateMailboxRightInRightsFieldWhenSet(server:
GuiceJamesServer): Unit = {
+ val sharedMailboxName: String = "AndreShared"
+ val andreMailboxPath: MailboxPath = MailboxPath.forUser(ANDRE,
sharedMailboxName)
+ val mailboxId: String = server.getProbe(classOf[MailboxProbeImpl])
+ .createMailbox(andreMailboxPath)
+ .serialize
+
+ server.getProbe(classOf[ACLProbeImpl])
+ .replaceRights(andreMailboxPath, BOB.asString, new
MailboxACL.Rfc4314Rights(Right.Lookup, Right.Read, Right.CreateMailbox))
+
+ `given`
+ .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+ .body(s"""{
+ | "using": [
+ | "urn:ietf:params:jmap:core",
+ | "urn:ietf:params:jmap:mail",
+ | "urn:apache:james:params:jmap:mail:shares"],
+ | "methodCalls": [[
+ | "Mailbox/get",
+ | {
+ | "accountId":
"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "ids": ["${mailboxId}"]
+ | },
+ | "c1"]]
+ |}""".stripMargin)
+ .when
+ .post
+ .`then`
+ .statusCode(SC_OK)
+ .body(s"$ARGUMENTS.list", hasSize(1))
+ .body(s"$FIRST_MAILBOX.rights['${BOB.asString}']", containsInAnyOrder(
+ Right.Lookup.asCharacter.toString,
+ Right.Read.asCharacter.toString,
+ Right.CreateMailbox.asCharacter.toString))
+ .body(s"$FIRST_MAILBOX.myRights.mayCreateChild", equalTo(true))
+ }
+
@Test
@Tag(CategoryTags.BASIC_FEATURE)
def getMailboxesShouldNotReturnInboxRoleToShareeWhenDelegatedInbox(server:
GuiceJamesServer): Unit = {
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/MailboxSetMethodContract.scala
b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/MailboxSetMethodContract.scala
index 5f83406267..fd3a02ba4c 100644
---
a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/MailboxSetMethodContract.scala
+++
b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/MailboxSetMethodContract.scala
@@ -2729,6 +2729,41 @@ trait MailboxSetMethodContract {
.body("methodResponses[0][1].destroyed[0]", equalTo(mailboxId.serialize))
}
+ @Test
+ def deleteSharedMailboxShouldFailWhenDoesNotHaveRight(server:
GuiceJamesServer): Unit = {
+ val path = MailboxPath.forUser(ANDRE, "mailbox")
+ val mailboxId: MailboxId =
server.getProbe(classOf[MailboxProbeImpl]).createMailbox(path)
+ server.getProbe(classOf[ACLProbeImpl])
+ .replaceRights(path, BOB.asString, MailboxACL.FULL_RIGHTS.except(new
MailboxACL.Rfc4314Rights(Right.DeleteMailbox)))
+
+ `given`
+ .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+ .body(
+ s"""
+ |{
+ | "using": [ "urn:ietf:params:jmap:core",
"urn:ietf:params:jmap:mail", "urn:apache:james:params:jmap:mail:shares" ],
+ | "methodCalls": [
+ | [
+ | "Mailbox/set",
+ | {
+ | "accountId":
"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "destroy": ["${mailboxId.serialize}"]
+ | },
+ | "c1"
+ | ]
+ | ]
+ |}
+ |""".stripMargin)
+ .when
+ .post
+ .`then`
+ .log().ifValidationFails()
+ .statusCode(SC_OK)
+ .contentType(JSON)
+ .body("methodResponses[0][1].notDestroyed", hasKey(mailboxId.serialize))
+ .body("methodResponses[0][1].notDestroyed." + mailboxId.serialize +
".type", equalTo("invalidArguments"))
+ }
+
@Test
def deleteShouldHandleInvalidMailboxId(): Unit = {
val request =
@@ -3910,13 +3945,13 @@ trait MailboxSetMethodContract {
| "mayRemoveItems": true,
| "maySetSeen": true,
| "maySetKeywords": true,
- | "mayCreateChild": false,
+ | "mayCreateChild": true,
| "mayRename": true,
| "mayDelete": true,
- | "maySubmit": false
+ | "maySubmit": true
| },
| "rights": {
- | "[email protected]": [ "a", "e", "i", "l", "p", "r",
"s", "t", "w", "x" ]
+ | "[email protected]": [ "a", "e", "i", "k", "l", "p",
"r", "s", "t", "w", "x" ]
| }
| }
| ],
@@ -9075,4 +9110,187 @@ trait MailboxSetMethodContract {
}
}
+ @Test
+ def
createSubfolderInSharedMailboxShouldSucceedWhenHasCreateMailboxRight(server:
GuiceJamesServer): Unit = {
+ val path = MailboxPath.forUser(ANDRE, "shared")
+ val sharedMailboxId: MailboxId =
server.getProbe(classOf[MailboxProbeImpl]).createMailbox(path)
+ server.getProbe(classOf[ACLProbeImpl])
+ .replaceRights(path, BOB.asString, new
MailboxACL.Rfc4314Rights(Right.Lookup, Right.CreateMailbox))
+
+ val response = `given`
+ .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+ .body(s"""{
+ | "using": [ "urn:ietf:params:jmap:core",
"urn:ietf:params:jmap:mail", "urn:apache:james:params:jmap:mail:shares" ],
+ | "methodCalls": [
+ | [ "Mailbox/set",
+ | {
+ | "accountId":
"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "create": {
+ | "C42": {
+ | "name": "child",
+ | "parentId": "${sharedMailboxId.serialize}"
+ | }
+ | }
+ | }, "c1"],
+ | [ "Mailbox/get",
+ | {
+ | "accountId":
"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "ids": ["#C42"],
+ | "properties": ["id", "name", "parentId"]
+ | }, "c2"]]
+ |}""".stripMargin)
+ .when
+ .post
+ .`then`
+ .log().ifValidationFails()
+ .statusCode(SC_OK)
+ .extract.body.asString
+
+ assertThatJson(response)
+ .inPath("methodResponses[1][1].list[0]")
+ .isEqualTo(s"""{
+ | "id": "$${json-unit.ignore}",
+ | "name": "child",
+ | "parentId": "${sharedMailboxId.serialize}"
+ |}""".stripMargin)
+ }
+
+ @Test
+ def
renameSubfolderInSharedMailboxShouldSucceedWhenHasDeleteMailboxRight(server:
GuiceJamesServer): Unit = {
+ val parentPath = MailboxPath.forUser(ANDRE, "shared")
+ val parentId: MailboxId =
server.getProbe(classOf[MailboxProbeImpl]).createMailbox(parentPath)
+ val childPath = parentPath.child("child", '.')
+ val childId: MailboxId =
server.getProbe(classOf[MailboxProbeImpl]).createMailbox(childPath)
+
+ server.getProbe(classOf[ACLProbeImpl])
+ .replaceRights(parentPath, BOB.asString, new
MailboxACL.Rfc4314Rights(Right.CreateMailbox))
+ server.getProbe(classOf[ACLProbeImpl])
+ .replaceRights(childPath, BOB.asString, new
MailboxACL.Rfc4314Rights(Right.Lookup, Right.DeleteMailbox))
+
+ val response = `given`
+ .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+ .body(s"""{
+ | "using": [ "urn:ietf:params:jmap:core",
"urn:ietf:params:jmap:mail", "urn:apache:james:params:jmap:mail:shares" ],
+ | "methodCalls": [
+ | [ "Mailbox/set",
+ | {
+ | "accountId":
"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "update": {
+ | "${childId.serialize}": {
+ | "name": "renamed"
+ | }
+ | }
+ | }, "c1"],
+ | [ "Mailbox/get",
+ | {
+ | "accountId":
"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "ids": ["${childId.serialize}"],
+ | "properties": ["id", "name", "parentId"]
+ | }, "c2"]]
+ |}""".stripMargin)
+ .when
+ .post
+ .`then`
+ .log().ifValidationFails()
+ .statusCode(SC_OK)
+ .extract.body.asString
+
+ assertThatJson(response)
+ .inPath("methodResponses[1][1].list[0]")
+ .isEqualTo(s"""{
+ | "id": "${childId.serialize}",
+ | "name": "renamed",
+ | "parentId": "${parentId.serialize}"
+ |}""".stripMargin)
+ }
+
+ @Test
+ def moveSubfolderInSharedMailboxShouldSucceedWhenHasRequiredRights(server:
GuiceJamesServer): Unit = {
+ val srcParentPath = MailboxPath.forUser(ANDRE, "src")
+ server.getProbe(classOf[MailboxProbeImpl]).createMailbox(srcParentPath)
+ val childPath = srcParentPath.child("child", '.')
+ val childId: MailboxId =
server.getProbe(classOf[MailboxProbeImpl]).createMailbox(childPath)
+ val destParentPath = MailboxPath.forUser(ANDRE, "dest")
+ val destParentId: MailboxId =
server.getProbe(classOf[MailboxProbeImpl]).createMailbox(destParentPath)
+
+ server.getProbe(classOf[ACLProbeImpl])
+ .replaceRights(childPath, BOB.asString, new
MailboxACL.Rfc4314Rights(Right.Lookup, Right.DeleteMailbox))
+ server.getProbe(classOf[ACLProbeImpl])
+ .replaceRights(destParentPath, BOB.asString, new
MailboxACL.Rfc4314Rights(Right.Lookup, Right.CreateMailbox))
+
+ val response = `given`
+ .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+ .body(s"""{
+ | "using": [ "urn:ietf:params:jmap:core",
"urn:ietf:params:jmap:mail", "urn:apache:james:params:jmap:mail:shares" ],
+ | "methodCalls": [
+ | [ "Mailbox/set",
+ | {
+ | "accountId":
"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "update": {
+ | "${childId.serialize}": {
+ | "parentId": "${destParentId.serialize}"
+ | }
+ | }
+ | }, "c1"],
+ | [ "Mailbox/get",
+ | {
+ | "accountId":
"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "ids": ["${childId.serialize}"],
+ | "properties": ["id", "name", "parentId"]
+ | }, "c2"]]
+ |}""".stripMargin)
+ .when
+ .post
+ .`then`
+ .log().ifValidationFails()
+ .statusCode(SC_OK)
+ .extract.body.asString
+
+ assertThatJson(response)
+ .inPath("methodResponses[1][1].list[0]")
+ .isEqualTo(s"""{
+ | "id": "${childId.serialize}",
+ | "name": "child",
+ | "parentId": "${destParentId.serialize}"
+ |}""".stripMargin)
+ }
+
+ @Test
+ def
deleteSubfolderInSharedMailboxShouldSucceedWhenHasDeleteMailboxRight(server:
GuiceJamesServer): Unit = {
+ val parentPath = MailboxPath.forUser(ANDRE, "shared")
+ server.getProbe(classOf[MailboxProbeImpl]).createMailbox(parentPath)
+ val childPath = parentPath.child("child", '.')
+ val childId: MailboxId =
server.getProbe(classOf[MailboxProbeImpl]).createMailbox(childPath)
+
+ server.getProbe(classOf[ACLProbeImpl])
+ .replaceRights(childPath, BOB.asString, new
MailboxACL.Rfc4314Rights(Right.Lookup, Right.DeleteMailbox))
+
+ val response = `given`
+ .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+ .body(s"""{
+ | "using": [ "urn:ietf:params:jmap:core",
"urn:ietf:params:jmap:mail", "urn:apache:james:params:jmap:mail:shares" ],
+ | "methodCalls": [
+ | [ "Mailbox/set",
+ | {
+ | "accountId":
"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "destroy": ["${childId.serialize}"]
+ | }, "c1"],
+ | [ "Mailbox/get",
+ | {
+ | "accountId":
"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "ids": ["${childId.serialize}"]
+ | }, "c2"]]
+ |}""".stripMargin)
+ .when
+ .post
+ .`then`
+ .log().ifValidationFails()
+ .statusCode(SC_OK)
+ .extract.body.asString
+
+ assertThatJson(response)
+ .inPath("methodResponses[1][1].notFound")
+ .isEqualTo(s"""["${childId.serialize}"]""")
+ }
+
}
diff --git
a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/MailboxFactory.scala
b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/MailboxFactory.scala
index 8589f879d0..9e980f76f6 100644
---
a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/MailboxFactory.scala
+++
b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/MailboxFactory.scala
@@ -110,10 +110,10 @@ class MailboxFactory @Inject() (mailboxManager:
MailboxManager,
mayRemoveItems = MayRemoveItems(rights.contains(Right.DeleteMessages)),
maySetSeen = MaySetSeen(rights.contains(Right.Seen)),
maySetKeywords = MaySetKeywords(rights.contains(Right.Write)),
- mayCreateChild = MayCreateChild(false),
+ mayCreateChild = MayCreateChild(rights.contains(Right.CreateMailbox)),
mayRename = MayRename(rights.contains(Right.DeleteMailbox)),
mayDelete = MayDelete(rights.contains(Right.DeleteMailbox)),
- maySubmit = MaySubmit(false))
+ maySubmit = MaySubmit(rights.contains(Right.Post)))
}
def create(mailboxMetaData: MailboxMetaData,
diff --git
a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/Rights.scala
b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/Rights.scala
index 888cca1395..3a37cafe92 100644
---
a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/Rights.scala
+++
b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/Rights.scala
@@ -68,8 +68,9 @@ object Right {
val Write = Right(JavaRight.Write)
val Post = Right(JavaRight.Post)
val DeleteMailbox = Right(JavaRight.DeleteMailbox)
+ val CreateMailbox = Right(JavaRight.CreateMailbox)
- private val allRights = Seq(Administer, Expunge, Insert, Lookup, Read, Seen,
DeleteMessages, Write, Post, DeleteMailbox)
+ private val allRights = Seq(Administer, Expunge, Insert, Lookup, Read, Seen,
DeleteMessages, Write, Post, DeleteMailbox, CreateMailbox)
def forRight(right: JavaRight): Option[Right] =
allRights.find(_.right.equals(right))
diff --git
a/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/mail/RightsTest.scala
b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/mail/RightsTest.scala
index 13f174a92f..13a1b10068 100644
---
a/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/mail/RightsTest.scala
+++
b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/mail/RightsTest.scala
@@ -58,7 +58,7 @@ class RightsTest extends AnyWordSpec with Matchers {
Right.forChar('p') must be(Some(Right.Post))
}
"return empty when unknown" in {
- Right.forChar('k') must be(None)
+ Right.forChar('b') must be(None)
}
}
"From ACL" should {
@@ -84,7 +84,7 @@ class RightsTest extends AnyWordSpec with Matchers {
val acl = new JavaMailboxACL(Map(
USER_ENTRYKEY ->
JavaRfc4314Rights.fromSerializedRfc4314Rights("aetxk")).asJava)
- Rights.fromACL(MailboxACL.fromJava(acl)) must
be(Rights.of(USER_ENTRYKEY, Seq(Right.Administer, Right.Expunge,
Right.DeleteMessages, Right.DeleteMailbox)))
+ Rights.fromACL(MailboxACL.fromJava(acl)) must
be(Rights.of(USER_ENTRYKEY, Seq(Right.Administer, Right.Expunge,
Right.CreateMailbox, Right.DeleteMessages, Right.DeleteMailbox)))
}
}
"To ACL" should {
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]