feat: test SMTP connection on save and retain password on edit
All checks were successful
CI / build (push) Successful in 2m20s
CI / docker (push) Successful in 1m36s

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) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-26 16:23:42 +02:00
parent 0413a5b882
commit 883e10ba7c
4 changed files with 81 additions and 9 deletions

View File

@@ -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<EmailConnectorResponse> 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));
}

View File

@@ -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<String, Object>) emailConnector.getOrDefault("config", Map.of());
var auth = (Map<String, Object>) 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);

View File

@@ -15,7 +15,7 @@ export interface SmtpConfigRequest {
host: string;
port: number;
username: string;
password: string;
password?: string;
fromEmail: string;
registrationEnabled?: boolean;
}

View File

@@ -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)}
/>
</FormField>
<FormField label="Password *">
<FormField label={isConfigured ? 'Password' : 'Password *'}>
<Input
type="password"
placeholder={isConfigured ? 'Enter new password' : 'Password'}
placeholder={isConfigured ? 'Leave blank to keep current' : 'Password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>