Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c028313ba | |||
| c5eee69018 | |||
| 7ce4087d08 | |||
| 2dd5e318d7 | |||
| d5b7605da5 | |||
| 121ebbfbcd | |||
| 5a465d7257 |
@@ -0,0 +1,34 @@
|
|||||||
|
using HrynCo.NotificationService.DAL.Abstract.Providers;
|
||||||
|
using HrynCo.NotificationService.DAL.Abstract.Repositories;
|
||||||
|
using HrynCo.NotificationService.DAL.EF;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace HrynCo.NotificationService.Web.IntegrationTests;
|
||||||
|
|
||||||
|
public sealed class AdminSmtpEditorViewTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task ExistingChannelTestIncludesIdWithoutRenderingSavedPassword()
|
||||||
|
{
|
||||||
|
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
|
||||||
|
using var scope = context.App.Services.CreateScope();
|
||||||
|
var channel = new EmailChannel
|
||||||
|
{
|
||||||
|
ServiceName = "TestService",
|
||||||
|
EmailChannelType = EmailChannelType.Smtp,
|
||||||
|
Settings = new SmtpChannelSettings
|
||||||
|
{
|
||||||
|
Host = "smtp.example.invalid", Username = "test-user",
|
||||||
|
Password = "saved-secret-must-not-render", FromEmail = "sender@example.invalid"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>().AddAsync(channel);
|
||||||
|
await scope.ServiceProvider.GetRequiredService<NotificationDbContext>().SaveChangesAsync();
|
||||||
|
|
||||||
|
var html = await context.Client.GetStringAsync($"/admin/channels/{channel.Id}");
|
||||||
|
|
||||||
|
Assert.Contains($"value=\"{channel.Id}\"", html);
|
||||||
|
Assert.Contains("const channelId = form.querySelector('[name=\"Id\"]').value", html);
|
||||||
|
Assert.DoesNotContain("saved-secret-must-not-render", html);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
using System.Net;
|
||||||
|
using HrynCo.NotificationService.DAL.Abstract.Providers;
|
||||||
|
using HrynCo.NotificationService.DAL.Abstract.Repositories;
|
||||||
|
using HrynCo.NotificationService.DAL.EF;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace HrynCo.NotificationService.Web.IntegrationTests;
|
||||||
|
|
||||||
|
public sealed class AdminSmtpSaveTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task BlankPasswordKeepsSavedValueWhenOnlySenderChanges()
|
||||||
|
{
|
||||||
|
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
|
||||||
|
var channel = await SeedChannel(context);
|
||||||
|
using var client = context.App.CreateClient(new() { AllowAutoRedirect = false });
|
||||||
|
var fields = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/channels/{channel.Id}"));
|
||||||
|
fields["UseSsl"] = "true";
|
||||||
|
fields["Password"] = "";
|
||||||
|
fields["FromName"] = "Updated sender";
|
||||||
|
|
||||||
|
var response = await client.PostAsync("/admin/channels/save", new FormUrlEncodedContent(fields));
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
|
||||||
|
var saved = await ReadSettings(context, channel.Id);
|
||||||
|
Assert.Equal("saved-test-secret", saved.Password);
|
||||||
|
Assert.Equal("Updated sender", saved.FromName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Host", "other.example.invalid")]
|
||||||
|
[InlineData("Port", "2525")]
|
||||||
|
[InlineData("Username", "other-user")]
|
||||||
|
[InlineData("UseSsl", "false")]
|
||||||
|
public async Task ChangedConnectionWithBlankPasswordIsRejected(string field, string value)
|
||||||
|
{
|
||||||
|
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
|
||||||
|
var channel = await SeedChannel(context);
|
||||||
|
using var client = context.App.CreateClient(new() { AllowAutoRedirect = false });
|
||||||
|
var fields = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/channels/{channel.Id}"));
|
||||||
|
fields["UseSsl"] = "true";
|
||||||
|
fields["Password"] = "";
|
||||||
|
fields[field] = value;
|
||||||
|
|
||||||
|
var response = await client.PostAsync("/admin/channels/save", new FormUrlEncodedContent(fields));
|
||||||
|
var html = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
Assert.Contains("Enter the password to save changed SMTP connection settings.", html);
|
||||||
|
Assert.DoesNotContain("saved-test-secret", html);
|
||||||
|
var saved = await ReadSettings(context, channel.Id);
|
||||||
|
Assert.Equal("saved-test-secret", saved.Password);
|
||||||
|
Assert.Equal("smtp.example.invalid", saved.Host);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExplicitPasswordCanReplaceSavedValue()
|
||||||
|
{
|
||||||
|
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
|
||||||
|
var channel = await SeedChannel(context);
|
||||||
|
using var client = context.App.CreateClient(new() { AllowAutoRedirect = false });
|
||||||
|
var fields = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/channels/{channel.Id}"));
|
||||||
|
fields["UseSsl"] = "true";
|
||||||
|
fields["Password"] = "replacement-test-secret";
|
||||||
|
fields["Host"] = "other.example.invalid";
|
||||||
|
|
||||||
|
var response = await client.PostAsync("/admin/channels/save", new FormUrlEncodedContent(fields));
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
|
||||||
|
var saved = await ReadSettings(context, channel.Id);
|
||||||
|
Assert.Equal("replacement-test-secret", saved.Password);
|
||||||
|
Assert.Equal("other.example.invalid", saved.Host);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NewChannelStillRequiresPassword()
|
||||||
|
{
|
||||||
|
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
|
||||||
|
using var client = context.App.CreateClient(new() { AllowAutoRedirect = false });
|
||||||
|
var fields = TemplateEditorTestFields.Read(await client.GetStringAsync("/admin/channels/create"));
|
||||||
|
fields["ServiceName"] = "NewTestService";
|
||||||
|
fields["Host"] = "smtp.example.invalid";
|
||||||
|
fields["Username"] = "test-user";
|
||||||
|
fields["FromEmail"] = "sender@example.invalid";
|
||||||
|
fields["FromName"] = "Sender";
|
||||||
|
fields["Password"] = "";
|
||||||
|
|
||||||
|
var response = await client.PostAsync("/admin/channels/save", new FormUrlEncodedContent(fields));
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
Assert.Contains("The Password field is required.", await response.Content.ReadAsStringAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvalidFormDoesNotRenderEnteredPassword()
|
||||||
|
{
|
||||||
|
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
|
||||||
|
var channel = await SeedChannel(context);
|
||||||
|
using var client = context.App.CreateClient(new() { AllowAutoRedirect = false });
|
||||||
|
var fields = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/channels/{channel.Id}"));
|
||||||
|
fields["Password"] = "replacement-test-secret";
|
||||||
|
fields["FromEmail"] = "invalid-address";
|
||||||
|
|
||||||
|
var response = await client.PostAsync("/admin/channels/save", new FormUrlEncodedContent(fields));
|
||||||
|
var html = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
Assert.DoesNotContain("replacement-test-secret", html);
|
||||||
|
Assert.Equal("saved-test-secret", (await ReadSettings(context, channel.Id)).Password);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<EmailChannel> SeedChannel(DefinitionApiTestContext context)
|
||||||
|
{
|
||||||
|
using var scope = context.App.Services.CreateScope();
|
||||||
|
var channel = new EmailChannel
|
||||||
|
{
|
||||||
|
ServiceName = "TestService", EmailChannelType = EmailChannelType.Smtp,
|
||||||
|
Settings = new SmtpChannelSettings
|
||||||
|
{
|
||||||
|
Host = "smtp.example.invalid", Port = 587, Username = "test-user",
|
||||||
|
Password = "saved-test-secret", UseSsl = true,
|
||||||
|
FromEmail = "sender@example.invalid", FromName = "Original sender"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>().AddAsync(channel);
|
||||||
|
await scope.ServiceProvider.GetRequiredService<NotificationDbContext>().SaveChangesAsync();
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<SmtpChannelSettings> ReadSettings(DefinitionApiTestContext context, Guid id)
|
||||||
|
{
|
||||||
|
using var scope = context.App.Services.CreateScope();
|
||||||
|
var channel = await scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>().GetByIdAsync(id);
|
||||||
|
return Assert.IsType<SmtpChannelSettings>(channel?.Settings);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using System.Text.Json;
|
||||||
|
using HrynCo.NotificationService.DAL.Abstract.Providers;
|
||||||
|
using HrynCo.NotificationService.Web.Controllers.Admin;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace HrynCo.NotificationService.Web.IntegrationTests;
|
||||||
|
|
||||||
|
public sealed class AdminSmtpTestControllerTests
|
||||||
|
{
|
||||||
|
private static readonly Guid ChannelId = Guid.NewGuid();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BlankPasswordUsesSavedSecretAndCurrentUnsavedSender()
|
||||||
|
{
|
||||||
|
var (controller, recorder) = CreateController();
|
||||||
|
var response = await controller.TestSmtp(Request(), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(Succeeded(response));
|
||||||
|
Assert.Equal("saved-test-secret", recorder.SentCommand?.Password);
|
||||||
|
Assert.Equal("changed@example.invalid", recorder.SentCommand?.FromEmail);
|
||||||
|
Assert.Equal("Changed sender", recorder.SentCommand?.FromName);
|
||||||
|
Assert.Equal(1, recorder.LookupCount);
|
||||||
|
Assert.DoesNotContain("saved-test-secret", JsonSerializer.Serialize(((OkObjectResult)response).Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExplicitPasswordOverridesSavedWithoutLookup()
|
||||||
|
{
|
||||||
|
var (controller, recorder) = CreateController();
|
||||||
|
var response = await controller.TestSmtp(Request() with { Password = "typed-test-secret" }, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(Succeeded(response));
|
||||||
|
Assert.Equal("typed-test-secret", recorder.SentCommand?.Password);
|
||||||
|
Assert.Equal(0, recorder.LookupCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("host")]
|
||||||
|
[InlineData("port")]
|
||||||
|
[InlineData("username")]
|
||||||
|
[InlineData("ssl")]
|
||||||
|
public async Task ChangedConnectionRequiresExplicitPassword(string field)
|
||||||
|
{
|
||||||
|
var (controller, recorder) = CreateController();
|
||||||
|
var request = field switch
|
||||||
|
{
|
||||||
|
"host" => Request() with { Host = "other.example.invalid" },
|
||||||
|
"port" => Request() with { Port = 2525 },
|
||||||
|
"username" => Request() with { Username = "other-user" },
|
||||||
|
_ => Request() with { UseSsl = false }
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await controller.TestSmtp(request, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(Succeeded(response));
|
||||||
|
Assert.Null(recorder.SentCommand);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UnknownChannelDoesNotSend()
|
||||||
|
{
|
||||||
|
var (controller, recorder) = CreateController();
|
||||||
|
recorder.Channel = null;
|
||||||
|
|
||||||
|
var response = await controller.TestSmtp(Request(), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(Succeeded(response));
|
||||||
|
Assert.Null(recorder.SentCommand);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NewChannelKeepsAdHocTestBehavior()
|
||||||
|
{
|
||||||
|
var (controller, recorder) = CreateController();
|
||||||
|
|
||||||
|
var response = await controller.TestSmtp(Request() with { ChannelId = null }, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(Succeeded(response));
|
||||||
|
Assert.Equal(string.Empty, recorder.SentCommand?.Password);
|
||||||
|
Assert.Equal(0, recorder.LookupCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (AdminChannelsController Controller, RecordingAdminSmtpMediator Recorder) CreateController()
|
||||||
|
{
|
||||||
|
var mediator = DispatchProxy.Create<IMediator, RecordingAdminSmtpMediator>();
|
||||||
|
var recorder = (RecordingAdminSmtpMediator)mediator;
|
||||||
|
recorder.Channel = new EmailChannel
|
||||||
|
{
|
||||||
|
Id = ChannelId,
|
||||||
|
ServiceName = "TestService",
|
||||||
|
EmailChannelType = EmailChannelType.Smtp,
|
||||||
|
Settings = new SmtpChannelSettings
|
||||||
|
{
|
||||||
|
Host = "smtp.example.invalid", Port = 587,
|
||||||
|
Username = "test-user", Password = "saved-test-secret", UseSsl = true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (new AdminChannelsController(mediator), recorder);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TestSmtpRequest Request() => new(
|
||||||
|
"smtp.example.invalid", 587, "test-user", "", true,
|
||||||
|
"changed@example.invalid", "Changed sender", "recipient@example.invalid", ChannelId);
|
||||||
|
|
||||||
|
private static bool Succeeded(IActionResult response)
|
||||||
|
{
|
||||||
|
var body = JsonSerializer.Serialize(((OkObjectResult)response).Value);
|
||||||
|
return JsonDocument.Parse(body).RootElement.GetProperty("success").GetBoolean();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using HrynCo.NotificationService.DAL.Abstract.Providers;
|
||||||
|
using HrynCo.NotificationService.Services.Core;
|
||||||
|
using HrynCo.NotificationService.Services.EmailChannels.Get;
|
||||||
|
using HrynCo.NotificationService.Services.EmailChannels.TestSmtp;
|
||||||
|
using MediatR;
|
||||||
|
using ServiceUnit = HrynCo.NotificationService.Services.Core.Unit;
|
||||||
|
|
||||||
|
namespace HrynCo.NotificationService.Web.IntegrationTests;
|
||||||
|
|
||||||
|
public class RecordingAdminSmtpMediator : DispatchProxy
|
||||||
|
{
|
||||||
|
public EmailChannel? Channel { get; set; }
|
||||||
|
public TestSmtpCommand? SentCommand { get; private set; }
|
||||||
|
public int LookupCount { get; private set; }
|
||||||
|
|
||||||
|
protected override object Invoke(MethodInfo? targetMethod, object?[]? args)
|
||||||
|
{
|
||||||
|
if (targetMethod?.Name != nameof(IMediator.Send) || args?.Length is null or 0)
|
||||||
|
throw new NotSupportedException();
|
||||||
|
|
||||||
|
return args[0] switch
|
||||||
|
{
|
||||||
|
GetEmailChannelQuery => RecordLookup(),
|
||||||
|
TestSmtpCommand command => RecordSend(command),
|
||||||
|
_ => throw new NotSupportedException()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<ServiceResult<EmailChannel>> RecordLookup()
|
||||||
|
{
|
||||||
|
LookupCount++;
|
||||||
|
return Task.FromResult(Channel is null
|
||||||
|
? ServiceResult<EmailChannel>.Failure("Channel not found.")
|
||||||
|
: ServiceResult<EmailChannel>.Success(Channel));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<ServiceResult<ServiceUnit>> RecordSend(TestSmtpCommand command)
|
||||||
|
{
|
||||||
|
SentCommand = command;
|
||||||
|
return Task.FromResult(ServiceResult<ServiceUnit>.Success(ServiceUnit.Value));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,37 @@ namespace HrynCo.NotificationService.Web.IntegrationTests;
|
|||||||
|
|
||||||
public sealed class TemplateCatalogPresentationTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
|
public sealed class TemplateCatalogPresentationTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
|
||||||
{
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task EditorTabsShowSavedVersionsAfterInvalidUnsavedChanges()
|
||||||
|
{
|
||||||
|
var connection = await fixture.CreateDatabaseAsync();
|
||||||
|
await using var app = new TemplateWebApplicationFactory(connection);
|
||||||
|
using var scope = app.Services.CreateScope();
|
||||||
|
var service = (await scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>().GetAllAsync()).Single();
|
||||||
|
var versions = scope.ServiceProvider.GetRequiredService<IEmailTemplateRepository>();
|
||||||
|
await versions.AddAsync(new EmailTemplate
|
||||||
|
{
|
||||||
|
ServiceName = service.Name, Key = "TabStatus", LanguageCode = "en",
|
||||||
|
Subject = "Saved", HtmlBody = "Saved", TextBody = "Saved"
|
||||||
|
});
|
||||||
|
var definition = (await scope.ServiceProvider.GetRequiredService<IEmailTemplateDefinitionRepository>()
|
||||||
|
.GetDefinitionAsync(service.Name, "TabStatus"))!;
|
||||||
|
using var client = app.CreateClient(new() { AllowAutoRedirect = false });
|
||||||
|
var baseUrl = $"/admin/services/{service.Id}/templates";
|
||||||
|
var html = await client.GetStringAsync($"{baseUrl}/{definition.Id}");
|
||||||
|
Assert.Matches("(?s)<span>en</span>\\s*<span class=\"template-language-status template-language-present\">Present</span>", html);
|
||||||
|
Assert.Matches("(?s)<span>uk</span>\\s*<span class=\"template-language-status template-language-missing\">Missing</span>", html);
|
||||||
|
|
||||||
|
var fields = TemplateEditorTestFields.Read(html);
|
||||||
|
TemplateEditorTestFields.SetLanguage(fields, "uk", "Unsaved", "", "");
|
||||||
|
fields["Languages[0].IsPresent"] = "false";
|
||||||
|
var response = await client.PostAsync(baseUrl + "/save", new FormUrlEncodedContent(fields));
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
var invalidHtml = await response.Content.ReadAsStringAsync();
|
||||||
|
Assert.Matches("(?s)<span>en</span>\\s*<span class=\"template-language-status template-language-present\">Present</span>", invalidHtml);
|
||||||
|
Assert.Matches("(?s)<span>uk</span>\\s*<span class=\"template-language-status template-language-missing\">Missing</span>", invalidHtml);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CatalogStatusesUseExactCodes_AndEmptyDefinitionsShowEveryLanguageMissing()
|
public async Task CatalogStatusesUseExactCodes_AndEmptyDefinitionsShowEveryLanguageMissing()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ public class AdminChannelsController(IMediator mediator) : Controller
|
|||||||
Host = smtp.Host,
|
Host = smtp.Host,
|
||||||
Port = smtp.Port,
|
Port = smtp.Port,
|
||||||
Username = smtp.Username,
|
Username = smtp.Username,
|
||||||
Password = smtp.Password,
|
|
||||||
UseSsl = smtp.UseSsl,
|
UseSsl = smtp.UseSsl,
|
||||||
FromEmail = smtp.FromEmail,
|
FromEmail = smtp.FromEmail,
|
||||||
FromName = smtp.FromName
|
FromName = smtp.FromName
|
||||||
@@ -80,14 +79,21 @@ public class AdminChannelsController(IMediator mediator) : Controller
|
|||||||
public async Task<IActionResult> Save(EmailChannelEditViewModel model, CancellationToken ct)
|
public async Task<IActionResult> Save(EmailChannelEditViewModel model, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
return View("Edit", model);
|
return EditWithErrors(model);
|
||||||
|
|
||||||
|
var (password, passwordError) = await ResolvePasswordForSave(model, ct);
|
||||||
|
if (passwordError is not null)
|
||||||
|
{
|
||||||
|
ModelState.AddModelError(nameof(model.Password), passwordError);
|
||||||
|
return EditWithErrors(model);
|
||||||
|
}
|
||||||
|
|
||||||
var smtpSettings = new SmtpChannelSettings
|
var smtpSettings = new SmtpChannelSettings
|
||||||
{
|
{
|
||||||
Host = model.Host,
|
Host = model.Host,
|
||||||
Port = model.Port,
|
Port = model.Port,
|
||||||
Username = model.Username,
|
Username = model.Username,
|
||||||
Password = model.Password,
|
Password = password,
|
||||||
UseSsl = model.UseSsl,
|
UseSsl = model.UseSsl,
|
||||||
FromEmail = model.FromEmail,
|
FromEmail = model.FromEmail,
|
||||||
FromName = model.FromName
|
FromName = model.FromName
|
||||||
@@ -109,7 +115,7 @@ public class AdminChannelsController(IMediator mediator) : Controller
|
|||||||
if (!result.IsSuccess)
|
if (!result.IsSuccess)
|
||||||
{
|
{
|
||||||
ModelState.AddModelError("", result.Error?.Message ?? "Failed to create channel.");
|
ModelState.AddModelError("", result.Error?.Message ?? "Failed to create channel.");
|
||||||
return View("Edit", model);
|
return EditWithErrors(model);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -127,19 +133,55 @@ public class AdminChannelsController(IMediator mediator) : Controller
|
|||||||
if (!result.IsSuccess)
|
if (!result.IsSuccess)
|
||||||
{
|
{
|
||||||
ModelState.AddModelError("", result.Error?.Message ?? "Failed to update channel.");
|
ModelState.AddModelError("", result.Error?.Message ?? "Failed to update channel.");
|
||||||
return View("Edit", model);
|
return EditWithErrors(model);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return RedirectToAction(nameof(Index));
|
return RedirectToAction(nameof(Index));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<(string Password, string? Error)> ResolvePasswordForSave(
|
||||||
|
EmailChannelEditViewModel model, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(model.Password))
|
||||||
|
return (model.Password, null);
|
||||||
|
if (model.IsNew)
|
||||||
|
return ("", "The Password field is required.");
|
||||||
|
|
||||||
|
var channelResult = await mediator.Send(new GetEmailChannelQuery(model.Id), ct);
|
||||||
|
if (!channelResult.IsSuccess || channelResult.Result?.Settings is not SmtpChannelSettings saved)
|
||||||
|
return ("", "SMTP channel not found.");
|
||||||
|
if (!MatchesSavedSmtpConnection(model.Host, model.Port, model.Username, model.UseSsl, saved))
|
||||||
|
return ("", "Enter the password to save changed SMTP connection settings.");
|
||||||
|
|
||||||
|
return (saved.Password, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IActionResult EditWithErrors(EmailChannelEditViewModel model)
|
||||||
|
{
|
||||||
|
model.Password = null;
|
||||||
|
return View("Edit", model);
|
||||||
|
}
|
||||||
|
|
||||||
// POST /admin/channels/test-smtp
|
// POST /admin/channels/test-smtp
|
||||||
[HttpPost("test-smtp")]
|
[HttpPost("test-smtp")]
|
||||||
public async Task<IActionResult> TestSmtp([FromBody] TestSmtpRequest request, CancellationToken ct)
|
public async Task<IActionResult> TestSmtp([FromBody] TestSmtpRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
var password = request.Password;
|
||||||
|
if (string.IsNullOrEmpty(password) && request.ChannelId is Guid channelId)
|
||||||
|
{
|
||||||
|
var channelResult = await mediator.Send(new GetEmailChannelQuery(channelId), ct);
|
||||||
|
if (!channelResult.IsSuccess || channelResult.Result?.Settings is not SmtpChannelSettings saved)
|
||||||
|
return Ok(new { success = false, message = "SMTP channel not found." });
|
||||||
|
|
||||||
|
if (!MatchesSavedSmtpConnection(request.Host, request.Port, request.Username, request.UseSsl, saved))
|
||||||
|
return Ok(new { success = false, message = "Enter the password to test changed SMTP connection settings." });
|
||||||
|
|
||||||
|
password = saved.Password;
|
||||||
|
}
|
||||||
|
|
||||||
var result = await mediator.Send(new TestSmtpCommand(
|
var result = await mediator.Send(new TestSmtpCommand(
|
||||||
request.Host, request.Port, request.Username, request.Password,
|
request.Host, request.Port, request.Username, password ?? string.Empty,
|
||||||
request.UseSsl, request.FromEmail, request.FromName, request.ToEmail), ct);
|
request.UseSsl, request.FromEmail, request.FromName, request.ToEmail), ct);
|
||||||
|
|
||||||
if (!result.IsSuccess)
|
if (!result.IsSuccess)
|
||||||
@@ -148,6 +190,13 @@ public class AdminChannelsController(IMediator mediator) : Controller
|
|||||||
return Ok(new { success = true, message = $"Test email sent to {request.ToEmail}." });
|
return Ok(new { success = true, message = $"Test email sent to {request.ToEmail}." });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool MatchesSavedSmtpConnection(
|
||||||
|
string host, int port, string username, bool useSsl, SmtpChannelSettings saved) =>
|
||||||
|
string.Equals(host, saved.Host, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& port == saved.Port
|
||||||
|
&& string.Equals(username, saved.Username, StringComparison.Ordinal)
|
||||||
|
&& useSsl == saved.UseSsl;
|
||||||
|
|
||||||
// POST /admin/channels/{id}/test
|
// POST /admin/channels/{id}/test
|
||||||
[HttpPost("{id:guid}/test")]
|
[HttpPost("{id:guid}/test")]
|
||||||
public async Task<IActionResult> Test(Guid id, [FromBody] TestChannelRequest request, CancellationToken ct)
|
public async Task<IActionResult> Test(Guid id, [FromBody] TestChannelRequest request, CancellationToken ct)
|
||||||
@@ -180,6 +229,3 @@ public class AdminChannelsController(IMediator mediator) : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
public record TestChannelRequest(string ToEmail);
|
public record TestChannelRequest(string ToEmail);
|
||||||
public record TestSmtpRequest(
|
|
||||||
string Host, int Port, string Username, string Password,
|
|
||||||
bool UseSsl, string FromEmail, string FromName, string ToEmail);
|
|
||||||
|
|||||||
@@ -69,8 +69,12 @@ public sealed class AdminTemplatesController(IMediator mediator, IEmailTemplateD
|
|||||||
var service = await services.GetAsync(serviceId, ct);
|
var service = await services.GetAsync(serviceId, ct);
|
||||||
if (service is null || model.ServiceId != serviceId || model.ServiceName != service.Name)
|
if (service is null || model.ServiceId != serviceId || model.ServiceName != service.Name)
|
||||||
return NotFound();
|
return NotFound();
|
||||||
if (model.Id is Guid definitionId && await OwnedDefinitionAsync(service, definitionId, ct) is null)
|
var persisted = model.Id is Guid definitionId ? await OwnedDefinitionAsync(service, definitionId, ct) : null;
|
||||||
|
if (model.Id is not null && persisted is null)
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
var presentCodes = persisted?.Languages.Select(l => l.LanguageCode).ToHashSet(StringComparer.Ordinal);
|
||||||
|
foreach (var language in model.Languages)
|
||||||
|
language.IsPresent = presentCodes?.Contains(language.LanguageCode) == true;
|
||||||
ValidateLanguageCatalog(model, service);
|
ValidateLanguageCatalog(model, service);
|
||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
return EditorView(model, service);
|
return EditorView(model, service);
|
||||||
@@ -226,7 +230,8 @@ public sealed class AdminTemplatesController(IMediator mediator, IEmailTemplateD
|
|||||||
VariablesJson = JsonSerializer.Serialize(definition.Variables, JsonOptions),
|
VariablesJson = JsonSerializer.Serialize(definition.Variables, JsonOptions),
|
||||||
Languages = definition.Languages.Select(l => new EmailTemplateLanguageEditViewModel
|
Languages = definition.Languages.Select(l => new EmailTemplateLanguageEditViewModel
|
||||||
{
|
{
|
||||||
LanguageCode = l.LanguageCode, Subject = l.Subject, HtmlBody = l.HtmlBody, TextBody = l.TextBody
|
LanguageCode = l.LanguageCode, Subject = l.Subject, HtmlBody = l.HtmlBody, TextBody = l.TextBody,
|
||||||
|
IsPresent = true
|
||||||
}).ToList()
|
}).ToList()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace HrynCo.NotificationService.Web.Controllers.Admin;
|
||||||
|
|
||||||
|
public record TestSmtpRequest(
|
||||||
|
string Host, int Port, string Username, string Password,
|
||||||
|
bool UseSsl, string FromEmail, string FromName, string ToEmail,
|
||||||
|
Guid? ChannelId = null);
|
||||||
+1
-2
@@ -34,8 +34,7 @@ public class EmailChannelEditViewModel
|
|||||||
[Required]
|
[Required]
|
||||||
public string Username { get; set; } = "";
|
public string Username { get; set; } = "";
|
||||||
|
|
||||||
[Required]
|
public string? Password { get; set; }
|
||||||
public string Password { get; set; } = "";
|
|
||||||
|
|
||||||
public bool UseSsl { get; set; } = true;
|
public bool UseSsl { get; set; } = true;
|
||||||
|
|
||||||
|
|||||||
+3
@@ -1,4 +1,5 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||||
|
|
||||||
namespace HrynCo.NotificationService.Web.Controllers.Admin.ViewModels;
|
namespace HrynCo.NotificationService.Web.Controllers.Admin.ViewModels;
|
||||||
|
|
||||||
@@ -12,6 +13,8 @@ public sealed class EmailTemplateLanguageEditViewModel : IValidatableObject
|
|||||||
public string HtmlBody { get; set; } = "";
|
public string HtmlBody { get; set; } = "";
|
||||||
[DisplayFormat(ConvertEmptyStringToNull = false)]
|
[DisplayFormat(ConvertEmptyStringToNull = false)]
|
||||||
public string TextBody { get; set; } = "";
|
public string TextBody { get; set; } = "";
|
||||||
|
[BindNever]
|
||||||
|
public bool IsPresent { get; set; }
|
||||||
public bool HasContent => !string.IsNullOrWhiteSpace(Subject) || !string.IsNullOrWhiteSpace(HtmlBody) || !string.IsNullOrWhiteSpace(TextBody);
|
public bool HasContent => !string.IsNullOrWhiteSpace(Subject) || !string.IsNullOrWhiteSpace(HtmlBody) || !string.IsNullOrWhiteSpace(TextBody);
|
||||||
|
|
||||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||||
|
|||||||
@@ -94,6 +94,10 @@
|
|||||||
<label asp-for="Password" class="form-label fw-semibold">Password</label>
|
<label asp-for="Password" class="form-label fw-semibold">Password</label>
|
||||||
<input asp-for="Password" class="form-control" type="password" autocomplete="new-password" />
|
<input asp-for="Password" class="form-control" type="password" autocomplete="new-password" />
|
||||||
<span asp-validation-for="Password" class="text-danger small"></span>
|
<span asp-validation-for="Password" class="text-danger small"></span>
|
||||||
|
@if (!Model.IsNew)
|
||||||
|
{
|
||||||
|
<div class="form-text">Leave blank to keep the saved password when SMTP connection settings are unchanged.</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -138,7 +142,7 @@
|
|||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<p class="text-muted small">Tests the current form settings — no need to save first.</p>
|
<p class="text-muted small">Tests the current form settings — no need to save first. For an existing channel, a blank password uses the saved password only when the SMTP connection settings are unchanged.</p>
|
||||||
<label for="testToEmail" class="form-label fw-semibold">Recipient Email</label>
|
<label for="testToEmail" class="form-label fw-semibold">Recipient Email</label>
|
||||||
<input type="email" id="testToEmail" class="form-control" placeholder="you@example.com" />
|
<input type="email" id="testToEmail" class="form-control" placeholder="you@example.com" />
|
||||||
<div id="testResult" class="mt-3" style="display:none"></div>
|
<div id="testResult" class="mt-3" style="display:none"></div>
|
||||||
@@ -173,7 +177,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const form = document.getElementById('channelForm');
|
const form = document.getElementById('channelForm');
|
||||||
|
const channelId = form.querySelector('[name="Id"]').value;
|
||||||
const payload = {
|
const payload = {
|
||||||
|
channelId: channelId === '00000000-0000-0000-0000-000000000000' ? null : channelId,
|
||||||
host: form.querySelector('[name="Host"]').value,
|
host: form.querySelector('[name="Host"]').value,
|
||||||
port: parseInt(form.querySelector('[name="Port"]').value, 10),
|
port: parseInt(form.querySelector('[name="Port"]').value, 10),
|
||||||
username: form.querySelector('[name="Username"]').value,
|
username: form.querySelector('[name="Username"]').value,
|
||||||
|
|||||||
@@ -34,18 +34,22 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label asp-for="VariablesJson" class="form-label fw-semibold">Shared variables (JSON)</label>
|
<label asp-for="VariablesJson" class="form-label fw-semibold">Shared variables (JSON)</label>
|
||||||
<textarea asp-for="VariablesJson" class="form-control font-monospace" rows="4"></textarea>
|
<textarea asp-for="VariablesJson" class="form-control font-monospace" rows="14"></textarea>
|
||||||
<div class="form-text">One contract for every language: <code>[{"name":"UserName","required":true}]</code></div>
|
<div class="form-text">One contract for every language: <code>[{"name":"UserName","required":true}]</code></div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-muted">Every configured service language has a tab. Leave all three content fields blank for an unused language; clear them to remove an existing version. Shared variables and the template remain.</p>
|
<p class="text-muted">Every configured service language has a tab. Present and Missing show saved versions. Leave all three content fields blank for an unused language; clear them to remove an existing version. Shared variables and the template remain.</p>
|
||||||
@if (Model.Languages.Count == 0) { <p>No service languages configured. You can save the key and shared variables now, then add language codes in Edit Service.</p> }
|
@if (Model.Languages.Count == 0) { <p>No service languages configured. You can save the key and shared variables now, then add language codes in Edit Service.</p> }
|
||||||
<div class="template-language-sheet mb-3" hidden="@(Model.Languages.Count == 0)">
|
<div class="template-language-sheet mb-3" hidden="@(Model.Languages.Count == 0)">
|
||||||
<div class="nav nav-tabs" role="tablist" aria-label="Template languages" id="languageTabs">
|
<div class="nav nav-tabs" role="tablist" aria-label="Template languages" id="languageTabs">
|
||||||
@for (var i = 0; i < Model.Languages.Count; i++)
|
@for (var i = 0; i < Model.Languages.Count; i++)
|
||||||
{
|
{
|
||||||
|
var language = Model.Languages[i];
|
||||||
<button type="button" role="tab" id="language-tab-@i" aria-controls="language-pane-@i"
|
<button type="button" role="tab" id="language-tab-@i" aria-controls="language-pane-@i"
|
||||||
aria-selected="@(i == active ? "true" : "false")" tabindex="@(i == active ? 0 : -1)"
|
aria-selected="@(i == active ? "true" : "false")" tabindex="@(i == active ? 0 : -1)"
|
||||||
class="nav-link @(i == active ? "active" : "")" data-pane="language-pane-@i">@Model.Languages[i].LanguageCode</button>
|
class="nav-link @(i == active ? "active" : "")" data-pane="language-pane-@i">
|
||||||
|
<span>@language.LanguageCode</span>
|
||||||
|
<span class="template-language-status @(language.IsPresent ? "template-language-present" : "template-language-missing")">@(language.IsPresent ? "Present" : "Missing")</span>
|
||||||
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div id="languagePanes">
|
<div id="languagePanes">
|
||||||
@@ -59,11 +63,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label asp-for="Languages[i].HtmlBody" class="form-label fw-semibold">HTML Body</label>
|
<label asp-for="Languages[i].HtmlBody" class="form-label fw-semibold">HTML Body</label>
|
||||||
<textarea asp-for="Languages[i].HtmlBody" class="form-control font-monospace" rows="10" data-content="html"></textarea>
|
<textarea asp-for="Languages[i].HtmlBody" class="form-control font-monospace" rows="15" data-content="html"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label asp-for="Languages[i].TextBody" class="form-label fw-semibold">Text Body</label>
|
<label asp-for="Languages[i].TextBody" class="form-label fw-semibold">Text Body</label>
|
||||||
<textarea asp-for="Languages[i].TextBody" class="form-control font-monospace" rows="5" data-content="text"></textarea>
|
<textarea asp-for="Languages[i].TextBody" class="form-control font-monospace" rows="8" data-content="text"></textarea>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,7 +160,13 @@ body {
|
|||||||
.admin-data-table .channel-actions {
|
.admin-data-table .channel-actions {
|
||||||
width: 1%;
|
width: 1%;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-align: right;
|
}
|
||||||
|
|
||||||
|
.admin-data-table .template-actions,
|
||||||
|
.admin-data-table .service-actions,
|
||||||
|
.admin-data-table .channel-actions,
|
||||||
|
.admin-data-table .service-language-actions {
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header .service-template-service-name {
|
.page-header .service-template-service-name {
|
||||||
@@ -243,12 +249,11 @@ body {
|
|||||||
/* ── Service language actions ─────────────────────────── */
|
/* ── Service language actions ─────────────────────────── */
|
||||||
.service-language-table .service-language-actions {
|
.service-language-table .service-language-actions {
|
||||||
width: 1%;
|
width: 1%;
|
||||||
text-align: right;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.service-language-table .service-language-action-buttons {
|
.service-language-table .service-language-action-buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: center;
|
||||||
gap: .5rem;
|
gap: .5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,7 +268,7 @@ body {
|
|||||||
@media (max-width: 575.98px) {
|
@media (max-width: 575.98px) {
|
||||||
.service-language-table .service-language-action-buttons {
|
.service-language-table .service-language-action-buttons {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-end;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.service-language-table .service-language-action-buttons > .btn {
|
.service-language-table .service-language-action-buttons > .btn {
|
||||||
@@ -405,6 +410,9 @@ body {
|
|||||||
|
|
||||||
.template-language-sheet > .nav-tabs .nav-link {
|
.template-language-sheet > .nav-tabs .nav-link {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .375rem;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,17 @@ published, accepted by the Gmail SMTP channel, acknowledged as delivery successe
|
|||||||
Invemory Outbox, and confirmed in the recipient's Gmail conversation. Today's channel
|
Invemory Outbox, and confirmed in the recipient's Gmail conversation. Today's channel
|
||||||
usage increased by two. Both queues ended with zero ready and unacknowledged messages.
|
usage increased by two. Both queues ended with zero ready and unacknowledged messages.
|
||||||
|
|
||||||
|
### Administrative SMTP test from Web — 2026-10-18
|
||||||
|
|
||||||
|
The channel editor's **Send Test Email** action sends directly from Web through SMTP;
|
||||||
|
it does not use the Worker or RabbitMQ. Its TCP 587 connection timed out while the
|
||||||
|
production network policy allowed external SMTP only from Worker. The additive
|
||||||
|
`web-to-external-smtp` policy in `production-network.yaml` grants Web the same public
|
||||||
|
IPv4 TCP 587 egress as Worker, with the same private and cluster address exclusions.
|
||||||
|
Apply this production overlay separately from the sandbox-only `notification.py`
|
||||||
|
render. No SMTP credentials, channel settings, templates, or workload restart are
|
||||||
|
required for this network change.
|
||||||
|
|
||||||
The activation helper also persists the production user, queue and expanded Worker ACL
|
The activation helper also persists the production user, queue and expanded Worker ACL
|
||||||
in the `rabbit-definitions` Secret. The private password and matching RabbitMQ hash
|
in the `rabbit-definitions` Secret. The private password and matching RabbitMQ hash
|
||||||
are saved with mode `0600`; updated `notification.py` reproduces that Secret during
|
are saved with mode `0600`; updated `notification.py` reproduces that Secret during
|
||||||
@@ -167,7 +178,8 @@ existing Dockerfile. Record the checked commit and archive SHA256, including any
|
|||||||
explicitly recorded uncommitted delta. Mirror PostgreSQL, RabbitMQ, Mailpit and a
|
explicitly recorded uncommitted delta. Mirror PostgreSQL, RabbitMQ, Mailpit and a
|
||||||
Python 3 Linux image to `registry.hrynco.com`. Copy `artifacts.example.json` to
|
Python 3 Linux image to `registry.hrynco.com`. Copy `artifacts.example.json` to
|
||||||
`artifacts.local.json` and record all seven immutable image digests, source hashes
|
`artifacts.local.json` and record all seven immutable image digests, source hashes
|
||||||
and the two reviewed migration IDs. The script rejects placeholder/tag references,
|
and the three reviewed migration IDs, ending with `20261017072410_EmailTemplateLocalizations`.
|
||||||
|
The script rejects placeholder/tag references,
|
||||||
other registries, mismatched application/migrator archives and unsupported DB/broker
|
other registries, mismatched application/migrator archives and unsupported DB/broker
|
||||||
major versions. The archive hash fields are an operator provenance record; independently
|
major versions. The archive hash fields are an operator provenance record; independently
|
||||||
verify the build inputs and registry digests before live application.
|
verify the build inputs and registry digests before live application.
|
||||||
@@ -255,13 +267,15 @@ python3 notification.py render --config config.local.json --artifacts artifacts.
|
|||||||
python3 notification.py check --config config.local.json --artifacts artifacts.local.json
|
python3 notification.py check --config config.local.json --artifacts artifacts.local.json
|
||||||
```
|
```
|
||||||
|
|
||||||
5. First install applies storage/isolated foundation and runs migrations with Web and
|
5. First install or a schema upgrade applies storage/isolated foundation and runs
|
||||||
Worker stopped. The migration target is exactly `InitialCreate` + `PendingChanges`.
|
migrations with Web and Worker stopped. The migration target is exactly
|
||||||
|
`InitialCreate` + `PendingChanges` + `EmailTemplateLocalizations`.
|
||||||
After migration success, the script verifies history, grants a separate
|
After migration success, the script verifies history, grants a separate
|
||||||
`notification_runtime` role only DML on the three application tables, and idempotently
|
`notification_runtime` role only DML on the application tables, including the
|
||||||
seeds the Mailpit channel and neutral English `TestEmail`. Existing matching-service
|
service/language/definition catalog, and idempotently seeds the Mailpit channel,
|
||||||
channels/templates are left unchanged. It resumes Web after these gates; Worker
|
sandbox service/catalog and neutral English `TestEmail`. Existing matching-service
|
||||||
remains stopped for inspection:
|
channels/templates and shared variable contracts are left unchanged. It resumes
|
||||||
|
Web after these gates; Worker remains stopped for inspection:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
python3 notification.py deploy --config config.local.json --artifacts artifacts.local.json --migrate
|
python3 notification.py deploy --config config.local.json --artifacts artifacts.local.json --migrate
|
||||||
@@ -320,6 +334,18 @@ a snapshot of RabbitMQ's queue files. Migration does not upgrade/recreate the br
|
|||||||
volume; durable queues remain on that retained volume. Broker disk backup/recovery
|
volume; durable queues remain on that retained volume. Broker disk backup/recovery
|
||||||
must be verified before a broker upgrade or storage migration.
|
must be verified before a broker upgrade or storage migration.
|
||||||
|
|
||||||
|
Before upgrading a live flat database to `EmailTemplateLocalizations`, inspect the
|
||||||
|
exact source columns/indexes/history and audit every template's identities and JSON
|
||||||
|
variable shape. Versions of one service/key must have the same variable contract
|
||||||
|
after sorting by variable name. Rehearse the current Migrator and sandbox/grants SQL
|
||||||
|
on a disposable restored PostgreSQL 17 copy, then verify preserved content and a
|
||||||
|
repeat no-op migration. The live `deploy --migrate` creates its own final backup after
|
||||||
|
stopping Web and Worker. Do not use the old Web/Worker images with the new schema.
|
||||||
|
Rollback requires restoring a verified pre-upgrade database backup with the matching
|
||||||
|
old application images; migration `Down` is not a general rollback path. The
|
||||||
|
production network add-on and production broker access remain separate, preserved
|
||||||
|
resources; verify them and the existing production queues before re-enabling Worker.
|
||||||
|
|
||||||
## Access and networking
|
## Access and networking
|
||||||
|
|
||||||
Use SSH to the deployment host, then forward one local port when needed:
|
Use SSH to the deployment host, then forward one local port when needed:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"sourceCommit": "REPLACE_WITH_CHECKED_SOURCE_COMMIT",
|
"sourceCommit": "REPLACE_WITH_CHECKED_SOURCE_COMMIT",
|
||||||
"sourceArchiveSha256": "REPLACE_WITH_VERIFIED_SOURCE_ARCHIVE_SHA256",
|
"sourceArchiveSha256": "REPLACE_WITH_VERIFIED_SOURCE_ARCHIVE_SHA256",
|
||||||
"migrations": ["20260501214629_InitialCreate", "20260502154249_PendingChanges"],
|
"migrations": ["20260501214629_InitialCreate", "20260502154249_PendingChanges", "20261017072410_EmailTemplateLocalizations"],
|
||||||
"images": {
|
"images": {
|
||||||
"web": {"image": "registry.hrynco.com/hrynco.notificationservice.web@sha256:REPLACE", "sourceArchiveSha256": "REPLACE_WITH_VERIFIED_SOURCE_ARCHIVE_SHA256"},
|
"web": {"image": "registry.hrynco.com/hrynco.notificationservice.web@sha256:REPLACE", "sourceArchiveSha256": "REPLACE_WITH_VERIFIED_SOURCE_ARCHIVE_SHA256"},
|
||||||
"worker": {"image": "registry.hrynco.com/hrynco.notificationservice.worker@sha256:REPLACE", "sourceArchiveSha256": "REPLACE_WITH_VERIFIED_SOURCE_ARCHIVE_SHA256"},
|
"worker": {"image": "registry.hrynco.com/hrynco.notificationservice.worker@sha256:REPLACE", "sourceArchiveSha256": "REPLACE_WITH_VERIFIED_SOURCE_ARCHIVE_SHA256"},
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import time
|
|||||||
|
|
||||||
NAMESPACE = "shared-notification"
|
NAMESPACE = "shared-notification"
|
||||||
MANAGED = "hrynco-shared-notification"
|
MANAGED = "hrynco-shared-notification"
|
||||||
MIGRATIONS = ["20260501214629_InitialCreate", "20260502154249_PendingChanges"]
|
MIGRATIONS = ["20260501214629_InitialCreate", "20260502154249_PendingChanges",
|
||||||
|
"20261017072410_EmailTemplateLocalizations"]
|
||||||
IMAGE = re.compile(r"registry\.hrynco\.com/[a-z0-9._/-]+@sha256:[a-f0-9]{64}$")
|
IMAGE = re.compile(r"registry\.hrynco\.com/[a-z0-9._/-]+@sha256:[a-f0-9]{64}$")
|
||||||
SHA = re.compile(r"[a-f0-9]{64}$")
|
SHA = re.compile(r"[a-f0-9]{64}$")
|
||||||
PRODUCTION_RESULT_QUEUE = "invemory.production.notification-results"
|
PRODUCTION_RESULT_QUEUE = "invemory.production.notification-results"
|
||||||
@@ -53,7 +54,8 @@ REVOKE ALL ON DATABASE notification_service FROM PUBLIC;
|
|||||||
GRANT CONNECT ON DATABASE notification_service TO notification_runtime;
|
GRANT CONNECT ON DATABASE notification_service TO notification_runtime;
|
||||||
REVOKE CREATE ON SCHEMA public FROM PUBLIC;
|
REVOKE CREATE ON SCHEMA public FROM PUBLIC;
|
||||||
GRANT USAGE ON SCHEMA public TO notification_runtime;
|
GRANT USAGE ON SCHEMA public TO notification_runtime;
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON email_channels, email_templates, email_channel_usage TO notification_runtime;
|
GRANT SELECT, INSERT, UPDATE, DELETE ON email_channels, email_templates, email_channel_usage,
|
||||||
|
services, service_languages, email_template_definitions TO notification_runtime;
|
||||||
'''
|
'''
|
||||||
|
|
||||||
SANDBOX = r'''
|
SANDBOX = r'''
|
||||||
@@ -62,11 +64,24 @@ INSERT INTO email_channels (id, service_name, priority, provider_type, settings,
|
|||||||
SELECT gen_random_uuid(), 'Invemory.Staging.Sandbox', 1, 1,
|
SELECT gen_random_uuid(), 'Invemory.Staging.Sandbox', 1, 1,
|
||||||
'{"EmailChannelType":1,"Host":"mailpit","Port":1025,"Username":"","Password":"","UseSsl":false,"FromEmail":"no-reply@local.hrynco.test","FromName":"Invemory Sandbox"}'::jsonb, 90, true, now()
|
'{"EmailChannelType":1,"Host":"mailpit","Port":1025,"Username":"","Password":"","UseSsl":false,"FromEmail":"no-reply@local.hrynco.test","FromName":"Invemory Sandbox"}'::jsonb, 90, true, now()
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM email_channels WHERE service_name = 'Invemory.Staging.Sandbox');
|
WHERE NOT EXISTS (SELECT 1 FROM email_channels WHERE service_name = 'Invemory.Staging.Sandbox');
|
||||||
INSERT INTO email_templates (id, service_name, key, language_code, subject, html_body, text_body, variables, created)
|
INSERT INTO services (id, name, revision, created)
|
||||||
VALUES (gen_random_uuid(), 'Invemory.Staging.Sandbox', 'TestEmail', 'en', 'Test notification',
|
VALUES (gen_random_uuid(), 'Invemory.Staging.Sandbox', gen_random_uuid(), now())
|
||||||
'<p>Hello {{RecipientName}},</p><p>{{Message}}</p>', 'Hello {{RecipientName}}, {{Message}}',
|
ON CONFLICT (name) DO NOTHING;
|
||||||
'[{"name":"RecipientName","required":true},{"name":"Message","required":true}]'::jsonb, now())
|
INSERT INTO service_languages (id, service_id, code, created)
|
||||||
ON CONFLICT (service_name, key, language_code) DO NOTHING;
|
SELECT gen_random_uuid(), s.id, 'en', now() FROM services s WHERE s.name = 'Invemory.Staging.Sandbox'
|
||||||
|
ON CONFLICT (service_id, code) DO NOTHING;
|
||||||
|
INSERT INTO email_template_definitions (id, service_id, key, revision, variables, created)
|
||||||
|
SELECT gen_random_uuid(), s.id, 'TestEmail', gen_random_uuid(),
|
||||||
|
'[{"name":"RecipientName","required":true},{"name":"Message","required":true}]'::jsonb, now()
|
||||||
|
FROM services s WHERE s.name = 'Invemory.Staging.Sandbox'
|
||||||
|
ON CONFLICT (service_id, key) DO NOTHING;
|
||||||
|
INSERT INTO email_templates (id, definition_id, service_id, service_language_id, subject, html_body, text_body, created)
|
||||||
|
SELECT gen_random_uuid(), d.id, s.id, l.id, 'Test notification',
|
||||||
|
'<p>Hello {{RecipientName}},</p><p>{{Message}}</p>', 'Hello {{RecipientName}}, {{Message}}', now()
|
||||||
|
FROM services s JOIN service_languages l ON l.service_id = s.id AND l.code = 'en'
|
||||||
|
JOIN email_template_definitions d ON d.service_id = s.id AND d.key = 'TestEmail'
|
||||||
|
WHERE s.name = 'Invemory.Staging.Sandbox'
|
||||||
|
ON CONFLICT (definition_id, service_language_id) DO NOTHING;
|
||||||
COMMIT;
|
COMMIT;
|
||||||
'''
|
'''
|
||||||
|
|
||||||
@@ -75,9 +90,12 @@ SANDBOX_CHECK = """SELECT
|
|||||||
settings->>'Host' = 'mailpit' AND settings->>'Port' = '1025' AND
|
settings->>'Host' = 'mailpit' AND settings->>'Port' = '1025' AND
|
||||||
settings->>'UseSsl' = 'false' AND settings->>'Username' = '' AND settings->>'Password' = '')
|
settings->>'UseSsl' = 'false' AND settings->>'Username' = '' AND settings->>'Password' = '')
|
||||||
FROM email_channels WHERE service_name = 'Invemory.Staging.Sandbox') AND
|
FROM email_channels WHERE service_name = 'Invemory.Staging.Sandbox') AND
|
||||||
EXISTS (SELECT 1 FROM email_templates WHERE service_name = 'Invemory.Staging.Sandbox'
|
EXISTS (SELECT 1 FROM email_templates t
|
||||||
AND key = 'TestEmail' AND language_code = 'en'
|
JOIN email_template_definitions d ON d.id = t.definition_id AND d.service_id = t.service_id
|
||||||
AND variables @> '[{"name":"RecipientName","required":true},{"name":"Message","required":true}]'::jsonb)"""
|
JOIN service_languages l ON l.id = t.service_language_id AND l.service_id = t.service_id
|
||||||
|
JOIN services s ON s.id = t.service_id
|
||||||
|
WHERE s.name = 'Invemory.Staging.Sandbox' AND d.key = 'TestEmail' AND l.code = 'en'
|
||||||
|
AND d.variables @> '[{"name":"RecipientName","required":true},{"name":"Message","required":true}]'::jsonb)"""
|
||||||
|
|
||||||
|
|
||||||
def read_json(path):
|
def read_json(path):
|
||||||
@@ -640,7 +658,7 @@ def deploy(cluster, config, artifacts, args):
|
|||||||
except Exception:
|
except Exception:
|
||||||
cluster.scale("worker", 0)
|
cluster.scale("worker", 0)
|
||||||
raise
|
raise
|
||||||
print("deployment complete; SMTP is sandbox-only, Invemory publisher cutover is separate")
|
print("deployment complete; verify configured SMTP channels and publisher routes separately")
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None):
|
def main(argv=None):
|
||||||
|
|||||||
@@ -83,3 +83,32 @@ spec:
|
|||||||
ports:
|
ports:
|
||||||
- protocol: TCP
|
- protocol: TCP
|
||||||
port: 587
|
port: 587
|
||||||
|
---
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: NetworkPolicy
|
||||||
|
metadata:
|
||||||
|
name: web-to-external-smtp
|
||||||
|
namespace: shared-notification
|
||||||
|
spec:
|
||||||
|
podSelector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/component: web
|
||||||
|
app.kubernetes.io/name: hrynco-shared-notification
|
||||||
|
app.kubernetes.io/managed-by: hrynco-shared-notification
|
||||||
|
policyTypes: [Egress]
|
||||||
|
egress:
|
||||||
|
- to:
|
||||||
|
- ipBlock:
|
||||||
|
cidr: 0.0.0.0/0
|
||||||
|
except:
|
||||||
|
- 10.0.0.0/8
|
||||||
|
- 100.64.0.0/10
|
||||||
|
- 127.0.0.0/8
|
||||||
|
- 169.254.0.0/16
|
||||||
|
- 172.16.0.0/12
|
||||||
|
- 192.168.0.0/16
|
||||||
|
- 224.0.0.0/4
|
||||||
|
- 240.0.0.0/4
|
||||||
|
ports:
|
||||||
|
- protocol: TCP
|
||||||
|
port: 587
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ The service/catalog lookup, validation, revision check, explicit removals, trans
|
|||||||
|
|
||||||
## Email channels
|
## Email channels
|
||||||
|
|
||||||
|
In the admin channel editor, **Send Test Email** tests the form's current values without saving them. An explicitly entered password takes precedence. When the password field is blank for an existing channel, the Web server reads its saved password only if host, port, username, and SSL/TLS mode still match the saved SMTP connection. Changed connection settings require an explicit password. The saved password is not placed in the page or test response. A new channel keeps the ad-hoc test behavior, including unauthenticated SMTP when username and password are empty.
|
||||||
|
|
||||||
|
Saving an existing channel follows the same password rule: a blank password keeps the saved value when host, port, username, and SSL/TLS mode are unchanged, so sender name/address and limit edits do not require re-entering it. A changed SMTP connection needs an explicitly entered password. Creating a channel in the admin editor still requires a password. Validation errors redisplay the editor without rendering the saved or submitted password.
|
||||||
|
|
||||||
- `GET /api/v1/email-channels/all` enumerates **all** channels, including channels whose exact `serviceName` has no service row.
|
- `GET /api/v1/email-channels/all` enumerates **all** channels, including channels whose exact `serviceName` has no service row.
|
||||||
- Existing `GET /api/v1/email-channels?serviceName={exactName}`, GET by ID, POST, PUT by ID and DELETE by ID remain available. Create returns 201/Location and its existing GUID response; update/delete return 200/Unit.
|
- Existing `GET /api/v1/email-channels?serviceName={exactName}`, GET by ID, POST, PUT by ID and DELETE by ID remain available. Create returns 201/Location and its existing GUID response; update/delete return 200/Unit.
|
||||||
- Create supports SMTP (`channelType: 1`). Request/response `settings` explicitly maps `host`, `port`, `username`, `password`, `useSsl`, `fromEmail`, and `fromName`; reads no longer lose derived SMTP fields through an abstract JSON type. Host/fromEmail and a valid port are required. The optional empty username/password support unauthenticated development SMTP.
|
- Create supports SMTP (`channelType: 1`). Request/response `settings` explicitly maps `host`, `port`, `username`, `password`, `useSsl`, `fromEmail`, and `fromName`; reads no longer lose derived SMTP fields through an abstract JSON type. Host/fromEmail and a valid port are required. The optional empty username/password support unauthenticated development SMTP.
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ The separate **Delete language and versions…** confirmation shows the current
|
|||||||
|
|
||||||
The administration starts at `/admin/services` (also the root redirect). A service name opens `/admin/services/{serviceId}`, which lists only that service's Email Templates, including definitions without translations. **Edit Service** is a separate action at `/admin/services/{id}/edit` for Name and the inline language table. There is no independent global Email Templates menu or extra page for a key: Template Key identifies the template edited directly from this service list. Create/edit/save/delete routes live under `/admin/services/{serviceId}/templates`; breadcrumbs, cancel and successful saves/deletes return to the selected service and retain its exact Key filter. Route IDs are bound explicitly from the route and cannot be replaced by form/query values. GET/POST validate definition ownership, and the mutation transaction also checks the selected service ID. Legacy global GET links redirect to the canonical service/template when identifiable, otherwise to Services; old global template POST routes are not retained. The language-specific API is unchanged.
|
The administration starts at `/admin/services` (also the root redirect). A service name opens `/admin/services/{serviceId}`, which lists only that service's Email Templates, including definitions without translations. **Edit Service** is a separate action at `/admin/services/{id}/edit` for Name and the inline language table. There is no independent global Email Templates menu or extra page for a key: Template Key identifies the template edited directly from this service list. Create/edit/save/delete routes live under `/admin/services/{serviceId}/templates`; breadcrumbs, cancel and successful saves/deletes return to the selected service and retain its exact Key filter. Route IDs are bound explicitly from the route and cannot be replaced by form/query values. GET/POST validate definition ownership, and the mutation transaction also checks the selected service ID. Legacy global GET links redirect to the canonical service/template when identifiable, otherwise to Services; old global template POST routes are not retained. The language-specific API is unchanged.
|
||||||
|
|
||||||
The service's exact configured language codes appear near its heading. Each template row lists every catalog language with a text-and-color **Present** or **Missing** status, comparing exact ordinal codes (`en` and `EN`, or `ru` and `ru-RU`, remain distinct). A definition with no versions shows all configured languages as Missing; a service with no catalog has an explicit no-languages state. Status display never creates translations. The GET Key filter, Filter and Clear controls live in the Template Key column header, which stays visible for an empty service or filter miss; the scoped header strip has a contrasting blue background. Clear returns to the same service without a key filter, while create/edit/save/cancel retain the selected key filter.
|
The service's exact configured language codes appear near its heading. Each template row lists every catalog language with a text-and-color **Present** or **Missing** status, comparing exact ordinal codes (`en` and `EN`, or `ru` and `ru-RU`, remain distinct). The editor repeats these statuses on its language tabs. They show saved versions, including after a failed form submission; unsaved field changes do not alter the status. A definition with no versions shows all configured languages as Missing; a service with no catalog has an explicit no-languages state. Status display never creates translations. The GET Key filter, Filter and Clear controls live in the Template Key column header, which stays visible for an empty service or filter miss; the scoped header strip has a contrasting blue background. Clear returns to the same service without a key filter, while create/edit/save/cancel retain the selected key filter. The Action header and controls are centered in all four administration grids; their column widths are unchanged. The editor initially shows fourteen rows for Shared variables, fifteen for HTML Body and eight for Text Body, without changing their content or contracts.
|
||||||
|
|
||||||
A template belongs to the selected service; its create/edit form shows Template Key and a tab for **every** language configured in that service. Languages are added, renamed and removed only through Edit Service. A tab with Subject, HTML Body and Text Body all blank does not persist a version; clearing all three removes an existing version while retaining the definition. A partially populated tab requires all three fields and prevents the entire save if incomplete. All tabs may be blank, and a service with no configured languages may still have a key/shared-variables definition. Submitted tabs must match the current service catalog exactly, including uniqueness. Tabs retain unsaved content and support Left/Right/Home/End keyboard navigation. Shared variables appear once as a camelCase JSON array: `[{"name":"RecipientName","required":true}]`. Each variable has a unique nonblank name and a boolean `required`; unknown JSON properties and malformed shapes are rejected. Variable contracts compare Name and Required semantically, independent of variable order.
|
A template belongs to the selected service; its create/edit form shows Template Key and a tab for **every** language configured in that service. Languages are added, renamed and removed only through Edit Service. A tab with Subject, HTML Body and Text Body all blank does not persist a version; clearing all three removes an existing version while retaining the definition. A partially populated tab requires all three fields and prevents the entire save if incomplete. All tabs may be blank, and a service with no configured languages may still have a key/shared-variables definition. Submitted tabs must match the current service catalog exactly, including uniqueness. Tabs retain unsaved content and support Left/Right/Home/End keyboard navigation. Shared variables appear once as a camelCase JSON array: `[{"name":"RecipientName","required":true}]`. Each variable has a unique nonblank name and a boolean `required`; unknown JSON properties and malformed shapes are rejected. Variable contracts compare Name and Required semantically, independent of variable order.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user