From 883e10ba7c056eeb9c6650b5026db1660d3d61e6 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 26 Apr 2026 16:23:42 +0200 Subject: [PATCH] feat: test SMTP connection on save and retain password on edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds testSmtpConnection() that performs EHLO + auth via JavaMailSender before persisting to Logto — saves fail fast with a clear error if SMTP credentials are wrong. Password is now optional when editing: if left blank the backend fetches the existing password from Logto's connector config, so users can update host/port/fromEmail without re-entering the password every time. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../saas/vendor/EmailConnectorController.java | 23 +++++++-- .../saas/vendor/EmailConnectorService.java | 51 +++++++++++++++++++ ui/src/api/email-connector-hooks.ts | 2 +- ui/src/pages/vendor/EmailConfigPage.tsx | 14 +++-- 4 files changed, 81 insertions(+), 9 deletions(-) diff --git a/src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorController.java b/src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorController.java index 34b7602..d99fd52 100644 --- a/src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorController.java +++ b/src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorController.java @@ -33,7 +33,7 @@ public class EmailConnectorController { @NotBlank String host, @Min(1) @Max(65535) int port, @NotBlank String username, - @NotBlank String password, + String password, @NotBlank @Email String fromEmail, Boolean registrationEnabled ) {} @@ -76,11 +76,28 @@ public class EmailConnectorController { } @PostMapping - public ResponseEntity save(@Valid @RequestBody SmtpConfigRequest request) { + public ResponseEntity save(@Valid @RequestBody SmtpConfigRequest request) { + // Resolve password: use provided value, or fall back to existing password from Logto + String password = request.password(); + if (password == null || password.isBlank()) { + password = emailConnectorService.getExistingPassword(); + if (password == null) { + return ResponseEntity.badRequest().body(Map.of("message", "Password is required for new configuration")); + } + } + var smtp = new EmailConnectorService.SmtpConfig( request.host(), request.port(), request.username(), - request.password(), request.fromEmail() + password, request.fromEmail() ); + + // Test SMTP connection before saving + try { + emailConnectorService.testSmtpConnection(smtp); + } catch (IllegalStateException e) { + return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); + } + var status = emailConnectorService.saveSmtpConnector(smtp, request.registrationEnabled()); return ResponseEntity.ok(EmailConnectorResponse.from(status)); } diff --git a/src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorService.java b/src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorService.java index b8fbffa..02a4ce1 100644 --- a/src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorService.java +++ b/src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorService.java @@ -5,6 +5,7 @@ import net.siegeln.cameleer.saas.provisioning.ProvisioningProperties; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.io.ClassPathResource; +import org.springframework.mail.javamail.JavaMailSenderImpl; import org.springframework.stereotype.Service; import java.io.IOException; @@ -12,6 +13,7 @@ import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Properties; @Service public class EmailConnectorService { @@ -68,6 +70,55 @@ public class EmailConnectorService { ); } + /** + * Retrieve the existing SMTP password from Logto, or null if not configured. + * Used to retain the password when the user edits other fields without re-entering it. + */ + @SuppressWarnings("unchecked") + public String getExistingPassword() { + 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 pass = String.valueOf(auth.getOrDefault("pass", "")); + return pass.isEmpty() ? null : pass; + } + + /** + * Test SMTP connection by performing EHLO + auth. Throws on failure. + */ + public void testSmtpConnection(SmtpConfig smtp) { + var sender = new JavaMailSenderImpl(); + sender.setHost(smtp.host()); + sender.setPort(smtp.port()); + sender.setUsername(smtp.username()); + sender.setPassword(smtp.password()); + sender.setDefaultEncoding("UTF-8"); + + Properties props = sender.getJavaMailProperties(); + props.put("mail.transport.protocol", "smtp"); + props.put("mail.smtp.auth", "true"); + if (smtp.port() == 465) { + props.put("mail.smtp.ssl.enable", "true"); + } else { + props.put("mail.smtp.starttls.enable", "true"); + } + props.put("mail.smtp.timeout", "10000"); + props.put("mail.smtp.connectiontimeout", "10000"); + + try { + sender.testConnection(); + log.info("SMTP connection test successful: {}:{}", smtp.host(), smtp.port()); + } catch (Exception e) { + log.warn("SMTP connection test failed: {}:{} — {}", smtp.host(), smtp.port(), e.getMessage()); + throw new IllegalStateException("SMTP connection failed: " + e.getMessage(), e); + } + } + /** Create or update the SMTP email connector. Returns the connector status. */ public EmailConnectorStatus saveSmtpConnector(SmtpConfig smtp, Boolean registrationEnabled) { var connectorConfig = buildSmtpConfig(smtp); diff --git a/ui/src/api/email-connector-hooks.ts b/ui/src/api/email-connector-hooks.ts index 0610a3f..8417e05 100644 --- a/ui/src/api/email-connector-hooks.ts +++ b/ui/src/api/email-connector-hooks.ts @@ -15,7 +15,7 @@ export interface SmtpConfigRequest { host: string; port: number; username: string; - password: string; + password?: string; fromEmail: string; registrationEnabled?: boolean; } diff --git a/ui/src/pages/vendor/EmailConfigPage.tsx b/ui/src/pages/vendor/EmailConfigPage.tsx index 062407b..3eb0b4a 100644 --- a/ui/src/pages/vendor/EmailConfigPage.tsx +++ b/ui/src/pages/vendor/EmailConfigPage.tsx @@ -50,8 +50,12 @@ export function EmailConfigPage() { } async function handleSave() { - if (!host || !username || !password || !fromEmail) { - toast({ title: 'All fields are required', variant: 'error' }); + if (!host || !username || !fromEmail) { + toast({ title: 'Host, username, and from email are required', variant: 'error' }); + return; + } + if (!isConfigured && !password) { + toast({ title: 'Password is required for new configuration', variant: 'error' }); return; } try { @@ -59,7 +63,7 @@ export function EmailConfigPage() { host, port: parseInt(port, 10) || 587, username, - password, + password: password || undefined, fromEmail, }); toast({ title: 'Email connector saved', variant: 'success' }); @@ -217,10 +221,10 @@ export function EmailConfigPage() { onChange={(e) => setUsername(e.target.value)} /> - + setPassword(e.target.value)} />