You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@james.apache.org by rc...@apache.org on 2021/10/28 07:21:26 UTC

[james-project] branch master updated: JAMES-3539 Implement inmemory PushSubscriptionRepository and contract tests

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 999117c  JAMES-3539 Implement inmemory PushSubscriptionRepository and contract tests
999117c is described below

commit 999117c02a5bdb379e421f4c2df0f0d8ccc4ddab
Author: Quan Tran <hq...@linagora.com>
AuthorDate: Thu Oct 21 17:39:42 2021 +0700

    JAMES-3539 Implement inmemory PushSubscriptionRepository and contract tests
    
    Refactoring TypeName and State trait location included
---
 .../james/jmap/rfc8621/RFC8621MethodsModule.java   |   2 +-
 server/data/data-jmap/pom.xml                      |   5 +
 .../PushSubscriptionRepository.java                |  46 +++
 .../MemoryPushSubscriptionRepository.java          | 161 +++++++++
 .../james/jmap/api/model/PushSubscription.scala    |  90 +++++
 .../org/apache/james/jmap/api/model/State.scala}   |  52 ++-
 .../apache/james/jmap/api/model/TypeName.scala}    |  56 ++-
 .../MemoryPushSubscriptionRepositoryTest.java}     |  79 ++---
 .../PushSubscriptionRepositoryContract.scala       | 374 +++++++++++++++++++++
 .../rfc8621/contract/CustomMethodContract.scala    |   6 +-
 .../james/jmap/change/JmapEventSerializer.scala    |   3 +-
 .../james/jmap/change/MailboxChangeListener.scala  |   4 +-
 .../org/apache/james/jmap/change/StateChange.scala |  12 +-
 .../james/jmap/change/StateChangeListener.scala    |   1 +
 .../james/jmap/change/TypeStateFactory.scala       |   2 +
 .../scala/org/apache/james/jmap/core/Session.scala |   5 +-
 .../james/jmap/core/WebSocketTransport.scala       |   3 +-
 .../apache/james/jmap/json/MailboxSerializer.scala |   2 +-
 .../apache/james/jmap/json/PushSerializer.scala    |   5 +-
 .../org/apache/james/jmap/mail/EmailSet.scala      |   3 +-
 .../jmap/method/VacationResponseSetMethod.scala    |   4 +-
 .../james/jmap/routes/EventSourceRoutes.scala      |   3 +-
 .../change/StateChangeEventSerializerTest.scala    |   1 +
 .../james/jmap/change/TypeStateFactoryTest.scala   |   1 +
 24 files changed, 790 insertions(+), 130 deletions(-)

diff --git a/server/container/guice/protocols/jmap/src/main/java/org/apache/james/jmap/rfc8621/RFC8621MethodsModule.java b/server/container/guice/protocols/jmap/src/main/java/org/apache/james/jmap/rfc8621/RFC8621MethodsModule.java
index e2ce681..ea9e131 100644
--- a/server/container/guice/protocols/jmap/src/main/java/org/apache/james/jmap/rfc8621/RFC8621MethodsModule.java
+++ b/server/container/guice/protocols/jmap/src/main/java/org/apache/james/jmap/rfc8621/RFC8621MethodsModule.java
@@ -29,13 +29,13 @@ import org.apache.commons.configuration2.ex.ConfigurationException;
 import org.apache.james.jmap.JMAPRoutes;
 import org.apache.james.jmap.JMAPRoutesHandler;
 import org.apache.james.jmap.Version;
+import org.apache.james.jmap.api.model.TypeName;
 import org.apache.james.jmap.change.EmailDeliveryTypeName$;
 import org.apache.james.jmap.change.EmailSubmissionTypeName$;
 import org.apache.james.jmap.change.EmailTypeName$;
 import org.apache.james.jmap.change.IdentityTypeName$;
 import org.apache.james.jmap.change.MailboxTypeName$;
 import org.apache.james.jmap.change.ThreadTypeName$;
-import org.apache.james.jmap.change.TypeName;
 import org.apache.james.jmap.change.VacationResponseTypeName$;
 import org.apache.james.jmap.core.JmapRfc8621Configuration;
 import org.apache.james.jmap.http.AuthenticationStrategy;
diff --git a/server/data/data-jmap/pom.xml b/server/data/data-jmap/pom.xml
index c5972d8..daec8fc 100644
--- a/server/data/data-jmap/pom.xml
+++ b/server/data/data-jmap/pom.xml
@@ -78,6 +78,11 @@
         </dependency>
         <dependency>
             <groupId>${james.groupId}</groupId>
+            <artifactId>james-server-testing</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>${james.groupId}</groupId>
             <artifactId>james-server-util</artifactId>
         </dependency>
         <dependency>
diff --git a/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/pushsubscription/PushSubscriptionRepository.java b/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/pushsubscription/PushSubscriptionRepository.java
new file mode 100644
index 0000000..bb9c563
--- /dev/null
+++ b/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/pushsubscription/PushSubscriptionRepository.java
@@ -0,0 +1,46 @@
+/******************************************************************
+ * 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.jmap.api.pushsubscription;
+
+import java.time.ZonedDateTime;
+import java.util.Set;
+
+import org.apache.james.core.Username;
+import org.apache.james.jmap.api.model.PushSubscription;
+import org.apache.james.jmap.api.model.PushSubscriptionCreationRequest;
+import org.apache.james.jmap.api.model.PushSubscriptionId;
+import org.apache.james.jmap.api.model.TypeName;
+import org.reactivestreams.Publisher;
+
+public interface PushSubscriptionRepository {
+    Publisher<PushSubscription> save(Username username, PushSubscriptionCreationRequest pushSubscriptionCreationRequest);
+
+    Publisher<Void> updateExpireTime(Username username, PushSubscriptionId id, ZonedDateTime newExpire);
+
+    Publisher<Void> updateTypes(Username username, PushSubscriptionId id, Set<TypeName> types);
+
+    Publisher<Void> validateVerificationCode(Username username, PushSubscriptionId id);
+
+    Publisher<Void> revoke(Username username, PushSubscriptionId id);
+
+    Publisher<PushSubscription> get(Username username, Set<PushSubscriptionId> ids);
+
+    Publisher<PushSubscription> list(Username username);
+}
diff --git a/server/data/data-jmap/src/main/java/org/apache/james/jmap/memory/pushsubscription/MemoryPushSubscriptionRepository.java b/server/data/data-jmap/src/main/java/org/apache/james/jmap/memory/pushsubscription/MemoryPushSubscriptionRepository.java
new file mode 100644
index 0000000..61c5c5a
--- /dev/null
+++ b/server/data/data-jmap/src/main/java/org/apache/james/jmap/memory/pushsubscription/MemoryPushSubscriptionRepository.java
@@ -0,0 +1,161 @@
+/******************************************************************
+ * 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.jmap.memory.pushsubscription;
+
+import static org.apache.james.jmap.api.model.PushSubscription.EXPIRES_TIME_MAX_DAY;
+
+import java.time.Clock;
+import java.time.ZonedDateTime;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+
+import javax.inject.Inject;
+
+import org.apache.james.core.Username;
+import org.apache.james.jmap.api.model.DeviceClientIdInvalidException;
+import org.apache.james.jmap.api.model.ExpireTimeInvalidException;
+import org.apache.james.jmap.api.model.PushSubscription;
+import org.apache.james.jmap.api.model.PushSubscriptionCreationRequest;
+import org.apache.james.jmap.api.model.PushSubscriptionExpiredTime;
+import org.apache.james.jmap.api.model.PushSubscriptionId;
+import org.apache.james.jmap.api.model.PushSubscriptionNotFoundException;
+import org.apache.james.jmap.api.model.TypeName;
+import org.apache.james.jmap.api.pushsubscription.PushSubscriptionRepository;
+import org.reactivestreams.Publisher;
+
+import com.google.common.collect.HashBasedTable;
+import com.google.common.collect.Table;
+
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import scala.Option;
+import scala.jdk.javaapi.CollectionConverters;
+import scala.jdk.javaapi.OptionConverters;
+
+public class MemoryPushSubscriptionRepository implements PushSubscriptionRepository {
+    private final Table<Username, PushSubscriptionId, PushSubscription> table;
+    private final Clock clock;
+
+    @Inject
+    public MemoryPushSubscriptionRepository(Clock clock) {
+        this.clock = clock;
+        this.table = HashBasedTable.create();
+    }
+
+    @Override
+    public Publisher<PushSubscription> save(Username username, PushSubscriptionCreationRequest request) {
+        return Mono.just(request)
+            .handle((req, sink) -> {
+                if (isInThePast(req.expires())) {
+                    sink.error(new ExpireTimeInvalidException(req.expires().get().value(), "expires must be greater than now"));
+                }
+                if (!isUniqueDeviceClientId(username, req.deviceClientId())) {
+                    sink.error(new DeviceClientIdInvalidException(req.deviceClientId(), "deviceClientId must be unique"));
+                }
+            })
+            .thenReturn(PushSubscription.from(request,
+                evaluateExpiresTime(OptionConverters.toJava(request.expires().map(PushSubscriptionExpiredTime::value)))))
+            .doOnNext(pushSubscription -> table.put(username, pushSubscription.id(), pushSubscription));
+    }
+
+    @Override
+    public Publisher<Void> updateExpireTime(Username username, PushSubscriptionId id, ZonedDateTime newExpire) {
+        return Mono.just(newExpire)
+            .handle((inputTime, sink) -> {
+                if (newExpire.isBefore(ZonedDateTime.now(clock))) {
+                    sink.error(new ExpireTimeInvalidException(inputTime, "expires must be greater than now"));
+                }
+            })
+            .then(Mono.justOrEmpty(table.get(username, id))
+                .doOnNext(pushSubscription -> table.put(username, id,
+                    pushSubscription.withExpires(evaluateExpiresTime(Optional.of(newExpire)))))
+                .switchIfEmpty(Mono.error(() -> new PushSubscriptionNotFoundException(id)))
+                .then());
+    }
+
+    @Override
+    public Publisher<Void> updateTypes(Username username, PushSubscriptionId id, Set<TypeName> types) {
+        return Mono.justOrEmpty(table.get(username, id))
+            .doOnNext(pushSubscription -> {
+                PushSubscription newPushSubscription = pushSubscription.withTypes(CollectionConverters.asScala(types).toSeq());
+                table.put(username, id, newPushSubscription);
+            })
+            .switchIfEmpty(Mono.error(() -> new PushSubscriptionNotFoundException(id)))
+            .then();
+    }
+
+    @Override
+    public Publisher<Void> revoke(Username username, PushSubscriptionId id) {
+        return Mono.fromCallable(() -> table.remove(username, id)).then();
+    }
+
+    @Override
+    public Publisher<PushSubscription> get(Username username, Set<PushSubscriptionId> ids) {
+        return Flux.fromStream(table.row(username).entrySet().stream())
+            .filter(entry -> ids.contains(entry.getKey()))
+            .map(Map.Entry::getValue)
+            .filter(subscription -> isNotOutdatedSubscription(subscription, clock));
+    }
+
+    @Override
+    public Publisher<PushSubscription> list(Username username) {
+        return Flux.fromStream(table.row(username).entrySet().stream())
+            .map(Map.Entry::getValue)
+            .filter(subscription -> isNotOutdatedSubscription(subscription, clock));
+    }
+
+    @Override
+    public Publisher<Void> validateVerificationCode(Username username, PushSubscriptionId id) {
+        return Mono.justOrEmpty(table.get(username, id))
+            .doOnNext(pushSubscription -> {
+                if (!pushSubscription.validated()) {
+                    PushSubscription newPushSubscription = pushSubscription.verified();
+                    table.put(username, id, newPushSubscription);
+                }
+            })
+            .switchIfEmpty(Mono.error(() -> new PushSubscriptionNotFoundException(id)))
+            .then();
+    }
+
+    private boolean isInThePast(PushSubscriptionExpiredTime expire) {
+        return expire.isBefore(ZonedDateTime.now(clock));
+    }
+
+    private boolean isInThePast(Option<PushSubscriptionExpiredTime> expire) {
+        return expire.map(this::isInThePast).getOrElse(() -> false);
+    }
+
+    private PushSubscriptionExpiredTime evaluateExpiresTime(Optional<ZonedDateTime> inputTime) {
+        ZonedDateTime now = ZonedDateTime.now(clock);
+        ZonedDateTime maxExpiresTime = now.plusDays(EXPIRES_TIME_MAX_DAY());
+        return PushSubscriptionExpiredTime.apply(inputTime.filter(input -> input.isBefore(maxExpiresTime))
+            .orElse(maxExpiresTime));
+    }
+
+    private boolean isNotOutdatedSubscription(PushSubscription subscription, Clock clock) {
+        return subscription.expires().isAfter(ZonedDateTime.now(clock));
+    }
+
+    private boolean isUniqueDeviceClientId(Username username, String deviceClientId) {
+        return table.row(username).values().stream()
+            .noneMatch(subscription -> subscription.deviceClientId().equals(deviceClientId));
+    }
+}
diff --git a/server/data/data-jmap/src/main/scala/org/apache/james/jmap/api/model/PushSubscription.scala b/server/data/data-jmap/src/main/scala/org/apache/james/jmap/api/model/PushSubscription.scala
new file mode 100644
index 0000000..68d45f9
--- /dev/null
+++ b/server/data/data-jmap/src/main/scala/org/apache/james/jmap/api/model/PushSubscription.scala
@@ -0,0 +1,90 @@
+/****************************************************************
+ * 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.jmap.api.model
+
+import java.net.URL
+import java.time.{Clock, ZonedDateTime}
+import java.util.UUID
+
+object PushSubscriptionId {
+  def generate(): PushSubscriptionId = PushSubscriptionId(UUID.randomUUID)
+}
+
+case class PushSubscriptionId(value: UUID)
+
+case class DeviceClientId(value: String) extends AnyVal
+
+object VerificationCode {
+  def generate(): VerificationCode = VerificationCode(UUID.randomUUID().toString)
+}
+
+case class VerificationCode(value: String) extends AnyVal
+
+case class PushSubscriptionServerURL(value: URL)
+
+case class PushSubscriptionExpiredTime(value: ZonedDateTime) {
+  def isAfter(date: ZonedDateTime): Boolean = value.isAfter(date)
+  def isBefore(date: ZonedDateTime): Boolean = value.isBefore(date)
+}
+
+case class PushSubscriptionKeys(p256dh: String, auth: String)
+
+case class PushSubscriptionCreationRequest(deviceClientId: DeviceClientId,
+                                           url: PushSubscriptionServerURL,
+                                           keys: Option[PushSubscriptionKeys] = None,
+                                           expires: Option[PushSubscriptionExpiredTime] = None,
+                                           types: Seq[TypeName])
+
+object PushSubscription {
+  val VALIDATED: Boolean = true
+  val EXPIRES_TIME_MAX_DAY: Int = 7
+
+  def from(creationRequest: PushSubscriptionCreationRequest,
+           expireTime: PushSubscriptionExpiredTime): PushSubscription =
+    PushSubscription(id = PushSubscriptionId.generate(),
+      deviceClientId = creationRequest.deviceClientId,
+      url = creationRequest.url,
+      keys = creationRequest.keys,
+      verificationCode = VerificationCode.generate(),
+      validated = !VALIDATED,
+      expires = expireTime,
+      types = creationRequest.types)
+}
+
+case class PushSubscription(id: PushSubscriptionId,
+                            deviceClientId: DeviceClientId,
+                            url: PushSubscriptionServerURL,
+                            keys: Option[PushSubscriptionKeys],
+                            verificationCode: VerificationCode,
+                            validated: Boolean,
+                            expires: PushSubscriptionExpiredTime,
+                            types: Seq[TypeName]) {
+  def withTypes(types: Seq[TypeName]): PushSubscription = copy(types = types)
+
+  def verified(): PushSubscription = copy(validated = true)
+
+  def withExpires(expires: PushSubscriptionExpiredTime): PushSubscription = copy(expires = expires)
+}
+
+case class PushSubscriptionNotFoundException(id: PushSubscriptionId) extends RuntimeException
+
+case class ExpireTimeInvalidException(expires: ZonedDateTime, message: String) extends RuntimeException
+
+case class DeviceClientIdInvalidException(deviceClientId: DeviceClientId, message: String) extends RuntimeException
\ No newline at end of file
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/change/TypeStateFactory.scala b/server/data/data-jmap/src/main/scala/org/apache/james/jmap/api/model/State.scala
similarity index 53%
copy from server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/change/TypeStateFactory.scala
copy to server/data/data-jmap/src/main/scala/org/apache/james/jmap/api/model/State.scala
index 594272e..fd7dca0 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/change/TypeStateFactory.scala
+++ b/server/data/data-jmap/src/main/scala/org/apache/james/jmap/api/model/State.scala
@@ -1,34 +1,24 @@
-/** **************************************************************
- * 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.                                           *
- * ************************************************************** */
+/******************************************************************
+ * 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.jmap.change
+package org.apache.james.jmap.api.model
 
-import javax.inject.Inject
-import scala.jdk.CollectionConverters._
-
-case class TypeStateFactory @Inject()(setTypeName: java.util.Set[TypeName]) {
-  val all: scala.collection.mutable.Set[TypeName] = setTypeName.asScala
-
-  def parse(string: String): Either[IllegalArgumentException, TypeName] =
-    all.flatMap(_.parse(string))
-      .headOption
-      .map(Right(_))
-      .getOrElse(Left(new IllegalArgumentException(s"Unknown typeName $string")))
+trait State {
+  def serialize: String
 }
-
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/change/TypeStateFactory.scala b/server/data/data-jmap/src/main/scala/org/apache/james/jmap/api/model/TypeName.scala
similarity index 52%
copy from server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/change/TypeStateFactory.scala
copy to server/data/data-jmap/src/main/scala/org/apache/james/jmap/api/model/TypeName.scala
index 594272e..8fcc515 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/change/TypeStateFactory.scala
+++ b/server/data/data-jmap/src/main/scala/org/apache/james/jmap/api/model/TypeName.scala
@@ -1,34 +1,30 @@
-/** **************************************************************
- * 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.                                           *
- * ************************************************************** */
+/******************************************************************
+ * 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.jmap.change
+package org.apache.james.jmap.api.model
 
-import javax.inject.Inject
-import scala.jdk.CollectionConverters._
+trait TypeName {
+  def asMap(maybeState: Option[State]): Map[TypeName, State] =
+    maybeState.map(state => Map[TypeName, State](this -> state))
+      .getOrElse(Map())
 
-case class TypeStateFactory @Inject()(setTypeName: java.util.Set[TypeName]) {
-  val all: scala.collection.mutable.Set[TypeName] = setTypeName.asScala
-
-  def parse(string: String): Either[IllegalArgumentException, TypeName] =
-    all.flatMap(_.parse(string))
-      .headOption
-      .map(Right(_))
-      .getOrElse(Left(new IllegalArgumentException(s"Unknown typeName $string")))
+  def asString(): String
+  def parse(string: String): Option[TypeName]
+  def parseState(string: String): Either[IllegalArgumentException, State]
 }
-
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/change/StateChangeListener.scala b/server/data/data-jmap/src/test/java/org/apache/james/jmap/memory/pushsubscription/MemoryPushSubscriptionRepositoryTest.java
similarity index 51%
copy from server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/change/StateChangeListener.scala
copy to server/data/data-jmap/src/test/java/org/apache/james/jmap/memory/pushsubscription/MemoryPushSubscriptionRepositoryTest.java
index 1dcbe82..19c38c5 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/change/StateChangeListener.scala
+++ b/server/data/data-jmap/src/test/java/org/apache/james/jmap/memory/pushsubscription/MemoryPushSubscriptionRepositoryTest.java
@@ -1,45 +1,46 @@
-/****************************************************************
- * 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.                                           *
- ****************************************************************/
+/******************************************************************
+ * 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.jmap.change
+package org.apache.james.jmap.memory.pushsubscription;
 
-import org.apache.james.events.Event
-import org.apache.james.events.EventListener.ReactiveEventListener
-import org.apache.james.jmap.core.OutboundMessage
-import org.reactivestreams.Publisher
-import reactor.core.publisher.Sinks
-import reactor.core.publisher.Sinks.EmitFailureHandler.FAIL_FAST
-import reactor.core.scala.publisher.SMono
+import org.apache.james.jmap.api.pushsubscription.PushSubscriptionRepository;
+import org.apache.james.jmap.api.pushsubscription.PushSubscriptionRepositoryContract;
+import org.apache.james.utils.UpdatableTickingClock;
+import org.junit.jupiter.api.BeforeEach;
 
-case class StateChangeListener(types: Set[TypeName], sink: Sinks.Many[OutboundMessage]) extends ReactiveEventListener {
-  override def reactiveEvent(event: Event): Publisher[Void] =
-    event match {
-      case stateChangeEvent: StateChangeEvent =>
-        SMono.fromCallable(() =>
-          stateChangeEvent.asStateChange.filter(types)
-            .foreach(next => sink.emitNext(next, FAIL_FAST)))
-          .asJava().`then`()
-      case _ => SMono.empty
+public class MemoryPushSubscriptionRepositoryTest implements PushSubscriptionRepositoryContract {
+    UpdatableTickingClock clock;
+    PushSubscriptionRepository pushSubscriptionRepository;
+
+    @BeforeEach
+    void setup() {
+        clock = new UpdatableTickingClock(PushSubscriptionRepositoryContract.NOW());
+        pushSubscriptionRepository = new MemoryPushSubscriptionRepository(clock);
+    }
+
+    @Override
+    public UpdatableTickingClock clock() {
+        return clock;
     }
 
-  override def isHandling(event: Event): Boolean = event match {
-    case _: StateChangeEvent => true
-    case _ => false
-  }
+    @Override
+    public PushSubscriptionRepository testee() {
+        return pushSubscriptionRepository;
+    }
 }
diff --git a/server/data/data-jmap/src/test/scala/org/apache/james/jmap/api/pushsubscription/PushSubscriptionRepositoryContract.scala b/server/data/data-jmap/src/test/scala/org/apache/james/jmap/api/pushsubscription/PushSubscriptionRepositoryContract.scala
new file mode 100644
index 0000000..cf6d28c
--- /dev/null
+++ b/server/data/data-jmap/src/test/scala/org/apache/james/jmap/api/pushsubscription/PushSubscriptionRepositoryContract.scala
@@ -0,0 +1,374 @@
+/** ****************************************************************
+ * 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.jmap.api.pushsubscription
+
+import java.net.URL
+import java.time.{Clock, Instant, ZoneId, ZonedDateTime}
+
+import org.apache.james.core.Username
+import org.apache.james.jmap.api.model.{DeviceClientId, DeviceClientIdInvalidException, ExpireTimeInvalidException, PushSubscription, PushSubscriptionCreationRequest, PushSubscriptionExpiredTime, PushSubscriptionId, PushSubscriptionNotFoundException, PushSubscriptionServerURL, State, TypeName}
+import org.apache.james.jmap.api.pushsubscription.PushSubscriptionRepositoryContract.{ALICE, INVALID_EXPIRE, MAX_EXPIRE, VALID_EXPIRE}
+import org.apache.james.utils.UpdatableTickingClock
+import org.assertj.core.api.Assertions.{assertThat, assertThatCode, assertThatThrownBy}
+import org.assertj.core.api.SoftAssertions
+import org.junit.jupiter.api.Test
+import reactor.core.scala.publisher.{SFlux, SMono}
+
+import scala.jdk.CollectionConverters._
+import scala.jdk.OptionConverters._
+
+case object CustomTypeName1 extends TypeName {
+  override val asString: String = "custom1"
+
+  override def parse(string: String): Option[TypeName] = string match {
+    case CustomTypeName1.asString => Some(CustomTypeName1)
+    case _ => None
+  }
+
+  override def parseState(string: String): Either[IllegalArgumentException, CustomState] = Right(CustomState(string))
+}
+
+case object CustomTypeName2 extends TypeName {
+  override val asString: String = "custom2"
+
+  override def parse(string: String): Option[TypeName] = string match {
+    case CustomTypeName2.asString => Some(CustomTypeName2)
+    case _ => None
+  }
+
+  override def parseState(string: String): Either[IllegalArgumentException, CustomState] = Right(CustomState(string))
+}
+
+case class CustomState(value: String) extends State {
+  override def serialize: String = value
+}
+
+object PushSubscriptionRepositoryContract {
+  val NOW: Instant = Instant.parse("2021-10-25T07:05:39.160Z")
+  val ZONE_ID: ZoneId = ZoneId.of("UTC")
+  val CLOCK: Clock = Clock.fixed(NOW, ZONE_ID)
+  val INVALID_EXPIRE: ZonedDateTime = ZonedDateTime.now(CLOCK).minusDays(10)
+  val VALID_EXPIRE: ZonedDateTime = ZonedDateTime.now(CLOCK).plusDays(2)
+  val MAX_EXPIRE: ZonedDateTime = ZonedDateTime.now(CLOCK).plusDays(7)
+  val ALICE: Username = Username.of("alice")
+}
+
+trait PushSubscriptionRepositoryContract {
+  def clock: UpdatableTickingClock
+  def testee: PushSubscriptionRepository
+
+  @Test
+  def validSubscriptionShouldBeSavedSuccessfully(): Unit = {
+    val validRequest = PushSubscriptionCreationRequest(
+      deviceClientId = DeviceClientId("1"),
+      url = PushSubscriptionServerURL(new URL("https://example.com/push")),
+      types = Seq(CustomTypeName1))
+    val pushSubscriptionId = SMono.fromPublisher(testee.save(ALICE, validRequest)).block().id
+    val singleRecordSaved = SFlux.fromPublisher(testee.get(ALICE, Set(pushSubscriptionId).asJava)).count().block()
+
+    assertThat(singleRecordSaved).isEqualTo(1)
+  }
+
+  @Test
+  def newSavedSubscriptionShouldNotBeValidated(): Unit = {
+    val validRequest = PushSubscriptionCreationRequest(
+      deviceClientId = DeviceClientId("1"),
+      url = PushSubscriptionServerURL(new URL("https://example.com/push")),
+      types = Seq(CustomTypeName1))
+    val pushSubscriptionId = SMono.fromPublisher(testee.save(ALICE, validRequest)).block().id
+    val newSavedSubscription = SFlux.fromPublisher(testee.get(ALICE, Set(pushSubscriptionId).asJava)).blockFirst().get
+
+    assertThat(newSavedSubscription.validated).isEqualTo(false)
+  }
+
+  @Test
+  def subscriptionWithExpireBiggerThanMaxExpireShouldBeSetToMaxExpire(): Unit = {
+    val request = PushSubscriptionCreationRequest(
+      deviceClientId = DeviceClientId("1"),
+      url = PushSubscriptionServerURL(new URL("https://example.com/push")),
+      expires = Some(PushSubscriptionExpiredTime(VALID_EXPIRE.plusDays(8))),
+      types = Seq(CustomTypeName1))
+    val pushSubscriptionId = SMono.fromPublisher(testee.save(ALICE, request)).block().id
+    val newSavedSubscription = SFlux.fromPublisher(testee.get(ALICE, Set(pushSubscriptionId).asJava)).blockFirst().get
+
+    assertThat(newSavedSubscription.expires.value).isEqualTo(MAX_EXPIRE)
+  }
+
+  @Test
+  def subscriptionWithInvalidExpireTimeShouldThrowException(): Unit = {
+    val invalidRequest = PushSubscriptionCreationRequest(
+      deviceClientId = DeviceClientId("1"),
+      url = PushSubscriptionServerURL(new URL("https://example.com/push")),
+      expires = Some(PushSubscriptionExpiredTime(INVALID_EXPIRE)),
+      types = Seq(CustomTypeName1))
+
+    assertThatThrownBy(() => SMono.fromPublisher(testee.save(ALICE, invalidRequest)).block())
+      .isInstanceOf(classOf[ExpireTimeInvalidException])
+  }
+
+  @Test
+  def subscriptionWithDuplicatedDeviceClientIdShouldThrowException(): Unit = {
+    val firstRequest = PushSubscriptionCreationRequest(
+      deviceClientId = DeviceClientId("1"),
+      url = PushSubscriptionServerURL(new URL("https://example.com/push")),
+      types = Seq(CustomTypeName1))
+    SMono.fromPublisher(testee.save(ALICE, firstRequest)).block()
+
+    val secondRequestWithDuplicatedDeviceClientId = PushSubscriptionCreationRequest(
+      deviceClientId = DeviceClientId("1"),
+      url = PushSubscriptionServerURL(new URL("https://example.com/push")),
+      types = Seq(CustomTypeName1))
+
+    assertThatThrownBy(() => SMono.fromPublisher(testee.save(ALICE, secondRequestWithDuplicatedDeviceClientId)).block())
+      .isInstanceOf(classOf[DeviceClientIdInvalidException])
+  }
+
+  @Test
+  def updateWithOutdatedExpiresShouldThrowException(): Unit = {
+    val validRequest = PushSubscriptionCreationRequest(
+      deviceClientId = DeviceClientId("1"),
+      url = PushSubscriptionServerURL(new URL("https://example.com/push")),
+      types = Seq(CustomTypeName1))
+    val pushSubscriptionId = SMono.fromPublisher(testee.save(ALICE, validRequest)).block().id
+
+    assertThatThrownBy(() => SMono.fromPublisher(testee.updateExpireTime(ALICE, pushSubscriptionId, INVALID_EXPIRE)).block())
+      .isInstanceOf(classOf[ExpireTimeInvalidException])
+  }
+
+  @Test
+  def updateWithExpiresBiggerThanMaxExpiresShouldBeSetToMaxExpires(): Unit = {
+    val validRequest = PushSubscriptionCreationRequest(
+      deviceClientId = DeviceClientId("1"),
+      url = PushSubscriptionServerURL(new URL("https://example.com/push")),
+      types = Seq(CustomTypeName1))
+    val pushSubscriptionId = SMono.fromPublisher(testee.save(ALICE, validRequest)).block().id
+    SMono.fromPublisher(testee.updateExpireTime(ALICE, pushSubscriptionId, MAX_EXPIRE.plusDays(1))).block()
+
+    val updatedSubscription = SFlux.fromPublisher(testee.get(ALICE, Set(pushSubscriptionId).asJava)).blockFirst().get
+    assertThat(updatedSubscription.expires.value).isEqualTo(MAX_EXPIRE)
+  }
+
+  @Test
+  def updateExpiresWithNotFoundPushSubscriptionIdShouldThrowException(): Unit = {
+    val randomId = PushSubscriptionId.generate()
+
+    assertThatThrownBy(() => SMono.fromPublisher(testee.updateExpireTime(ALICE, randomId, VALID_EXPIRE)).block())
+      .isInstanceOf(classOf[PushSubscriptionNotFoundException])
+  }
+
+  @Test
+  def updateWithValidExpiresShouldSucceed(): Unit = {
+    val validRequest = PushSubscriptionCreationRequest(
+      deviceClientId = DeviceClientId("1"),
+      url = PushSubscriptionServerURL(new URL("https://example.com/push")),
+      types = Seq(CustomTypeName1))
+    val pushSubscriptionId = SMono.fromPublisher(testee.save(ALICE, validRequest)).block().id
+    SMono.fromPublisher(testee.updateExpireTime(ALICE, pushSubscriptionId, VALID_EXPIRE)).block()
+
+    val updatedSubscription = SFlux.fromPublisher(testee.get(ALICE, Set(pushSubscriptionId).asJava)).blockFirst().get
+    assertThat(updatedSubscription.expires.value).isEqualTo(VALID_EXPIRE)
+  }
+
+  @Test
+  def updateWithValidTypesShouldSucceed(): Unit = {
+    val validRequest = PushSubscriptionCreationRequest(
+      deviceClientId = DeviceClientId("1"),
+      url = PushSubscriptionServerURL(new URL("https://example.com/push")),
+      types = Seq(CustomTypeName1))
+    val pushSubscriptionId = SMono.fromPublisher(testee.save(ALICE, validRequest)).block().id
+
+    val newTypes: Set[TypeName] = Set(CustomTypeName1, CustomTypeName2)
+    SMono.fromPublisher(testee.updateTypes(ALICE, pushSubscriptionId, newTypes.asJava)).block()
+
+    val updatedSubscription = SFlux.fromPublisher(testee.get(ALICE, Set(pushSubscriptionId).asJava)).blockFirst().get
+    assertThat(updatedSubscription.types.toSet.asJava).containsExactlyInAnyOrder(CustomTypeName1, CustomTypeName2)
+  }
+
+  @Test
+  def updateTypesWithNotFoundShouldThrowException(): Unit = {
+    val randomId = PushSubscriptionId.generate()
+    val newTypes: Set[TypeName] = Set(CustomTypeName1, CustomTypeName2)
+
+    assertThatThrownBy(() => SMono.fromPublisher(testee.updateTypes(ALICE, randomId, newTypes.asJava)).block())
+      .isInstanceOf(classOf[PushSubscriptionNotFoundException])
+  }
+
+  @Test
+  def getNotFoundShouldReturnEmpty(): Unit = {
+    val randomId = PushSubscriptionId.generate()
+
+    assertThat(SMono.fromPublisher(testee.get(ALICE, Set(randomId).asJava)).blockOption().toJava)
+      .isEmpty
+  }
+
+  @Test
+  def revokeStoredSubscriptionShouldSucceed(): Unit = {
+    val validRequest = PushSubscriptionCreationRequest(
+      deviceClientId = DeviceClientId("1"),
+      url = PushSubscriptionServerURL(new URL("https://example.com/push")),
+      types = Seq(CustomTypeName1))
+    val pushSubscriptionId = SMono.fromPublisher(testee.save(ALICE, validRequest)).block().id
+    val singleRecordSaved = SFlux.fromPublisher(testee.get(ALICE, Set(pushSubscriptionId).asJava)).count().block()
+    assertThat(singleRecordSaved).isEqualTo(1)
+
+    SMono.fromPublisher(testee.revoke(ALICE, pushSubscriptionId)).block()
+    val remaining = SFlux.fromPublisher(testee.get(ALICE, Set(pushSubscriptionId).asJava)).collectSeq().block().asJava
+
+    assertThat(remaining).isEmpty()
+  }
+
+  @Test
+  def revokeNotFoundShouldNotFail(): Unit = {
+    val pushSubscriptionId = PushSubscriptionId.generate()
+    assertThatCode(() => SMono.fromPublisher(testee.revoke(ALICE, pushSubscriptionId)).block())
+      .doesNotThrowAnyException()
+  }
+
+  @Test
+  def getStoredSubscriptionShouldSucceed(): Unit = {
+    val deviceClientId1 = DeviceClientId("1")
+    val deviceClientId2 = DeviceClientId("2")
+    val validRequest1 = PushSubscriptionCreationRequest(
+      deviceClientId = deviceClientId1,
+      url = PushSubscriptionServerURL(new URL("https://example.com/push")),
+      expires = Option(PushSubscriptionExpiredTime(VALID_EXPIRE)),
+      types = Seq(CustomTypeName1))
+    val validRequest2 = PushSubscriptionCreationRequest(
+      deviceClientId = deviceClientId2,
+      url = PushSubscriptionServerURL(new URL("https://example.com/push")),
+      expires = Option(PushSubscriptionExpiredTime(VALID_EXPIRE)),
+      types = Seq(CustomTypeName2))
+    val pushSubscriptionId1 = SMono.fromPublisher(testee.save(ALICE, validRequest1)).block().id
+    val pushSubscriptionId2 = SMono.fromPublisher(testee.save(ALICE, validRequest2)).block().id
+
+    val pushSubscriptions = SFlux.fromPublisher(testee.get(ALICE, Set(pushSubscriptionId1, pushSubscriptionId2).asJava)).collectSeq().block()
+
+    assertThat(pushSubscriptions.map(_.id).toList.asJava).containsExactlyInAnyOrder(pushSubscriptionId1, pushSubscriptionId2)
+  }
+
+  @Test
+  def getShouldMixFoundAndNotFound(): Unit = {
+    val deviceClientId1 = DeviceClientId("1")
+    val validRequest1 = PushSubscriptionCreationRequest(
+      deviceClientId = deviceClientId1,
+      url = PushSubscriptionServerURL(new URL("https://example.com/push")),
+      expires = Option(PushSubscriptionExpiredTime(VALID_EXPIRE)),
+      types = Seq(CustomTypeName1))
+    val pushSubscriptionId1 = SMono.fromPublisher(testee.save(ALICE, validRequest1)).block().id
+    val pushSubscriptionId2 = PushSubscriptionId.generate()
+
+    val pushSubscriptions = SFlux.fromPublisher(testee.get(ALICE, Set(pushSubscriptionId1, pushSubscriptionId2).asJava)).collectSeq().block()
+
+    assertThat(pushSubscriptions.map(_.id).toList.asJava).containsExactlyInAnyOrder(pushSubscriptionId1)
+  }
+
+  @Test
+  def getSubscriptionShouldNotReturnOutdatedSubscriptions(): Unit = {
+    val deviceClientId1 = DeviceClientId("1")
+    val deviceClientId2 = DeviceClientId("2")
+    val validRequest1 = PushSubscriptionCreationRequest(
+      deviceClientId = deviceClientId1,
+      url = PushSubscriptionServerURL(new URL("https://example.com/push")),
+      expires = Option(PushSubscriptionExpiredTime(VALID_EXPIRE.plusDays(1))),
+      types = Seq(CustomTypeName1))
+    val validRequest2 = PushSubscriptionCreationRequest(
+      deviceClientId = deviceClientId2,
+      url = PushSubscriptionServerURL(new URL("https://example.com/push")),
+      expires = Option(PushSubscriptionExpiredTime(VALID_EXPIRE.plusDays(3))),
+      types = Seq(CustomTypeName2))
+    val pushSubscriptionId1 = SMono.fromPublisher(testee.save(ALICE, validRequest1)).block().id
+    val pushSubscriptionId2 = SMono.fromPublisher(testee.save(ALICE, validRequest2)).block().id
+
+    clock.setInstant(VALID_EXPIRE.plusDays(2).toInstant)
+
+    val pushSubscriptions = SFlux.fromPublisher(testee.get(ALICE, Set(pushSubscriptionId1, pushSubscriptionId2).asJava)).collectSeq().block()
+
+    assertThat(pushSubscriptions.map(_.id).toList.asJava).containsExactlyInAnyOrder(pushSubscriptionId2)
+  }
+
+  @Test
+  def listStoredSubscriptionShouldSucceed(): Unit = {
+    val deviceClientId1 = DeviceClientId("1")
+    val deviceClientId2 = DeviceClientId("2")
+    val validRequest1 = PushSubscriptionCreationRequest(
+      deviceClientId = deviceClientId1,
+      url = PushSubscriptionServerURL(new URL("https://example.com/push")),
+      types = Seq(CustomTypeName1))
+    val validRequest2 = PushSubscriptionCreationRequest(
+      deviceClientId = deviceClientId2,
+      url = PushSubscriptionServerURL(new URL("https://example.com/push")),
+      types = Seq(CustomTypeName2))
+    val pushSubscriptionId1: PushSubscriptionId = SMono.fromPublisher(testee.save(ALICE, validRequest1)).block().id
+    val pushSubscriptionId2: PushSubscriptionId = SMono.fromPublisher(testee.save(ALICE, validRequest2)).block().id
+
+    val idList: List[PushSubscription] = SFlux(testee.list(ALICE)).collectSeq().block().toList
+
+    SoftAssertions.assertSoftly(softly => {
+      softly.assertThat(idList.map(_.id).asJava).containsExactlyInAnyOrder(pushSubscriptionId1, pushSubscriptionId2)
+      softly.assertThat(idList.map(_.deviceClientId).asJava).containsExactlyInAnyOrder(deviceClientId1, deviceClientId2)
+    })
+  }
+
+  @Test
+  def listSubscriptionShouldNotReturnOutdatedSubscriptions(): Unit = {
+    val deviceClientId1 = DeviceClientId("1")
+    val deviceClientId2 = DeviceClientId("2")
+    val validRequest1 = PushSubscriptionCreationRequest(
+      deviceClientId = deviceClientId1,
+      url = PushSubscriptionServerURL(new URL("https://example.com/push")),
+      expires = Option(PushSubscriptionExpiredTime(VALID_EXPIRE.plusDays(1))),
+      types = Seq(CustomTypeName1))
+    val validRequest2 = PushSubscriptionCreationRequest(
+      deviceClientId = deviceClientId2,
+      url = PushSubscriptionServerURL(new URL("https://example.com/push")),
+      expires = Option(PushSubscriptionExpiredTime(VALID_EXPIRE.plusDays(3))),
+      types = Seq(CustomTypeName2))
+    SMono.fromPublisher(testee.save(ALICE, validRequest1)).block().id
+    val pushSubscriptionId2 = SMono.fromPublisher(testee.save(ALICE, validRequest2)).block().id
+
+    clock.setInstant(VALID_EXPIRE.plusDays(2).toInstant)
+
+    val pushSubscriptions = SFlux.fromPublisher(testee.list(ALICE)).collectSeq().block()
+
+    assertThat(pushSubscriptions.map(_.id).toList.asJava).containsExactlyInAnyOrder(pushSubscriptionId2)
+  }
+
+  @Test
+  def validateVerificationCodeShouldSucceed(): Unit = {
+    val validRequest = PushSubscriptionCreationRequest(
+      deviceClientId = DeviceClientId("1"),
+      url = PushSubscriptionServerURL(new URL("https://example.com/push")),
+      types = Seq(CustomTypeName1))
+    val pushSubscriptionId = SMono.fromPublisher(testee.save(ALICE, validRequest)).block().id
+    SMono.fromPublisher(testee.validateVerificationCode(ALICE, pushSubscriptionId)).block()
+
+    val validatedSubscription = SFlux.fromPublisher(testee.get(ALICE, Set(pushSubscriptionId).asJava)).blockFirst().get
+    assertThat(validatedSubscription.validated).isEqualTo(true)
+  }
+
+  @Test
+  def validateVerificationCodeWithNotFoundPushSubscriptionIdShouldThrowException(): Unit = {
+    val randomId = PushSubscriptionId.generate()
+
+    assertThatThrownBy(() => SMono.fromPublisher(testee.validateVerificationCode(ALICE, randomId)).block())
+      .isInstanceOf(classOf[PushSubscriptionNotFoundException])
+  }
+}
+
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/CustomMethodContract.scala b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/CustomMethodContract.scala
index 7edfb4f..84a813d 100644
--- a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/CustomMethodContract.scala
+++ b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/CustomMethodContract.scala
@@ -37,13 +37,13 @@ import org.apache.http.HttpStatus.SC_OK
 import org.apache.james.GuiceJamesServer
 import org.apache.james.events.Event.EventId
 import org.apache.james.events.EventBus
-import org.apache.james.jmap.api.model.AccountId
+import org.apache.james.jmap.api.model.{AccountId, State, TypeName}
 import org.apache.james.jmap.api.model.Size.Size
-import org.apache.james.jmap.change.{AccountIdRegistrationKey, StateChangeEvent, TypeName}
+import org.apache.james.jmap.change.{AccountIdRegistrationKey, StateChangeEvent}
 import org.apache.james.jmap.core.CapabilityIdentifier.{CapabilityIdentifier, JMAP_CORE}
 import org.apache.james.jmap.core.Invocation.MethodName
 import org.apache.james.jmap.core.ResponseObject.SESSION_STATE
-import org.apache.james.jmap.core.{Capability, CapabilityProperties, State}
+import org.apache.james.jmap.core.{Capability, CapabilityProperties}
 import org.apache.james.jmap.draft.JmapGuiceProbe
 import org.apache.james.jmap.http.UserCredential
 import org.apache.james.jmap.mail
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/change/JmapEventSerializer.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/change/JmapEventSerializer.scala
index fefe754..5867d94 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/change/JmapEventSerializer.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/change/JmapEventSerializer.scala
@@ -26,7 +26,8 @@ import javax.inject.Inject
 import org.apache.james.core.Username
 import org.apache.james.events.Event.EventId
 import org.apache.james.events.{Event, EventSerializer}
-import org.apache.james.jmap.core.{State, UuidState}
+import org.apache.james.jmap.api.model.{State, TypeName}
+import org.apache.james.jmap.core.UuidState
 import org.apache.james.json.JsonGenericSerializer
 
 import scala.jdk.CollectionConverters._
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/change/MailboxChangeListener.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/change/MailboxChangeListener.scala
index 0fd1501..600437e 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/change/MailboxChangeListener.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/change/MailboxChangeListener.scala
@@ -28,9 +28,9 @@ import org.apache.james.events.EventListener.ReactiveGroupEventListener
 import org.apache.james.events.{Event, EventBus, Group}
 import org.apache.james.jmap.InjectionKeys
 import org.apache.james.jmap.api.change.{EmailChange, EmailChangeRepository, JmapChange, MailboxAndEmailChange, MailboxChange, MailboxChangeRepository}
-import org.apache.james.jmap.api.model.AccountId
+import org.apache.james.jmap.api.model.{AccountId, State, TypeName}
 import org.apache.james.jmap.change.MailboxChangeListener.LOGGER
-import org.apache.james.jmap.core.{State, UuidState}
+import org.apache.james.jmap.core.UuidState
 import org.apache.james.mailbox.MailboxManager
 import org.apache.james.mailbox.events.MailboxEvents.{Added, Expunged, FlagsUpdated, MailboxACLUpdated, MailboxAdded, MailboxDeletion, MailboxEvent, MailboxRenamed}
 import org.apache.james.mailbox.model.{MailboxACL, MailboxId}
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/change/StateChange.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/change/StateChange.scala
index 07167da..bcecded 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/change/StateChange.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/change/StateChange.scala
@@ -22,17 +22,9 @@ package org.apache.james.jmap.change
 import org.apache.james.core.Username
 import org.apache.james.events.Event
 import org.apache.james.events.Event.EventId
-import org.apache.james.jmap.core.{AccountId, PushState, State, StateChange, UuidState}
+import org.apache.james.jmap.api.model.{State, TypeName}
+import org.apache.james.jmap.core.{AccountId, PushState, StateChange, UuidState}
 
-trait TypeName {
-  def asMap(maybeState: Option[State]): Map[TypeName, State] =
-    maybeState.map(state => Map[TypeName, State](this -> state))
-      .getOrElse(Map())
-
-  def asString(): String
-  def parse(string: String): Option[TypeName]
-  def parseState(string: String): Either[IllegalArgumentException, State]
-}
 case object MailboxTypeName extends TypeName {
   override val asString: String = "Mailbox"
 
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/change/StateChangeListener.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/change/StateChangeListener.scala
index 1dcbe82..1cf9d5d 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/change/StateChangeListener.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/change/StateChangeListener.scala
@@ -21,6 +21,7 @@ package org.apache.james.jmap.change
 
 import org.apache.james.events.Event
 import org.apache.james.events.EventListener.ReactiveEventListener
+import org.apache.james.jmap.api.model.TypeName
 import org.apache.james.jmap.core.OutboundMessage
 import org.reactivestreams.Publisher
 import reactor.core.publisher.Sinks
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/change/TypeStateFactory.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/change/TypeStateFactory.scala
index 594272e..fea1ad0 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/change/TypeStateFactory.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/change/TypeStateFactory.scala
@@ -20,6 +20,8 @@
 package org.apache.james.jmap.change
 
 import javax.inject.Inject
+import org.apache.james.jmap.api.model.TypeName
+
 import scala.jdk.CollectionConverters._
 
 case class TypeStateFactory @Inject()(setTypeName: java.util.Set[TypeName]) {
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/Session.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/Session.scala
index b25456d..cd869ec 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/Session.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/Session.scala
@@ -29,6 +29,7 @@ import eu.timepit.refined.refineV
 import eu.timepit.refined.string.Uuid
 import org.apache.james.core.Username
 import org.apache.james.jmap.api.change.{EmailChanges, MailboxChanges, State => JavaState}
+import org.apache.james.jmap.api.model.State
 import org.apache.james.jmap.core.CapabilityIdentifier.CapabilityIdentifier
 import org.apache.james.jmap.core.Id.Id
 import org.apache.james.jmap.core.UuidState.INSTANCE
@@ -104,10 +105,6 @@ object UuidState {
     .left.map(new IllegalArgumentException(_))
 }
 
-trait State {
-  def serialize: String
-}
-
 case class UuidState(value: UUID) extends State {
   override def serialize: String = value.toString
 }
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/WebSocketTransport.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/WebSocketTransport.scala
index 9225fe5..bfe8368 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/WebSocketTransport.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/WebSocketTransport.scala
@@ -22,7 +22,8 @@ package org.apache.james.jmap.core
 import java.nio.charset.StandardCharsets
 
 import com.google.common.hash.Hashing
-import org.apache.james.jmap.change.{TypeName, TypeState}
+import org.apache.james.jmap.api.model.{State, TypeName}
+import org.apache.james.jmap.change.TypeState
 import org.apache.james.jmap.routes.PingPolicy.Interval
 
 sealed trait WebSocketInboundMessage
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/MailboxSerializer.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/MailboxSerializer.scala
index c5c4e51..9b7e06e 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/MailboxSerializer.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/MailboxSerializer.scala
@@ -26,7 +26,7 @@ import org.apache.james.core.{Domain, Username}
 import org.apache.james.jmap.core.CapabilityIdentifier.CapabilityIdentifier
 import org.apache.james.jmap.core.Id.IdConstraint
 import org.apache.james.jmap.core.{ClientId, Properties, SetError, UuidState}
-import org.apache.james.jmap.mail.{Ids, IsSubscribed, Mailbox, MailboxChangesRequest, MailboxChangesResponse, MailboxCreationId, MailboxCreationRequest, MailboxCreationResponse, MailboxGetRequest, MailboxGetResponse, MailboxNamespace, MailboxPatchObject, MailboxRights, MailboxSetRequest, MailboxSetResponse, MailboxUpdateResponse, MayAddItems, MayCreateChild, MayDelete, MayReadItems, MayRemoveItems, MayRename, MaySetKeywords, MaySetSeen, MaySubmit, NotFound, PersonalNamespace, Quota, Quot [...]
+import org.apache.james.jmap.mail.{Ids, IsSubscribed, Mailbox, MailboxChangesRequest, MailboxChangesResponse, MailboxCreationId, MailboxCreationRequest, MailboxCreationResponse, MailboxGetRequest, MailboxGetResponse, MailboxNamespace, MailboxPatchObject, MailboxRights, MailboxSetRequest, MailboxSetResponse, MailboxUpdateResponse, MayAddItems, MayCreateChild, MayDelete, MayReadItems, MayRemoveItems, MayRename, MaySetKeywords, MaySetSeen, MaySubmit, NotFound, Quota, QuotaId, QuotaRoot, Quo [...]
 import org.apache.james.mailbox.Role
 import org.apache.james.mailbox.model.MailboxACL.{Right => JavaRight}
 import org.apache.james.mailbox.model.{MailboxACL, MailboxId}
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/PushSerializer.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/PushSerializer.scala
index 0342568..13313a2 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/PushSerializer.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/PushSerializer.scala
@@ -20,8 +20,9 @@
 package org.apache.james.jmap.json
 
 import javax.inject.Inject
-import org.apache.james.jmap.change.{TypeName, TypeState, TypeStateFactory}
-import org.apache.james.jmap.core.{AccountId, OutboundMessage, PingMessage, PushState, RequestId, State, StateChange, WebSocketError, WebSocketInboundMessage, WebSocketPushDisable, WebSocketPushEnable, WebSocketRequest, WebSocketResponse}
+import org.apache.james.jmap.api.model.{State, TypeName}
+import org.apache.james.jmap.change.{TypeState, TypeStateFactory}
+import org.apache.james.jmap.core.{AccountId, OutboundMessage, PingMessage, PushState, RequestId, StateChange, WebSocketError, WebSocketInboundMessage, WebSocketPushDisable, WebSocketPushEnable, WebSocketRequest, WebSocketResponse}
 import play.api.libs.json.{Format, JsError, JsNull, JsObject, JsResult, JsString, JsSuccess, JsValue, Json, OWrites, Reads, Writes}
 
 import scala.util.Try
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailSet.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailSet.scala
index afa1b41..9fd289d 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailSet.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailSet.scala
@@ -20,6 +20,7 @@ package org.apache.james.jmap.mail
 
 import java.nio.charset.{StandardCharsets, Charset => NioCharset}
 import java.util.Date
+
 import cats.implicits._
 import com.google.common.net.MediaType
 import com.google.common.net.MediaType.{HTML_UTF_8, PLAIN_TEXT_UTF_8}
@@ -32,8 +33,8 @@ import org.apache.james.jmap.method.WithAccountId
 import org.apache.james.jmap.routes.{Blob, BlobResolvers}
 import org.apache.james.mailbox.MailboxSession
 import org.apache.james.mailbox.model.{Cid, MessageId}
-import org.apache.james.mime4j.codec.{DecodeMonitor, EncoderUtil}
 import org.apache.james.mime4j.codec.EncoderUtil.Usage
+import org.apache.james.mime4j.codec.{DecodeMonitor, EncoderUtil}
 import org.apache.james.mime4j.dom.field.{ContentIdField, ContentTypeField, FieldName}
 import org.apache.james.mime4j.dom.{Entity, Message}
 import org.apache.james.mime4j.field.{ContentIdFieldImpl, Fields}
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/VacationResponseSetMethod.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/VacationResponseSetMethod.scala
index 710c6b0..a107d39 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/VacationResponseSetMethod.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/VacationResponseSetMethod.scala
@@ -19,8 +19,6 @@
 
 package org.apache.james.jmap.method
 
-import java.util.UUID
-
 import eu.timepit.refined.auto._
 import javax.inject.{Inject, Named}
 import org.apache.james.events.Event.EventId
@@ -30,9 +28,9 @@ import org.apache.james.jmap.api.model.AccountId
 import org.apache.james.jmap.api.vacation.{VacationPatch, VacationRepository}
 import org.apache.james.jmap.change.{AccountIdRegistrationKey, StateChangeEvent, VacationResponseTypeName}
 import org.apache.james.jmap.core.CapabilityIdentifier.{CapabilityIdentifier, JMAP_CORE, JMAP_VACATION_RESPONSE}
+import org.apache.james.jmap.core.{Invocation, UuidState}
 import org.apache.james.jmap.core.Invocation.{Arguments, MethodName}
 import org.apache.james.jmap.core.SetError.SetErrorDescription
-import org.apache.james.jmap.core.{Invocation, UuidState}
 import org.apache.james.jmap.json.{ResponseSerializer, VacationSerializer}
 import org.apache.james.jmap.method.VacationResponseSetMethod.VACATION_RESPONSE_PATCH_OBJECT_KEY
 import org.apache.james.jmap.routes.SessionSupplier
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/routes/EventSourceRoutes.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/routes/EventSourceRoutes.scala
index 3bfc965..f9e277f 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/routes/EventSourceRoutes.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/routes/EventSourceRoutes.scala
@@ -33,7 +33,8 @@ import javax.inject.{Inject, Named}
 import org.apache.james.events.{EventBus, Registration}
 import org.apache.james.jmap.HttpConstants.JSON_CONTENT_TYPE
 import org.apache.james.jmap.JMAPUrls.EVENT_SOURCE
-import org.apache.james.jmap.change.{AccountIdRegistrationKey, StateChangeListener, TypeName, TypeStateFactory}
+import org.apache.james.jmap.api.model.TypeName
+import org.apache.james.jmap.change.{AccountIdRegistrationKey, StateChangeListener, TypeStateFactory}
 import org.apache.james.jmap.core.{OutboundMessage, PingMessage, ProblemDetails, StateChange}
 import org.apache.james.jmap.exceptions.UnauthorizedException
 import org.apache.james.jmap.http.rfc8621.InjectionKeys
diff --git a/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/change/StateChangeEventSerializerTest.scala b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/change/StateChangeEventSerializerTest.scala
index ce4aa40..4ba5f43 100644
--- a/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/change/StateChangeEventSerializerTest.scala
+++ b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/change/StateChangeEventSerializerTest.scala
@@ -21,6 +21,7 @@ package org.apache.james.jmap.change
 import org.apache.james.JsonSerializationVerifier
 import org.apache.james.core.Username
 import org.apache.james.events.Event.EventId
+import org.apache.james.jmap.api.model.TypeName
 import org.apache.james.jmap.change.StateChangeEventSerializerTest.{EVENT, EVENT_EMPTY_TYPE_STATE_MAP, EVENT_ID, EVENT_JSON, EVENT_JSON_EMPTY_TYPE_STATE_MAP, EVENT_JSON_NO_DELIVERY, EVENT_NO_DELIVERY, USERNAME}
 import org.apache.james.jmap.core.UuidState
 import org.apache.james.json.JsonGenericSerializer
diff --git a/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/change/TypeStateFactoryTest.scala b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/change/TypeStateFactoryTest.scala
index be2b8d4..2803b3c 100644
--- a/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/change/TypeStateFactoryTest.scala
+++ b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/change/TypeStateFactoryTest.scala
@@ -19,6 +19,7 @@
 
 package org.apache.james.jmap.change
 
+import org.apache.james.jmap.api.model.TypeName
 import org.assertj.core.api.Assertions.assertThat
 import org.assertj.core.api.SoftAssertions
 import org.junit.jupiter.api.Test

---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@james.apache.org
For additional commands, e-mail: notifications-help@james.apache.org