Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions/Functions/Admin/EmailTemplatesFunction.cs
T
Oliver Kieselbach 7569061983 feat(admin): editable email templates — preview, test send and HTML override in Settings
- EmailTemplateService: override-or-built-in resolution per send (PreviewConfig
  table, partition EmailTemplates, 5-min cache, {{domainName}} placeholder,
  30k limit); send path fail-soft, admin read path strict
- GA endpoints global/email-templates/{kind} (GET/PUT/DELETE) and /test, which
  mails the effective template or an unsaved draft to the caller's tenant
  contact address
- portal: Settings → Email Templates with preview frame, test send, HTML editor
- mail header: brand mark drawn with CSS borders, no images left in the mails

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 00:50:10 +02:00

164 lines
7.5 KiB
C#

using System.Net;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Admin;
/// <summary>
/// Global-Admin management of the two transactional email templates (welcome, farewell):
/// read the effective template, store/reset an HTML override, and send a test mail.
/// All routes are GlobalAdminOnly via <c>EndpointAccessPolicyCatalog</c>.
/// </summary>
public class EmailTemplatesFunction
{
private readonly ILogger<EmailTemplatesFunction> _logger;
private readonly EmailTemplateService _templates;
private readonly IEmailService _email;
private readonly PreviewWhitelistService _previewWhitelistService;
private readonly TenantConfigurationService _tenantConfigurationService;
public EmailTemplatesFunction(
ILogger<EmailTemplatesFunction> logger,
EmailTemplateService templates,
IEmailService email,
PreviewWhitelistService previewWhitelistService,
TenantConfigurationService tenantConfigurationService)
{
_logger = logger;
_templates = templates;
_email = email;
_previewWhitelistService = previewWhitelistService;
_tenantConfigurationService = tenantConfigurationService;
}
/// <summary>
/// GET /api/global/email-templates/{kind}
/// Effective template: the override when stored, otherwise the built-in (raw, with the
/// {{domainName}} placeholder so it can be edited as-is).
/// </summary>
[Function("GetEmailTemplate")]
public async Task<HttpResponseData> Get(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/email-templates/{kind}")] HttpRequestData req,
string kind)
{
if (!EmailTemplateService.TryParseKind(kind, out var templateKind))
return await BadRequest(req, "Unknown template kind. Use 'welcome' or 'farewell'.");
var overrideEntry = await _templates.GetOverrideAsync(templateKind);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
{
kind = EmailTemplateService.KindKey(templateKind),
subject = EmailTemplateService.Subject(templateKind),
isOverridden = overrideEntry is not null,
html = overrideEntry?.Html ?? EmailTemplateService.BuiltInRaw(templateKind),
builtInHtml = EmailTemplateService.BuiltInRaw(templateKind),
updatedBy = overrideEntry?.UpdatedBy,
updatedUtc = overrideEntry?.UpdatedUtc,
placeholder = EmailTemplateService.DomainPlaceholder,
maxLength = EmailTemplateService.MaxHtmlLength,
});
return response;
}
/// <summary>PUT /api/global/email-templates/{kind} — body { html } stores an override.</summary>
[Function("SaveEmailTemplate")]
public async Task<HttpResponseData> Save(
[HttpTrigger(AuthorizationLevel.Anonymous, "put", Route = "global/email-templates/{kind}")] HttpRequestData req,
string kind)
{
if (!EmailTemplateService.TryParseKind(kind, out var templateKind))
return await BadRequest(req, "Unknown template kind. Use 'welcome' or 'farewell'.");
var body = await req.ReadFromJsonAsync<EmailTemplateRequest>();
var error = EmailTemplateService.Validate(body?.Html);
if (error is not null)
return await BadRequest(req, error);
var upn = TenantHelper.GetUserIdentifier(req);
var saved = await _templates.SaveOverrideAsync(templateKind, body!.Html!, upn);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { kind = saved.Kind, isOverridden = true, updatedBy = saved.UpdatedBy, updatedUtc = saved.UpdatedUtc });
return response;
}
/// <summary>DELETE /api/global/email-templates/{kind} — resets to the built-in template.</summary>
[Function("ResetEmailTemplate")]
public async Task<HttpResponseData> Reset(
[HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "global/email-templates/{kind}")] HttpRequestData req,
string kind)
{
if (!EmailTemplateService.TryParseKind(kind, out var templateKind))
return await BadRequest(req, "Unknown template kind. Use 'welcome' or 'farewell'.");
await _templates.DeleteOverrideAsync(templateKind, TenantHelper.GetUserIdentifier(req));
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { kind = EmailTemplateService.KindKey(templateKind), isOverridden = false });
return response;
}
/// <summary>
/// POST /api/global/email-templates/{kind}/test — body { html? } sends the effective template
/// (or the unsaved draft) to the caller's tenant contact address: the activation
/// notification email, then TenantConfiguration.ContactEmail, then the caller's UPN.
/// Domain = the caller's tenant domain.
/// </summary>
[Function("SendEmailTemplateTest")]
public async Task<HttpResponseData> SendTest(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "global/email-templates/{kind}/test")] HttpRequestData req,
string kind)
{
if (!EmailTemplateService.TryParseKind(kind, out var templateKind))
return await BadRequest(req, "Unknown template kind. Use 'welcome' or 'farewell'.");
var body = await req.ReadFromJsonAsync<EmailTemplateRequest>();
var draft = string.IsNullOrWhiteSpace(body?.Html) ? null : body!.Html;
if (draft is not null)
{
var error = EmailTemplateService.Validate(draft);
if (error is not null) return await BadRequest(req, error);
}
var tenantId = TenantHelper.GetTenantId(req);
var upn = TenantHelper.GetUserIdentifier(req);
var tenantConfig = await _tenantConfigurationService.GetConfigurationAsync(tenantId);
var toEmail = await _previewWhitelistService.GetNotificationEmailAsync(tenantId);
if (string.IsNullOrWhiteSpace(toEmail)) toEmail = tenantConfig.ContactEmail;
if (string.IsNullOrWhiteSpace(toEmail)) toEmail = upn.Contains('@') ? upn : null;
if (string.IsNullOrWhiteSpace(toEmail))
return await BadRequest(req, "No contact email found for your tenant — set a notification email or ContactEmail first.");
var sent = await _email.SendTestAsync(templateKind, toEmail, tenantConfig.DomainName, draft);
_logger.LogInformation("Email template test {Kind} requested by {Upn} → {ToEmail}: {Result}", templateKind, upn, toEmail, sent ? "accepted" : "failed");
if (!sent)
{
var failed = req.CreateResponse(HttpStatusCode.BadGateway);
await failed.WriteAsJsonAsync(new { error = "The email provider did not accept the message. Check the backend log for the provider response.", sentTo = toEmail });
return failed;
}
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { sentTo = toEmail, domainName = tenantConfig.DomainName, draft = draft is not null });
return response;
}
private static async Task<HttpResponseData> BadRequest(HttpRequestData req, string error)
{
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
await bad.WriteAsJsonAsync(new { error });
return bad;
}
}
public class EmailTemplateRequest
{
public string? Html { get; set; }
}