You are viewing a plain text version of this content. The canonical link for it is here.
Posted to server-dev@james.apache.org by bt...@apache.org on 2018/05/08 08:42:41 UTC

[2/3] james-project git commit: MAILBOX-331 Implement Quota Threshold notifier as an EventSourcing project

http://git-wip-us.apache.org/repos/asf/james-project/blob/0e437d25/mailbox/plugin/quota-mailing/src/main/java/org/apache/james/mailbox/quota/mailing/subscribers/QuotaThresholdNotice.java
----------------------------------------------------------------------
diff --git a/mailbox/plugin/quota-mailing/src/main/java/org/apache/james/mailbox/quota/mailing/subscribers/QuotaThresholdNotice.java b/mailbox/plugin/quota-mailing/src/main/java/org/apache/james/mailbox/quota/mailing/subscribers/QuotaThresholdNotice.java
new file mode 100644
index 0000000..34b166b
--- /dev/null
+++ b/mailbox/plugin/quota-mailing/src/main/java/org/apache/james/mailbox/quota/mailing/subscribers/QuotaThresholdNotice.java
@@ -0,0 +1,199 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *   http://www.apache.org/licenses/LICENSE-2.0                 *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.mailbox.quota.mailing.subscribers;
+
+import java.util.Objects;
+import java.util.Optional;
+
+import org.apache.commons.io.FileUtils;
+import org.apache.james.mailbox.model.Quota;
+import org.apache.james.mailbox.quota.QuotaCount;
+import org.apache.james.mailbox.quota.QuotaSize;
+import org.apache.james.mailbox.quota.model.HistoryEvolution;
+import org.apache.james.mailbox.quota.model.QuotaThreshold;
+import org.apache.james.mailbox.quota.model.QuotaThresholdChange;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Preconditions;
+
+public class QuotaThresholdNotice {
+
+    public static class Builder {
+        private Optional<QuotaThreshold> countThreshold;
+        private Optional<QuotaThreshold> sizeThreshold;
+        private Quota<QuotaSize> sizeQuota;
+        private Quota<QuotaCount> countQuota;
+
+        public Builder() {
+            countThreshold = Optional.empty();
+            sizeThreshold = Optional.empty();
+        }
+
+        public Builder sizeQuota(Quota<QuotaSize> sizeQuota) {
+            this.sizeQuota = sizeQuota;
+            return this;
+        }
+
+        public Builder countQuota(Quota<QuotaCount> countQuota) {
+            this.countQuota = countQuota;
+            return this;
+        }
+
+        public Builder countThreshold(HistoryEvolution countHistoryEvolution) {
+            this.countThreshold = Optional.of(countHistoryEvolution)
+                .filter(this::needsNotification)
+                .flatMap(HistoryEvolution::getThresholdChange)
+                .map(QuotaThresholdChange::getQuotaThreshold);
+            return this;
+        }
+
+        public Builder sizeThreshold(HistoryEvolution sizeHistoryEvolution) {
+            this.sizeThreshold = Optional.of(sizeHistoryEvolution)
+                .filter(this::needsNotification)
+                .flatMap(HistoryEvolution::getThresholdChange)
+                .map(QuotaThresholdChange::getQuotaThreshold);
+            return this;
+        }
+
+        boolean needsNotification(HistoryEvolution evolution) {
+            return evolution.getThresholdHistoryChange() == HistoryEvolution.HistoryChangeType.HigherThresholdReached
+                && evolution.currentThresholdNotRecentlyReached();
+        }
+
+        public Optional<QuotaThresholdNotice> build() {
+            Preconditions.checkNotNull(sizeQuota);
+            Preconditions.checkNotNull(countQuota);
+
+            if (sizeThreshold.isPresent() || countThreshold.isPresent()) {
+                return Optional.of(
+                    new QuotaThresholdNotice(countThreshold, sizeThreshold, sizeQuota, countQuota));
+            }
+            return Optional.empty();
+        }
+    }
+
+    public static class MessageBuilder {
+        public static final String PREAMBLE = "You receive this email because you recently exceeded a threshold related " +
+            "to the quotas of your email account.\n\n";
+        public static final String CONCLUSION = "You need to be aware that actions leading to exceeded quotas will be denied. " +
+            "This will result in a degraded service.\n" +
+            "To mitigate this issue you might reach your administrator in order to increase your configured quota. " +
+            "You might also delete some non important emails.";
+
+        private final StringBuilder stringBuilder;
+
+
+        public MessageBuilder() {
+            this.stringBuilder = new StringBuilder();
+        }
+
+        public MessageBuilder appendSizeReport(QuotaThreshold threshold, Quota<QuotaSize> sizeQuota) {
+            stringBuilder.append(String.format("You currently occupy more than %d %% of the total size allocated to you.\n" +
+                    "You currently occupy %s on a total of %s allocated to you.\n\n",
+                threshold.getQuotaOccupationRatioAsPercent(),
+                FileUtils.byteCountToDisplaySize(sizeQuota.getUsed().asLong()),
+                FileUtils.byteCountToDisplaySize(sizeQuota.getLimit().asLong())));
+            return this;
+        }
+
+        public MessageBuilder appendCountReport(QuotaThreshold threshold, Quota<QuotaCount> countQuota) {
+            stringBuilder.append(String.format("You currently occupy more than %d %% of the total message count allocated to you.\n" +
+                    "You currently have %d messages on a total of %d allowed for you.\n\n",
+                threshold.getQuotaOccupationRatioAsPercent(),
+                countQuota.getUsed().asLong(),
+                countQuota.getLimit().asLong()));
+            return this;
+        }
+
+        public MessageBuilder appendSizeReport(Optional<QuotaThreshold> threshold, Quota<QuotaSize> sizeQuota) {
+            if (threshold.isPresent()) {
+                return appendSizeReport(threshold.get(), sizeQuota);
+            }
+            return this;
+        }
+
+        public MessageBuilder appendCountReport(Optional<QuotaThreshold> threshold, Quota<QuotaCount> countQuota) {
+            if (threshold.isPresent()) {
+                return appendCountReport(threshold.get(), countQuota);
+            }
+            return this;
+        }
+
+        public MessageBuilder appendPreamble() {
+            stringBuilder.append(PREAMBLE);
+            return this;
+        }
+
+        public MessageBuilder appendConclusion() {
+            stringBuilder.append(CONCLUSION);
+            return this;
+        }
+
+        public String build() {
+            return stringBuilder.toString();
+        }
+    }
+
+    public static Builder builder() {
+        return new Builder();
+    }
+
+    private final Optional<QuotaThreshold> countThreshold;
+    private final Optional<QuotaThreshold> sizeThreshold;
+    private final Quota<QuotaSize> sizeQuota;
+    private final Quota<QuotaCount> countQuota;
+
+    @VisibleForTesting
+    QuotaThresholdNotice(Optional<QuotaThreshold> countThreshold, Optional<QuotaThreshold> sizeThreshold,
+                         Quota<QuotaSize> sizeQuota, Quota<QuotaCount> countQuota) {
+        this.countThreshold = countThreshold;
+        this.sizeThreshold = sizeThreshold;
+        this.sizeQuota = sizeQuota;
+        this.countQuota = countQuota;
+    }
+
+    public String generateReport() {
+        return new MessageBuilder()
+            .appendPreamble()
+            .appendSizeReport(sizeThreshold, sizeQuota)
+            .appendCountReport(countThreshold, countQuota)
+            .appendConclusion()
+            .build();
+    }
+
+    @Override
+    public final boolean equals(Object o) {
+        if (o instanceof QuotaThresholdNotice) {
+            QuotaThresholdNotice that = (QuotaThresholdNotice) o;
+
+            return Objects.equals(this.countThreshold, that.countThreshold)
+                && Objects.equals(this.sizeThreshold, that.sizeThreshold)
+                && Objects.equals(this.sizeQuota, that.sizeQuota)
+                && Objects.equals(this.countQuota, that.countQuota);
+        }
+        return false;
+    }
+
+    @Override
+    public final int hashCode() {
+        return Objects.hash(countThreshold, sizeThreshold, sizeQuota, countQuota);
+    }
+
+}

http://git-wip-us.apache.org/repos/asf/james-project/blob/0e437d25/mailbox/plugin/quota-mailing/src/main/java/org/apache/james/mailbox/quota/model/HistoryEvolution.java
----------------------------------------------------------------------
diff --git a/mailbox/plugin/quota-mailing/src/main/java/org/apache/james/mailbox/quota/model/HistoryEvolution.java b/mailbox/plugin/quota-mailing/src/main/java/org/apache/james/mailbox/quota/model/HistoryEvolution.java
new file mode 100644
index 0000000..e7cdf14
--- /dev/null
+++ b/mailbox/plugin/quota-mailing/src/main/java/org/apache/james/mailbox/quota/model/HistoryEvolution.java
@@ -0,0 +1,111 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *   http://www.apache.org/licenses/LICENSE-2.0                 *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.mailbox.quota.model;
+
+import java.util.Objects;
+import java.util.Optional;
+
+public class HistoryEvolution {
+
+    public static HistoryEvolution noChanges() {
+        return new HistoryEvolution(HistoryChangeType.NoChange,
+            Optional.empty(),
+            Optional.empty()
+            );
+    }
+
+    public static HistoryEvolution lowerThresholdReached(QuotaThresholdChange currentThreshold) {
+        return new HistoryEvolution(HistoryChangeType.LowerThresholdReached,
+            Optional.empty(),
+            Optional.of(currentThreshold));
+    }
+
+    public static HistoryEvolution higherThresholdReached(QuotaThresholdChange currentThreshold, HighestThresholdRecentness recentness) {
+        return new HistoryEvolution(HistoryChangeType.HigherThresholdReached,
+            Optional.of(recentness),
+            Optional.of(currentThreshold));
+    }
+
+    public enum HistoryChangeType {
+        HigherThresholdReached,
+        NoChange,
+        LowerThresholdReached
+    }
+
+    public enum HighestThresholdRecentness {
+        AlreadyReachedDuringGracePriod,
+        NotAlreadyReachedDuringGracePeriod
+    }
+
+    private final HistoryChangeType thresholdHistoryChange;
+    private final Optional<HighestThresholdRecentness> recentness;
+    private final Optional<QuotaThresholdChange> thresholdChange;
+
+    private HistoryEvolution(HistoryChangeType thresholdHistoryChange, Optional<HighestThresholdRecentness> recentness, Optional<QuotaThresholdChange> thresholdChange) {
+        this.thresholdHistoryChange = thresholdHistoryChange;
+        this.recentness = recentness;
+        this.thresholdChange = thresholdChange;
+    }
+
+    public boolean isChange() {
+        return thresholdHistoryChange != HistoryChangeType.NoChange;
+    }
+
+    public boolean currentThresholdNotRecentlyReached() {
+        return recentness
+            .map(value -> value == HighestThresholdRecentness.NotAlreadyReachedDuringGracePeriod)
+            .orElse(false);
+    }
+
+    public Optional<QuotaThresholdChange> getThresholdChange() {
+        return thresholdChange;
+    }
+
+    public HistoryChangeType getThresholdHistoryChange() {
+        return thresholdHistoryChange;
+    }
+
+    @Override
+    public final boolean equals(Object o) {
+        if (o instanceof HistoryEvolution) {
+            HistoryEvolution that = (HistoryEvolution) o;
+
+            return Objects.equals(this.thresholdHistoryChange, that.thresholdHistoryChange)
+                && Objects.equals(this.recentness, that.recentness)
+                && Objects.equals(this.thresholdChange, that.thresholdChange);
+        }
+        return false;
+    }
+
+    @Override
+    public final int hashCode() {
+        return Objects.hash(thresholdHistoryChange, recentness, thresholdChange);
+    }
+
+
+    @Override
+    public String toString() {
+        return "HistoryEvolution{" +
+            "thresholdHistoryChange=" + thresholdHistoryChange +
+            ", recentness=" + recentness +
+            ", thresholdChange=" + thresholdChange +
+            '}';
+    }
+}

http://git-wip-us.apache.org/repos/asf/james-project/blob/0e437d25/mailbox/plugin/quota-mailing/src/main/java/org/apache/james/mailbox/quota/model/QuotaThreshold.java
----------------------------------------------------------------------
diff --git a/mailbox/plugin/quota-mailing/src/main/java/org/apache/james/mailbox/quota/model/QuotaThreshold.java b/mailbox/plugin/quota-mailing/src/main/java/org/apache/james/mailbox/quota/model/QuotaThreshold.java
new file mode 100644
index 0000000..dea2676
--- /dev/null
+++ b/mailbox/plugin/quota-mailing/src/main/java/org/apache/james/mailbox/quota/model/QuotaThreshold.java
@@ -0,0 +1,99 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *   http://www.apache.org/licenses/LICENSE-2.0                 *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.mailbox.quota.model;
+
+import java.util.Objects;
+import java.util.Optional;
+
+import org.apache.james.mailbox.model.Quota;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Preconditions;
+
+public class QuotaThreshold implements Comparable<QuotaThreshold> {
+
+    public static final QuotaThreshold ZERO = new QuotaThreshold(0.);
+
+    private final double quotaOccupationRatio;
+
+    public QuotaThreshold(double quotaOccupationRatio) {
+        Preconditions.checkArgument(quotaOccupationRatio >= 0., "Threshold should be contained in [0., 1.] range");
+        Preconditions.checkArgument(quotaOccupationRatio <= 1., "Threshold should be contained in [0., 1.] range");
+        this.quotaOccupationRatio = quotaOccupationRatio;
+    }
+
+    public double getQuotaOccupationRatio() {
+        return quotaOccupationRatio;
+    }
+
+    public int getQuotaOccupationRatioAsPercent() {
+        return Double.valueOf(quotaOccupationRatio * 100).intValue();
+    }
+
+    public boolean isExceeded(Quota<?> quota) {
+        if (quota.getLimit().isUnlimited()) {
+            return false;
+        }
+        double used = toDouble(quota.getUsed().asLong());
+        double limit = toDouble(quota.getLimit().asLong());
+
+        double ratio = (used / limit);
+
+        return ratio > quotaOccupationRatio;
+    }
+
+    public Optional<QuotaThreshold> nonZero() {
+        if (this.equals(ZERO)) {
+            return Optional.empty();
+        }
+        return Optional.of(this);
+    }
+
+    @Override
+    public int compareTo(QuotaThreshold o) {
+        return Double.compare(this.quotaOccupationRatio, o.quotaOccupationRatio);
+    }
+
+    private double toDouble(long aLong) {
+        return Long.valueOf(aLong).doubleValue();
+    }
+
+    @Override
+    public final boolean equals(Object o) {
+        if (o instanceof QuotaThreshold) {
+            QuotaThreshold that = (QuotaThreshold) o;
+
+            return Objects.equals(this.quotaOccupationRatio, that.quotaOccupationRatio);
+        }
+        return false;
+    }
+
+    @Override
+    public final int hashCode() {
+        return Objects.hash(quotaOccupationRatio);
+    }
+
+    @Override
+    public String toString() {
+        return MoreObjects.toStringHelper(this)
+            .add("quotaOccupationInPercent", quotaOccupationRatio)
+            .toString();
+    }
+}

http://git-wip-us.apache.org/repos/asf/james-project/blob/0e437d25/mailbox/plugin/quota-mailing/src/main/java/org/apache/james/mailbox/quota/model/QuotaThresholdChange.java
----------------------------------------------------------------------
diff --git a/mailbox/plugin/quota-mailing/src/main/java/org/apache/james/mailbox/quota/model/QuotaThresholdChange.java b/mailbox/plugin/quota-mailing/src/main/java/org/apache/james/mailbox/quota/model/QuotaThresholdChange.java
new file mode 100644
index 0000000..874d1ca
--- /dev/null
+++ b/mailbox/plugin/quota-mailing/src/main/java/org/apache/james/mailbox/quota/model/QuotaThresholdChange.java
@@ -0,0 +1,71 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *   http://www.apache.org/licenses/LICENSE-2.0                 *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.mailbox.quota.model;
+
+import java.time.Instant;
+import java.util.Objects;
+
+import com.google.common.base.MoreObjects;
+
+public class QuotaThresholdChange {
+    private final QuotaThreshold quotaThreshold;
+    private final Instant instant;
+
+    public QuotaThresholdChange(QuotaThreshold quotaThreshold, Instant instant) {
+        this.quotaThreshold = quotaThreshold;
+        this.instant = instant;
+    }
+
+    public boolean isAfter(Instant instant) {
+        return this.instant.isAfter(instant);
+    }
+
+    public QuotaThreshold getQuotaThreshold() {
+        return quotaThreshold;
+    }
+
+    public Instant getInstant() {
+        return instant;
+    }
+
+    @Override
+    public final boolean equals(Object o) {
+        if (o instanceof QuotaThresholdChange) {
+            QuotaThresholdChange that = (QuotaThresholdChange) o;
+
+            return Objects.equals(this.quotaThreshold, that.quotaThreshold)
+                && Objects.equals(this.instant, that.instant);
+        }
+        return false;
+    }
+
+    @Override
+    public final int hashCode() {
+        return Objects.hash(quotaThreshold, instant);
+    }
+
+    @Override
+    public String toString() {
+        return MoreObjects.toStringHelper(this)
+            .add("quotaThreshold", quotaThreshold)
+            .add("instant", instant)
+            .toString();
+    }
+}

http://git-wip-us.apache.org/repos/asf/james-project/blob/0e437d25/mailbox/plugin/quota-mailing/src/main/java/org/apache/james/mailbox/quota/model/QuotaThresholdHistory.java
----------------------------------------------------------------------
diff --git a/mailbox/plugin/quota-mailing/src/main/java/org/apache/james/mailbox/quota/model/QuotaThresholdHistory.java b/mailbox/plugin/quota-mailing/src/main/java/org/apache/james/mailbox/quota/model/QuotaThresholdHistory.java
new file mode 100644
index 0000000..b80a39f
--- /dev/null
+++ b/mailbox/plugin/quota-mailing/src/main/java/org/apache/james/mailbox/quota/model/QuotaThresholdHistory.java
@@ -0,0 +1,109 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *   http://www.apache.org/licenses/LICENSE-2.0                 *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.mailbox.quota.model;
+
+import static org.apache.james.mailbox.quota.model.HistoryEvolution.HighestThresholdRecentness.AlreadyReachedDuringGracePriod;
+import static org.apache.james.mailbox.quota.model.HistoryEvolution.HighestThresholdRecentness.NotAlreadyReachedDuringGracePeriod;
+
+import java.time.Duration;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+import com.github.steveash.guavate.Guavate;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+
+public class QuotaThresholdHistory {
+
+    private final ImmutableList<QuotaThresholdChange> changes;
+
+    public QuotaThresholdHistory() {
+        this(ImmutableList.of());
+    }
+
+    public QuotaThresholdHistory(QuotaThresholdChange... changes) {
+        this(Arrays.asList(changes));
+    }
+
+    public QuotaThresholdHistory(List<QuotaThresholdChange> changes) {
+        this.changes = changes.stream()
+            .sorted(Comparator.comparing(QuotaThresholdChange::getInstant))
+            .collect(Guavate.toImmutableList());
+    }
+
+    public HistoryEvolution compareWithCurrentThreshold(QuotaThresholdChange thresholdChange, Duration gracePeriod) {
+        Optional<QuotaThreshold> lastThreshold = Optional.ofNullable(Iterables.getLast(changes, null))
+            .map(QuotaThresholdChange::getQuotaThreshold);
+
+        return compareWithCurrentThreshold(thresholdChange, gracePeriod, lastThreshold.orElse(QuotaThreshold.ZERO));
+    }
+
+    private HistoryEvolution compareWithCurrentThreshold(QuotaThresholdChange thresholdChange, Duration gracePeriod, QuotaThreshold lastThreshold) {
+        QuotaThreshold quotaThreshold = thresholdChange.getQuotaThreshold();
+        int comparisonResult = quotaThreshold.compareTo(lastThreshold);
+
+        if (comparisonResult < 0) {
+            return HistoryEvolution.lowerThresholdReached(thresholdChange);
+        }
+        if (comparisonResult == 0) {
+            return HistoryEvolution.noChanges();
+        }
+        return recentlyExceededQuotaThreshold(thresholdChange, gracePeriod)
+                .map(any -> HistoryEvolution.higherThresholdReached(thresholdChange, AlreadyReachedDuringGracePriod))
+                .orElse(HistoryEvolution.higherThresholdReached(thresholdChange, NotAlreadyReachedDuringGracePeriod));
+    }
+
+    private Optional<QuotaThresholdChange> recentlyExceededQuotaThreshold(QuotaThresholdChange thresholdChange, Duration gracePeriod) {
+        return changes.stream()
+            .filter(change -> change.isAfter(thresholdChange.getInstant().minus(gracePeriod)))
+            .filter(change -> change.getQuotaThreshold().compareTo(thresholdChange.getQuotaThreshold()) >= 0)
+            .findFirst();
+    }
+
+    public QuotaThresholdHistory combineWith(QuotaThresholdChange change) {
+        return new QuotaThresholdHistory(
+            ImmutableList.<QuotaThresholdChange>builder()
+                .addAll(changes)
+                .add(change)
+                .build());
+    }
+
+    public ImmutableList<QuotaThresholdChange> getChanges() {
+        return changes;
+    }
+
+    @Override
+    public final boolean equals(Object o) {
+        if (o instanceof QuotaThresholdHistory) {
+            QuotaThresholdHistory that = (QuotaThresholdHistory) o;
+
+            return Objects.equals(this.changes, that.changes);
+        }
+        return false;
+    }
+
+    @Override
+    public final int hashCode() {
+        return Objects.hash(changes);
+    }
+}

http://git-wip-us.apache.org/repos/asf/james-project/blob/0e437d25/mailbox/plugin/quota-mailing/src/main/java/org/apache/james/mailbox/quota/model/QuotaThresholds.java
----------------------------------------------------------------------
diff --git a/mailbox/plugin/quota-mailing/src/main/java/org/apache/james/mailbox/quota/model/QuotaThresholds.java b/mailbox/plugin/quota-mailing/src/main/java/org/apache/james/mailbox/quota/model/QuotaThresholds.java
new file mode 100644
index 0000000..7a7cff4
--- /dev/null
+++ b/mailbox/plugin/quota-mailing/src/main/java/org/apache/james/mailbox/quota/model/QuotaThresholds.java
@@ -0,0 +1,58 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *   http://www.apache.org/licenses/LICENSE-2.0                 *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.mailbox.quota.model;
+
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.List;
+
+import org.apache.james.mailbox.model.Quota;
+
+import com.github.steveash.guavate.Guavate;
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableList;
+
+public class QuotaThresholds {
+    private final ImmutableList<QuotaThreshold> quotaThresholds;
+
+    public QuotaThresholds(QuotaThreshold... thresholds) {
+        this(Arrays.asList(thresholds));
+    }
+
+    public QuotaThresholds(List<QuotaThreshold> quotaThresholds) {
+        this.quotaThresholds = quotaThresholds.stream()
+            .sorted(Comparator.comparing(QuotaThreshold::getQuotaOccupationRatio).reversed())
+            .collect(Guavate.toImmutableList());
+    }
+
+    public QuotaThreshold highestExceededThreshold(Quota quota) {
+        return quotaThresholds.stream()
+            .filter(quotaLevel -> quotaLevel.isExceeded(quota))
+            .findFirst()
+            .orElse(QuotaThreshold.ZERO);
+    }
+
+    @Override
+    public String toString() {
+        return MoreObjects.toStringHelper(this)
+            .add("quotaThresholds", quotaThresholds)
+            .toString();
+    }
+}

http://git-wip-us.apache.org/repos/asf/james-project/blob/0e437d25/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/eventsourcing/DataCollectorSubscriber.java
----------------------------------------------------------------------
diff --git a/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/eventsourcing/DataCollectorSubscriber.java b/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/eventsourcing/DataCollectorSubscriber.java
new file mode 100644
index 0000000..85fccd1
--- /dev/null
+++ b/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/eventsourcing/DataCollectorSubscriber.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.eventsourcing;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import com.google.common.collect.ImmutableList;
+
+public class DataCollectorSubscriber implements Subscriber {
+
+    private final List<String> data;
+
+    public DataCollectorSubscriber() {
+        data = new ArrayList<>();
+    }
+
+    @Override
+    public void handle(Event event) {
+        if (event instanceof TestEvent) {
+            data.add(((TestEvent) event).getData());
+        }
+    }
+
+
+    public List<String> getData() {
+        return ImmutableList.copyOf(data);
+    }
+}

http://git-wip-us.apache.org/repos/asf/james-project/blob/0e437d25/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/eventsourcing/EventIdTest.java
----------------------------------------------------------------------
diff --git a/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/eventsourcing/EventIdTest.java b/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/eventsourcing/EventIdTest.java
new file mode 100644
index 0000000..ffdc5f2
--- /dev/null
+++ b/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/eventsourcing/EventIdTest.java
@@ -0,0 +1,85 @@
+/****************************************************************
+ * 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.eventsourcing;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.junit.jupiter.api.Test;
+
+import nl.jqno.equalsverifier.EqualsVerifier;
+
+public class EventIdTest {
+
+    @Test
+    public void shouldMatchBeanContract() {
+        EqualsVerifier.forClass(EventStore.History.class)
+            .allFieldsShouldBeUsed()
+            .verify();
+    }
+
+    @Test
+    public void firstShouldReturnAConstant() {
+        assertThat(EventId.first())
+            .isEqualTo(EventId.first());
+    }
+
+    @Test
+    public void previousShouldReturnEmptyWhenBeforeFirst() {
+        assertThat(EventId.first().previous())
+            .isEmpty();
+    }
+
+    @Test
+    public void compareToShouldReturnNegativeWhenComparedToNext() {
+        assertThat(EventId.first())
+            .isLessThan(EventId.first().next());
+    }
+
+    @Test
+    public void compareToShouldReturnNegativeWhenComparedToPrevious() {
+        assertThat(EventId.first().next())
+            .isGreaterThan(EventId.first());
+    }
+
+    @Test
+    public void nextShouldAlwaysHaveTheSameIncrement() {
+        assertThat(EventId.first().next())
+            .isEqualTo(EventId.first().next());
+    }
+
+    @Test
+    public void previousShouldRevertNext() {
+        assertThat(EventId.first().next().previous())
+            .contains(EventId.first());
+    }
+
+    @Test
+    public void compareToShouldReturnNegativeWhenComparedToNextWithPreviousCall() {
+        assertThat(EventId.first().next().previous().get())
+            .isLessThan(EventId.first().next());
+    }
+
+    @Test
+    public void compareToShouldReturnNegativeWhenComparedToPreviousWithPreviousCall() {
+        assertThat(EventId.first().next())
+            .isGreaterThan(EventId.first().next().previous().get());
+    }
+
+}
\ No newline at end of file

http://git-wip-us.apache.org/repos/asf/james-project/blob/0e437d25/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/eventsourcing/EventSourcingSystemTest.java
----------------------------------------------------------------------
diff --git a/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/eventsourcing/EventSourcingSystemTest.java b/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/eventsourcing/EventSourcingSystemTest.java
new file mode 100644
index 0000000..b2b26a3
--- /dev/null
+++ b/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/eventsourcing/EventSourcingSystemTest.java
@@ -0,0 +1,251 @@
+/****************************************************************
+ * 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.eventsourcing;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Matchers.any;
+import static org.mockito.Matchers.anyList;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import com.github.steveash.guavate.Guavate;
+import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+
+
+interface EventSourcingSystemTest {
+
+    String PAYLOAD_1 = "payload1";
+    String PAYLOAD_2 = "payload2";
+    TestAggregateId AGGREGATE_ID = TestAggregateId.testId(42);
+
+    class MyCommand implements CommandDispatcher.Command {
+        private final String payload;
+
+        public MyCommand(String payload) {
+            this.payload = payload;
+        }
+
+        public String getPayload() {
+            return payload;
+        }
+    }
+
+    @Test
+    default void dispatchShouldApplyCommandHandlerThenCallSubscribers(EventStore eventStore) {
+        DataCollectorSubscriber subscriber = new DataCollectorSubscriber();
+        EventSourcingSystem eventSourcingSystem = new EventSourcingSystem(
+            ImmutableSet.of(simpleDispatcher(eventStore)),
+            ImmutableSet.of(subscriber),
+            eventStore);
+
+        eventSourcingSystem.dispatch(new MyCommand(PAYLOAD_1));
+
+        assertThat(subscriber.getData()).containsExactly(PAYLOAD_1);
+    }
+
+    @Test
+    default void throwingSubscribersShouldNotAbortSubscriberChain(EventStore eventStore) {
+        DataCollectorSubscriber subscriber = new DataCollectorSubscriber();
+        EventSourcingSystem eventSourcingSystem = new EventSourcingSystem(
+            ImmutableSet.of(simpleDispatcher(eventStore)),
+            ImmutableSet.of(
+                events -> {
+                    throw new RuntimeException();
+                },
+                subscriber),
+            eventStore);
+
+        eventSourcingSystem.dispatch(new MyCommand(PAYLOAD_1));
+
+        assertThat(subscriber.getData()).containsExactly(PAYLOAD_1);
+    }
+
+    @Test
+    default void throwingStoreShouldNotLeadToPusblishing() {
+        EventStore eventStore = mock(EventStore.class);
+        doThrow(new RuntimeException()).when(eventStore).appendAll(anyList());
+        when(eventStore.getEventsOfAggregate(any())).thenReturn(EventStore.History.empty());
+
+        DataCollectorSubscriber subscriber = new DataCollectorSubscriber();
+        EventSourcingSystem eventSourcingSystem = new EventSourcingSystem(
+            ImmutableSet.of(simpleDispatcher(eventStore)),
+            ImmutableSet.of(
+                events -> {
+                    throw new RuntimeException();
+                },
+                subscriber),
+            eventStore);
+
+        assertThatThrownBy(() -> eventSourcingSystem.dispatch(new MyCommand(PAYLOAD_1)))
+            .isInstanceOf(RuntimeException.class);
+
+        assertThat(subscriber.getData()).isEmpty();
+    }
+
+    @Test
+    default void dispatchShouldApplyCommandHandlerThenStoreGeneratedEvents(EventStore eventStore) {
+        DataCollectorSubscriber subscriber = new DataCollectorSubscriber();
+        EventSourcingSystem eventSourcingSystem = new EventSourcingSystem(
+            ImmutableSet.of(simpleDispatcher(eventStore)),
+            ImmutableSet.of(subscriber),
+            eventStore);
+
+        eventSourcingSystem.dispatch(new MyCommand(PAYLOAD_1));
+
+        TestEvent expectedEvent = new TestEvent(EventId.first(), AGGREGATE_ID, PAYLOAD_1);
+        assertThat(eventStore.getEventsOfAggregate(AGGREGATE_ID).getEvents())
+            .containsOnly(expectedEvent);
+    }
+
+    @Test
+    default void dispatchShouldCallSubscriberForSubsequentCommands(EventStore eventStore) {
+        DataCollectorSubscriber subscriber = new DataCollectorSubscriber();
+        EventSourcingSystem eventSourcingSystem = new EventSourcingSystem(
+            ImmutableSet.of(simpleDispatcher(eventStore)),
+            ImmutableSet.of(subscriber),
+            eventStore);
+
+        eventSourcingSystem.dispatch(new MyCommand(PAYLOAD_1));
+        eventSourcingSystem.dispatch(new MyCommand(PAYLOAD_2));
+
+        assertThat(subscriber.getData()).containsExactly(PAYLOAD_1, PAYLOAD_2);
+    }
+
+    @Test
+    default void dispatchShouldStoreEventsForSubsequentCommands(EventStore eventStore) {
+        DataCollectorSubscriber subscriber = new DataCollectorSubscriber();
+        EventSourcingSystem eventSourcingSystem = new EventSourcingSystem(
+            ImmutableSet.of(simpleDispatcher(eventStore)),
+            ImmutableSet.of(subscriber),
+            eventStore);
+
+        eventSourcingSystem.dispatch(new MyCommand(PAYLOAD_1));
+        eventSourcingSystem.dispatch(new MyCommand(PAYLOAD_2));
+
+        TestEvent expectedEvent1 = new TestEvent(EventId.first(), AGGREGATE_ID, PAYLOAD_1);
+        TestEvent expectedEvent2 = new TestEvent(expectedEvent1.eventId().next(), AGGREGATE_ID, PAYLOAD_2);
+        assertThat(eventStore.getEventsOfAggregate(AGGREGATE_ID).getEvents())
+            .containsOnly(expectedEvent1, expectedEvent2);
+    }
+
+    @Test
+    default void dispatcherShouldBeAbleToReturnSeveralEvents(EventStore eventStore) {
+        DataCollectorSubscriber subscriber = new DataCollectorSubscriber();
+        EventSourcingSystem eventSourcingSystem = new EventSourcingSystem(
+            ImmutableSet.of(wordCuttingDispatcher(eventStore)),
+            ImmutableSet.of(subscriber),
+            eventStore);
+
+        eventSourcingSystem.dispatch(new MyCommand("This is a test"));
+
+        assertThat(subscriber.getData()).containsExactly("This", "is", "a", "test");
+    }
+
+    @Test
+    default void unknownCommandsShouldBeIgnored(EventStore eventStore) {
+        DataCollectorSubscriber subscriber = new DataCollectorSubscriber();
+        EventSourcingSystem eventSourcingSystem = new EventSourcingSystem(
+            ImmutableSet.of(wordCuttingDispatcher(eventStore)),
+            ImmutableSet.of(subscriber),
+            eventStore);
+
+        assertThatThrownBy(() -> eventSourcingSystem.dispatch(new CommandDispatcher.Command() {}))
+            .isInstanceOf(CommandDispatcher.UnknownCommandException.class);
+    }
+
+    @Test
+    default void constructorShouldThrowWhenSeveralHandlersForTheSameCommand(EventStore eventStore) {
+        DataCollectorSubscriber subscriber = new DataCollectorSubscriber();
+
+        assertThatThrownBy(() ->
+            new EventSourcingSystem(
+                ImmutableSet.of(wordCuttingDispatcher(eventStore),
+                    simpleDispatcher(eventStore)),
+                ImmutableSet.of(subscriber),
+                eventStore))
+            .isInstanceOf(IllegalArgumentException.class);
+    }
+
+    default CommandDispatcher.CommandHandler<MyCommand> simpleDispatcher(EventStore eventStore) {
+        return new CommandDispatcher.CommandHandler<MyCommand>() {
+            @Override
+            public Class<MyCommand> handledClass() {
+                return MyCommand.class;
+            }
+
+            @Override
+            public List<? extends Event> handle(MyCommand myCommand) {
+                EventStore.History history = eventStore.getEventsOfAggregate(AGGREGATE_ID);
+
+                return ImmutableList.of(new TestEvent(
+                    history.getNextEventId(),
+                    AGGREGATE_ID,
+                    myCommand.getPayload()));
+            }
+        };
+    }
+
+    default CommandDispatcher.CommandHandler<MyCommand> wordCuttingDispatcher(EventStore eventStore) {
+        return new CommandDispatcher.CommandHandler<MyCommand>() {
+            @Override
+            public Class<MyCommand> handledClass() {
+                return MyCommand.class;
+            }
+
+            @Override
+            public List<? extends Event> handle(MyCommand myCommand) {
+                EventStore.History history = eventStore.getEventsOfAggregate(AGGREGATE_ID);
+
+                EventIdIncrementer eventIdIncrementer = new EventIdIncrementer(history.getNextEventId());
+
+                return Splitter.on(" ")
+                    .splitToList(myCommand.getPayload())
+                    .stream()
+                    .map(word -> new TestEvent(
+                        eventIdIncrementer.next(),
+                        AGGREGATE_ID,
+                        word))
+                    .collect(Guavate.toImmutableList());
+            }
+        };
+    }
+
+    class EventIdIncrementer {
+        private EventId currentEventId;
+
+        public EventIdIncrementer(EventId base) {
+            this.currentEventId = base;
+        }
+
+        public EventId next() {
+            currentEventId = currentEventId.next();
+            return currentEventId;
+        }
+    }
+
+}
\ No newline at end of file

http://git-wip-us.apache.org/repos/asf/james-project/blob/0e437d25/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/eventsourcing/EventStoreTest.java
----------------------------------------------------------------------
diff --git a/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/eventsourcing/EventStoreTest.java b/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/eventsourcing/EventStoreTest.java
new file mode 100644
index 0000000..c847f81
--- /dev/null
+++ b/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/eventsourcing/EventStoreTest.java
@@ -0,0 +1,75 @@
+package org.apache.james.eventsourcing;
+
+import static org.apache.james.eventsourcing.TestAggregateId.testId;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import org.junit.jupiter.api.Test;
+
+import com.google.common.collect.ImmutableList;
+
+import nl.jqno.equalsverifier.EqualsVerifier;
+
+interface EventStoreTest {
+
+    TestAggregateId AGGREGATE_1 = testId(1);
+    TestAggregateId AGGREGATE_2 = testId(2);
+
+    @Test
+    default void historyShouldMatchBeanContract() {
+        EqualsVerifier.forClass(EventStore.History.class)
+            .allFieldsShouldBeUsed()
+            .verify();
+    }
+
+    @Test
+    default void getEventsOfAggregateShouldThrowOnNullAggregateId(EventStore testee) {
+        assertThatThrownBy(() -> testee.getEventsOfAggregate(null))
+            .isInstanceOf(NullPointerException.class);
+    }
+
+    @Test
+    default void appendShouldThrowWhenEventFromSeveralAggregates(EventStore testee) {
+        TestEvent event1 = new TestEvent(EventId.first(), AGGREGATE_1, "first");
+        TestEvent event2 = new TestEvent(event1.eventId().next(), AGGREGATE_2, "second");
+        assertThatThrownBy(() -> testee.appendAll(event1, event2)).isInstanceOf(IllegalArgumentException.class);
+    }
+
+    @Test
+    default void appendShouldDoNothingOnEmptyEventList(EventStore testee) {
+        assertThatCode(testee::appendAll).doesNotThrowAnyException();
+    }
+
+    @Test
+    default void appendShouldThrowWhenTryingToRewriteHistory(EventStore testee) {
+        TestEvent event1 = new TestEvent(EventId.first(), AGGREGATE_1, "first");
+        testee.append(event1);
+        TestEvent event2 = new TestEvent(EventId.first(), AGGREGATE_1, "second");
+        assertThatThrownBy(() -> testee.append(event2)).isInstanceOf(EventStore.EventStoreFailedException.class);
+    }
+
+    @Test
+    default void getEventsOfAggregateShouldReturnEmptyHistoryWhenUnknown(EventStore testee) {
+        assertThat(testee.getEventsOfAggregate(AGGREGATE_1)).isEqualTo(EventStore.History.empty());
+    }
+
+    @Test
+    default void getEventsOfAggregateShouldReturnAppendedEvent(EventStore testee) {
+        TestEvent event = new TestEvent(EventId.first(), AGGREGATE_1, "first");
+        testee.append(event);
+        assertThat(testee.getEventsOfAggregate(AGGREGATE_1))
+            .isEqualTo(EventStore.History.of(ImmutableList.of(event)));
+    }
+
+    @Test
+    default void getEventsOfAggregateShouldReturnAppendedEvents(EventStore testee) {
+        TestEvent event1 = new TestEvent(EventId.first(), AGGREGATE_1, "first");
+        TestEvent event2 = new TestEvent(event1.eventId().next(), AGGREGATE_1, "second");
+        testee.append(event1);
+        testee.append(event2);
+        assertThat(testee.getEventsOfAggregate(AGGREGATE_1))
+            .isEqualTo(EventStore.History.of(ImmutableList.of(event1, event2)));
+    }
+
+}
\ No newline at end of file

http://git-wip-us.apache.org/repos/asf/james-project/blob/0e437d25/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/eventsourcing/HistoryTest.java
----------------------------------------------------------------------
diff --git a/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/eventsourcing/HistoryTest.java b/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/eventsourcing/HistoryTest.java
new file mode 100644
index 0000000..9419087
--- /dev/null
+++ b/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/eventsourcing/HistoryTest.java
@@ -0,0 +1,87 @@
+/****************************************************************
+ * 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.eventsourcing;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import org.junit.jupiter.api.Test;
+
+import nl.jqno.equalsverifier.EqualsVerifier;
+
+public class HistoryTest {
+
+    @Test
+    public void shouldMatchBeanContract() {
+        EqualsVerifier.forClass(EventStore.History.class)
+            .verify();
+    }
+
+    @Test
+    public void emptyShouldGenerateAnEmptyHistory() {
+        assertThat(EventStore.History.empty())
+            .isEqualTo(EventStore.History.of());
+    }
+
+    @Test
+    public void getVersionShouldReturnEmptyWhenEmpty() {
+        assertThat(EventStore.History.empty()
+            .getVersion())
+            .isEmpty();
+    }
+
+    @Test
+    public void getVersionShouldReturnSingleEventIdWhenSingleEvent() {
+        assertThat(EventStore.History
+            .of(new TestEvent(EventId.first(),
+                TestAggregateId.testId(42),
+                "any"))
+            .getVersion())
+            .contains(EventId.first());
+    }
+
+    @Test
+    public void getVersionShouldReturnHighestEventId() {
+        TestEvent event1 = new TestEvent(EventId.first(),
+            TestAggregateId.testId(42),
+            "any");
+        TestEvent event2 = new TestEvent(event1.eventId().next(),
+            TestAggregateId.testId(42),
+            "any");
+
+        assertThat(EventStore.History.of(event1, event2)
+            .getVersion())
+            .contains(event2.eventId());
+    }
+
+    @Test
+    public void duplicateHistoryShouldThrow() {
+        TestEvent event1 = new TestEvent(EventId.first(),
+            TestAggregateId.testId(42),
+            "any");
+        TestEvent event2 = new TestEvent(EventId.first(),
+            TestAggregateId.testId(42),
+            "any");
+
+        assertThatThrownBy(() -> EventStore.History.of(event1, event2))
+            .isInstanceOf(EventStore.EventStoreFailedException.class);
+    }
+
+}

http://git-wip-us.apache.org/repos/asf/james-project/blob/0e437d25/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/eventsourcing/TestAggregateId.java
----------------------------------------------------------------------
diff --git a/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/eventsourcing/TestAggregateId.java b/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/eventsourcing/TestAggregateId.java
new file mode 100644
index 0000000..1d7c8af
--- /dev/null
+++ b/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/eventsourcing/TestAggregateId.java
@@ -0,0 +1,64 @@
+/****************************************************************
+ * 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.eventsourcing;
+
+import java.util.Objects;
+
+import com.google.common.base.MoreObjects;
+
+public class TestAggregateId implements AggregateId  {
+
+    public static TestAggregateId testId(int id) {
+        return new TestAggregateId(id);
+    }
+
+    private final int id;
+
+    private TestAggregateId(int id) {
+        this.id = id;
+    }
+
+    @Override
+    public String asAggregateKey() {
+        return "TestAggregateId-" + id;
+    }
+
+    @Override
+    public final boolean equals(Object o) {
+        if (o instanceof TestAggregateId) {
+            TestAggregateId that = (TestAggregateId) o;
+
+            return Objects.equals(this.id, that.id);
+        }
+        return false;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(id);
+    }
+
+    @Override
+    public String toString() {
+        return MoreObjects.toStringHelper(this)
+            .add("id", id)
+            .toString();
+    }
+}

http://git-wip-us.apache.org/repos/asf/james-project/blob/0e437d25/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/eventsourcing/TestEvent.java
----------------------------------------------------------------------
diff --git a/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/eventsourcing/TestEvent.java b/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/eventsourcing/TestEvent.java
new file mode 100644
index 0000000..c46f804
--- /dev/null
+++ b/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/eventsourcing/TestEvent.java
@@ -0,0 +1,82 @@
+/****************************************************************
+ * 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.eventsourcing;
+
+import java.util.Comparator;
+import java.util.Objects;
+
+import com.google.common.base.MoreObjects;
+
+public class TestEvent implements Event {
+    private final EventId id;
+    private final TestAggregateId aggregateId;
+    private final String data;
+
+    public TestEvent(EventId id, TestAggregateId aggregateId, String data) {
+        this.id = id;
+        this.aggregateId = aggregateId;
+        this.data = data;
+    }
+
+    @Override
+    public EventId eventId() {
+        return id;
+    }
+
+    @Override
+    public TestAggregateId getAggregateId() {
+        return aggregateId;
+    }
+
+    public String getData() {
+        return data;
+    }
+
+    @Override
+    public int compareTo(Event o) {
+        return Comparator.<EventId>naturalOrder().compare(id, o.eventId());
+    }
+
+    @Override
+    public final boolean equals(Object o) {
+        if (o instanceof TestEvent) {
+            TestEvent testEvent = (TestEvent) o;
+
+            return Objects.equals(this.id, testEvent.id)
+                && Objects.equals(this.aggregateId, testEvent.aggregateId)
+                && Objects.equals(this.data, testEvent.data);
+        }
+        return false;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(id, aggregateId, data);
+    }
+
+    @Override
+    public String toString() {
+        return MoreObjects.toStringHelper(this)
+            .add("id", id)
+            .add("aggregateId", aggregateId)
+            .add("data", data)
+            .toString();
+    }
+}

http://git-wip-us.apache.org/repos/asf/james-project/blob/0e437d25/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/mailbox/quota/mailing/aggregates/UserQuotaThresholdsTest.java
----------------------------------------------------------------------
diff --git a/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/mailbox/quota/mailing/aggregates/UserQuotaThresholdsTest.java b/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/mailbox/quota/mailing/aggregates/UserQuotaThresholdsTest.java
new file mode 100644
index 0000000..45aef96
--- /dev/null
+++ b/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/mailbox/quota/mailing/aggregates/UserQuotaThresholdsTest.java
@@ -0,0 +1,47 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *   http://www.apache.org/licenses/LICENSE-2.0                 *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.mailbox.quota.mailing.aggregates;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.apache.james.core.User;
+import org.junit.jupiter.api.Test;
+
+import nl.jqno.equalsverifier.EqualsVerifier;
+
+public class UserQuotaThresholdsTest {
+
+    public static final User BOB = User.fromUsername("bob@domain");
+
+    @Test
+    public void aggregateShouldMatchBeanContract() {
+        EqualsVerifier.forClass(UserQuotaThresholds.Id.class)
+            .allFieldsShouldBeUsed()
+            .verify();
+    }
+
+    @Test
+    public void asAggregationKeyShouldConvertAggregateToAStringRepresentation() {
+        assertThat(UserQuotaThresholds.Id.from(BOB)
+            .asAggregateKey())
+            .isEqualTo("QuotaThreasholdEvents-bob@domain");
+    }
+
+}
\ No newline at end of file

http://git-wip-us.apache.org/repos/asf/james-project/blob/0e437d25/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/mailbox/quota/mailing/commands/DetectThresholdCrossingTest.java
----------------------------------------------------------------------
diff --git a/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/mailbox/quota/mailing/commands/DetectThresholdCrossingTest.java b/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/mailbox/quota/mailing/commands/DetectThresholdCrossingTest.java
new file mode 100644
index 0000000..9a287c2
--- /dev/null
+++ b/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/mailbox/quota/mailing/commands/DetectThresholdCrossingTest.java
@@ -0,0 +1,33 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *   http://www.apache.org/licenses/LICENSE-2.0                 *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.mailbox.quota.mailing.commands;
+
+import org.junit.jupiter.api.Test;
+
+import nl.jqno.equalsverifier.EqualsVerifier;
+
+public class DetectThresholdCrossingTest {
+
+    @Test
+    public void aggregateShouldMatchBeanContract() {
+        EqualsVerifier.forClass(DetectThresholdCrossing.class)
+            .verify();
+    }
+}
\ No newline at end of file

http://git-wip-us.apache.org/repos/asf/james-project/blob/0e437d25/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/mailbox/quota/mailing/listeners/QuotaThresholdListenersTestSystem.java
----------------------------------------------------------------------
diff --git a/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/mailbox/quota/mailing/listeners/QuotaThresholdListenersTestSystem.java b/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/mailbox/quota/mailing/listeners/QuotaThresholdListenersTestSystem.java
new file mode 100644
index 0000000..cfae4fc
--- /dev/null
+++ b/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/mailbox/quota/mailing/listeners/QuotaThresholdListenersTestSystem.java
@@ -0,0 +1,58 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *   http://www.apache.org/licenses/LICENSE-2.0                 *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.mailbox.quota.mailing.listeners;
+
+import org.apache.james.eventsourcing.EventSourcingSystem;
+import org.apache.james.eventsourcing.EventStore;
+import org.apache.james.mailbox.Event;
+import org.apache.james.mailbox.exception.MailboxException;
+import org.apache.james.mailbox.mock.MockMailboxSession;
+import org.apache.james.mailbox.quota.mailing.QuotaMailingListenerConfiguration;
+import org.apache.james.mailbox.quota.mailing.commands.DetectThresholdCrossingHandler;
+import org.apache.james.mailbox.quota.mailing.subscribers.QuotaThresholdMailer;
+import org.apache.james.mailbox.store.event.DefaultDelegatingMailboxListener;
+import org.apache.james.user.memory.MemoryUsersRepository;
+import org.apache.mailet.MailetContext;
+
+import com.google.common.collect.ImmutableSet;
+
+public class QuotaThresholdListenersTestSystem {
+
+    private final DefaultDelegatingMailboxListener delegatingListener;
+
+    public QuotaThresholdListenersTestSystem(MailetContext mailetContext, EventStore eventStore, QuotaMailingListenerConfiguration configuration) throws MailboxException {
+        delegatingListener = new DefaultDelegatingMailboxListener();
+
+        EventSourcingSystem eventSourcingSystem = new EventSourcingSystem(
+            ImmutableSet.of(new DetectThresholdCrossingHandler(eventStore, configuration)),
+            ImmutableSet.of(new QuotaThresholdMailer(mailetContext, MemoryUsersRepository.withVirtualHosting())),
+            eventStore);
+
+        QuotaThresholdCrossingListener thresholdCrossingListener =
+            new QuotaThresholdCrossingListener(eventSourcingSystem);
+
+        MockMailboxSession mailboxSession = new MockMailboxSession("system");
+        delegatingListener.addGlobalListener(thresholdCrossingListener, mailboxSession);
+    }
+
+    public void event(Event event) {
+        delegatingListener.event(event);
+    }
+}

http://git-wip-us.apache.org/repos/asf/james-project/blob/0e437d25/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/mailbox/quota/mailing/listeners/QuotaThresholdMailingIntegrationTest.java
----------------------------------------------------------------------
diff --git a/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/mailbox/quota/mailing/listeners/QuotaThresholdMailingIntegrationTest.java b/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/mailbox/quota/mailing/listeners/QuotaThresholdMailingIntegrationTest.java
new file mode 100644
index 0000000..54854cc
--- /dev/null
+++ b/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/mailbox/quota/mailing/listeners/QuotaThresholdMailingIntegrationTest.java
@@ -0,0 +1,217 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *   http://www.apache.org/licenses/LICENSE-2.0                 *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.mailbox.quota.mailing.listeners;
+
+import static org.apache.james.mailbox.quota.model.QuotaThresholdFixture.TestConstants.BOB_SESSION;
+import static org.apache.james.mailbox.quota.model.QuotaThresholdFixture.TestConstants.DEFAULT_CONFIGURATION;
+import static org.apache.james.mailbox.quota.model.QuotaThresholdFixture.TestConstants.GRACE_PERIOD;
+import static org.apache.james.mailbox.quota.model.QuotaThresholdFixture.TestConstants.NOW;
+import static org.apache.james.mailbox.quota.model.QuotaThresholdFixture.TestConstants.ONE_HOUR_AGO;
+import static org.apache.james.mailbox.quota.model.QuotaThresholdFixture.TestConstants.QUOTAROOT;
+import static org.apache.james.mailbox.quota.model.QuotaThresholdFixture.TestConstants.SIX_DAYS_AGO;
+import static org.apache.james.mailbox.quota.model.QuotaThresholdFixture.TestConstants.SIX_HOURS_AGO;
+import static org.apache.james.mailbox.quota.model.QuotaThresholdFixture.TestConstants.TWELVE_DAYS_AGO;
+import static org.apache.james.mailbox.quota.model.QuotaThresholdFixture.TestConstants.TWELVE_HOURS_AGO;
+import static org.apache.james.mailbox.quota.model.QuotaThresholdFixture.TestConstants.TWO_DAYS_AGO;
+import static org.apache.james.mailbox.quota.model.QuotaThresholdFixture._50;
+import static org.apache.james.mailbox.quota.model.QuotaThresholdFixture._80;
+import static org.apache.james.mailbox.quota.model.QuotaThresholdFixture.mailetContext;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.concurrent.TimeUnit;
+
+import org.apache.james.eventsourcing.EventStore;
+import org.apache.james.mailbox.MailboxListener.QuotaUsageUpdatedEvent;
+import org.apache.james.mailbox.quota.mailing.QuotaMailingListenerConfiguration;
+import org.apache.james.mailbox.quota.model.QuotaThresholdFixture.Quotas.Counts;
+import org.apache.james.mailbox.quota.model.QuotaThresholdFixture.Quotas.Sizes;
+import org.apache.james.mailbox.quota.model.QuotaThresholds;
+import org.apache.james.util.concurrency.ConcurrentTestRunner;
+import org.apache.mailet.base.test.FakeMailContext;
+import org.junit.jupiter.api.Test;
+
+public interface QuotaThresholdMailingIntegrationTest {
+
+    @Test
+    default void shouldNotSendMailWhenUnderAllThresholds(EventStore store) throws Exception {
+        FakeMailContext mailetContext = mailetContext();
+        QuotaThresholdListenersTestSystem testee = new QuotaThresholdListenersTestSystem(mailetContext, store, DEFAULT_CONFIGURATION);
+
+        testee.event(new QuotaUsageUpdatedEvent(BOB_SESSION, QUOTAROOT, Counts._40_PERCENT, Sizes._30_PERCENT, NOW));
+
+        assertThat(mailetContext.getSentMails()).isEmpty();
+    }
+
+    @Test
+    default void shouldNotSendMailWhenNoThresholdUpdate(EventStore store) throws Exception {
+        FakeMailContext mailetContext = mailetContext();
+        QuotaThresholdListenersTestSystem testee = new QuotaThresholdListenersTestSystem(mailetContext, store, DEFAULT_CONFIGURATION);
+        testee.event(new QuotaUsageUpdatedEvent(BOB_SESSION, QUOTAROOT, Counts._40_PERCENT, Sizes._55_PERCENT, ONE_HOUR_AGO));
+        mailetContext.resetSentMails();
+
+        testee.event(new QuotaUsageUpdatedEvent(BOB_SESSION, QUOTAROOT, Counts._40_PERCENT, Sizes._55_PERCENT, NOW));
+
+        assertThat(mailetContext.getSentMails()).isEmpty();
+    }
+
+    @Test
+    default void shouldNotSendMailWhenThresholdOverPassedRecently(EventStore store) throws Exception {
+        FakeMailContext mailetContext = mailetContext();
+        QuotaThresholdListenersTestSystem testee = new QuotaThresholdListenersTestSystem(mailetContext, store, DEFAULT_CONFIGURATION);
+        testee.event(new QuotaUsageUpdatedEvent(BOB_SESSION, QUOTAROOT, Counts._40_PERCENT, Sizes._55_PERCENT, TWELVE_HOURS_AGO));
+        testee.event(new QuotaUsageUpdatedEvent(BOB_SESSION, QUOTAROOT, Counts._40_PERCENT, Sizes._30_PERCENT, SIX_HOURS_AGO));
+        mailetContext.resetSentMails();
+
+        testee.event(new QuotaUsageUpdatedEvent(BOB_SESSION, QUOTAROOT, Counts._40_PERCENT, Sizes._55_PERCENT, NOW));
+
+        assertThat(mailetContext.getSentMails()).isEmpty();
+    }
+
+    @Test
+    default void shouldSendMailWhenThresholdOverPassed(EventStore store) throws Exception {
+        FakeMailContext mailetContext = mailetContext();
+        QuotaThresholdListenersTestSystem testee = new QuotaThresholdListenersTestSystem(mailetContext, store, DEFAULT_CONFIGURATION);
+
+        testee.event(new QuotaUsageUpdatedEvent(BOB_SESSION, QUOTAROOT, Counts._40_PERCENT, Sizes._55_PERCENT, NOW));
+
+        assertThat(mailetContext.getSentMails()).hasSize(1);
+    }
+
+    @Test
+    default void shouldNotSendDuplicates(EventStore store) throws Exception {
+        FakeMailContext mailetContext = mailetContext();
+        QuotaThresholdListenersTestSystem testee = new QuotaThresholdListenersTestSystem(mailetContext, store, DEFAULT_CONFIGURATION);
+        testee.event(new QuotaUsageUpdatedEvent(BOB_SESSION, QUOTAROOT, Counts._40_PERCENT, Sizes._55_PERCENT, ONE_HOUR_AGO));
+
+        testee.event(new QuotaUsageUpdatedEvent(BOB_SESSION, QUOTAROOT, Counts._40_PERCENT, Sizes._55_PERCENT, NOW));
+
+        assertThat(mailetContext.getSentMails()).hasSize(1);
+    }
+
+    @Test
+    default void shouldNotifySeparatelyCountAndSize(EventStore store) throws Exception {
+        FakeMailContext mailetContext = mailetContext();
+        QuotaThresholdListenersTestSystem testee = new QuotaThresholdListenersTestSystem(mailetContext, store, DEFAULT_CONFIGURATION);
+        testee.event(new QuotaUsageUpdatedEvent(BOB_SESSION, QUOTAROOT, Counts._40_PERCENT, Sizes._55_PERCENT, ONE_HOUR_AGO));
+
+        testee.event(new QuotaUsageUpdatedEvent(BOB_SESSION, QUOTAROOT, Counts._52_PERCENT, Sizes._60_PERCENT, NOW));
+
+        assertThat(mailetContext.getSentMails()).hasSize(2);
+    }
+
+    @Test
+    default void shouldGroupSizeAndCountNotificationsWhenTriggeredByASingleEvent(EventStore store) throws Exception {
+        FakeMailContext mailetContext = mailetContext();
+        QuotaThresholdListenersTestSystem testee = new QuotaThresholdListenersTestSystem(mailetContext, store, DEFAULT_CONFIGURATION);
+
+        testee.event(new QuotaUsageUpdatedEvent(BOB_SESSION, QUOTAROOT, Counts._52_PERCENT, Sizes._55_PERCENT, NOW));
+
+        assertThat(mailetContext.getSentMails()).hasSize(1);
+    }
+
+    @Test
+    default void shouldSendMailWhenThresholdOverPassedOverGracePeriod(EventStore store) throws Exception {
+        FakeMailContext mailetContext = mailetContext();
+        QuotaThresholdListenersTestSystem testee = new QuotaThresholdListenersTestSystem(mailetContext, store, DEFAULT_CONFIGURATION);
+        testee.event(new QuotaUsageUpdatedEvent(BOB_SESSION, QUOTAROOT, Counts._40_PERCENT, Sizes._55_PERCENT, TWELVE_DAYS_AGO));
+        testee.event(new QuotaUsageUpdatedEvent(BOB_SESSION, QUOTAROOT, Counts._40_PERCENT, Sizes._30_PERCENT, SIX_DAYS_AGO));
+        mailetContext.resetSentMails();
+
+        testee.event(new QuotaUsageUpdatedEvent(BOB_SESSION, QUOTAROOT, Counts._40_PERCENT, Sizes._55_PERCENT, NOW));
+
+        assertThat(mailetContext.getSentMails()).hasSize(1);
+    }
+
+    @Test
+    default void shouldNotSendMailWhenNoThresholdUpdateForCount(EventStore store) throws Exception {
+        FakeMailContext mailetContext = mailetContext();
+        QuotaThresholdListenersTestSystem testee = new QuotaThresholdListenersTestSystem(mailetContext, store, DEFAULT_CONFIGURATION);
+        testee.event(new QuotaUsageUpdatedEvent(BOB_SESSION, QUOTAROOT, Counts._32_PERCENT, Sizes._55_PERCENT, TWO_DAYS_AGO));
+        mailetContext.resetSentMails();
+
+        testee.event(new QuotaUsageUpdatedEvent(BOB_SESSION, QUOTAROOT, Counts._40_PERCENT, Sizes._60_PERCENT, TWO_DAYS_AGO));
+
+        assertThat(mailetContext.getSentMails()).isEmpty();
+    }
+
+    @Test
+    default void shouldNotSendMailWhenThresholdOverPassedRecentlyForCount(EventStore store) throws Exception {
+        FakeMailContext mailetContext = mailetContext();
+        QuotaThresholdListenersTestSystem testee = new QuotaThresholdListenersTestSystem(mailetContext, store, DEFAULT_CONFIGURATION);
+        testee.event(new QuotaUsageUpdatedEvent(BOB_SESSION, QUOTAROOT, Counts._52_PERCENT, Sizes._30_PERCENT, TWELVE_HOURS_AGO));
+        testee.event(new QuotaUsageUpdatedEvent(BOB_SESSION, QUOTAROOT, Counts._40_PERCENT, Sizes._30_PERCENT, SIX_HOURS_AGO));
+        mailetContext.resetSentMails();
+
+        testee.event(new QuotaUsageUpdatedEvent(BOB_SESSION, QUOTAROOT, Counts._52_PERCENT, Sizes._30_PERCENT, NOW));
+
+        assertThat(mailetContext.getSentMails()).isEmpty();
+    }
+
+    @Test
+    default void shouldSendMailWhenThresholdOverPassedForCount(EventStore store) throws Exception {
+        FakeMailContext mailetContext = mailetContext();
+        QuotaThresholdListenersTestSystem testee = new QuotaThresholdListenersTestSystem(mailetContext, store, DEFAULT_CONFIGURATION);
+
+        testee.event(new QuotaUsageUpdatedEvent(BOB_SESSION, QUOTAROOT, Counts._52_PERCENT, Sizes._30_PERCENT, TWELVE_HOURS_AGO));
+
+        assertThat(mailetContext.getSentMails()).hasSize(1);
+    }
+
+    @Test
+    default void shouldSendMailWhenThresholdOverPassedOverGracePeriodForCount(EventStore store) throws Exception {
+        FakeMailContext mailetContext = mailetContext();
+        QuotaThresholdListenersTestSystem testee = new QuotaThresholdListenersTestSystem(mailetContext, store, DEFAULT_CONFIGURATION);
+        testee.event(new QuotaUsageUpdatedEvent(BOB_SESSION, QUOTAROOT, Counts._52_PERCENT, Sizes._30_PERCENT, TWELVE_DAYS_AGO));
+        testee.event(new QuotaUsageUpdatedEvent(BOB_SESSION, QUOTAROOT, Counts._40_PERCENT, Sizes._30_PERCENT, SIX_DAYS_AGO));
+        mailetContext.resetSentMails();
+
+        testee.event(new QuotaUsageUpdatedEvent(BOB_SESSION, QUOTAROOT, Counts._52_PERCENT, Sizes._30_PERCENT, NOW));
+
+        assertThat(mailetContext.getSentMails()).hasSize(1);
+    }
+
+    @Test
+    default void shouldSendOneNoticePerThreshold(EventStore store) throws Exception {
+        FakeMailContext mailetContext = mailetContext();
+        QuotaThresholdListenersTestSystem testee = new QuotaThresholdListenersTestSystem(mailetContext, store,
+            new QuotaMailingListenerConfiguration(new QuotaThresholds(_50, _80), GRACE_PERIOD));
+
+        testee.event(new QuotaUsageUpdatedEvent(BOB_SESSION, QUOTAROOT, Counts._52_PERCENT, Sizes._30_PERCENT, NOW));
+        testee.event(new QuotaUsageUpdatedEvent(BOB_SESSION, QUOTAROOT, Counts._85_PERCENT, Sizes._42_PERCENT, NOW));
+
+        assertThat(mailetContext.getSentMails())
+            .hasSize(2);
+    }
+
+    @Test
+    default void shouldSendOneMailUponConcurrentEvents(EventStore store) throws Exception {
+        FakeMailContext mailetContext = mailetContext();
+        QuotaThresholdListenersTestSystem testee = new QuotaThresholdListenersTestSystem(mailetContext, store, new QuotaMailingListenerConfiguration(new QuotaThresholds(_50, _80), GRACE_PERIOD));
+
+        new ConcurrentTestRunner(10, 1, (threadNb, step) ->
+            testee.event(new QuotaUsageUpdatedEvent(BOB_SESSION, QUOTAROOT, Counts._40_PERCENT, Sizes._55_PERCENT, NOW)))
+            .run()
+            .awaitTermination(1, TimeUnit.MINUTES);
+
+        assertThat(mailetContext.getSentMails())
+            .hasSize(1);
+    }
+
+}
\ No newline at end of file


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