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 3f9b29a68d JAMES-4148 Rule flag criteria (#2825)
3f9b29a68d is described below
commit 3f9b29a68de587d6ded4f951de390508e1cdee23
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]