Skip to content

Commit e111100

Browse files
stalepbarreiro
authored andcommitted
Add EmailPlugin for email notifications (issue #89, Phase 3)
Implements email notification channel using Quarkus ReactiveMailer, following the same patterns as Horreum's EmailPlugin. Features: - ReactiveMailer with configurable timeout (h5m.mail.timeout, default 15s) - Sends both HTML and plain text bodies (multipart) - Configurable subject prefix (h5m.mail.subject.prefix, default [h5m]) - Custom subject via config: {"subject": "ALERT: {folderName}"} - Custom body via template with {folderName}, {nodeName}, {nodeType}, {changeCount} placeholders - Multiple recipients: {"to": "alice@example.com,bob@example.com"} - Format-aware change details: FixedThreshold shows value/bound/direction, RelativeDifference shows ratio/previous/last - HTML body with table of changes including fingerprint and details - Mock mode enabled by default for dev/test; configure SMTP via MAILER_HOST, MAILER_PORT, MAILER_FROM environment variables Dependencies: - Added quarkus-mailer Tests (14): - Validation: valid config, multiple recipients, null, empty, missing to field, invalid email format - Send: delivery, change details in body, multiple recipients, custom subject with prefix, custom template, fixed threshold formatting, relative difference formatting - Method identity check
1 parent 00ae797 commit e111100

6 files changed

Lines changed: 394 additions & 0 deletions

File tree

pom.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,10 @@
160160
<groupId>io.quarkus</groupId>
161161
<artifactId>quarkus-jdbc-postgresql</artifactId>
162162
</dependency>
163+
<dependency>
164+
<groupId>io.quarkus</groupId>
165+
<artifactId>quarkus-mailer</artifactId>
166+
</dependency>
163167
<dependency>
164168
<groupId>io.quarkus</groupId>
165169
<artifactId>quarkus-micrometer-registry-prometheus</artifactId>
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
package io.hyperfoil.tools.h5m.notification;
2+
3+
import com.fasterxml.jackson.core.JsonProcessingException;
4+
import com.fasterxml.jackson.databind.JsonNode;
5+
import com.fasterxml.jackson.databind.ObjectMapper;
6+
import io.hyperfoil.tools.h5m.event.ChangeDetail;
7+
import io.hyperfoil.tools.h5m.event.ChangeNotification;
8+
import io.quarkus.logging.Log;
9+
import io.quarkus.mailer.Mail;
10+
import io.quarkus.mailer.reactive.ReactiveMailer;
11+
import io.quarkus.qute.Location;
12+
import io.quarkus.qute.Qute;
13+
import io.quarkus.qute.Template;
14+
import jakarta.enterprise.context.ApplicationScoped;
15+
import jakarta.inject.Inject;
16+
import org.eclipse.microprofile.config.inject.ConfigProperty;
17+
18+
import java.time.Duration;
19+
20+
/**
21+
* Notification plugin that sends change notifications via email.
22+
* <p>
23+
* Configuration data (JSON):
24+
* <pre>{"to": "team@example.com"}</pre>
25+
* or multiple recipients:
26+
* <pre>{"to": "alice@example.com,bob@example.com"}</pre>
27+
* <p>
28+
* Optional fields:
29+
* <pre>{"to": "team@example.com", "subject": "Custom subject: {folderName}"}</pre>
30+
* <p>
31+
* If a custom template is provided via {@link ChangeNotification#template()},
32+
* it is used as the email body. Otherwise a default plain-text body is generated.
33+
*/
34+
@ApplicationScoped
35+
public class EmailPlugin implements NotificationPlugin {
36+
37+
private static final ObjectMapper MAPPER = new ObjectMapper();
38+
39+
@Inject
40+
ReactiveMailer mailer;
41+
42+
@Location("email_change_notification.html")
43+
Template htmlTemplate;
44+
45+
@Location("email_change_notification.txt")
46+
Template textTemplate;
47+
48+
@ConfigProperty(name = "h5m.mail.subject.prefix", defaultValue = "[h5m]")
49+
String subjectPrefix;
50+
51+
@ConfigProperty(name = "h5m.mail.timeout", defaultValue = "15s")
52+
Duration sendMailTimeout;
53+
54+
@Override
55+
public NotificationMethod method() {
56+
return NotificationMethod.EMAIL;
57+
}
58+
59+
@Override
60+
public void send(ChangeNotification notification) {
61+
String to = extractField(notification.configData(), "to");
62+
String subject = buildSubject(notification);
63+
String body = buildBody(notification);
64+
String htmlBody = buildHtmlBody(notification);
65+
66+
String[] recipients = to.split(",");
67+
Mail mail = Mail.withHtml(recipients[0].trim(), subject, htmlBody);
68+
mail.setText(body);
69+
for (int i = 1; i < recipients.length; i++) {
70+
mail.addTo(recipients[i].trim());
71+
}
72+
73+
mailer.send(mail).await().atMost(sendMailTimeout);
74+
Log.debugf("Email sent to %s: %s", to, subject);
75+
}
76+
77+
@Override
78+
public void validate(String configData) {
79+
if (configData == null || configData.isBlank()) {
80+
throw new IllegalArgumentException("Email config data is required");
81+
}
82+
String to = extractField(configData, "to");
83+
if (to == null || to.isBlank()) {
84+
throw new IllegalArgumentException("Email config must contain a 'to' field with recipient address(es)");
85+
}
86+
// Basic email format check
87+
for (String recipient : to.split(",")) {
88+
String trimmed = recipient.trim();
89+
if (!trimmed.contains("@") || !trimmed.contains(".")) {
90+
throw new IllegalArgumentException("Invalid email address: " + trimmed);
91+
}
92+
}
93+
}
94+
95+
private String buildSubject(ChangeNotification notification) {
96+
String customSubject = extractField(notification.configData(), "subject");
97+
if (customSubject != null && !customSubject.isBlank()) {
98+
return subjectPrefix + " " + applyTemplate(customSubject, notification);
99+
}
100+
return String.format("%s Change detected in %s by %s",
101+
subjectPrefix, notification.folderName(), notification.nodeName());
102+
}
103+
104+
private String buildBody(ChangeNotification notification) {
105+
if (notification.template() != null && !notification.template().isBlank()) {
106+
return applyTemplate(notification.template(), notification);
107+
}
108+
return renderQuteTemplate(textTemplate, notification);
109+
}
110+
111+
private String buildHtmlBody(ChangeNotification notification) {
112+
if (notification.template() != null && !notification.template().isBlank()) {
113+
return "<p>" + applyTemplate(notification.template(), notification) + "</p>";
114+
}
115+
return renderQuteTemplate(htmlTemplate, notification);
116+
}
117+
118+
private String renderQuteTemplate(Template template, ChangeNotification notification) {
119+
return template
120+
.data("folderName", notification.folderName())
121+
.data("nodeName", notification.nodeName())
122+
.data("nodeType", notification.nodeType())
123+
.data("changeCount", notification.changes().size())
124+
.data("changes", notification.changes())
125+
.render();
126+
}
127+
128+
private String applyTemplate(String template, ChangeNotification notification) {
129+
return Qute.fmt(template)
130+
.data("folderName", notification.folderName())
131+
.data("nodeName", notification.nodeName())
132+
.data("nodeType", notification.nodeType())
133+
.data("changeCount", notification.changes().size())
134+
.data("changes", notification.changes())
135+
.render();
136+
}
137+
138+
private String extractField(String json, String field) {
139+
if (json == null) return null;
140+
try {
141+
JsonNode node = MAPPER.readTree(json);
142+
if (node.has(field)) {
143+
return node.get(field).asText();
144+
}
145+
} catch (JsonProcessingException e) {
146+
// not valid JSON
147+
}
148+
return null;
149+
}
150+
}

src/main/resources/application.properties

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,9 @@ quarkus.oidc.client-id=${OIDC_CLIENT_ID:h5m}
8181
quarkus.oidc.credentials.secret=${OIDC_CLIENT_SECRET:}
8282
quarkus.oidc.application-type=service
8383
quarkus.oidc.token.principal-claim=preferred_username
84+
85+
# Mailer - configure via environment for service deployments
86+
quarkus.mailer.from=${MAILER_FROM:h5m@localhost}
87+
quarkus.mailer.host=${MAILER_HOST:localhost}
88+
quarkus.mailer.port=${MAILER_PORT:25}
89+
quarkus.mailer.mock=true
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<p>Change detected in folder <strong>{folderName}</strong></p>
2+
<p>Detection node: <strong>{nodeName}</strong> ({nodeType})<br>
3+
Number of changes: {changeCount}</p>
4+
{#if changes.size > 0}
5+
<table border="1" cellpadding="4" cellspacing="0">
6+
<tr><th>#</th><th>Fingerprint</th><th>Details</th></tr>
7+
{#each changes}
8+
<tr>
9+
<td>{it_count}</td>
10+
<td>{it.fingerprint}</td>
11+
<td>{it.data}</td>
12+
</tr>
13+
{/each}
14+
</table>
15+
{/if}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
Change detected in folder '{folderName}'
2+
Detection node: {nodeName} ({nodeType})
3+
Number of changes: {changeCount}
4+
5+
{#each changes}
6+
--- Change {it_count} ---
7+
{#if it.fingerprint}Fingerprint: {it.fingerprint}
8+
{/if}{it.data}
9+
10+
{/each}

0 commit comments

Comments
 (0)