# Email Connector UI Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Move email connector configuration from the installer/bootstrap into the vendor admin UI, giving platform admins runtime control over email delivery and self-service registration. **Architecture:** The SaaS backend gets a new `EmailConnectorService` + `EmailConnectorController` that manages Logto email connectors via the Management API. The vendor UI gets an Email Config page under an expandable "Identity" sidebar section. The installer, compose templates, and bootstrap script are stripped of all SMTP config — email is configured entirely at runtime. **Tech Stack:** Spring Boot (Java 21), React 19, @cameleer/design-system, @tanstack/react-query, Logto Management API --- ### Task 1: Add email connector methods to LogtoManagementClient **Files:** - Modify: `src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java:399` (after SSO connector section) - [ ] **Step 1: Add connector management methods** Add the following methods after line 399 (after `unlinkSsoConnectorFromOrg`), before the user management methods: ```java // --- Email Connector Management --- /** List all connector factories available in Logto. */ @SuppressWarnings("unchecked") public List> listConnectorFactories() { if (!isAvailable()) return List.of(); try { var resp = restClient.get() .uri(config.getLogtoEndpoint() + "/api/connector-factories") .header("Authorization", "Bearer " + getAccessToken()) .retrieve() .body(List.class); return resp != null ? resp : List.of(); } catch (Exception e) { log.warn("Failed to list connector factories: {}", e.getMessage()); return List.of(); } } /** List all connectors. */ @SuppressWarnings("unchecked") public List> listConnectors() { if (!isAvailable()) return List.of(); try { var resp = restClient.get() .uri(config.getLogtoEndpoint() + "/api/connectors") .header("Authorization", "Bearer " + getAccessToken()) .retrieve() .body(List.class); return resp != null ? resp : List.of(); } catch (Exception e) { log.warn("Failed to list connectors: {}", e.getMessage()); return List.of(); } } /** Create a connector from a factory. */ @SuppressWarnings("unchecked") public Map createConnector(String factoryId, Map connectorConfig) { if (!isAvailable()) return null; var body = new java.util.HashMap(); body.put("connectorId", factoryId); body.put("config", connectorConfig); return (Map) restClient.post() .uri(config.getLogtoEndpoint() + "/api/connectors") .header("Authorization", "Bearer " + getAccessToken()) .contentType(MediaType.APPLICATION_JSON) .body(body) .retrieve() .body(Map.class); } /** Update an existing connector's config. */ @SuppressWarnings("unchecked") public Map updateConnector(String connectorId, Map connectorConfig) { if (!isAvailable()) return null; return (Map) restClient.patch() .uri(config.getLogtoEndpoint() + "/api/connectors/" + connectorId) .header("Authorization", "Bearer " + getAccessToken()) .contentType(MediaType.APPLICATION_JSON) .body(Map.of("config", connectorConfig)) .retrieve() .body(Map.class); } /** Delete a connector. */ public void deleteConnector(String connectorId) { if (!isAvailable()) return; restClient.delete() .uri(config.getLogtoEndpoint() + "/api/connectors/" + connectorId) .header("Authorization", "Bearer " + getAccessToken()) .retrieve() .toBodilessEntity(); } /** Test a connector by sending a real email. Uses Logto's built-in test endpoint. */ public void testConnector(String factoryId, String email, Map connectorConfig) { if (!isAvailable()) throw new IllegalStateException("Logto not configured"); restClient.post() .uri(config.getLogtoEndpoint() + "/api/connectors/" + factoryId + "/test") .header("Authorization", "Bearer " + getAccessToken()) .contentType(MediaType.APPLICATION_JSON) .body(Map.of("email", email, "config", connectorConfig)) .retrieve() .toBodilessEntity(); } /** Get the current sign-in experience config. */ @SuppressWarnings("unchecked") public Map getSignInExperience() { if (!isAvailable()) return null; try { return (Map) restClient.get() .uri(config.getLogtoEndpoint() + "/api/sign-in-exp") .header("Authorization", "Bearer " + getAccessToken()) .retrieve() .body(Map.class); } catch (Exception e) { log.warn("Failed to get sign-in experience: {}", e.getMessage()); return null; } } /** Update the sign-in experience config (partial update). */ @SuppressWarnings("unchecked") public Map updateSignInExperience(Map updates) { if (!isAvailable()) return null; return (Map) restClient.patch() .uri(config.getLogtoEndpoint() + "/api/sign-in-exp") .header("Authorization", "Bearer " + getAccessToken()) .contentType(MediaType.APPLICATION_JSON) .body(updates) .retrieve() .body(Map.class); } ``` - [ ] **Step 2: Verify compilation** Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-saas && ./mvnw compile -q` Expected: BUILD SUCCESS - [ ] **Step 3: Commit** ```bash git add src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java git commit -m "feat: add email connector and sign-in experience methods to LogtoManagementClient" ``` --- ### Task 2: Create EmailConnectorService **Files:** - Create: `src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorService.java` - [ ] **Step 1: Create the service** ```java 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; } } ``` - [ ] **Step 2: Verify compilation** Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-saas && ./mvnw compile -q` Expected: BUILD SUCCESS - [ ] **Step 3: Commit** ```bash git add src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorService.java git commit -m "feat: add EmailConnectorService for Logto email connector management" ``` --- ### Task 3: Create EmailConnectorController **Files:** - Create: `src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorController.java` - [ ] **Step 1: Create the controller** ```java package net.siegeln.cameleer.saas.vendor; import jakarta.validation.Valid; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.Map; @RestController @RequestMapping("/api/vendor/email-connector") @PreAuthorize("hasAuthority('SCOPE_platform:admin')") public class EmailConnectorController { private final EmailConnectorService emailConnectorService; public EmailConnectorController(EmailConnectorService emailConnectorService) { this.emailConnectorService = emailConnectorService; } // --- Request/Response types --- public record SmtpConfigRequest( @NotBlank String host, @Min(1) @Max(65535) int port, @NotBlank String username, @NotBlank String password, @NotBlank @Email String fromEmail, Boolean registrationEnabled ) {} public record TestEmailRequest( @NotBlank @Email String to ) {} public record EmailConnectorResponse( String connectorId, String factoryId, String host, int port, String username, String fromEmail, boolean registrationEnabled ) { static EmailConnectorResponse from(EmailConnectorService.EmailConnectorStatus status) { return new EmailConnectorResponse( status.connectorId(), status.factoryId(), status.host(), status.port(), status.username(), status.fromEmail(), status.registrationEnabled() ); } } // --- Endpoints --- @GetMapping public ResponseEntity get() { var status = emailConnectorService.getEmailConnector(); if (status == null) { return ResponseEntity.notFound().build(); } return ResponseEntity.ok(EmailConnectorResponse.from(status)); } @PostMapping public ResponseEntity save(@Valid @RequestBody SmtpConfigRequest request) { var smtp = new EmailConnectorService.SmtpConfig( request.host(), request.port(), request.username(), request.password(), request.fromEmail() ); var status = emailConnectorService.saveSmtpConnector(smtp, request.registrationEnabled()); return ResponseEntity.ok(EmailConnectorResponse.from(status)); } @DeleteMapping public ResponseEntity delete() { emailConnectorService.deleteEmailConnector(); return ResponseEntity.noContent().build(); } @PostMapping("/test") public ResponseEntity> test(@Valid @RequestBody TestEmailRequest request) { try { emailConnectorService.sendTestEmail(request.to()); return ResponseEntity.ok(Map.of("status", "sent", "message", "Test email sent to " + request.to())); } catch (Exception e) { return ResponseEntity.badRequest().body(Map.of("status", "failed", "message", e.getMessage())); } } @PostMapping("/registration") public ResponseEntity toggleRegistration(@RequestBody Map body) { boolean enabled = body.getOrDefault("enabled", false); var existing = emailConnectorService.getEmailConnector(); if (existing == null && enabled) { return ResponseEntity.badRequest().build(); } emailConnectorService.setRegistrationEnabled(enabled); return ResponseEntity.noContent().build(); } } ``` - [ ] **Step 2: Verify compilation** Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-saas && ./mvnw compile -q` Expected: BUILD SUCCESS - [ ] **Step 3: Commit** ```bash git add src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorController.java git commit -m "feat: add EmailConnectorController with CRUD, test, and registration toggle endpoints" ``` --- ### Task 4: Create frontend hooks **Files:** - Create: `ui/src/api/email-connector-hooks.ts` - [ ] **Step 1: Create the hooks file** ```typescript import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { api } from './client'; export interface EmailConnectorResponse { connectorId: string; factoryId: string; host: string; port: number; username: string; fromEmail: string; registrationEnabled: boolean; } export interface SmtpConfigRequest { host: string; port: number; username: string; password: string; fromEmail: string; registrationEnabled?: boolean; } export interface TestEmailResult { status: string; message: string; } export function useEmailConnector() { return useQuery({ queryKey: ['vendor', 'email-connector'], queryFn: async () => { try { return await api.get('/vendor/email-connector'); } catch (e) { if (e instanceof Error && e.message.includes('404')) return null; throw e; } }, }); } export function useSaveEmailConnector() { const qc = useQueryClient(); return useMutation({ mutationFn: (config) => api.post('/vendor/email-connector', config), onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'email-connector'] }), }); } export function useDeleteEmailConnector() { const qc = useQueryClient(); return useMutation({ mutationFn: () => api.delete('/vendor/email-connector'), onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'email-connector'] }), }); } export function useTestEmailConnector() { return useMutation({ mutationFn: (to) => api.post('/vendor/email-connector/test', { to }), }); } export function useToggleRegistration() { const qc = useQueryClient(); return useMutation({ mutationFn: (enabled) => api.post('/vendor/email-connector/registration', { enabled }), onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'email-connector'] }), }); } ``` - [ ] **Step 2: Verify TypeScript compiles** Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-saas/ui && npx tsc --noEmit` Expected: No errors - [ ] **Step 3: Commit** ```bash git add ui/src/api/email-connector-hooks.ts git commit -m "feat: add React Query hooks for email connector API" ``` --- ### Task 5: Create EmailConfigPage **Files:** - Create: `ui/src/pages/vendor/EmailConfigPage.tsx` - [ ] **Step 1: Create the page component** ```tsx import { useState } from 'react'; import { Alert, Button, Card, FormField, Input, Spinner, useToast, } from '@cameleer/design-system'; import { Mail, Send, Trash2, Save, Power } from 'lucide-react'; import { useEmailConnector, useSaveEmailConnector, useDeleteEmailConnector, useTestEmailConnector, useToggleRegistration, } from '../../api/email-connector-hooks'; import styles from '../../styles/platform.module.css'; export function EmailConfigPage() { const { toast } = useToast(); const { data: connector, isLoading, isError } = useEmailConnector(); const saveMutation = useSaveEmailConnector(); const deleteMutation = useDeleteEmailConnector(); const testMutation = useTestEmailConnector(); const toggleMutation = useToggleRegistration(); const [editing, setEditing] = useState(false); const [host, setHost] = useState(''); const [port, setPort] = useState('587'); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [fromEmail, setFromEmail] = useState(''); const [testTo, setTestTo] = useState(''); const [confirmDelete, setConfirmDelete] = useState(false); const isConfigured = connector != null; const showForm = !isConfigured || editing; function startEditing() { if (connector) { setHost(connector.host); setPort(String(connector.port)); setUsername(connector.username); setPassword(''); setFromEmail(connector.fromEmail); } setEditing(true); } async function handleSave() { if (!host || !username || !password || !fromEmail) { toast({ title: 'All fields are required', variant: 'error' }); return; } try { await saveMutation.mutateAsync({ host, port: parseInt(port, 10) || 587, username, password, fromEmail, }); toast({ title: 'Email connector saved', variant: 'success' }); setEditing(false); setPassword(''); } catch (err) { toast({ title: 'Failed to save', description: String(err), variant: 'error' }); } } async function handleDelete() { try { await deleteMutation.mutateAsync(); toast({ title: 'Email connector removed', variant: 'success' }); setConfirmDelete(false); setEditing(false); } catch (err) { toast({ title: 'Failed to delete', description: String(err), variant: 'error' }); } } async function handleTest() { if (!testTo) { toast({ title: 'Enter a recipient email address', variant: 'error' }); return; } try { const result = await testMutation.mutateAsync(testTo); if (result.status === 'sent') { toast({ title: 'Test email sent', description: result.message, variant: 'success' }); } else { toast({ title: 'Test failed', description: result.message, variant: 'error' }); } } catch (err) { toast({ title: 'Test failed', description: String(err), variant: 'error' }); } } async function handleToggleRegistration() { if (!connector) return; const newValue = !connector.registrationEnabled; try { await toggleMutation.mutateAsync(newValue); toast({ title: newValue ? 'Registration enabled' : 'Registration disabled', variant: 'success', }); } catch (err) { toast({ title: 'Failed to toggle registration', description: String(err), variant: 'error' }); } } if (isLoading) { return (
); } if (isError) { return (
Could not fetch email connector data. Please refresh.
); } return (

Email Connector

{!isConfigured && ( Self-service registration is disabled. Configure an SMTP connector to enable email verification for sign-up and password reset. )}
{/* Current config card (when configured and not editing) */} {isConfigured && !editing && (
Host {connector.host}
Port {connector.port}
Username {connector.username}
Password {'*'.repeat(8)}
From Email {connector.fromEmail}
{!confirmDelete ? ( ) : ( <> )}
)} {/* SMTP form (when unconfigured or editing) */} {showForm && (
setHost(e.target.value)} /> setPort(e.target.value)} /> setUsername(e.target.value)} /> setPassword(e.target.value)} /> setFromEmail(e.target.value)} />
{editing && ( )}
)} {/* Registration toggle (when configured) */} {isConfigured && !editing && (

{connector.registrationEnabled ? 'New users can register with their email address and verify via a code sent to their inbox.' : 'Registration is disabled. Only admin-invited users can sign in.'}

)} {/* Test email (when configured and not editing) */} {isConfigured && !editing && (
setTestTo(e.target.value)} />
)}
); } ``` - [ ] **Step 2: Verify TypeScript compiles** Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-saas/ui && npx tsc --noEmit` Expected: No errors - [ ] **Step 3: Commit** ```bash git add ui/src/pages/vendor/EmailConfigPage.tsx git commit -m "feat: add EmailConfigPage with SMTP form, registration toggle, and test email" ``` --- ### Task 6: Wire up router and sidebar **Files:** - Modify: `ui/src/router.tsx:18` (add import) - Modify: `ui/src/router.tsx:104` (add route) - Modify: `ui/src/components/Layout.tsx:7` (add icon import) - Modify: `ui/src/components/Layout.tsx:128-133` (replace Identity link with expandable section) - [ ] **Step 1: Add route in router.tsx** Add the import after line 18 (`VendorMetricsPage` import): ```typescript import { EmailConfigPage } from './pages/vendor/EmailConfigPage'; ``` Add the route after the infrastructure route block (after line 104): ```tsx }> } /> ``` - [ ] **Step 2: Update sidebar in Layout.tsx** Add `Mail` to the lucide-react import on line 7: ```typescript import { LayoutDashboard, ShieldCheck, Users, Settings, Shield, Building, ScrollText, Mail } from 'lucide-react'; ``` Replace the "Identity (Logto)" div at lines 128-133 with an expandable Identity section containing Email Connector and Logto Console: Replace this block: ```tsx
window.open(`${window.location.protocol}//${window.location.hostname}:3002`, '_blank', 'noopener')} > Identity (Logto)
``` With: ```tsx
navigate('/vendor/email')} > Email Connector
window.open(`${window.location.protocol}//${window.location.hostname}:3002`, '_blank', 'noopener')} > Logto Console
``` - [ ] **Step 3: Verify TypeScript compiles** Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-saas/ui && npx tsc --noEmit` Expected: No errors - [ ] **Step 4: Commit** ```bash git add ui/src/router.tsx ui/src/components/Layout.tsx git commit -m "feat: add email connector route and sidebar navigation" ``` --- ### Task 7: Remove SMTP from bootstrap **Files:** - Modify: `docker/logto-bootstrap.sh:567-682` (remove Phase 8b, modify Phase 8c) - [ ] **Step 1: Remove Phase 8b (SMTP connector creation)** Delete lines 567-649 entirely — the full "PHASE 8b: Configure SMTP email connector" block including the comment header, the `if` block, the connector config JSON, the create/update logic, and the else/warning blocks. - [ ] **Step 2: Modify Phase 8c (registration default)** Replace the Phase 8c block (lines 651-682 after the deletion above will have shifted) with sign-in only mode. The new Phase 8c should be: ```bash # ============================================================ # PHASE 8c: Configure sign-in experience (sign-in only) # ============================================================ # Registration is disabled by default. The vendor admin enables it # via the Email Connector UI after configuring SMTP delivery. log "Configuring sign-in experience (sign-in only, no registration)..." api_patch "/api/sign-in-exp" '{ "signInMode": "SignIn", "signIn": { "methods": [ { "identifier": "username", "password": true, "verificationCode": false, "isPasswordPrimary": true } ] } }' >/dev/null 2>&1 log "Sign-in experience configured: SignIn only (registration disabled until email is configured)." ``` - [ ] **Step 3: Commit** ```bash git add docker/logto-bootstrap.sh git commit -m "feat: remove SMTP connector from bootstrap, default to sign-in only" ``` --- ### Task 8: Remove SMTP from compose template **Files:** - Modify: `installer/templates/docker-compose.saas.yml:30-35` - [ ] **Step 1: Remove SMTP env vars from cameleer-logto service** Delete lines 30-35 (the SMTP comment and 5 env vars): ```yaml # SMTP (for email verification during registration) SMTP_HOST: ${SMTP_HOST:-} SMTP_PORT: ${SMTP_PORT:-587} SMTP_USER: ${SMTP_USER:-} SMTP_PASS: ${SMTP_PASS:-} SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL:-noreply@cameleer.io} ``` - [ ] **Step 2: Commit** ```bash cd /c/Users/Hendrik/Documents/projects/cameleer-saas/installer git add templates/docker-compose.saas.yml git commit -m "feat: remove SMTP env vars from Logto compose template" ``` --- ### Task 9: Remove SMTP from bash installer **Files:** - Modify: `installer/install.sh` (multiple locations) - [ ] **Step 1: Remove SMTP env var captures (lines 49-53)** Delete: ```bash _ENV_SMTP_HOST="${SMTP_HOST:-}" _ENV_SMTP_PORT="${SMTP_PORT:-}" _ENV_SMTP_USER="${SMTP_USER:-}" _ENV_SMTP_PASS="${SMTP_PASS:-}" _ENV_SMTP_FROM_EMAIL="${SMTP_FROM_EMAIL:-}" ``` - [ ] **Step 2: Remove SMTP variable declarations (lines 80-84)** Delete: ```bash SMTP_HOST="" SMTP_PORT="" SMTP_USER="" SMTP_PASS="" SMTP_FROM_EMAIL="" ``` - [ ] **Step 3: Remove SMTP CLI args (lines 197-201)** Delete: ```bash --smtp-host) SMTP_HOST="$2"; shift ;; --smtp-port) SMTP_PORT="$2"; shift ;; --smtp-user) SMTP_USER="$2"; shift ;; --smtp-pass) SMTP_PASS="$2"; shift ;; --smtp-from-email) SMTP_FROM_EMAIL="$2"; shift ;; ``` - [ ] **Step 4: Remove SMTP from config file parsing (lines 296-300)** Delete: ```bash smtp_host) [ -z "$SMTP_HOST" ] && SMTP_HOST="$value" ;; smtp_port) [ -z "$SMTP_PORT" ] && SMTP_PORT="$value" ;; smtp_user) [ -z "$SMTP_USER" ] && SMTP_USER="$value" ;; smtp_pass) [ -z "$SMTP_PASS" ] && SMTP_PASS="$value" ;; smtp_from_email) [ -z "$SMTP_FROM_EMAIL" ] && SMTP_FROM_EMAIL="$value" ;; ``` - [ ] **Step 5: Remove SMTP env fallbacks (lines 331-335)** Delete: ```bash [ -z "$SMTP_HOST" ] && SMTP_HOST="$_ENV_SMTP_HOST" [ -z "$SMTP_PORT" ] && SMTP_PORT="$_ENV_SMTP_PORT" [ -z "$SMTP_USER" ] && SMTP_USER="$_ENV_SMTP_USER" [ -z "$SMTP_PASS" ] && SMTP_PASS="$_ENV_SMTP_PASS" [ -z "$SMTP_FROM_EMAIL" ] && SMTP_FROM_EMAIL="$_ENV_SMTP_FROM_EMAIL" ``` - [ ] **Step 6: Remove SMTP prompt block (lines 499-509)** Delete the entire block: ```bash # SMTP for email verification (SaaS mode only) if [ "$DEPLOYMENT_MODE" = "saas" ]; then echo "" if prompt_yesno "Configure SMTP for email verification? (required for self-service sign-up)"; then prompt SMTP_HOST "SMTP host" "${SMTP_HOST:-}" prompt SMTP_PORT "SMTP port" "${SMTP_PORT:-587}" prompt SMTP_USER "SMTP username" "${SMTP_USER:-}" prompt_password SMTP_PASS "SMTP password" "${SMTP_PASS:-}" prompt SMTP_FROM_EMAIL "From email address" "${SMTP_FROM_EMAIL:-noreply@${PUBLIC_HOST}}" fi fi ``` - [ ] **Step 7: Remove SMTP from .env generation (lines 780-784 and 794)** Delete: ```bash # SMTP (for email verification during registration) SMTP_HOST=${SMTP_HOST} SMTP_PORT=${SMTP_PORT:-587} SMTP_USER=${SMTP_USER} SMTP_FROM_EMAIL=${SMTP_FROM_EMAIL:-noreply@${PUBLIC_HOST}} ``` And delete: ```bash env_val "$f" SMTP_PASS "$SMTP_PASS" ``` - [ ] **Step 8: Remove SMTP from cameleer.conf generation (lines 975-978 and 983)** Delete: ```bash smtp_host=${SMTP_HOST} smtp_port=${SMTP_PORT} smtp_user=${SMTP_USER} smtp_from_email=${SMTP_FROM_EMAIL} ``` And delete: ```bash env_val "$f" smtp_pass "$SMTP_PASS" ``` - [ ] **Step 9: Commit** ```bash cd /c/Users/Hendrik/Documents/projects/cameleer-saas/installer git add install.sh git commit -m "feat: remove SMTP configuration from bash installer" ``` --- ### Task 10: Remove SMTP from PowerShell installer **Files:** - Modify: `installer/install.ps1` (multiple locations) - [ ] **Step 1: Remove SMTP env var reads (lines 95-99)** Delete: ```powershell $_ENV_SMTP_HOST = $env:SMTP_HOST $_ENV_SMTP_PORT = $env:SMTP_PORT $_ENV_SMTP_USER = $env:SMTP_USER $_ENV_SMTP_PASS = $env:SMTP_PASS $_ENV_SMTP_FROM_EMAIL = $env:SMTP_FROM_EMAIL ``` - [ ] **Step 2: Remove SMTP config file parsing (lines 305-309)** Delete: ```powershell 'smtp_host' { if (-not $script:cfg.SmtpHost) { $script:cfg.SmtpHost = $val } } 'smtp_port' { if (-not $script:cfg.SmtpPort) { $script:cfg.SmtpPort = $val } } 'smtp_user' { if (-not $script:cfg.SmtpUser) { $script:cfg.SmtpUser = $val } } 'smtp_pass' { if (-not $script:cfg.SmtpPass) { $script:cfg.SmtpPass = $val } } 'smtp_from_email' { if (-not $script:cfg.SmtpFromEmail) { $script:cfg.SmtpFromEmail = $val } } ``` - [ ] **Step 3: Remove SMTP env fallbacks (lines 342-346)** Delete: ```powershell if (-not $c.SmtpHost) { $c.SmtpHost = $_ENV_SMTP_HOST } if (-not $c.SmtpPort) { $c.SmtpPort = $_ENV_SMTP_PORT } if (-not $c.SmtpUser) { $c.SmtpUser = $_ENV_SMTP_USER } if (-not $c.SmtpPass) { $c.SmtpPass = $_ENV_SMTP_PASS } if (-not $c.SmtpFromEmail) { $c.SmtpFromEmail = $_ENV_SMTP_FROM_EMAIL } ``` - [ ] **Step 4: Remove SMTP prompt block (lines 516-526)** Delete: ```powershell # SMTP for email verification (SaaS mode only) if ($c.DeploymentMode -eq 'saas') { Write-Host '' if (Prompt-YesNo 'Configure SMTP for email verification? (required for self-service sign-up)') { $c.SmtpHost = Prompt-Value 'SMTP host' (Coalesce $c.SmtpHost '') $c.SmtpPort = Prompt-Value 'SMTP port' (Coalesce $c.SmtpPort '587') $c.SmtpUser = Prompt-Value 'SMTP username' (Coalesce $c.SmtpUser '') $c.SmtpPass = Prompt-Password 'SMTP password' (Coalesce $c.SmtpPass '') $c.SmtpFromEmail = Prompt-Value 'From email address' (Coalesce $c.SmtpFromEmail "noreply@$($c.PublicHost)") } } ``` - [ ] **Step 5: Remove SMTP from .env generation (lines 778-782 and 789)** Delete: ```powershell # SMTP (for email verification during registration) SMTP_HOST=$($c.SmtpHost) SMTP_PORT=$(if ($c.SmtpPort) { $c.SmtpPort } else { '587' }) SMTP_USER=$($c.SmtpUser) SMTP_FROM_EMAIL=$(if ($c.SmtpFromEmail) { $c.SmtpFromEmail } else { "noreply@$($c.PublicHost)" }) ``` And delete: ```powershell $content += "`n$(Format-EnvVal 'SMTP_PASS' $c.SmtpPass)" ``` - [ ] **Step 6: Remove SMTP from cameleer.conf generation (lines 1028-1031 and 1036)** Delete: ```powershell smtp_host=$($c.SmtpHost) smtp_port=$($c.SmtpPort) smtp_user=$($c.SmtpUser) smtp_from_email=$($c.SmtpFromEmail) ``` And delete: ```powershell $txt += "`n$(Format-EnvVal 'smtp_pass' $c.SmtpPass)" ``` - [ ] **Step 7: Commit** ```bash cd /c/Users/Hendrik/Documents/projects/cameleer-saas/installer git add install.ps1 git commit -m "feat: remove SMTP configuration from PowerShell installer" ``` --- ### Task 11: Update installer CLAUDE.md documentation **Files:** - Modify: `installer/CLAUDE.md` - [ ] **Step 1: Remove or update the SMTP configuration section** Replace the "## SMTP configuration" section in `installer/CLAUDE.md` with: ```markdown ## SMTP configuration SMTP / email connector configuration has been moved from the installer to the SaaS vendor admin UI (Email Connector page at `/vendor/email`). The installer no longer prompts for or persists SMTP settings. Previously, SMTP env vars (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM_EMAIL`) were passed to the `cameleer-logto` container and configured via the bootstrap script. This one-shot approach was fragile — email delivery is now configured at runtime through the Logto Management API. ``` - [ ] **Step 2: Commit** ```bash cd /c/Users/Hendrik/Documents/projects/cameleer-saas/installer git add CLAUDE.md git commit -m "docs: update installer CLAUDE.md to reflect SMTP removal" ``` --- ### Task 12: Update docker CLAUDE.md documentation **Files:** - Modify: `docker/CLAUDE.md` - [ ] **Step 1: Update the Bootstrap section** In the `docker/CLAUDE.md` file, find the paragraph about SMTP env vars in the Bootstrap section: ``` SMTP env vars for email verification: `SMTP_HOST`, `SMTP_PORT` (default 587), `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM_EMAIL` (default `noreply@cameleer.io`). Passed to `cameleer-logto` container via docker-compose. Both installers prompt for these in SaaS mode. ``` Replace with: ``` SMTP / email connector configuration is managed at runtime via the vendor admin UI (Email Connector page). The bootstrap no longer creates email connectors — it defaults to sign-in only mode. Registration is enabled automatically when the admin configures an email connector through the UI. ``` Also update the bootstrap phases list: remove `8b. Configure SMTP email connector` and update `8c` to note it defaults to sign-in only. - [ ] **Step 2: Commit** ```bash git add docker/CLAUDE.md git commit -m "docs: update docker CLAUDE.md to reflect SMTP bootstrap removal" ``` --- ### Task 13: Manual verification - [ ] **Step 1: Rebuild and verify the Java backend compiles** Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-saas && ./mvnw compile -q` Expected: BUILD SUCCESS - [ ] **Step 2: Verify the frontend compiles** Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-saas/ui && npx tsc --noEmit` Expected: No errors - [ ] **Step 3: Start the dev server and test the UI** Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-saas/ui && npm run dev` Open the vendor console in the browser. Navigate to the sidebar — verify: 1. "Identity (Logto)" link has been replaced with "Email Connector" and "Logto Console" items 2. Clicking "Email Connector" navigates to `/platform/vendor/email` 3. The unconfigured state shows the info alert and SMTP form 4. Fill in SMTP config and save — verify the configured state shows 5. Test the "Send Test Email" functionality 6. Toggle registration on/off 7. Delete the connector — verify it returns to unconfigured state 8. "Logto Console" still opens the Logto admin in a new tab - [ ] **Step 4: Verify bootstrap changes** If you can reset the Logto bootstrap (delete `/data/logto-bootstrap.json` in the container and restart), verify that: 1. Bootstrap no longer attempts SMTP connector creation 2. Sign-in mode defaults to "SignIn" (no registration) 3. Admin user can still sign in with username+password