feat: load email templates from classpath with watermark URL resolution

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-26 10:35:16 +02:00
parent 3741ac2658
commit b974f233f4
2 changed files with 93 additions and 6 deletions

View File

@@ -1,10 +1,14 @@
package net.siegeln.cameleer.saas.vendor;
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
import net.siegeln.cameleer.saas.provisioning.ProvisioningProperties;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -16,9 +20,11 @@ public class EmailConnectorService {
private static final String SMTP_FACTORY_ID = "simple-mail-transfer-protocol";
private final LogtoManagementClient logtoClient;
private final ProvisioningProperties provisioningProps;
public EmailConnectorService(LogtoManagementClient logtoClient) {
public EmailConnectorService(LogtoManagementClient logtoClient, ProvisioningProperties provisioningProps) {
this.logtoClient = logtoClient;
this.provisioningProps = provisioningProps;
}
public record SmtpConfig(String host, int port, String username, String password, String fromEmail) {}
@@ -154,6 +160,19 @@ public class EmailConnectorService {
return "SignInAndRegister".equals(signInExp.get("signInMode"));
}
/** Load an email template from classpath and resolve the watermark URL placeholder. */
private String loadTemplate(String filename) {
try {
String content = new ClassPathResource("email-templates/" + filename)
.getContentAsString(StandardCharsets.UTF_8);
String watermarkUrl = provisioningProps.publicProtocol() + "://"
+ provisioningProps.publicHost() + "/platform/assets/email-watermark.png";
return content.replace("{{watermarkUrl}}", watermarkUrl);
} catch (IOException e) {
throw new IllegalStateException("Failed to load email template: " + filename, e);
}
}
/** Build the Logto SMTP connector config with Cameleer-branded email templates. */
private Map<String, Object> buildSmtpConfig(SmtpConfig smtp) {
var config = new HashMap<String, Object>();
@@ -165,26 +184,26 @@ public class EmailConnectorService {
Map.of(
"usageType", "Register",
"contentType", "text/html",
"subject", "Verify your email for Cameleer",
"content", "<div style=\"font-family:sans-serif;max-width:480px;margin:0 auto;padding:24px\"><div style=\"text-align:center;margin-bottom:24px\"><span style=\"font-size:24px;font-weight:700;color:#C6820E\">Cameleer</span></div><p style=\"color:#333;font-size:15px;line-height:1.6\">Enter this code to verify your email and create your account:</p><div style=\"text-align:center;margin:24px 0\"><span style=\"font-size:32px;font-weight:700;letter-spacing:6px;color:#C6820E\">{{code}}</span></div><p style=\"color:#666;font-size:13px\">This code expires in 10 minutes. If you did not request this, you can safely ignore this email.</p></div>"
"subject", "Your caravan pass is almost ready",
"content", loadTemplate("register.html")
),
Map.of(
"usageType", "SignIn",
"contentType", "text/html",
"subject", "Your Cameleer sign-in code",
"content", "<div style=\"font-family:sans-serif;max-width:480px;margin:0 auto;padding:24px\"><div style=\"text-align:center;margin-bottom:24px\"><span style=\"font-size:24px;font-weight:700;color:#C6820E\">Cameleer</span></div><p style=\"color:#333;font-size:15px;line-height:1.6\">Your sign-in verification code:</p><div style=\"text-align:center;margin:24px 0\"><span style=\"font-size:32px;font-weight:700;letter-spacing:6px;color:#C6820E\">{{code}}</span></div><p style=\"color:#666;font-size:13px\">This code expires in 10 minutes.</p></div>"
"content", loadTemplate("sign-in.html")
),
Map.of(
"usageType", "ForgotPassword",
"contentType", "text/html",
"subject", "Reset your Cameleer password",
"content", "<div style=\"font-family:sans-serif;max-width:480px;margin:0 auto;padding:24px\"><div style=\"text-align:center;margin-bottom:24px\"><span style=\"font-size:24px;font-weight:700;color:#C6820E\">Cameleer</span></div><p style=\"color:#333;font-size:15px;line-height:1.6\">Enter this code to reset your password:</p><div style=\"text-align:center;margin:24px 0\"><span style=\"font-size:32px;font-weight:700;letter-spacing:6px;color:#C6820E\">{{code}}</span></div><p style=\"color:#666;font-size:13px\">This code expires in 10 minutes. If you did not request a password reset, you can safely ignore this email.</p></div>"
"content", loadTemplate("forgot-password.html")
),
Map.of(
"usageType", "Generic",
"contentType", "text/html",
"subject", "Your Cameleer verification code",
"content", "<div style=\"font-family:sans-serif;max-width:480px;margin:0 auto;padding:24px\"><div style=\"text-align:center;margin-bottom:24px\"><span style=\"font-size:24px;font-weight:700;color:#C6820E\">Cameleer</span></div><p style=\"color:#333;font-size:15px;line-height:1.6\">Your verification code:</p><div style=\"text-align:center;margin:24px 0\"><span style=\"font-size:32px;font-weight:700;letter-spacing:6px;color:#C6820E\">{{code}}</span></div><p style=\"color:#666;font-size:13px\">This code expires in 10 minutes.</p></div>"
"content", loadTemplate("generic.html")
)
));
return config;

View File

@@ -0,0 +1,68 @@
package net.siegeln.cameleer.saas.vendor;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.*;
class EmailTemplateLoadingTest {
private static final String[] TEMPLATE_FILES = {
"email-templates/register.html",
"email-templates/sign-in.html",
"email-templates/forgot-password.html",
"email-templates/generic.html"
};
@Test
void allTemplateFilesExistOnClasspath() {
for (String path : TEMPLATE_FILES) {
var resource = new ClassPathResource(path);
assertTrue(resource.exists(), "Template file missing: " + path);
}
}
@Test
void templatesContainCodePlaceholder() throws IOException {
for (String path : TEMPLATE_FILES) {
String content = new ClassPathResource(path).getContentAsString(StandardCharsets.UTF_8);
assertTrue(content.contains("{{code}}"),
path + " must contain {{code}} placeholder");
}
}
@Test
void templatesContainWatermarkPlaceholder() throws IOException {
for (String path : TEMPLATE_FILES) {
String content = new ClassPathResource(path).getContentAsString(StandardCharsets.UTF_8);
assertTrue(content.contains("{{watermarkUrl}}"),
path + " must contain {{watermarkUrl}} placeholder");
}
}
@Test
void watermarkPlaceholderIsReplaced() throws IOException {
String content = new ClassPathResource("email-templates/register.html")
.getContentAsString(StandardCharsets.UTF_8);
String resolved = content.replace("{{watermarkUrl}}",
"https://example.com/platform/assets/email-watermark.png");
assertFalse(resolved.contains("{{watermarkUrl}}"));
assertTrue(resolved.contains("https://example.com/platform/assets/email-watermark.png"));
}
@Test
void templatesContainBrandElements() throws IOException {
for (String path : TEMPLATE_FILES) {
String content = new ClassPathResource(path).getContentAsString(StandardCharsets.UTF_8);
assertTrue(content.contains("Cameleer.io"),
path + " must contain Cameleer.io header");
assertTrue(content.contains("Apache Camel observability"),
path + " must contain tagline");
assertTrue(content.contains("#C6820E"),
path + " must use brand color");
}
}
}