mail service restructuring

This commit is contained in:
Oliver Kieselbach
2026-03-09 19:51:19 +01:00
parent 9cc918dcdd
commit dc411d112e
7 changed files with 110 additions and 28 deletions
@@ -1 +1 @@
377
378
@@ -115,10 +115,11 @@ public class PreviewWhitelistFunction
}
// Fire-and-forget: send welcome email if notification email is configured
if (!string.IsNullOrWhiteSpace(tenantConfig.PreviewNotificationEmail))
var notificationEmail = await _previewWhitelistService.GetNotificationEmailAsync(tenantId);
if (!string.IsNullOrWhiteSpace(notificationEmail))
{
_ = _resendEmailService.SendPreviewApprovedEmailAsync(
tenantConfig.PreviewNotificationEmail, tenantConfig.DomainName)
notificationEmail, tenantConfig.DomainName)
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
"Fire-and-forget welcome email failed for tenant {TenantId}", tenantId),
TaskContinuationOptions.OnlyOnFaulted);
@@ -161,6 +162,24 @@ public class PreviewWhitelistFunction
return response;
}
/// <summary>
/// GET /api/preview/notification-email/{tenantId}
/// Returns the notification email for a tenant. Galactic Admin only.
/// </summary>
[Function("GetPreviewNotificationEmail")]
[Authorize]
public async Task<HttpResponseData> GetNotificationEmail(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "preview/notification-email/{tenantId}")] HttpRequestData req,
string tenantId,
FunctionContext context)
{
var email = await _previewWhitelistService.GetNotificationEmailAsync(tenantId);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { email = email ?? "" });
return response;
}
/// <summary>
/// PUT /api/preview/notification-email
/// Saves the caller's notification email for Private Preview approval.
@@ -192,9 +211,7 @@ public class PreviewWhitelistFunction
return bad;
}
var tenantConfig = await _tenantConfigurationService.GetConfigurationAsync(tenantId);
tenantConfig.PreviewNotificationEmail = email;
await _tenantConfigurationService.SaveConfigurationAsync(tenantConfig);
await _previewWhitelistService.SaveNotificationEmailAsync(tenantId, email);
_logger.LogInformation(
"Preview notification email updated for tenant {TenantId}: {Email}",
@@ -208,7 +225,7 @@ public class PreviewWhitelistFunction
/// <summary>
/// POST /api/preview/send-welcome-email/{tenantId}
/// Sends (or resends) the Private Preview welcome email. Galactic Admin only.
/// Accepts optional { email } in body — if provided, saves it to tenant config before sending.
/// Accepts optional { email } in body — if provided, saves it to PreviewWhitelist table before sending.
/// </summary>
[Function("SendPreviewWelcomeEmail")]
[Authorize]
@@ -219,18 +236,17 @@ public class PreviewWhitelistFunction
{
// Authentication + GalacticAdminOnly authorization enforced by PolicyEnforcementMiddleware
var tenantConfig = await _tenantConfigurationService.GetConfigurationAsync(tenantId);
// If the caller provides an email in the body, save it first
var body = await req.ReadFromJsonAsync<SaveNotificationEmailRequest>();
var bodyEmail = body?.Email?.Trim();
if (!string.IsNullOrWhiteSpace(bodyEmail))
{
tenantConfig.PreviewNotificationEmail = bodyEmail;
await _tenantConfigurationService.SaveConfigurationAsync(tenantConfig);
await _previewWhitelistService.SaveNotificationEmailAsync(tenantId, bodyEmail);
}
var email = tenantConfig.PreviewNotificationEmail;
var email = !string.IsNullOrWhiteSpace(bodyEmail)
? bodyEmail
: await _previewWhitelistService.GetNotificationEmailAsync(tenantId);
if (string.IsNullOrWhiteSpace(email))
{
@@ -239,6 +255,7 @@ public class PreviewWhitelistFunction
return bad;
}
var tenantConfig = await _tenantConfigurationService.GetConfigurationAsync(tenantId);
await _resendEmailService.SendPreviewApprovedEmailAsync(email, tenantConfig.DomainName);
var principal = context.GetUser();
@@ -152,6 +152,7 @@ public static class EndpointAccessPolicyCatalog
new("GET", "preview/whitelist", EndpointPolicy.GalacticAdminOnly),
new("POST", "preview/whitelist/{tenantId}", EndpointPolicy.GalacticAdminOnly),
new("DELETE", "preview/whitelist/{tenantId}", EndpointPolicy.GalacticAdminOnly),
new("GET", "preview/notification-email/{tenantId}", EndpointPolicy.GalacticAdminOnly),
new("POST", "preview/send-welcome-email/{tenantId}", EndpointPolicy.GalacticAdminOnly),
new("GET", "galactic/sessions", EndpointPolicy.GalacticAdminOnly),
new("GET", "galactic/audit/logs", EndpointPolicy.GalacticAdminOnly),
@@ -113,6 +113,52 @@ public class PreviewWhitelistService
return results.OrderBy(e => e.PartitionKey).ToList();
}
/// <summary>
/// Gets the notification email for a tenant (stored in PreviewWhitelist table).
/// </summary>
public async Task<string?> GetNotificationEmailAsync(string tenantId)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.PreviewWhitelist);
var entity = await tableClient.GetEntityAsync<PreviewNotificationEntity>(tenantId, "notification-email");
return entity?.Value?.Email;
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
return null;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to read notification email for tenant {TenantId}", tenantId);
return null;
}
}
/// <summary>
/// Saves (or clears) the notification email for a tenant.
/// </summary>
public async Task SaveNotificationEmailAsync(string tenantId, string? email)
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.PreviewWhitelist);
if (string.IsNullOrWhiteSpace(email))
{
// Clear: delete the row if it exists
try { await tableClient.DeleteEntityAsync(tenantId, "notification-email"); }
catch (RequestFailedException ex) when (ex.Status == 404) { /* already gone */ }
return;
}
var entity = new PreviewNotificationEntity
{
PartitionKey = tenantId,
RowKey = "notification-email",
Email = email.Trim()
};
await tableClient.UpsertEntityAsync(entity);
}
}
/// <summary>
@@ -128,3 +174,18 @@ public class PreviewWhitelistEntity : ITableEntity
public DateTime ApprovedAt { get; set; }
public string ApprovedBy { get; set; } = string.Empty;
}
/// <summary>
/// Entity storing the notification email for a tenant in the PreviewWhitelist table.
/// PartitionKey = TenantId, RowKey = "notification-email".
/// Temporary — remove after GA.
/// </summary>
public class PreviewNotificationEntity : ITableEntity
{
public string PartitionKey { get; set; } = string.Empty; // TenantId
public string RowKey { get; set; } = "notification-email";
public DateTimeOffset? Timestamp { get; set; }
public ETag ETag { get; set; }
public string Email { get; set; } = string.Empty;
}
@@ -33,14 +33,6 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public string UpdatedBy { get; set; }
// ===== PRIVATE PREVIEW =====
/// <summary>
/// Email address for Private Preview approval notification.
/// Entered by the user on the waitlist page or by a Galactic Admin.
/// </summary>
public string PreviewNotificationEmail { get; set; }
// ===== TENANT STATUS =====
/// <summary>
@@ -13,7 +13,6 @@ export interface TenantConfiguration {
disabled: boolean;
disabledReason?: string;
disabledUntil?: string;
previewNotificationEmail?: string;
rateLimitRequestsPerMinute: number;
manufacturerWhitelist: string;
modelWhitelist: string;
@@ -60,6 +59,7 @@ export function TenantManagementSection({
// Preview Whitelist state
const [togglingPreviewTenant, setTogglingPreviewTenant] = useState<string | null>(null);
const [sendingWelcomeEmail, setSendingWelcomeEmail] = useState(false);
const [notificationEmail, setNotificationEmail] = useState("");
// Filter and sort tenants
const filteredTenants = tenants.filter(t => {
@@ -403,8 +403,19 @@ export function TenantManagementSection({
: "Approve"}
</button>
<button
onClick={() => {
onClick={async () => {
setEditingTenant(tenant);
setNotificationEmail("");
try {
const resp = await authenticatedFetch(
`${API_BASE_URL}/api/preview/notification-email/${tenant.tenantId}`,
getAccessToken
);
if (resp.ok) {
const data = await resp.json();
setNotificationEmail(data.email || "");
}
} catch { /* best-effort */ }
}}
className="px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
@@ -529,14 +540,14 @@ export function TenantManagementSection({
<div className="flex items-center gap-2">
<input
type="email"
value={editingTenant.previewNotificationEmail || ''}
onChange={(e) => setEditingTenant({ ...editingTenant, previewNotificationEmail: e.target.value })}
value={notificationEmail}
onChange={(e) => setNotificationEmail(e.target.value)}
placeholder="user@example.com"
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
/>
<button
onClick={() => handleSendWelcomeEmail(editingTenant.tenantId, editingTenant.previewNotificationEmail)}
disabled={sendingWelcomeEmail || !editingTenant.previewNotificationEmail?.trim()}
onClick={() => handleSendWelcomeEmail(editingTenant.tenantId, notificationEmail)}
disabled={sendingWelcomeEmail || !notificationEmail.trim()}
className="px-3 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors whitespace-nowrap flex items-center gap-1.5"
title="Send or resend the Private Preview welcome email"
>
@@ -556,7 +567,7 @@ export function TenantManagementSection({
</button>
</div>
<p className="text-xs text-indigo-600 mt-2">
Save changes first if you edited the email. The welcome email is also sent automatically on approval if set.
The email is saved and sent in one step. Also sent automatically on approval if set.
</p>
</div>
File diff suppressed because one or more lines are too long