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

commit 9ed79ee84e81f1d71d206a663cbd6848bb23a75f
Author: Rene Cordier <[email protected]>
AuthorDate: Fri Oct 3 08:48:10 2025 +0700

    JAMES-4148 Rule flag criteria (#2825)
---
 .../modules/servers/partials/operate/webadmin.adoc |  12 +++
 .../src/test/resources/json/eventComplex-v4.json   |  15 +++
 .../org/apache/james/jmap/api/filtering/Rule.java  |   7 +-
 .../james/jmap/api/filtering/RuleFixture.java      |  14 ++-
 .../james/jmap/mailet/filter/ContentMatcher.java   |  56 +++++++++++
 .../james/jmap/mailet/filter/FilteringHeaders.java |  14 +++
 .../james/jmap/mailet/filter/HeaderExtractor.java  |   2 +
 .../data/jmap/RunRulesOnMailboxRoutesTest.java     | 106 +++++++++++++++++++++
 src/site/markdown/server/manage-webadmin.md        |  12 +++
 9 files changed, 235 insertions(+), 3 deletions(-)

diff --git a/docs/modules/servers/partials/operate/webadmin.adoc 
b/docs/modules/servers/partials/operate/webadmin.adoc
index fa0324519b..36e5d7d985 100644
--- a/docs/modules/servers/partials/operate/webadmin.adoc
+++ b/docs/modules/servers/partials/operate/webadmin.adoc
@@ -1839,6 +1839,18 @@ Resource name `usernameToBeUsed` should be an existing 
user.
 
 Resource name `mailboxName` should not be empty, nor contain `% *` characters, 
nor starting with `#`.
 
+The rule json payload has some extra conditions available compared to the JMAP 
filtering mailet as some operations would make sense:
+
+- Flags:
+  * field: flag
+  * comparators: isSet, isUnset
+  * value: system flag ("$seen", "$flagged", etc) or a custom user flag.
+
+- Dates:
+  * fields: sentDate, savedDate, internalDate
+  * comparators: isOlderThan, isNewerThan
+  * values: durations ("2d", "6h", ...)
+
 Response codes:
 
 * 201: Success. Corresponding task id is returned.
diff --git 
a/server/data/data-jmap-cassandra/src/test/resources/json/eventComplex-v4.json 
b/server/data/data-jmap-cassandra/src/test/resources/json/eventComplex-v4.json
index 96ea617cad..bcb310b29c 100644
--- 
a/server/data/data-jmap-cassandra/src/test/resources/json/eventComplex-v4.json
+++ 
b/server/data/data-jmap-cassandra/src/test/resources/json/eventComplex-v4.json
@@ -33,6 +33,21 @@
             "field": "internalDate",
             "comparator": "isNewerThan",
             "value": "2d"
+          },
+          {
+            "field": "flag",
+            "comparator": "isSet",
+            "value": "$seen"
+          },
+          {
+            "field": "flag",
+            "comparator": "isUnset",
+            "value": "$recent"
+          },
+          {
+            "field": "flag",
+            "comparator": "isSet",
+            "value": "custom"
           }
         ]
       },
diff --git 
a/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/filtering/Rule.java
 
b/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/filtering/Rule.java
index 123439a385..bd64c8fb56 100644
--- 
a/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/filtering/Rule.java
+++ 
b/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/filtering/Rule.java
@@ -151,7 +151,8 @@ public class Rule {
             public static Field SENT_DATE = new FixedField("sentDate");
             public static Field SAVED_DATE = new FixedField("savedDate");
             public static Field INTERNAL_DATE = new FixedField("internalDate");
-            public static final ImmutableList<Field> VALUES = 
ImmutableList.of(FROM, TO, CC, SUBJECT, RECIPIENT, SENT_DATE, SAVED_DATE, 
INTERNAL_DATE);
+            public static Field FLAG = new FixedField("flag");
+            public static final ImmutableList<Field> VALUES = 
ImmutableList.of(FROM, TO, CC, SUBJECT, RECIPIENT, SENT_DATE, SAVED_DATE, 
INTERNAL_DATE, FLAG);
 
             public static Optional<Field> find(String fieldName) {
                 return VALUES.stream()
@@ -186,7 +187,9 @@ public class Rule {
             NOT_CONTAINS("not-contains"),
             EXACTLY_EQUALS("exactly-equals"),
             NOT_EXACTLY_EQUALS("not-exactly-equals"),
-            START_WITH("start-with");
+            START_WITH("start-with"),
+            IS_SET("isSet"),
+            IS_UNSET("isUnset");
             
             public static Optional<Comparator> find(String comparatorName) {
                 return Arrays.stream(values())
diff --git 
a/server/data/data-jmap/src/test/java/org/apache/james/jmap/api/filtering/RuleFixture.java
 
b/server/data/data-jmap/src/test/java/org/apache/james/jmap/api/filtering/RuleFixture.java
index daac0973bf..3b946e3758 100644
--- 
a/server/data/data-jmap/src/test/java/org/apache/james/jmap/api/filtering/RuleFixture.java
+++ 
b/server/data/data-jmap/src/test/java/org/apache/james/jmap/api/filtering/RuleFixture.java
@@ -138,7 +138,19 @@ public interface RuleFixture {
             Rule.Condition.of(
                 Rule.Condition.FixedField.INTERNAL_DATE,
                 Rule.Condition.Comparator.IS_NEWER_THAN,
-                "2d"))
+                "2d"),
+            Rule.Condition.of(
+                Rule.Condition.FixedField.FLAG,
+                Rule.Condition.Comparator.IS_SET,
+                "$seen"),
+            Rule.Condition.of(
+                Rule.Condition.FixedField.FLAG,
+                Rule.Condition.Comparator.IS_UNSET,
+                "$recent"),
+            Rule.Condition.of(
+                Rule.Condition.FixedField.FLAG,
+                Rule.Condition.Comparator.IS_SET,
+                "custom"))
         .build();
 
     Rule RULE_TO_2 = Rule.builder()
diff --git 
a/server/protocols/jmap-rfc-8621/src/main/java/org/apache/james/jmap/mailet/filter/ContentMatcher.java
 
b/server/protocols/jmap-rfc-8621/src/main/java/org/apache/james/jmap/mailet/filter/ContentMatcher.java
index 8369caeb95..4ca7e5ca55 100644
--- 
a/server/protocols/jmap-rfc-8621/src/main/java/org/apache/james/jmap/mailet/filter/ContentMatcher.java
+++ 
b/server/protocols/jmap-rfc-8621/src/main/java/org/apache/james/jmap/mailet/filter/ContentMatcher.java
@@ -31,6 +31,7 @@ import jakarta.mail.internet.InternetAddress;
 
 import org.apache.commons.lang3.StringUtils;
 import org.apache.james.jmap.api.filtering.Rule;
+import org.apache.james.jmap.mail.Keyword;
 import org.apache.james.mime4j.codec.DecodeMonitor;
 import org.apache.james.mime4j.field.DateTimeFieldLenientImpl;
 import org.apache.james.mime4j.stream.RawField;
@@ -42,6 +43,8 @@ import org.slf4j.LoggerFactory;
 import com.google.common.base.Preconditions;
 import com.google.common.collect.ImmutableMap;
 
+import scala.util.Either;
+
 public interface ContentMatcher {
 
     class AddressHeader {
@@ -95,7 +98,42 @@ public interface ContentMatcher {
             return contents.map(ContentMatcher::asAddressHeader)
                 .anyMatch(addressHeaderToMatch::matchesIgnoreCase);
         }
+    }
+
+    class ParsedFlag {
+        private final Optional<Keyword> keyword;
+
+        private ParsedFlag(String flag) {
+            this.keyword = parseFlag(flag);
+        }
+
+        private Optional<Keyword> parseFlag(String maybeFlag) {
+            if (maybeFlag == null) {
+                return Optional.empty();
+            }
+
+            String sanitizedFlag = 
sanitizeFlag(maybeFlag).trim().toUpperCase();
+
+            Either<String, Keyword> result = Keyword.parse(sanitizedFlag);
+
+            if (result.isRight()) {
+                return Optional.of(result.right().get());
+            } else {
+                return Optional.empty();
+            }
+        }
+
+        private String sanitizeFlag(String maybeFlag) {
+            if (maybeFlag.startsWith("\\") || maybeFlag.startsWith("$")) {
+                return maybeFlag.substring(1);
+            }
+            return maybeFlag;
+        }
 
+        boolean matches(ParsedFlag otherFlag) {
+            return OptionalUtils.matches(keyword, otherFlag.keyword,
+                (k1, k2) -> k1.getFlagName().equals(k2.getFlagName()));
+        }
     }
 
     ContentMatcher STRING_CONTAINS_MATCHER = (contents, valueToMatch) -> 
contents.anyMatch(content -> StringUtils.contains(content, valueToMatch));
@@ -113,6 +151,18 @@ public interface ContentMatcher {
             .map(dateField -> DateTimeFieldLenientImpl.PARSER.parse(new 
RawField("Date", dateField), DecodeMonitor.SILENT).getDate().toInstant())
             .anyMatch(date -> date.isAfter(horizon));
     };
+    ContentMatcher FLAG_IS_SET_MATCHER = (contents, valueToMatch) -> {
+        ParsedFlag flagToMatch = new ParsedFlag(valueToMatch);
+        return contents
+            .map(ParsedFlag::new)
+            .anyMatch(flag -> flag.matches(flagToMatch));
+    };
+    ContentMatcher FLAG_IS_UNSET_MATCHER = (contents, valueToMatch) -> {
+        ParsedFlag flagToMatch = new ParsedFlag(valueToMatch);
+        return contents
+            .map(ParsedFlag::new)
+            .noneMatch(flag -> flag.matches(flagToMatch));
+    };
     ContentMatcher STRING_NOT_CONTAINS_MATCHER = 
negate(STRING_CONTAINS_MATCHER);
     ContentMatcher STRING_EXACTLY_EQUALS_MATCHER = (contents, valueToMatch) -> 
contents.anyMatch(content -> StringUtils.equals(content, valueToMatch));
     ContentMatcher STRING_NOT_EXACTLY_EQUALS_MATCHER = 
negate(STRING_EXACTLY_EQUALS_MATCHER);
@@ -132,6 +182,11 @@ public interface ContentMatcher {
         .put(Rule.Condition.Comparator.IS_OLDER_THAN, IS_OLDER_THAN_MATCHER)
         .build();
 
+    Map<Rule.Condition.Comparator, ContentMatcher> FLAG_MATCHER_REGISTRY = 
ImmutableMap.<Rule.Condition.Comparator, ContentMatcher>builder()
+        .put(Rule.Condition.Comparator.IS_SET, FLAG_IS_SET_MATCHER)
+        .put(Rule.Condition.Comparator.IS_UNSET, FLAG_IS_UNSET_MATCHER)
+        .build();
+
     Map<Rule.Condition.Comparator, ContentMatcher> 
HEADER_ADDRESS_MATCHER_REGISTRY = ImmutableMap.<Rule.Condition.Comparator, 
ContentMatcher>builder()
         .put(Rule.Condition.Comparator.CONTAINS, ADDRESS_CONTAINS_MATCHER)
         .put(Rule.Condition.Comparator.NOT_CONTAINS, 
ADDRESS_NOT_CONTAINS_MATCHER)
@@ -157,6 +212,7 @@ public interface ContentMatcher {
         .put(Rule.Condition.FixedField.SENT_DATE, DATE_MATCHER_REGISTRY)
         .put(Rule.Condition.FixedField.INTERNAL_DATE, DATE_MATCHER_REGISTRY)
         .put(Rule.Condition.FixedField.SAVED_DATE, DATE_MATCHER_REGISTRY)
+        .put(Rule.Condition.FixedField.FLAG, FLAG_MATCHER_REGISTRY)
         .build();
 
     static ContentMatcher negate(ContentMatcher contentMatcher) {
diff --git 
a/server/protocols/jmap-rfc-8621/src/main/java/org/apache/james/jmap/mailet/filter/FilteringHeaders.java
 
b/server/protocols/jmap-rfc-8621/src/main/java/org/apache/james/jmap/mailet/filter/FilteringHeaders.java
index 1997b142ba..4315b86253 100644
--- 
a/server/protocols/jmap-rfc-8621/src/main/java/org/apache/james/jmap/mailet/filter/FilteringHeaders.java
+++ 
b/server/protocols/jmap-rfc-8621/src/main/java/org/apache/james/jmap/mailet/filter/FilteringHeaders.java
@@ -64,6 +64,11 @@ public interface FilteringHeaders {
         public Stream<String> getSavedDate() {
             throw new NotImplementedException("Not implemented");
         }
+
+        @Override
+        public Stream<String> getFlags() {
+            throw new NotImplementedException("Not implemented");
+        }
     }
 
     class MessageResultFilteringHeaders implements FilteringHeaders {
@@ -112,6 +117,13 @@ public interface FilteringHeaders {
             return Stream.of(messageResult.getSaveDate().map(date -> 
MimeUtil.formatDate(date, TimeZone.getDefault()))
                 .orElse(MimeUtil.formatDate(new Date(), 
TimeZone.getDefault())));
         }
+
+        @Override
+        public Stream<String> getFlags() {
+            return Arrays.stream(messageResult.getFlags()
+                .toString()
+                .split(" "));
+        }
     }
 
     String[] getHeader(String name) throws Exception;
@@ -121,4 +133,6 @@ public interface FilteringHeaders {
     Stream<String> getInternalDate();
 
     Stream<String> getSavedDate();
+
+    Stream<String> getFlags();
 }
diff --git 
a/server/protocols/jmap-rfc-8621/src/main/java/org/apache/james/jmap/mailet/filter/HeaderExtractor.java
 
b/server/protocols/jmap-rfc-8621/src/main/java/org/apache/james/jmap/mailet/filter/HeaderExtractor.java
index e199bf9aea..eabec4376d 100644
--- 
a/server/protocols/jmap-rfc-8621/src/main/java/org/apache/james/jmap/mailet/filter/HeaderExtractor.java
+++ 
b/server/protocols/jmap-rfc-8621/src/main/java/org/apache/james/jmap/mailet/filter/HeaderExtractor.java
@@ -50,6 +50,7 @@ public interface HeaderExtractor extends 
ThrowingFunction<FilteringHeaders, Stre
     HeaderExtractor SENT_EXTRACTOR = headers -> 
StreamUtils.ofNullables(headers.getHeader("Date"));
     HeaderExtractor RECIPIENT_EXTRACTOR = and(TO_EXTRACTOR, CC_EXTRACTOR);
     HeaderExtractor FROM_EXTRACTOR = addressExtractor(filteringHeaders -> 
filteringHeaders.getHeader(FROM), FROM);
+    HeaderExtractor FLAG_EXTRACTOR = FilteringHeaders::getFlags;
 
     Map<Rule.Condition.Field, HeaderExtractor> HEADER_EXTRACTOR_REGISTRY = 
ImmutableMap.<Rule.Condition.Field, HeaderExtractor>builder()
         .put(Rule.Condition.FixedField.SUBJECT, SUBJECT_EXTRACTOR)
@@ -60,6 +61,7 @@ public interface HeaderExtractor extends 
ThrowingFunction<FilteringHeaders, Stre
         .put(Rule.Condition.FixedField.FROM, FROM_EXTRACTOR)
         .put(Rule.Condition.FixedField.CC, CC_EXTRACTOR)
         .put(Rule.Condition.FixedField.TO, TO_EXTRACTOR)
+        .put(Rule.Condition.FixedField.FLAG, FLAG_EXTRACTOR)
         .build();
 
     boolean STRICT_PARSING = true;
diff --git 
a/server/protocols/webadmin/webadmin-jmap/src/test/java/org/apache/james/webadmin/data/jmap/RunRulesOnMailboxRoutesTest.java
 
b/server/protocols/webadmin/webadmin-jmap/src/test/java/org/apache/james/webadmin/data/jmap/RunRulesOnMailboxRoutesTest.java
index e59e6825a9..f65955ecc6 100644
--- 
a/server/protocols/webadmin/webadmin-jmap/src/test/java/org/apache/james/webadmin/data/jmap/RunRulesOnMailboxRoutesTest.java
+++ 
b/server/protocols/webadmin/webadmin-jmap/src/test/java/org/apache/james/webadmin/data/jmap/RunRulesOnMailboxRoutesTest.java
@@ -37,8 +37,11 @@ import java.time.Duration;
 import java.util.Date;
 import java.util.Map;
 
+import jakarta.mail.Flags;
+
 import org.apache.james.core.Username;
 import org.apache.james.json.DTOConverter;
+import org.apache.james.mailbox.FlagsBuilder;
 import org.apache.james.mailbox.MailboxSession;
 import org.apache.james.mailbox.MessageIdManager;
 import org.apache.james.mailbox.MessageManager;
@@ -875,6 +878,109 @@ public class RunRulesOnMailboxRoutesTest {
         );
     }
 
+    @Test
+    void runRulesOnMailboxShouldApplyFlagCriteria() throws Exception {
+        MailboxPath mailboxPath = MailboxPath.forUser(USERNAME, MAILBOX_NAME);
+        MailboxPath otherMailboxPath = MailboxPath.forUser(USERNAME, 
OTHER_MAILBOX_NAME);
+        MailboxSession systemSession = 
mailboxManager.createSystemSession(USERNAME);
+
+        mailboxManager.createMailbox(mailboxPath, systemSession);
+        mailboxManager.createMailbox(otherMailboxPath, systemSession);
+
+        MessageManager messageManager = mailboxManager.getMailbox(mailboxPath, 
systemSession);
+
+        messageManager.appendMessage(
+            MessageManager.AppendCommand.builder()
+                .withFlags(new FlagsBuilder().add(Flags.Flag.FLAGGED, 
Flags.Flag.SEEN)
+                    .build())
+                .build(Message.Builder.of()
+                    .setSubject("plop")
+                    .setFrom("[email protected]")
+                    .setBody("body", StandardCharsets.UTF_8)),
+            systemSession).getId();
+
+        messageManager.appendMessage(
+            MessageManager.AppendCommand.builder()
+                .withFlags(new FlagsBuilder().add(Flags.Flag.ANSWERED)
+                    .add("custom")
+                    .build())
+                .build(Message.Builder.of()
+                    .setSubject("hello")
+                    .setFrom("[email protected]")
+                    .setBody("body", StandardCharsets.UTF_8)),
+            systemSession).getId();
+
+        messageManager.appendMessage(
+            MessageManager.AppendCommand.builder()
+                .withFlags(new FlagsBuilder().add(Flags.Flag.SEEN)
+                    .add("custom")
+                    .build())
+                .build(Message.Builder.of()
+                    .setSubject("hello")
+                    .setFrom("[email protected]")
+                    .setBody("body", StandardCharsets.UTF_8)),
+            systemSession).getId();
+
+        MailboxId otherMailboxId = mailboxManager.getMailbox(otherMailboxPath, 
systemSession).getId();
+
+        String taskId = given()
+            .queryParam("action", "triage")
+            .body("""
+            {
+              "id": "1",
+              "name": "rule 1",
+              "action": {
+                "appendIn": {
+                  "mailboxIds": ["%s"]
+                },
+                "important": false,
+                "keyworkds": [],
+                "reject": false,
+                "seen": false
+              },
+              "conditionGroup": {
+                "conditionCombiner": "AND",
+                "conditions": [
+                  {
+                    "comparator": "isSet",
+                    "field": "flag",
+                    "value": "$seen"
+                  },
+                  {
+                    "comparator": "isUnset",
+                    "field": "flag",
+                    "value": "$flagged"
+                  },
+                  {
+                    "comparator": "isSet",
+                    "field": "flag",
+                    "value": "custom"
+                  }
+                ]
+              }
+            }""".formatted(otherMailboxId.serialize()))
+            .post(MAILBOX_NAME + "/messages")
+        .then()
+            .statusCode(CREATED_201)
+            .extract()
+            .jsonPath()
+            .get("taskId");
+
+        given()
+            .basePath(TasksRoutes.BASE)
+        .when()
+            .get(taskId + "/await");
+
+        SoftAssertions.assertSoftly(
+            softly -> {
+                softly.assertThat(Throwing.supplier(() -> 
mailboxManager.getMailbox(mailboxPath, 
systemSession).getMailboxCounters(systemSession).getCount()).get())
+                    .isEqualTo(2);
+                softly.assertThat(Throwing.supplier(() -> 
mailboxManager.getMailbox(otherMailboxPath, 
systemSession).getMailboxCounters(systemSession).getCount()).get())
+                    .isEqualTo(1);
+            }
+        );
+    }
+
     @Test
     void runRulesOnMailboxShouldReturnTaskDetails() throws Exception {
         MailboxPath mailboxPath = MailboxPath.forUser(USERNAME, MAILBOX_NAME);
diff --git a/src/site/markdown/server/manage-webadmin.md 
b/src/site/markdown/server/manage-webadmin.md
index 3b2cff8de5..de0f231a14 100644
--- a/src/site/markdown/server/manage-webadmin.md
+++ b/src/site/markdown/server/manage-webadmin.md
@@ -1782,6 +1782,18 @@ Resource name `usernameToBeUsed` should be an existing 
user.
 
 Resource name `mailboxName` should not be empty, nor contain `% *` characters, 
nor starting with `#`.
 
+The rule json payload has some extra conditions available compared to the JMAP 
filtering mailet as some operations would make sense:
+
+- Flags:
+    * fields: flag
+    * comparators: isSet, isUnset
+    * values: system flag ("$seen", "$flagged", etc) or a custom user flag.
+
+- Dates:
+    * fields: sentDate, savedDate, internalDate
+    * comparators: isOlderThan, isNewerThan
+    * values: durations ("2d", "6h", ...)
+
 Response codes:
 
 * 201: Success. Corresponding task id is returned.


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

Reply via email to