This is an automated email from the ASF dual-hosted git repository.

rcordier 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 3629f5a2b0 JAMES-4190 Allow registering read only IMAP annotations 
(#2975)
3629f5a2b0 is described below

commit 3629f5a2b0f5abece2cd78d2978ea3c90ea1c27f
Author: Benoit TELLIER <[email protected]>
AuthorDate: Mon Mar 16 03:53:05 2026 +0100

    JAMES-4190 Allow registering read only IMAP annotations (#2975)
---
 .../james/mailbox/ReadOnlyAnnotationPredicate.java | 37 +++++++++++++++++++
 .../resources/META-INF/spring/spring-mailbox.xml   |  6 +++
 .../AggregatedReadOnlyAnnotationPredicate.java     | 42 +++++++++++++++++++++
 .../store/StoreMailboxAnnotationManager.java       | 25 ++++++++++++-
 .../store/StoreMailboxManagerAnnotationTest.java   | 39 +++++++++++++++++++-
 .../imap/processor/SetMetadataProcessorTest.java   | 14 +++++++
 .../org/apache/james/modules/MailboxModule.java    |  2 +
 .../AnnotationModule.java}                         | 43 ++++++++++------------
 8 files changed, 181 insertions(+), 27 deletions(-)

diff --git 
a/mailbox/api/src/main/java/org/apache/james/mailbox/ReadOnlyAnnotationPredicate.java
 
b/mailbox/api/src/main/java/org/apache/james/mailbox/ReadOnlyAnnotationPredicate.java
new file mode 100644
index 0000000000..b6952ea4f2
--- /dev/null
+++ 
b/mailbox/api/src/main/java/org/apache/james/mailbox/ReadOnlyAnnotationPredicate.java
@@ -0,0 +1,37 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *   http://www.apache.org/licenses/LICENSE-2.0                 *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.mailbox;
+
+import org.apache.james.mailbox.model.MailboxAnnotationKey;
+
+/**
+ * Extension point allowing modules to declare annotation keys that are
+ * server-computed and must not be modified by clients via SETMETADATA.
+ *
+ * Implementations are collected via Guice multibinding and aggregated by
+ * {@link 
org.apache.james.mailbox.store.AggregatedReadOnlyAnnotationPredicate}.
+ */
+@FunctionalInterface
+public interface ReadOnlyAnnotationPredicate {
+
+    ReadOnlyAnnotationPredicate ALLOW_ALL = key -> false;
+
+    boolean isReadOnly(MailboxAnnotationKey key);
+}
diff --git 
a/mailbox/spring/src/main/resources/META-INF/spring/spring-mailbox.xml 
b/mailbox/spring/src/main/resources/META-INF/spring/spring-mailbox.xml
index 1c69f8705e..d342629c3a 100644
--- a/mailbox/spring/src/main/resources/META-INF/spring/spring-mailbox.xml
+++ b/mailbox/spring/src/main/resources/META-INF/spring/spring-mailbox.xml
@@ -47,9 +47,15 @@
      -->
     <bean id="messageParser" 
class="org.apache.james.mailbox.store.mail.model.impl.MessageParserImpl"/>
 
+    <bean id="allowAllReadOnlyAnnotationPredicate"
+          
class="org.springframework.beans.factory.config.FieldRetrievingFactoryBean">
+        <property name="staticField" 
value="org.apache.james.mailbox.ReadOnlyAnnotationPredicate.ALLOW_ALL"/>
+    </bean>
+
     <bean id="storeMailboxAnnotationManager" 
class="org.apache.james.mailbox.store.StoreMailboxAnnotationManager">
         <constructor-arg index="0" ref="messageMapperFactory" />
         <constructor-arg index="1" ref="storeRightManager" />
+        <constructor-arg index="2" ref="allowAllReadOnlyAnnotationPredicate" />
     </bean>
 
     <bean id="storeRightManager" 
class="org.apache.james.mailbox.store.StoreRightManager" >
diff --git 
a/mailbox/store/src/main/java/org/apache/james/mailbox/store/AggregatedReadOnlyAnnotationPredicate.java
 
b/mailbox/store/src/main/java/org/apache/james/mailbox/store/AggregatedReadOnlyAnnotationPredicate.java
new file mode 100644
index 0000000000..26284682ba
--- /dev/null
+++ 
b/mailbox/store/src/main/java/org/apache/james/mailbox/store/AggregatedReadOnlyAnnotationPredicate.java
@@ -0,0 +1,42 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *   http://www.apache.org/licenses/LICENSE-2.0                 *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.mailbox.store;
+
+import java.util.Set;
+
+import jakarta.inject.Inject;
+
+import org.apache.james.mailbox.ReadOnlyAnnotationPredicate;
+import org.apache.james.mailbox.model.MailboxAnnotationKey;
+
+public class AggregatedReadOnlyAnnotationPredicate implements 
ReadOnlyAnnotationPredicate {
+
+    private final Set<ReadOnlyAnnotationPredicate> predicates;
+
+    @Inject
+    public 
AggregatedReadOnlyAnnotationPredicate(Set<ReadOnlyAnnotationPredicate> 
predicates) {
+        this.predicates = predicates;
+    }
+
+    @Override
+    public boolean isReadOnly(MailboxAnnotationKey key) {
+        return predicates.stream().anyMatch(predicate -> 
predicate.isReadOnly(key));
+    }
+}
diff --git 
a/mailbox/store/src/main/java/org/apache/james/mailbox/store/StoreMailboxAnnotationManager.java
 
b/mailbox/store/src/main/java/org/apache/james/mailbox/store/StoreMailboxAnnotationManager.java
index 544043c265..89a38e71dc 100644
--- 
a/mailbox/store/src/main/java/org/apache/james/mailbox/store/StoreMailboxAnnotationManager.java
+++ 
b/mailbox/store/src/main/java/org/apache/james/mailbox/store/StoreMailboxAnnotationManager.java
@@ -27,6 +27,7 @@ import jakarta.inject.Inject;
 
 import org.apache.james.mailbox.MailboxAnnotationManager;
 import org.apache.james.mailbox.MailboxSession;
+import org.apache.james.mailbox.ReadOnlyAnnotationPredicate;
 import org.apache.james.mailbox.exception.AnnotationException;
 import org.apache.james.mailbox.exception.InsufficientRightsException;
 import org.apache.james.mailbox.exception.MailboxException;
@@ -49,24 +50,41 @@ public class StoreMailboxAnnotationManager implements 
MailboxAnnotationManager {
     private final StoreRightManager rightManager;
     private final int limitOfAnnotations;
     private final int limitAnnotationSize;
+    private final ReadOnlyAnnotationPredicate readOnlyPredicate;
 
     @Inject
     public StoreMailboxAnnotationManager(MailboxSessionMapperFactory 
mailboxSessionMapperFactory,
-                                         StoreRightManager rightManager) {
+                                         StoreRightManager rightManager,
+                                         ReadOnlyAnnotationPredicate 
readOnlyPredicate) {
         this(mailboxSessionMapperFactory,
             rightManager,
             MailboxConstants.DEFAULT_LIMIT_ANNOTATIONS_ON_MAILBOX,
-            MailboxConstants.DEFAULT_LIMIT_ANNOTATION_SIZE);
+            MailboxConstants.DEFAULT_LIMIT_ANNOTATION_SIZE,
+            readOnlyPredicate);
+    }
+
+    public StoreMailboxAnnotationManager(MailboxSessionMapperFactory 
mailboxSessionMapperFactory, StoreRightManager rightManager) {
+        this(mailboxSessionMapperFactory, rightManager, 
ReadOnlyAnnotationPredicate.ALLOW_ALL);
     }
 
     public StoreMailboxAnnotationManager(MailboxSessionMapperFactory 
mailboxSessionMapperFactory,
                                          StoreRightManager rightManager,
                                          int limitOfAnnotations,
                                          int limitAnnotationSize) {
+        this(mailboxSessionMapperFactory, rightManager, limitOfAnnotations, 
limitAnnotationSize,
+            ReadOnlyAnnotationPredicate.ALLOW_ALL);
+    }
+
+    public StoreMailboxAnnotationManager(MailboxSessionMapperFactory 
mailboxSessionMapperFactory,
+                                         StoreRightManager rightManager,
+                                         int limitOfAnnotations,
+                                         int limitAnnotationSize,
+                                         ReadOnlyAnnotationPredicate 
readOnlyPredicate) {
         this.mailboxSessionMapperFactory = mailboxSessionMapperFactory;
         this.rightManager = rightManager;
         this.limitOfAnnotations = limitOfAnnotations;
         this.limitAnnotationSize = limitAnnotationSize;
+        this.readOnlyPredicate = readOnlyPredicate;
     }
 
     @Override
@@ -114,6 +132,9 @@ public class StoreMailboxAnnotationManager implements 
MailboxAnnotationManager {
                 .switchIfEmpty(Mono.error(new 
MailboxNotFoundException(mailboxPath)))
             .flatMapMany(mailboxId -> Flux.fromIterable(mailboxAnnotations)
                 .concatMap(annotation -> {
+                    if (readOnlyPredicate.isReadOnly(annotation.getKey())) {
+                        return Mono.error(new AnnotationException("annotation 
is read-only: " + annotation.getKey().asString()));
+                    }
                     if (annotation.isNil()) {
                         return 
Mono.from(annotationMapper.deleteAnnotationReactive(mailboxId, 
annotation.getKey()));
                     }
diff --git 
a/mailbox/store/src/test/java/org/apache/james/mailbox/store/StoreMailboxManagerAnnotationTest.java
 
b/mailbox/store/src/test/java/org/apache/james/mailbox/store/StoreMailboxManagerAnnotationTest.java
index 338f7a5299..9677c17d85 100644
--- 
a/mailbox/store/src/test/java/org/apache/james/mailbox/store/StoreMailboxManagerAnnotationTest.java
+++ 
b/mailbox/store/src/test/java/org/apache/james/mailbox/store/StoreMailboxManagerAnnotationTest.java
@@ -34,6 +34,8 @@ import java.util.Set;
 import org.apache.james.core.Username;
 import org.apache.james.mailbox.MailboxSession;
 import org.apache.james.mailbox.MailboxSessionUtil;
+import org.apache.james.mailbox.ReadOnlyAnnotationPredicate;
+import org.apache.james.mailbox.exception.AnnotationException;
 import org.apache.james.mailbox.exception.MailboxException;
 import org.apache.james.mailbox.model.Mailbox;
 import org.apache.james.mailbox.model.MailboxACL;
@@ -58,6 +60,7 @@ import reactor.core.publisher.Mono;
 class StoreMailboxManagerAnnotationTest {
     static final MailboxAnnotationKey PRIVATE_KEY = new 
MailboxAnnotationKey("/private/comment");
     static final MailboxAnnotationKey SHARED_KEY = new 
MailboxAnnotationKey("/shared/comment");
+    static final MailboxAnnotationKey SERVER_KEY = new 
MailboxAnnotationKey("/shared/vendor/server/quota");
 
     static final MailboxAnnotation PRIVATE_ANNOTATION = 
MailboxAnnotation.newInstance(PRIVATE_KEY, "My comment");
     static final MailboxAnnotation SHARED_ANNOTATION =  
MailboxAnnotation.newInstance(SHARED_KEY, "My shared comment");
@@ -102,7 +105,11 @@ class StoreMailboxManagerAnnotationTest {
             .thenReturn(Mono.just(true));
 
         annotationManager = spy(new 
StoreMailboxAnnotationManager(mailboxSessionMapperFactory,
-            storeRightManager));
+            storeRightManager, 
AggregatedReadOnlyAnnotationPredicate.ALLOW_ALL));
+    }
+
+    private StoreMailboxAnnotationManager 
managerWithReadOnly(ReadOnlyAnnotationPredicate predicate) {
+        return new StoreMailboxAnnotationManager(mailboxSessionMapperFactory, 
storeRightManager, predicate);
     }
 
     @Test
@@ -172,4 +179,34 @@ class StoreMailboxManagerAnnotationTest {
 
         assertThat(annotationManager.getAnnotationsByKeys(mailboxPath, 
session, KEYS)).isEqualTo(ANNOTATIONS);
     }
+
+    @Test
+    void updateAnnotationsShouldRejectReadOnlyKey() {
+        StoreMailboxAnnotationManager manager = managerWithReadOnly(key -> 
key.equals(SERVER_KEY));
+        MailboxAnnotation serverAnnotation = 
MailboxAnnotation.newInstance(SERVER_KEY, "42");
+
+        assertThatThrownBy(() -> manager.updateAnnotations(mailboxPath, 
session, ImmutableList.of(serverAnnotation)))
+            .isInstanceOf(AnnotationException.class)
+            .hasMessageContaining("read-only");
+    }
+
+    @Test
+    void updateAnnotationsShouldRejectReadOnlyKeyEvenWhenNil() {
+        StoreMailboxAnnotationManager manager = managerWithReadOnly(key -> 
key.equals(SERVER_KEY));
+
+        assertThatThrownBy(() -> manager.updateAnnotations(mailboxPath, 
session, ImmutableList.of(MailboxAnnotation.nil(SERVER_KEY))))
+            .isInstanceOf(AnnotationException.class)
+            .hasMessageContaining("read-only");
+    }
+
+    @Test
+    void 
updateAnnotationsShouldAllowWritableKeysWhenReadOnlyPredicateIsRegistered() 
throws MailboxException {
+        StoreMailboxAnnotationManager manager = managerWithReadOnly(key -> 
key.equals(SERVER_KEY));
+        when(annotationMapper.existReactive(eq(mailboxId), 
any())).thenReturn(Mono.just(true));
+        when(annotationMapper.insertAnnotationReactive(eq(mailboxId), 
any())).thenReturn(Mono.empty());
+
+        manager.updateAnnotations(mailboxPath, session, 
ImmutableList.of(PRIVATE_ANNOTATION));
+
+        verify(annotationMapper, 
times(1)).insertAnnotationReactive(eq(mailboxId), eq(PRIVATE_ANNOTATION));
+    }
 }
diff --git 
a/protocols/imap/src/test/java/org/apache/james/imap/processor/SetMetadataProcessorTest.java
 
b/protocols/imap/src/test/java/org/apache/james/imap/processor/SetMetadataProcessorTest.java
index 4a7c580a1a..267cb9279d 100644
--- 
a/protocols/imap/src/test/java/org/apache/james/imap/processor/SetMetadataProcessorTest.java
+++ 
b/protocols/imap/src/test/java/org/apache/james/imap/processor/SetMetadataProcessorTest.java
@@ -163,6 +163,20 @@ class SetMetadataProcessorTest {
         verify(mockStatusResponseFactory, times(1)).taggedNo(any(Tag.class), 
any(ImapCommand.class), humanTextCaptor.capture());
 
         
assertThat(humanTextCaptor.getAllValues().get(FIRST_ELEMENT_INDEX).getKey()).isEqualTo(HumanReadableText.MAILBOX_ANNOTATION_KEY);
+    }
+
+    @Test
+    void 
processShouldResponseNoWithReadOnlyMessageWhenManagerThrowsReadOnlyAnnotationException()
 {
+        MailboxAnnotationKey readOnlyKey = new 
MailboxAnnotationKey("/private/key");
+        when(mockMailboxManager.updateAnnotationsReactive(eq(inbox), 
eq(mockMailboxSession), eq(mailboxAnnotations)))
+            .thenReturn(Mono.error(new AnnotationException("annotation is 
read-only: " + readOnlyKey.asString())));
+
+        processor.process(request, mockResponder, imapSession);
+
+        verify(mockStatusResponseFactory, times(1)).taggedNo(any(Tag.class), 
any(ImapCommand.class), humanTextCaptor.capture());
 
+        HumanReadableText humanReadableText = 
humanTextCaptor.getAllValues().get(FIRST_ELEMENT_INDEX);
+        
assertThat(humanReadableText.getKey()).isEqualTo(HumanReadableText.MAILBOX_ANNOTATION_KEY);
+        
assertThat(humanReadableText.getDefaultValue()).contains("read-only").contains(readOnlyKey.asString());
     }
 }
\ No newline at end of file
diff --git 
a/server/container/guice/mailbox/src/main/java/org/apache/james/modules/MailboxModule.java
 
b/server/container/guice/mailbox/src/main/java/org/apache/james/modules/MailboxModule.java
index 0b6fefe568..00711819cd 100644
--- 
a/server/container/guice/mailbox/src/main/java/org/apache/james/modules/MailboxModule.java
+++ 
b/server/container/guice/mailbox/src/main/java/org/apache/james/modules/MailboxModule.java
@@ -22,6 +22,7 @@ import org.apache.james.mailbox.SystemMailboxesProvider;
 import org.apache.james.mailbox.acl.MailboxACLResolver;
 import org.apache.james.mailbox.acl.UnionMailboxACLResolver;
 import org.apache.james.mailbox.store.SystemMailboxesProviderImpl;
+import org.apache.james.modules.mailbox.AnnotationModule;
 import org.apache.james.modules.mailbox.MailReceptionHealthCheckModule;
 import org.apache.james.modules.mailbox.PreDeletionHookModule;
 import org.apache.james.utils.GuiceProbe;
@@ -36,6 +37,7 @@ public class MailboxModule extends AbstractModule {
     protected void configure() {
         install(new PreDeletionHookModule());
         install(new MailReceptionHealthCheckModule());
+        install(new AnnotationModule());
 
         Multibinder<GuiceProbe> probeMultiBinder = 
Multibinder.newSetBinder(binder(), GuiceProbe.class);
         probeMultiBinder.addBinding().to(MailboxProbeImpl.class);
diff --git 
a/server/container/guice/mailbox/src/main/java/org/apache/james/modules/MailboxModule.java
 
b/server/container/guice/mailbox/src/main/java/org/apache/james/modules/mailbox/AnnotationModule.java
similarity index 52%
copy from 
server/container/guice/mailbox/src/main/java/org/apache/james/modules/MailboxModule.java
copy to 
server/container/guice/mailbox/src/main/java/org/apache/james/modules/mailbox/AnnotationModule.java
index 0b6fefe568..dd5db27367 100644
--- 
a/server/container/guice/mailbox/src/main/java/org/apache/james/modules/MailboxModule.java
+++ 
b/server/container/guice/mailbox/src/main/java/org/apache/james/modules/mailbox/AnnotationModule.java
@@ -16,38 +16,33 @@
  * specific language governing permissions and limitations      *
  * under the License.                                           *
  ****************************************************************/
-package org.apache.james.modules;
 
-import org.apache.james.mailbox.SystemMailboxesProvider;
-import org.apache.james.mailbox.acl.MailboxACLResolver;
-import org.apache.james.mailbox.acl.UnionMailboxACLResolver;
-import org.apache.james.mailbox.store.SystemMailboxesProviderImpl;
-import org.apache.james.modules.mailbox.MailReceptionHealthCheckModule;
-import org.apache.james.modules.mailbox.PreDeletionHookModule;
-import org.apache.james.utils.GuiceProbe;
+package org.apache.james.modules.mailbox;
+
+import org.apache.james.mailbox.ReadOnlyAnnotationPredicate;
+import org.apache.james.mailbox.store.AggregatedReadOnlyAnnotationPredicate;
 
 import com.google.inject.AbstractModule;
 import com.google.inject.Scopes;
 import com.google.inject.multibindings.Multibinder;
 
-public class MailboxModule extends AbstractModule {
+/**
+ * Wires the read-only annotation extension point.
+ *
+ * Other modules can contribute read-only keys by adding bindings to the
+ * {@code Set<ReadOnlyAnnotationPredicate>} multibinder, for example:
+ *
+ * <pre>
+ * Multibinder.newSetBinder(binder(), ReadOnlyAnnotationPredicate.class)
+ *     .addBinding().toInstance(key -> 
key.asString().startsWith("/shared/vendor/mymodule/"));
+ * </pre>
+ */
+public class AnnotationModule extends AbstractModule {
 
     @Override
     protected void configure() {
-        install(new PreDeletionHookModule());
-        install(new MailReceptionHealthCheckModule());
-
-        Multibinder<GuiceProbe> probeMultiBinder = 
Multibinder.newSetBinder(binder(), GuiceProbe.class);
-        probeMultiBinder.addBinding().to(MailboxProbeImpl.class);
-        probeMultiBinder.addBinding().to(QuotaProbesImpl.class);
-        probeMultiBinder.addBinding().to(ACLProbeImpl.class);
-        probeMultiBinder.addBinding().to(ConfigurationProbe.class);
-
-        bind(UnionMailboxACLResolver.class).in(Scopes.SINGLETON);
-        bind(MailboxACLResolver.class).to(UnionMailboxACLResolver.class);
-
-        bind(SystemMailboxesProviderImpl.class).in(Scopes.SINGLETON);
-        
bind(SystemMailboxesProvider.class).to(SystemMailboxesProviderImpl.class);
+        Multibinder.newSetBinder(binder(), ReadOnlyAnnotationPredicate.class);
+        bind(AggregatedReadOnlyAnnotationPredicate.class).in(Scopes.SINGLETON);
+        
bind(ReadOnlyAnnotationPredicate.class).to(AggregatedReadOnlyAnnotationPredicate.class);
     }
-
 }


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to