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]
