diff --git a/src/main/java/io/cameleer/saas/notification/TenantWelcomeNotificationService.java b/src/main/java/io/cameleer/saas/notification/TenantWelcomeNotificationService.java new file mode 100644 index 0000000..6f134a7 --- /dev/null +++ b/src/main/java/io/cameleer/saas/notification/TenantWelcomeNotificationService.java @@ -0,0 +1,109 @@ +package io.cameleer.saas.notification; + +import io.cameleer.saas.identity.LogtoManagementClient; +import io.cameleer.saas.provisioning.ProvisioningProperties; +import io.cameleer.saas.vendor.EmailConnectorService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.io.ClassPathResource; +import org.springframework.mail.javamail.JavaMailSenderImpl; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.Properties; + +@Service +public class TenantWelcomeNotificationService { + + private static final Logger log = LoggerFactory.getLogger(TenantWelcomeNotificationService.class); + + private final EmailConnectorService emailConnectorService; + private final LogtoManagementClient logtoClient; + private final ProvisioningProperties provisioningProps; + + public TenantWelcomeNotificationService(EmailConnectorService emailConnectorService, + LogtoManagementClient logtoClient, + ProvisioningProperties provisioningProps) { + this.emailConnectorService = emailConnectorService; + this.logtoClient = logtoClient; + this.provisioningProps = provisioningProps; + } + + /** + * Sends a welcome email to the tenant admin after provisioning completes. + * Fire-and-forget: logs a warning on failure but does not throw. + */ + public void sendWelcomeEmail(String toEmail, String username, String tenantName, String slug) { + try { + doSend(toEmail, username, tenantName, slug); + } catch (Exception e) { + log.warn("Failed to send welcome email to {}: {}", toEmail, e.getMessage()); + } + } + + @SuppressWarnings("unchecked") + private void doSend(String toEmail, String username, String tenantName, String slug) throws Exception { + var connectorStatus = emailConnectorService.getEmailConnector(); + if (connectorStatus == null) { + log.debug("No email connector configured — skipping welcome email for {}", toEmail); + return; + } + + var connectors = logtoClient.listConnectors(); + var raw = connectors.stream() + .filter(c -> "Email".equals(c.get("type"))) + .findFirst() + .orElse(null); + if (raw == null) return; + var config = (Map) raw.getOrDefault("config", Map.of()); + var auth = (Map) config.getOrDefault("auth", Map.of()); + + String host = connectorStatus.host(); + int port = connectorStatus.port(); + String smtpUsername = connectorStatus.username(); + String fromEmail = connectorStatus.fromEmail(); + String password = String.valueOf(auth.getOrDefault("pass", "")); + + String dashboardUrl = provisioningProps.publicProtocol() + "://" + + provisioningProps.publicHost() + "/t/" + slug; + String watermarkUrl = provisioningProps.publicProtocol() + "://" + + provisioningProps.publicHost() + "/platform/assets/email-watermark.png"; + + String htmlBody = new ClassPathResource("email-templates/welcome-tenant.html") + .getContentAsString(StandardCharsets.UTF_8) + .replace("{{watermarkUrl}}", watermarkUrl) + .replace("{{tenantName}}", tenantName) + .replace("{{username}}", username) + .replace("{{dashboardUrl}}", dashboardUrl); + + var sender = new JavaMailSenderImpl(); + sender.setHost(host); + sender.setPort(port); + sender.setUsername(smtpUsername); + sender.setPassword(password); + sender.setDefaultEncoding("UTF-8"); + + Properties props = sender.getJavaMailProperties(); + props.put("mail.transport.protocol", "smtp"); + props.put("mail.smtp.auth", "true"); + if (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"); + + var mimeMessage = sender.createMimeMessage(); + var helper = new MimeMessageHelper(mimeMessage, false, "UTF-8"); + helper.setTo(toEmail); + helper.setFrom(fromEmail); + helper.setSubject("Your Cameleer tenant is ready"); + helper.setText(htmlBody, true); + + sender.send(mimeMessage); + log.info("Welcome email sent to {} for tenant {}", toEmail, slug); + } +} diff --git a/src/main/java/io/cameleer/saas/provisioning/TenantDataCleanupService.java b/src/main/java/io/cameleer/saas/provisioning/TenantDataCleanupService.java index 6f5a3a9..5043f8d 100644 --- a/src/main/java/io/cameleer/saas/provisioning/TenantDataCleanupService.java +++ b/src/main/java/io/cameleer/saas/provisioning/TenantDataCleanupService.java @@ -44,11 +44,13 @@ public class TenantDataCleanupService { try (Connection conn = DriverManager.getConnection(url, props.clickhouseUser(), props.clickhousePassword()); Statement stmt = conn.createStatement()) { - // Find all tables with a tenant_id column + // Find all base tables with a tenant_id column (skip materialized views) List tables = new ArrayList<>(); try (ResultSet rs = stmt.executeQuery( - "SELECT DISTINCT table FROM system.columns " + - "WHERE database = currentDatabase() AND name = 'tenant_id'")) { + "SELECT DISTINCT c.table FROM system.columns c " + + "INNER JOIN system.tables t ON c.table = t.name AND c.database = t.database " + + "WHERE c.database = currentDatabase() AND c.name = 'tenant_id' " + + "AND t.engine NOT LIKE '%MaterializedView%'")) { while (rs.next()) { tables.add(rs.getString(1)); } diff --git a/src/main/java/io/cameleer/saas/tenant/TenantEntity.java b/src/main/java/io/cameleer/saas/tenant/TenantEntity.java index 9762c14..9d69d27 100644 --- a/src/main/java/io/cameleer/saas/tenant/TenantEntity.java +++ b/src/main/java/io/cameleer/saas/tenant/TenantEntity.java @@ -61,6 +61,9 @@ public class TenantEntity { @Column(name = "db_password") private String dbPassword; + @Column(name = "admin_email") + private String adminEmail; + @Column(name = "ca_applied_at") private Instant caAppliedAt; @@ -105,6 +108,8 @@ public class TenantEntity { public void setProvisionError(String provisionError) { this.provisionError = provisionError; } public String getDbPassword() { return dbPassword; } public void setDbPassword(String dbPassword) { this.dbPassword = dbPassword; } + public String getAdminEmail() { return adminEmail; } + public void setAdminEmail(String adminEmail) { this.adminEmail = adminEmail; } public Instant getCaAppliedAt() { return caAppliedAt; } public void setCaAppliedAt(Instant caAppliedAt) { this.caAppliedAt = caAppliedAt; } public Instant getCreatedAt() { return createdAt; } diff --git a/src/main/java/io/cameleer/saas/tenant/dto/TenantResponse.java b/src/main/java/io/cameleer/saas/tenant/dto/TenantResponse.java index 37abf05..5ceecb6 100644 --- a/src/main/java/io/cameleer/saas/tenant/dto/TenantResponse.java +++ b/src/main/java/io/cameleer/saas/tenant/dto/TenantResponse.java @@ -11,6 +11,7 @@ public record TenantResponse( String slug, String tier, String status, + String adminEmail, String serverEndpoint, String provisionError, Instant createdAt, @@ -20,6 +21,7 @@ public record TenantResponse( return new TenantResponse( e.getId(), e.getName(), e.getSlug(), e.getTier().name(), e.getStatus().name(), + e.getAdminEmail(), e.getServerEndpoint(), e.getProvisionError(), e.getCreatedAt(), e.getUpdatedAt() ); diff --git a/src/main/java/io/cameleer/saas/vendor/VendorTenantController.java b/src/main/java/io/cameleer/saas/vendor/VendorTenantController.java index e446966..e0e7e91 100644 --- a/src/main/java/io/cameleer/saas/vendor/VendorTenantController.java +++ b/src/main/java/io/cameleer/saas/vendor/VendorTenantController.java @@ -45,6 +45,7 @@ public class VendorTenantController { String slug, String tier, String status, + String adminEmail, String serverState, String licenseExpiry, String provisionError, @@ -94,6 +95,7 @@ public class VendorTenantController { return new VendorTenantSummary( tenant.getId(), tenant.getName(), tenant.getSlug(), tenant.getTier().name(), tenant.getStatus().name(), + tenant.getAdminEmail(), status.state().name(), licenseExpiry, tenant.getProvisionError(), agentCount, environmentCount, agentLimit ); diff --git a/src/main/java/io/cameleer/saas/vendor/VendorTenantService.java b/src/main/java/io/cameleer/saas/vendor/VendorTenantService.java index 295236a..3bd02f6 100644 --- a/src/main/java/io/cameleer/saas/vendor/VendorTenantService.java +++ b/src/main/java/io/cameleer/saas/vendor/VendorTenantService.java @@ -56,6 +56,7 @@ public class VendorTenantService { private final ProvisioningProperties provisioningProps; private final TenantDataCleanupService dataCleanupService; private final TenantDatabaseService tenantDatabaseService; + private final io.cameleer.saas.notification.TenantWelcomeNotificationService welcomeNotificationService; private final VendorTenantService self; public VendorTenantService(TenantService tenantService, @@ -70,6 +71,7 @@ public class VendorTenantService { ProvisioningProperties provisioningProps, TenantDataCleanupService dataCleanupService, TenantDatabaseService tenantDatabaseService, + io.cameleer.saas.notification.TenantWelcomeNotificationService welcomeNotificationService, @Lazy VendorTenantService self) { this.tenantService = tenantService; this.tenantRepository = tenantRepository; @@ -83,6 +85,7 @@ public class VendorTenantService { this.provisioningProps = provisioningProps; this.dataCleanupService = dataCleanupService; this.tenantDatabaseService = tenantDatabaseService; + this.welcomeNotificationService = welcomeNotificationService; this.self = self; } @@ -91,6 +94,12 @@ public class VendorTenantService { // 1. Create tenant record (sets status = PROVISIONING) + Logto org TenantEntity tenant = tenantService.create(request, actorId); + // Store admin email on entity + if (request.adminEmail() != null && !request.adminEmail().isBlank()) { + tenant.setAdminEmail(request.adminEmail()); + tenantRepository.save(tenant); + } + // 2. Create initial admin user in Logto org (if credentials provided) if (tenant.getLogtoOrgId() != null && logtoClient.isAvailable()) { String ownerRoleId = logtoClient.findOrgRoleIdByName("owner"); @@ -218,6 +227,13 @@ public class VendorTenantService { } log.info("Tenant {} provisioned successfully", slug); + + // Send welcome email to tenant admin + if (tenant.getAdminEmail() != null) { + String username = tenant.getAdminEmail().substring(0, tenant.getAdminEmail().indexOf('@')); + welcomeNotificationService.sendWelcomeEmail( + tenant.getAdminEmail(), username, tenant.getName(), slug); + } } else { tenant.setProvisionError(result.error()); tenantRepository.save(tenant); diff --git a/src/main/resources/db/migration/V004__tenant_admin_email.sql b/src/main/resources/db/migration/V004__tenant_admin_email.sql new file mode 100644 index 0000000..8d93022 --- /dev/null +++ b/src/main/resources/db/migration/V004__tenant_admin_email.sql @@ -0,0 +1 @@ +ALTER TABLE tenants ADD COLUMN admin_email VARCHAR(255); diff --git a/src/main/resources/email-templates/welcome-tenant.html b/src/main/resources/email-templates/welcome-tenant.html new file mode 100644 index 0000000..026f6e2 --- /dev/null +++ b/src/main/resources/email-templates/welcome-tenant.html @@ -0,0 +1,24 @@ +
+
+ Cameleer.io +
+
+ +
+

Welcome to Cameleer

+

Your tenant {{tenantName}} has been provisioned and is ready to use.

+
+

+ Username: {{username}}
+ Dashboard: {{dashboardUrl}} +

+
+

Sign in with the credentials provided by your administrator. We recommend changing your password and enabling MFA after your first login.

+ Open Dashboard +
+
+
+

Questions? Contact your administrator

+

Cameleer — Apache Camel observability

+
+
diff --git a/src/test/java/io/cameleer/saas/vendor/VendorTenantServiceTest.java b/src/test/java/io/cameleer/saas/vendor/VendorTenantServiceTest.java index e15dee7..871c84e 100644 --- a/src/test/java/io/cameleer/saas/vendor/VendorTenantServiceTest.java +++ b/src/test/java/io/cameleer/saas/vendor/VendorTenantServiceTest.java @@ -77,6 +77,9 @@ class VendorTenantServiceTest { @Mock private TenantDatabaseService tenantDatabaseService; + @Mock + private io.cameleer.saas.notification.TenantWelcomeNotificationService welcomeNotificationService; + private VendorTenantService vendorTenantService; @BeforeEach @@ -91,11 +94,13 @@ class VendorTenantServiceTest { vendorTenantService = new VendorTenantService( tenantService, tenantRepository, licenseService, signingKeyService, tenantProvisioner, serverApiClient, logtoClient, logtoConfig, - auditService, provisioningProps, dataCleanupService, tenantDatabaseService, null); + auditService, provisioningProps, dataCleanupService, tenantDatabaseService, + welcomeNotificationService, null); vendorTenantService = new VendorTenantService( tenantService, tenantRepository, licenseService, signingKeyService, tenantProvisioner, serverApiClient, logtoClient, logtoConfig, - auditService, provisioningProps, dataCleanupService, tenantDatabaseService, vendorTenantService); + auditService, provisioningProps, dataCleanupService, tenantDatabaseService, + welcomeNotificationService, vendorTenantService); // Enable transaction synchronization so afterCommit callbacks can be registered if (!TransactionSynchronizationManager.isSynchronizationActive()) { diff --git a/ui/src/pages/vendor/TenantDetailPage.tsx b/ui/src/pages/vendor/TenantDetailPage.tsx index 88c01e0..ab87c4f 100644 --- a/ui/src/pages/vendor/TenantDetailPage.tsx +++ b/ui/src/pages/vendor/TenantDetailPage.tsx @@ -400,6 +400,12 @@ export function TenantDetailPage() { Slug {tenant.slug} + {tenant.adminEmail && ( +
+ Admin + {tenant.adminEmail} +
+ )}
Created {new Date(tenant.createdAt).toLocaleDateString()} diff --git a/ui/src/pages/vendor/VendorTenantsPage.tsx b/ui/src/pages/vendor/VendorTenantsPage.tsx index 7fb2e09..58f4ba7 100644 --- a/ui/src/pages/vendor/VendorTenantsPage.tsx +++ b/ui/src/pages/vendor/VendorTenantsPage.tsx @@ -41,6 +41,13 @@ const columns: Column[] = [ {row.slug} ), }, + { + key: 'adminEmail', + header: 'Admin', + render: (_v, row) => ( + {row.adminEmail ?? '—'} + ), + }, { key: 'tier', header: 'Tier', diff --git a/ui/src/types/api.ts b/ui/src/types/api.ts index 03cf640..deeae4f 100644 --- a/ui/src/types/api.ts +++ b/ui/src/types/api.ts @@ -4,6 +4,7 @@ export interface TenantResponse { slug: string; tier: string; status: string; + adminEmail: string | null; serverEndpoint: string | null; provisionError: string | null; createdAt: string; @@ -75,6 +76,7 @@ export interface VendorTenantSummary { slug: string; tier: string; status: string; + adminEmail: string | null; serverState: string; licenseExpiry: string | null; provisionError: string | null;