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:
@@ -1,10 +1,14 @@
|
|||||||
package net.siegeln.cameleer.saas.vendor;
|
package net.siegeln.cameleer.saas.vendor;
|
||||||
|
|
||||||
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
|
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
|
||||||
|
import net.siegeln.cameleer.saas.provisioning.ProvisioningProperties;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.core.io.ClassPathResource;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -16,9 +20,11 @@ public class EmailConnectorService {
|
|||||||
private static final String SMTP_FACTORY_ID = "simple-mail-transfer-protocol";
|
private static final String SMTP_FACTORY_ID = "simple-mail-transfer-protocol";
|
||||||
|
|
||||||
private final LogtoManagementClient logtoClient;
|
private final LogtoManagementClient logtoClient;
|
||||||
|
private final ProvisioningProperties provisioningProps;
|
||||||
|
|
||||||
public EmailConnectorService(LogtoManagementClient logtoClient) {
|
public EmailConnectorService(LogtoManagementClient logtoClient, ProvisioningProperties provisioningProps) {
|
||||||
this.logtoClient = logtoClient;
|
this.logtoClient = logtoClient;
|
||||||
|
this.provisioningProps = provisioningProps;
|
||||||
}
|
}
|
||||||
|
|
||||||
public record SmtpConfig(String host, int port, String username, String password, String fromEmail) {}
|
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"));
|
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. */
|
/** Build the Logto SMTP connector config with Cameleer-branded email templates. */
|
||||||
private Map<String, Object> buildSmtpConfig(SmtpConfig smtp) {
|
private Map<String, Object> buildSmtpConfig(SmtpConfig smtp) {
|
||||||
var config = new HashMap<String, Object>();
|
var config = new HashMap<String, Object>();
|
||||||
@@ -165,26 +184,26 @@ public class EmailConnectorService {
|
|||||||
Map.of(
|
Map.of(
|
||||||
"usageType", "Register",
|
"usageType", "Register",
|
||||||
"contentType", "text/html",
|
"contentType", "text/html",
|
||||||
"subject", "Verify your email for Cameleer",
|
"subject", "Your caravan pass is almost ready",
|
||||||
"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>"
|
"content", loadTemplate("register.html")
|
||||||
),
|
),
|
||||||
Map.of(
|
Map.of(
|
||||||
"usageType", "SignIn",
|
"usageType", "SignIn",
|
||||||
"contentType", "text/html",
|
"contentType", "text/html",
|
||||||
"subject", "Your Cameleer sign-in code",
|
"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(
|
Map.of(
|
||||||
"usageType", "ForgotPassword",
|
"usageType", "ForgotPassword",
|
||||||
"contentType", "text/html",
|
"contentType", "text/html",
|
||||||
"subject", "Reset your Cameleer password",
|
"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(
|
Map.of(
|
||||||
"usageType", "Generic",
|
"usageType", "Generic",
|
||||||
"contentType", "text/html",
|
"contentType", "text/html",
|
||||||
"subject", "Your Cameleer verification code",
|
"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;
|
return config;
|
||||||
|
|||||||
68
src/test/java/net/siegeln/cameleer/saas/vendor/EmailTemplateLoadingTest.java
vendored
Normal file
68
src/test/java/net/siegeln/cameleer/saas/vendor/EmailTemplateLoadingTest.java
vendored
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user