This is an automated email from the ASF dual-hosted git repository.

quantranhong1999 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 a92d8cea18 JAMES-4216 Allow HTML templating for overquota emails 
(#3097)
a92d8cea18 is described below

commit a92d8cea1865abc99d57f6a867e2f616894a0606
Author: Benoit TELLIER <[email protected]>
AuthorDate: Mon Jul 27 10:59:17 2026 +0200

    JAMES-4216 Allow HTML templating for overquota emails (#3097)
---
 .../servers/partials/configure/listeners.adoc      |   5 +
 .../mailing/QuotaMailingListenerConfiguration.java |  71 ++++++++++++--
 .../mailing/subscribers/QuotaThresholdNotice.java  |  33 ++++++-
 .../templates/QuotaThresholdMailHtmlBody.mustache  |  22 +++++
 .../QuotaMailingListenerConfigurationTest.java     |  64 ++++++++++++-
 .../QuotaThresholdMailingIntegrationTest.java      |  30 ++++++
 .../subscribers/QuotaThresholdNoticeTest.java      | 102 +++++++++++++++++++++
 .../src/test/resources/templates/html1.mustache    |   1 +
 .../src/test/resources/templates/html2.mustache    |   1 +
 9 files changed, 315 insertions(+), 14 deletions(-)

diff --git a/docs/modules/servers/partials/configure/listeners.adoc 
b/docs/modules/servers/partials/configure/listeners.adoc
index f4b9d657ef..0328c20132 100644
--- a/docs/modules/servers/partials/configure/listeners.adoc
+++ b/docs/modules/servers/partials/configure/listeners.adoc
@@ -99,6 +99,11 @@ different occupation thresholds.
 | bodyTemplate
 | Mustache template for rendering the body of the warning email.
 
+| htmlBodyTemplate
+| Optional. Mustache template for rendering an HTML body of the warning email. 
When specified, the email is sent as a
+`multipart/alternative` message combining the plain text body (from 
`bodyTemplate`) and this HTML body. It is exposed the
+same rendering variables as `bodyTemplate`.
+
 | thresholds
 | Floating number between 0 and 1 representing the threshold of quota 
occupation from which a mail should be sent.
 Configuring several thresholds is supported.
diff --git 
a/mailbox/plugin/quota-mailing/src/main/java/org/apache/james/mailbox/quota/mailing/QuotaMailingListenerConfiguration.java
 
b/mailbox/plugin/quota-mailing/src/main/java/org/apache/james/mailbox/quota/mailing/QuotaMailingListenerConfiguration.java
index e10a8adb0d..60102d4e62 100644
--- 
a/mailbox/plugin/quota-mailing/src/main/java/org/apache/james/mailbox/quota/mailing/QuotaMailingListenerConfiguration.java
+++ 
b/mailbox/plugin/quota-mailing/src/main/java/org/apache/james/mailbox/quota/mailing/QuotaMailingListenerConfiguration.java
@@ -45,6 +45,7 @@ public class QuotaMailingListenerConfiguration {
     interface XmlKeys {
         String SUBJECT_TEMPLATE = "subjectTemplate";
         String BODY_TEMPLATE = "bodyTemplate";
+        String HTML_BODY_TEMPLATE = "htmlBodyTemplate";
         String GRACE_PERIOD = "gracePeriod";
         String THRESHOLDS = "thresholds.threshold";
         String THRESHOLD_VALUE = "value";
@@ -56,6 +57,7 @@ public class QuotaMailingListenerConfiguration {
             .addThresholds(readThresholds(config))
             .subjectTemplate(readSubjectTemplate(config))
             .bodyTemplate(readBodyTemplate(config))
+            .htmlBodyTemplate(readHtmlBodyTemplate(config))
             .gracePeriod(readGracePeriod(config))
             .name(readName(config))
             .build();
@@ -73,6 +75,10 @@ public class QuotaMailingListenerConfiguration {
         return Optional.ofNullable(config.getString(XmlKeys.BODY_TEMPLATE, 
null));
     }
 
+    private static Optional<String> 
readHtmlBodyTemplate(HierarchicalConfiguration<ImmutableNode> config) {
+        return 
Optional.ofNullable(config.getString(XmlKeys.HTML_BODY_TEMPLATE, null));
+    }
+
     private static Optional<Duration> 
readGracePeriod(HierarchicalConfiguration<ImmutableNode> config) {
         return Optional.ofNullable(config.getString(XmlKeys.GRACE_PERIOD, 
null))
             .map(string -> DurationParser.parse(string, ChronoUnit.DAYS));
@@ -85,7 +91,8 @@ public class QuotaMailingListenerConfiguration {
                 node.getDouble(XmlKeys.THRESHOLD_VALUE),
                 RenderingInformation.from(
                     Optional.ofNullable(node.getString(XmlKeys.BODY_TEMPLATE)),
-                    
Optional.ofNullable(node.getString(XmlKeys.SUBJECT_TEMPLATE)))))
+                    
Optional.ofNullable(node.getString(XmlKeys.SUBJECT_TEMPLATE)),
+                    
Optional.ofNullable(node.getString(XmlKeys.HTML_BODY_TEMPLATE)))))
             .collect(ImmutableMap.toImmutableMap(
                 pair -> new QuotaThreshold(pair.getLeft()),
                 Pair::getRight));
@@ -94,22 +101,34 @@ public class QuotaMailingListenerConfiguration {
     public static class RenderingInformation {
         private final Optional<String> bodyTemplate;
         private final Optional<String> subjectTemplate;
+        private final Optional<String> htmlBodyTemplate;
 
         public static RenderingInformation from(Optional<String> bodyTemplate, 
Optional<String> subjectTemplate) {
+            return from(bodyTemplate, subjectTemplate, Optional.empty());
+        }
+
+        public static RenderingInformation from(Optional<String> bodyTemplate, 
Optional<String> subjectTemplate, Optional<String> htmlBodyTemplate) {
             return new RenderingInformation(
                 bodyTemplate,
-                subjectTemplate);
+                subjectTemplate,
+                htmlBodyTemplate);
         }
 
         public static RenderingInformation from(String bodyTemplate, String 
subjectTemplate) {
             return from(Optional.of(bodyTemplate), 
Optional.of(subjectTemplate));
         }
 
-        private RenderingInformation(Optional<String> bodyTemplate, 
Optional<String> subjectTemplate) {
+        public static RenderingInformation from(String bodyTemplate, String 
subjectTemplate, String htmlBodyTemplate) {
+            return from(Optional.of(bodyTemplate), 
Optional.of(subjectTemplate), Optional.of(htmlBodyTemplate));
+        }
+
+        private RenderingInformation(Optional<String> bodyTemplate, 
Optional<String> subjectTemplate, Optional<String> htmlBodyTemplate) {
             Preconditions.checkArgument(!bodyTemplate.equals(Optional.of("")), 
"Pass a non empty bodyTemplate");
             
Preconditions.checkArgument(!subjectTemplate.equals(Optional.of("")), "Pass a 
non empty subjectTemplate");
+            
Preconditions.checkArgument(!htmlBodyTemplate.equals(Optional.of("")), "Pass a 
non empty htmlBodyTemplate");
             this.bodyTemplate = bodyTemplate;
             this.subjectTemplate = subjectTemplate;
+            this.htmlBodyTemplate = htmlBodyTemplate;
         }
 
         public Optional<String> getBodyTemplate() {
@@ -120,20 +139,25 @@ public class QuotaMailingListenerConfiguration {
             return subjectTemplate;
         }
 
+        public Optional<String> getHtmlBodyTemplate() {
+            return htmlBodyTemplate;
+        }
+
         @Override
         public final boolean equals(Object o) {
             if (o instanceof RenderingInformation) {
                 RenderingInformation that = (RenderingInformation) o;
 
                 return Objects.equals(this.bodyTemplate, that.bodyTemplate)
-                    && Objects.equals(this.subjectTemplate, 
that.subjectTemplate);
+                    && Objects.equals(this.subjectTemplate, 
that.subjectTemplate)
+                    && Objects.equals(this.htmlBodyTemplate, 
that.htmlBodyTemplate);
             }
             return false;
         }
 
         @Override
         public final int hashCode() {
-            return Objects.hash(bodyTemplate, subjectTemplate);
+            return Objects.hash(bodyTemplate, subjectTemplate, 
htmlBodyTemplate);
         }
 
         @Override
@@ -141,6 +165,7 @@ public class QuotaMailingListenerConfiguration {
             return MoreObjects.toStringHelper(this)
                 .add("bodyTemplate", bodyTemplate)
                 .add("subjectTemplate", subjectTemplate)
+                .add("htmlBodyTemplate", htmlBodyTemplate)
                 .toString();
         }
     }
@@ -151,6 +176,7 @@ public class QuotaMailingListenerConfiguration {
         private Optional<Duration> gradePeriod;
         private Optional<String> bodyTemplate;
         private Optional<String> subjectTemplate;
+        private Optional<String> htmlBodyTemplate;
         private Optional<String> name;
 
         private Builder() {
@@ -159,6 +185,7 @@ public class QuotaMailingListenerConfiguration {
             gradePeriod = Optional.empty();
             bodyTemplate = Optional.empty();
             subjectTemplate = Optional.empty();
+            htmlBodyTemplate = Optional.empty();
             name = Optional.empty();
         }
 
@@ -206,11 +233,22 @@ public class QuotaMailingListenerConfiguration {
             return this;
         }
 
+        public Builder htmlBodyTemplate(String htmlBodyTemplate) {
+            
Preconditions.checkArgument(!Strings.isNullOrEmpty(htmlBodyTemplate), "Pass a 
non null/empty htmlBodyTemplate");
+            this.htmlBodyTemplate = Optional.of(htmlBodyTemplate);
+            return this;
+        }
+
         public Builder bodyTemplate(Optional<String> bodyTemplate) {
             bodyTemplate.ifPresent(this::bodyTemplate);
             return this;
         }
 
+        public Builder htmlBodyTemplate(Optional<String> htmlBodyTemplate) {
+            htmlBodyTemplate.ifPresent(this::htmlBodyTemplate);
+            return this;
+        }
+
         public Builder subjectTemplate(Optional<String> subjectTemplate) {
             subjectTemplate.ifPresent(this::subjectTemplate);
             return this;
@@ -239,12 +277,15 @@ public class QuotaMailingListenerConfiguration {
                 gradePeriod.orElse(DEFAULT_GRACE_PERIOD),
                 bodyTemplate,
                 subjectTemplate,
+                htmlBodyTemplate,
                 name.orElse(DEFAULT_NAME));
         }
     }
 
     public static final String DEFAULT_BODY_TEMPLATE = 
FileSystem.CLASSPATH_PROTOCOL + "//templates/QuotaThresholdMailBody.mustache";
     public static final String DEFAULT_SUBJECT_TEMPLATE = 
FileSystem.CLASSPATH_PROTOCOL + 
"//templates/QuotaThresholdMailSubject.mustache";
+    /** Not applied by default: HTML rendering needs to be explicitly opted 
in. */
+    public static final String SAMPLE_HTML_BODY_TEMPLATE = 
FileSystem.CLASSPATH_PROTOCOL + 
"//templates/QuotaThresholdMailHtmlBody.mustache";
     public static final RenderingInformation DEFAULT_RENDERING_INFORMATION = 
RenderingInformation.from(Optional.empty(), Optional.empty());
     public static final Duration DEFAULT_GRACE_PERIOD = Duration.ofDays(1);
     private static final String DEFAULT_NAME = "default";
@@ -263,15 +304,18 @@ public class QuotaMailingListenerConfiguration {
     private final Duration gracePeriod;
     private final Optional<String> bodyTemplate;
     private final Optional<String> subjectTemplate;
+    private final Optional<String> htmlBodyTemplate;
     private final String name;
 
     private QuotaMailingListenerConfiguration(ImmutableMap<QuotaThreshold, 
RenderingInformation> toRenderingInformation,
-                                              QuotaThresholds thresholds, 
Duration gracePeriod, Optional<String> bodyTemplate, Optional<String> 
subjectTemplate, String name) {
+                                              QuotaThresholds thresholds, 
Duration gracePeriod, Optional<String> bodyTemplate, Optional<String> 
subjectTemplate,
+                                              Optional<String> 
htmlBodyTemplate, String name) {
         this.toRenderingInformation = toRenderingInformation;
         this.thresholds = thresholds;
         this.gracePeriod = gracePeriod;
         this.bodyTemplate = bodyTemplate;
         this.subjectTemplate = subjectTemplate;
+        this.htmlBodyTemplate = htmlBodyTemplate;
         this.name = name;
     }
 
@@ -301,6 +345,17 @@ public class QuotaMailingListenerConfiguration {
             .orElse(DEFAULT_SUBJECT_TEMPLATE);
     }
 
+    /**
+     * @return the template to be rendered as a text/html alternative part, or 
empty when HTML rendering is not opted in.
+     */
+    public Optional<String> getHtmlBodyTemplate(QuotaThreshold quotaThreshold) 
{
+        return Optional
+            .ofNullable(
+                toRenderingInformation.get(quotaThreshold))
+                    .flatMap(RenderingInformation::getHtmlBodyTemplate)
+            .or(() -> htmlBodyTemplate);
+    }
+
     public String getName() {
         return name;
     }
@@ -315,6 +370,7 @@ public class QuotaMailingListenerConfiguration {
                 && Objects.equals(this.gracePeriod, that.gracePeriod)
                 && Objects.equals(this.subjectTemplate, that.subjectTemplate)
                 && Objects.equals(this.bodyTemplate, that.bodyTemplate)
+                && Objects.equals(this.htmlBodyTemplate, that.htmlBodyTemplate)
                 && Objects.equals(this.name, that.name);
         }
         return false;
@@ -322,7 +378,7 @@ public class QuotaMailingListenerConfiguration {
 
     @Override
     public final int hashCode() {
-        return Objects.hash(toRenderingInformation, thresholds, 
subjectTemplate, bodyTemplate, gracePeriod, name);
+        return Objects.hash(toRenderingInformation, thresholds, 
subjectTemplate, bodyTemplate, htmlBodyTemplate, gracePeriod, name);
     }
 
     @Override
@@ -332,6 +388,7 @@ public class QuotaMailingListenerConfiguration {
             .add("thresholds", thresholds)
             .add("bodyTemplate", bodyTemplate)
             .add("subjectTemplate", subjectTemplate)
+            .add("htmlBodyTemplate", htmlBodyTemplate)
             .add("gracePeriod", gracePeriod)
             .add("name", name)
             .toString();
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
index 919883c29d..ced3ec6cc2 100644
--- 
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
@@ -32,6 +32,8 @@ import java.util.Objects;
 import java.util.Optional;
 import java.util.stream.Stream;
 
+import jakarta.mail.MessagingException;
+
 import org.apache.commons.io.IOUtils;
 import org.apache.james.core.builder.MimeMessageBuilder;
 import org.apache.james.core.quota.QuotaCountLimit;
@@ -46,6 +48,7 @@ import org.apache.james.mailbox.quota.model.QuotaThreshold;
 import org.apache.james.mailbox.quota.model.QuotaThresholdChange;
 import org.apache.james.util.SizeFormat;
 
+import com.github.fge.lambdas.Throwing;
 import com.github.mustachejava.DefaultMustacheFactory;
 import com.github.mustachejava.Mustache;
 import com.github.mustachejava.MustacheFactory;
@@ -53,6 +56,8 @@ import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Preconditions;
 
 public class QuotaThresholdNotice {
+    private static final String ALTERNATIVE_SUB_TYPE = "alternative";
+    private static final String TEXT_HTML_UTF8_TYPE = "text/html; 
charset=UTF-8";
 
     public static class Builder {
         private Optional<QuotaThreshold> countThreshold;
@@ -135,10 +140,22 @@ public class QuotaThresholdNotice {
         this.configuration = configuration;
     }
 
-    public MimeMessageBuilder generateMimeMessage(FileSystem fileSystem) 
throws IOException {
-        return MimeMessageBuilder.mimeMessageBuilder()
-            .setSubject(generateSubject(fileSystem))
-            .setText(generateReport(fileSystem));
+    public MimeMessageBuilder generateMimeMessage(FileSystem fileSystem) 
throws IOException, MessagingException {
+        MimeMessageBuilder mimeMessageBuilder = 
MimeMessageBuilder.mimeMessageBuilder()
+            .setSubject(generateSubject(fileSystem));
+
+        Optional<String> htmlReport = generateHtmlReport(fileSystem);
+        if (htmlReport.isEmpty()) {
+            return mimeMessageBuilder.setText(generateReport(fileSystem));
+        }
+        return 
mimeMessageBuilder.setContent(MimeMessageBuilder.multipartBuilder()
+            .subType(ALTERNATIVE_SUB_TYPE)
+            .addBody(MimeMessageBuilder.bodyPartBuilder()
+                .data(generateReport(fileSystem))
+                .type(MimeMessageBuilder.DEFAULT_TEXT_PLAIN_UTF8_TYPE))
+            .addBody(MimeMessageBuilder.bodyPartBuilder()
+                .data(htmlReport.get())
+                .type(TEXT_HTML_UTF8_TYPE)));
     }
 
     @VisibleForTesting
@@ -153,6 +170,12 @@ public class QuotaThresholdNotice {
             configuration.getBodyTemplate(mostSignificantThreshold()));
     }
 
+    @VisibleForTesting
+    Optional<String> generateHtmlReport(FileSystem fileSystem) throws 
IOException {
+        return configuration.getHtmlBodyTemplate(mostSignificantThreshold())
+            .map(Throwing.function((String template) -> 
renderTemplate(fileSystem, template)).sneakyThrow());
+    }
+
     private QuotaThreshold mostSignificantThreshold() {
         return Stream.of(countThreshold, sizeThreshold)
             .flatMap(Optional::stream)
@@ -162,7 +185,7 @@ public class QuotaThresholdNotice {
 
     private String renderTemplate(FileSystem fileSystem, String template) 
throws IOException {
         try (ByteArrayOutputStream byteArrayOutputStream = new 
ByteArrayOutputStream();
-             Writer writer = new OutputStreamWriter(byteArrayOutputStream)) {
+             Writer writer = new OutputStreamWriter(byteArrayOutputStream, 
StandardCharsets.UTF_8)) {
 
             MustacheFactory mf = new DefaultMustacheFactory();
             Mustache mustache = mf.compile(getPatternReader(fileSystem, 
template), "example");
diff --git 
a/mailbox/plugin/quota-mailing/src/main/resources/templates/QuotaThresholdMailHtmlBody.mustache
 
b/mailbox/plugin/quota-mailing/src/main/resources/templates/QuotaThresholdMailHtmlBody.mustache
new file mode 100644
index 0000000000..a1cecfc6a3
--- /dev/null
+++ 
b/mailbox/plugin/quota-mailing/src/main/resources/templates/QuotaThresholdMailHtmlBody.mustache
@@ -0,0 +1,22 @@
+<html>
+<head>
+    <meta charset="UTF-8"/>
+</head>
+<body style="font-family: Helvetica, Arial, sans-serif; color: #333333;">
+<p>You receive this email because you recently exceeded a threshold related to 
the quotas of your email account.</p>
+{{#hasExceededSizeThreshold}}
+<p>
+    You currently occupy more than <strong>{{sizeThreshold}} %</strong> of the 
total size allocated to you.<br/>
+    You currently occupy <strong>{{usedSize}}</strong>{{#hasSizeLimit}} on a 
total of <strong>{{limitSize}}</strong> allocated to you{{/hasSizeLimit}}.
+</p>
+{{/hasExceededSizeThreshold}}
+{{#hasExceededCountThreshold}}
+<p>
+    You currently occupy more than <strong>{{countThreshold}} %</strong> of 
the total message count allocated to you.<br/>
+    You currently have <strong>{{usedCount}}</strong> 
messages{{#hasCountLimit}} on a total of <strong>{{limitCount}}</strong> 
allowed for you{{/hasCountLimit}}.
+</p>
+{{/hasExceededCountThreshold}}
+<p>You need to be aware that actions leading to exceeded quotas will be 
denied. This will result in a degraded service.</p>
+<p>To mitigate this issue you might reach your administrator in order to 
increase your configured quota. You might also delete some non important 
emails.</p>
+</body>
+</html>
diff --git 
a/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/mailbox/quota/mailing/QuotaMailingListenerConfigurationTest.java
 
b/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/mailbox/quota/mailing/QuotaMailingListenerConfigurationTest.java
index 919c2c7e02..46ecf2e851 100644
--- 
a/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/mailbox/quota/mailing/QuotaMailingListenerConfigurationTest.java
+++ 
b/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/mailbox/quota/mailing/QuotaMailingListenerConfigurationTest.java
@@ -44,6 +44,9 @@ public class QuotaMailingListenerConfigurationTest {
     private static final String OTHER_BODY_TEMPLATE = "other_body.mustache";
     private static final String YET_ANOTHER_SUBJECT_TEMPLATE = 
"yet_another_sbj.mustache";
     private static final String YET_ANOTHER_BODY_TEMPLATE = 
"yet_another_body.mustache";
+    private static final String HTML_BODY_TEMPLATE = "html_body.mustache";
+    private static final String OTHER_HTML_BODY_TEMPLATE = 
"other_html_body.mustache";
+    private static final String YET_ANOTHER_HTML_BODY_TEMPLATE = 
"yet_another_html_body.mustache";
 
     @Test
     public void shouldMatchBeanContract() {
@@ -60,15 +63,18 @@ public class QuotaMailingListenerConfigurationTest {
                 "      <value>0.85</value>" +
                 "      <subjectTemplate>" + SUBJECT_TEMPLATE + 
"</subjectTemplate>\n" +
                 "      <bodyTemplate>" + BODY_TEMPLATE + "</bodyTemplate>\n" +
+                "      <htmlBodyTemplate>" + HTML_BODY_TEMPLATE + 
"</htmlBodyTemplate>\n" +
                 "    </threshold>\n" +
                 "    <threshold>\n" +
                 "      <value>0.98</value>\n" +
                 "      <subjectTemplate>" + OTHER_SUBJECT_TEMPLATE + 
"</subjectTemplate>\n" +
                 "      <bodyTemplate>" + OTHER_BODY_TEMPLATE + 
"</bodyTemplate>\n" +
+                "      <htmlBodyTemplate>" + OTHER_HTML_BODY_TEMPLATE + 
"</htmlBodyTemplate>\n" +
                 "    </threshold>\n" +
                 "  </thresholds>\n" +
                 "  <subjectTemplate>" + YET_ANOTHER_SUBJECT_TEMPLATE + 
"</subjectTemplate>\n" +
                 "  <bodyTemplate>" + YET_ANOTHER_BODY_TEMPLATE + 
"</bodyTemplate>\n" +
+                "  <htmlBodyTemplate>" + YET_ANOTHER_HTML_BODY_TEMPLATE + 
"</htmlBodyTemplate>\n" +
                 "  <gracePeriod>3 days</gracePeriod>\n" +
                 "  <name>listener-name</name>\n" +
                 "</configuration>"));
@@ -78,12 +84,13 @@ public class QuotaMailingListenerConfigurationTest {
         assertThat(result)
             .isEqualTo(QuotaMailingListenerConfiguration.builder()
                 .addThreshold(new QuotaThreshold(0.85),
-                    RenderingInformation.from(BODY_TEMPLATE, SUBJECT_TEMPLATE))
+                    RenderingInformation.from(BODY_TEMPLATE, SUBJECT_TEMPLATE, 
HTML_BODY_TEMPLATE))
                 .addThreshold(new QuotaThreshold(0.98),
-                    RenderingInformation.from(OTHER_BODY_TEMPLATE, 
OTHER_SUBJECT_TEMPLATE))
+                    RenderingInformation.from(OTHER_BODY_TEMPLATE, 
OTHER_SUBJECT_TEMPLATE, OTHER_HTML_BODY_TEMPLATE))
                 .gracePeriod(Duration.ofDays(3))
                 .subjectTemplate(YET_ANOTHER_SUBJECT_TEMPLATE)
                 .bodyTemplate(YET_ANOTHER_BODY_TEMPLATE)
+                .htmlBodyTemplate(YET_ANOTHER_HTML_BODY_TEMPLATE)
                 .name("listener-name")
                 .build());
     }
@@ -215,6 +222,59 @@ public class QuotaMailingListenerConfigurationTest {
             .isInstanceOf(IllegalArgumentException.class);
     }
 
+    @Test
+    public void fromShouldNotSetHtmlBodyTemplateWhenOmitted() throws Exception 
{
+        XMLConfiguration xmlConfiguration = 
FileConfigurationProvider.getConfig(toStream(
+            "<configuration>\n" +
+                "  <thresholds>\n" +
+                "    <threshold>" +
+                "      <value>0.85</value>" +
+                "      <bodyTemplate>" + BODY_TEMPLATE + "</bodyTemplate>\n" +
+                "    </threshold>\n" +
+                "  </thresholds>\n" +
+                "</configuration>"));
+
+        QuotaMailingListenerConfiguration result = 
QuotaMailingListenerConfiguration.from(xmlConfiguration);
+
+        assertThat(result.getHtmlBodyTemplate(new QuotaThreshold(0.85)))
+            .isEmpty();
+    }
+
+    @Test
+    public void 
getHtmlBodyTemplateShouldFallbackToGlobalValueWhenThresholdValueIsOmitted() 
throws Exception {
+        XMLConfiguration xmlConfiguration = 
FileConfigurationProvider.getConfig(toStream(
+            "<configuration>\n" +
+                "  <thresholds>\n" +
+                "    <threshold>" +
+                "      <value>0.85</value>" +
+                "    </threshold>\n" +
+                "  </thresholds>\n" +
+                "  <htmlBodyTemplate>" + HTML_BODY_TEMPLATE + 
"</htmlBodyTemplate>\n" +
+                "</configuration>"));
+
+        QuotaMailingListenerConfiguration result = 
QuotaMailingListenerConfiguration.from(xmlConfiguration);
+
+        assertThat(result.getHtmlBodyTemplate(new QuotaThreshold(0.85)))
+            .contains(HTML_BODY_TEMPLATE);
+    }
+
+    @Test
+    public void fromShouldThrowOnEmptyHtmlBodyTemplate() throws Exception {
+        XMLConfiguration xmlConfiguration = 
FileConfigurationProvider.getConfig(toStream(
+            "<configuration>\n" +
+                "  <thresholds>\n" +
+                "    <threshold>" +
+                "      <value>0.85</value>" +
+                "      <htmlBodyTemplate></htmlBodyTemplate>\n" +
+                "    </threshold>\n" +
+                "  </thresholds>\n" +
+                "  <name>listener-name</name>\n" +
+                "</configuration>"));
+
+        assertThatThrownBy(() -> 
QuotaMailingListenerConfiguration.from(xmlConfiguration))
+            .isInstanceOf(IllegalArgumentException.class);
+    }
+
     @Test
     public void fromShouldThrowOnEmptyBodyTemplate() throws Exception {
         XMLConfiguration xmlConfiguration = 
FileConfigurationProvider.getConfig(toStream(
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
index 3a44044139..6f675759ec 100644
--- 
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
@@ -37,6 +37,8 @@ import static org.assertj.core.api.Assertions.assertThat;
 
 import java.time.Duration;
 
+import jakarta.mail.internet.MimeMultipart;
+
 import org.apache.james.events.Event;
 import org.apache.james.eventsourcing.eventstore.EventStore;
 import org.apache.james.mailbox.quota.QuotaFixture.Counts;
@@ -45,6 +47,7 @@ import 
org.apache.james.mailbox.quota.mailing.QuotaMailingListenerConfiguration;
 import org.apache.james.mailbox.store.event.EventFactory;
 import org.apache.james.util.concurrency.ConcurrentTestRunner;
 import org.apache.mailet.base.test.FakeMailContext;
+import org.assertj.core.api.SoftAssertions;
 import org.junit.jupiter.api.Test;
 
 public interface QuotaThresholdMailingIntegrationTest {
@@ -316,6 +319,33 @@ public interface QuotaThresholdMailingIntegrationTest {
             .hasSize(2);
     }
 
+    @Test
+    default void 
shouldSendMultipartAlternativeMailWhenHtmlTemplateIsConfigured(EventStore 
store) throws Exception {
+        FakeMailContext mailetContext = mailetContext();
+        QuotaThresholdListenersTestSystem testee = new 
QuotaThresholdListenersTestSystem(mailetContext, store,
+            QuotaMailingListenerConfiguration.builder()
+                .addThresholds(_50)
+                
.htmlBodyTemplate(QuotaMailingListenerConfiguration.SAMPLE_HTML_BODY_TEMPLATE)
+                .gracePeriod(GRACE_PERIOD)
+                .build());
+
+        testee.event(eventBase()
+            .quotaCount(Counts._52_PERCENT)
+            .quotaSize(Sizes._30_PERCENT)
+            .instant(NOW)
+            .build());
+
+        MimeMultipart content = (MimeMultipart) 
mailetContext.getSentMails().get(0).getMsg().getContent();
+
+        SoftAssertions softly = new SoftAssertions();
+        
softly.assertThat(content.getContentType()).startsWith("multipart/alternative");
+        
softly.assertThat(content.getBodyPart(0).getContentType()).startsWith("text/plain");
+        softly.assertThat((String) content.getBodyPart(1).getContent())
+            .startsWith("<html>")
+            .contains("You currently occupy more than <strong>50 %</strong> of 
the total message count allocated to you.");
+        softly.assertAll();
+    }
+
     @Test
     default void shouldSendOneMailUponConcurrentEvents(EventStore store) 
throws Exception {
         FakeMailContext mailetContext = mailetContext();
diff --git 
a/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/mailbox/quota/mailing/subscribers/QuotaThresholdNoticeTest.java
 
b/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/mailbox/quota/mailing/subscribers/QuotaThresholdNoticeTest.java
index 4f96458132..8d131e876d 100644
--- 
a/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/mailbox/quota/mailing/subscribers/QuotaThresholdNoticeTest.java
+++ 
b/mailbox/plugin/quota-mailing/src/test/java/org/apache/james/mailbox/quota/mailing/subscribers/QuotaThresholdNoticeTest.java
@@ -29,6 +29,9 @@ import static org.assertj.core.api.Assertions.assertThat;
 
 import java.util.Optional;
 
+import jakarta.mail.internet.MimeMessage;
+import jakarta.mail.internet.MimeMultipart;
+
 import org.apache.james.core.quota.QuotaCountLimit;
 import org.apache.james.core.quota.QuotaCountUsage;
 import org.apache.james.core.quota.QuotaSizeLimit;
@@ -449,6 +452,95 @@ class QuotaThresholdNoticeTest {
         softly.assertAll();
     }
 
+    @Test
+    void generateHtmlReportShouldBeEmptyWhenHtmlTemplateIsNotConfigured() 
throws Exception {
+        QuotaThresholdChange sizeThresholdChange = new 
QuotaThresholdChange(_80, NOW);
+
+        assertThat(noticeFor(DEFAULT_CONFIGURATION, sizeThresholdChange)
+            .generateHtmlReport(fileSystem))
+            .isEmpty();
+    }
+
+    @Test
+    void generateHtmlReportShouldUsePerThresholdTemplate() throws Exception {
+        QuotaMailingListenerConfiguration configuration = 
QuotaMailingListenerConfiguration.builder()
+            .addThreshold(_80, RenderingInformation.from(
+                "classpath://templates/body1.mustache",
+                "classpath://templates/subject1.mustache",
+                "classpath://templates/html1.mustache"))
+            .htmlBodyTemplate("classpath://templates/html2.mustache")
+            .build();
+
+        assertThat(noticeFor(configuration, new QuotaThresholdChange(_80, NOW))
+            .generateHtmlReport(fileSystem))
+            .contains("[HTML_1]");
+    }
+
+    @Test
+    void 
generateHtmlReportShouldFallbackToGlobalTemplateWhenSpecificThresholdValueIsOmitted()
 throws Exception {
+        QuotaMailingListenerConfiguration configuration = 
QuotaMailingListenerConfiguration.builder()
+            .addThreshold(_80, RenderingInformation.from(
+                "classpath://templates/body1.mustache",
+                "classpath://templates/subject1.mustache"))
+            .htmlBodyTemplate("classpath://templates/html2.mustache")
+            .build();
+
+        assertThat(noticeFor(configuration, new QuotaThresholdChange(_80, NOW))
+            .generateHtmlReport(fileSystem))
+            .contains("[HTML_2]");
+    }
+
+    @Test
+    void generateMimeMessageShouldBePlainTextWhenHtmlTemplateIsNotConfigured() 
throws Exception {
+        MimeMessage mimeMessage = noticeFor(DEFAULT_CONFIGURATION, new 
QuotaThresholdChange(_80, NOW))
+            .generateMimeMessage(fileSystem)
+            .build();
+
+        assertThat(mimeMessage.getContentType()).startsWith("text/plain");
+    }
+
+    @Test
+    void 
generateMimeMessageShouldCombineBothAlternativesWhenHtmlTemplateIsConfigured() 
throws Exception {
+        QuotaMailingListenerConfiguration configuration = 
QuotaMailingListenerConfiguration.builder()
+            .bodyTemplate("classpath://templates/body1.mustache")
+            .htmlBodyTemplate("classpath://templates/html1.mustache")
+            .build();
+
+        MimeMessage mimeMessage = noticeFor(configuration, new 
QuotaThresholdChange(_80, NOW))
+            .generateMimeMessage(fileSystem)
+            .build();
+        MimeMultipart multipart = (MimeMultipart) mimeMessage.getContent();
+
+        SoftAssertions softly = new SoftAssertions();
+        
softly.assertThat(mimeMessage.getContentType()).startsWith("multipart/alternative");
+        softly.assertThat(multipart.getCount()).isEqualTo(2);
+        
softly.assertThat(multipart.getBodyPart(0).getContentType()).startsWith("text/plain");
+        
softly.assertThat(multipart.getBodyPart(0).getContent()).isEqualTo("[BODY_1]");
+        
softly.assertThat(multipart.getBodyPart(1).getContentType()).startsWith("text/html");
+        
softly.assertThat(multipart.getBodyPart(1).getContent()).isEqualTo("[HTML_1]");
+        softly.assertAll();
+    }
+
+    @Test
+    void defaultHtmlTemplateShouldGenerateAHumanReadableMessage() throws 
Exception {
+        QuotaMailingListenerConfiguration configuration = 
QuotaMailingListenerConfiguration.builder()
+            
.htmlBodyTemplate(QuotaMailingListenerConfiguration.SAMPLE_HTML_BODY_TEMPLATE)
+            .build();
+
+        assertThat(QuotaThresholdNotice.builder()
+            .withConfiguration(configuration)
+            .sizeQuota(Sizes._82_PERCENT)
+            .countQuota(Counts._92_PERCENT)
+            .sizeThreshold(HistoryEvolution.higherThresholdReached(new 
QuotaThresholdChange(_80, NOW), NotAlreadyReachedDuringGracePeriod))
+            .countThreshold(HistoryEvolution.higherThresholdReached(new 
QuotaThresholdChange(_80, NOW), NotAlreadyReachedDuringGracePeriod))
+            .build()
+            .get()
+            .generateHtmlReport(fileSystem)
+            .get())
+            .contains("You currently occupy <strong>82 bytes</strong> on a 
total of <strong>100 bytes</strong> allocated to you.")
+            .contains("You currently have <strong>92</strong> messages on a 
total of <strong>100</strong> allowed for you.");
+    }
+
     @Test
     void 
renderingShouldDefaultToDefaultValueWhenSpecificThresholdAndGlobalValueIsOmited()
 throws Exception {
         QuotaMailingListenerConfiguration configuration = 
QuotaMailingListenerConfiguration.builder()
@@ -472,4 +564,14 @@ class QuotaThresholdNoticeTest {
             .isEqualTo("[BODY_2]");
         softly.assertAll();
     }
+
+    private QuotaThresholdNotice noticeFor(QuotaMailingListenerConfiguration 
configuration, QuotaThresholdChange sizeThresholdChange) {
+        return QuotaThresholdNotice.builder()
+            .withConfiguration(configuration)
+            .sizeQuota(Sizes._82_PERCENT)
+            .countQuota(Counts._92_PERCENT)
+            
.sizeThreshold(HistoryEvolution.higherThresholdReached(sizeThresholdChange, 
NotAlreadyReachedDuringGracePeriod))
+            .build()
+            .get();
+    }
 }
\ No newline at end of file
diff --git 
a/mailbox/plugin/quota-mailing/src/test/resources/templates/html1.mustache 
b/mailbox/plugin/quota-mailing/src/test/resources/templates/html1.mustache
new file mode 100644
index 0000000000..eefc529170
--- /dev/null
+++ b/mailbox/plugin/quota-mailing/src/test/resources/templates/html1.mustache
@@ -0,0 +1 @@
+[HTML_1]
\ No newline at end of file
diff --git 
a/mailbox/plugin/quota-mailing/src/test/resources/templates/html2.mustache 
b/mailbox/plugin/quota-mailing/src/test/resources/templates/html2.mustache
new file mode 100644
index 0000000000..45c98f33cf
--- /dev/null
+++ b/mailbox/plugin/quota-mailing/src/test/resources/templates/html2.mustache
@@ -0,0 +1 @@
+[HTML_2]
\ No newline at end of file


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


Reply via email to