diff --git a/src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorService.java b/src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorService.java new file mode 100644 index 0000000..776b329 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorService.java @@ -0,0 +1,186 @@ +package net.siegeln.cameleer.saas.vendor; + +import net.siegeln.cameleer.saas.identity.LogtoManagementClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +public class EmailConnectorService { + + private static final Logger log = LoggerFactory.getLogger(EmailConnectorService.class); + private static final String SMTP_FACTORY_ID = "simple-mail-transfer-protocol"; + + private final LogtoManagementClient logtoClient; + + public EmailConnectorService(LogtoManagementClient logtoClient) { + this.logtoClient = logtoClient; + } + + public record SmtpConfig(String host, int port, String username, String password, String fromEmail) {} + + public record EmailConnectorStatus( + String connectorId, + String factoryId, + String host, + int port, + String username, + String fromEmail, + boolean registrationEnabled + ) {} + + /** Get the current email connector config, or null if none is configured. */ + @SuppressWarnings("unchecked") + public EmailConnectorStatus getEmailConnector() { + var connectors = logtoClient.listConnectors(); + var emailConnector = connectors.stream() + .filter(c -> "Email".equals(c.get("type"))) + .findFirst() + .orElse(null); + + if (emailConnector == null) { + return null; + } + + var config = (Map) emailConnector.getOrDefault("config", Map.of()); + var auth = (Map) config.getOrDefault("auth", Map.of()); + String host = String.valueOf(config.getOrDefault("host", "")); + int port = config.containsKey("port") ? ((Number) config.get("port")).intValue() : 587; + String username = String.valueOf(auth.getOrDefault("user", "")); + String fromEmail = String.valueOf(config.getOrDefault("fromEmail", "")); + + boolean registrationEnabled = isRegistrationEnabled(); + + return new EmailConnectorStatus( + String.valueOf(emailConnector.get("id")), + String.valueOf(emailConnector.get("connectorId")), + host, port, username, fromEmail, registrationEnabled + ); + } + + /** Create or update the SMTP email connector. Returns the connector status. */ + public EmailConnectorStatus saveSmtpConnector(SmtpConfig smtp, Boolean registrationEnabled) { + var connectorConfig = buildSmtpConfig(smtp); + + // Check if an email connector already exists + var existing = getEmailConnector(); + if (existing != null) { + logtoClient.updateConnector(existing.connectorId(), connectorConfig); + log.info("Updated SMTP email connector: {}", existing.connectorId()); + } else { + var result = logtoClient.createConnector(SMTP_FACTORY_ID, connectorConfig); + log.info("Created SMTP email connector: {}", result != null ? result.get("id") : "unknown"); + } + + // Handle registration toggle + boolean enableReg = registrationEnabled != null ? registrationEnabled : (existing == null); + setRegistrationEnabled(enableReg); + + return getEmailConnector(); + } + + /** Delete the email connector and disable registration. */ + public void deleteEmailConnector() { + var existing = getEmailConnector(); + if (existing != null) { + logtoClient.deleteConnector(existing.connectorId()); + setRegistrationEnabled(false); + log.info("Deleted email connector: {}", existing.connectorId()); + } + } + + /** Send a test email through the configured connector. */ + public void sendTestEmail(String toEmail) { + var existing = getEmailConnector(); + if (existing == null) { + throw new IllegalStateException("No email connector configured"); + } + // Re-read the full config from Logto to pass to the test endpoint + var connectors = logtoClient.listConnectors(); + @SuppressWarnings("unchecked") + var emailConnector = connectors.stream() + .filter(c -> "Email".equals(c.get("type"))) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Email connector not found")); + @SuppressWarnings("unchecked") + var config = (Map) emailConnector.getOrDefault("config", Map.of()); + logtoClient.testConnector(existing.factoryId(), toEmail, config); + } + + /** Set registration mode on the Logto sign-in experience. */ + public void setRegistrationEnabled(boolean enabled) { + if (enabled) { + logtoClient.updateSignInExperience(Map.of( + "signInMode", "SignInAndRegister", + "signUp", Map.of( + "identifiers", List.of("email"), + "password", true, + "verify", true + ), + "signIn", Map.of( + "methods", List.of( + Map.of("identifier", "email", "password", true, "verificationCode", false, "isPasswordPrimary", true), + Map.of("identifier", "username", "password", true, "verificationCode", false, "isPasswordPrimary", true) + ) + ) + )); + } else { + logtoClient.updateSignInExperience(Map.of( + "signInMode", "SignIn", + "signIn", Map.of( + "methods", List.of( + Map.of("identifier", "username", "password", true, "verificationCode", false, "isPasswordPrimary", true) + ) + ) + )); + } + } + + /** Check if registration is currently enabled in Logto. */ + @SuppressWarnings("unchecked") + private boolean isRegistrationEnabled() { + var signInExp = logtoClient.getSignInExperience(); + if (signInExp == null) return false; + return "SignInAndRegister".equals(signInExp.get("signInMode")); + } + + /** Build the Logto SMTP connector config with Cameleer-branded email templates. */ + private Map buildSmtpConfig(SmtpConfig smtp) { + var config = new HashMap(); + config.put("host", smtp.host()); + config.put("port", smtp.port()); + config.put("auth", Map.of("user", smtp.username(), "pass", smtp.password())); + config.put("fromEmail", smtp.fromEmail()); + config.put("templates", List.of( + Map.of( + "usageType", "Register", + "contentType", "text/html", + "subject", "Verify your email for Cameleer", + "content", "
Cameleer

Enter this code to verify your email and create your account:

{{code}}

This code expires in 10 minutes. If you did not request this, you can safely ignore this email.

" + ), + Map.of( + "usageType", "SignIn", + "contentType", "text/html", + "subject", "Your Cameleer sign-in code", + "content", "
Cameleer

Your sign-in verification code:

{{code}}

This code expires in 10 minutes.

" + ), + Map.of( + "usageType", "ForgotPassword", + "contentType", "text/html", + "subject", "Reset your Cameleer password", + "content", "
Cameleer

Enter this code to reset your password:

{{code}}

This code expires in 10 minutes. If you did not request a password reset, you can safely ignore this email.

" + ), + Map.of( + "usageType", "Generic", + "contentType", "text/html", + "subject", "Your Cameleer verification code", + "content", "
Cameleer

Your verification code:

{{code}}

This code expires in 10 minutes.

" + ) + )); + return config; + } +}