Add default server email rules in migration

This commit is contained in:
Nicolas Dorier
2025-11-09 23:31:51 +09:00
parent dcf60e20b9
commit 3948eb13cd
18 changed files with 174 additions and 28 deletions

View File

@@ -11,7 +11,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Migrations.Operations;
namespace BTCPayServer.Abstractions.Contracts
{
public abstract class BaseDbContextFactory<T> where T : DbContext
public abstract class BaseDbContextFactory<T> : IDbContextFactory<T> where T : DbContext
{
private readonly IOptions<DatabaseOptions> _options;
private readonly string _migrationTableName;
@@ -90,5 +90,8 @@ namespace BTCPayServer.Abstractions.Contracts
var searchPaths = connectionStringBuilder.SearchPath?.Split(',');
return searchPaths is not { Length: > 0 } ? null : searchPaths[0];
}
T IDbContextFactory<T>.CreateDbContext()
=> this.CreateContext();
}
}

View File

@@ -15,11 +15,6 @@ public class ConfigureEmailPMO(PlaywrightTester s)
public string? Password { get; set; }
public bool? EnabledCertificateCheck { get; set; }
}
public async Task UseMailPit()
{
await s.Page.ClickAsync("#mailpit");
await s.FindAlertMessage(StatusMessageModel.StatusSeverity.Info);
}
public async Task<EmailRulesPMO> ConfigureEmailRules()
{
@@ -27,14 +22,14 @@ public class ConfigureEmailPMO(PlaywrightTester s)
return new EmailRulesPMO(s);
}
public Task FillMailPit(Form form)
public Task FillMailPit(Form? form = null)
=> Fill(new()
{
Server = s.Server.MailPitSettings.Hostname,
Port = s.Server.MailPitSettings.SmtpPort,
From = form.From,
Login = form.Login,
Password = form.Password,
From = form?.From ?? "from@example.com",
Login = form?.Login ?? "login@example.com",
Password = form?.Password ?? "password",
EnabledCertificateCheck = false,
});

View File

@@ -10,6 +10,11 @@ public class EmailRulesPMO(PlaywrightTester s)
await s.Page.ClickAsync("#CreateEmailRule");
return new EmailRulePMO(s);
}
public async Task EditRule(string trigger, int nth = 0)
{
await s.Page.ClickAsync($"tr[data-trigger='{trigger}']:nth-child({nth + 1}) a:has-text('Edit')");
}
}
public class EmailRulePMO(PlaywrightTester s)

View File

@@ -1535,9 +1535,9 @@ namespace BTCPayServer.Tests
await s.GoToServer(ServerNavPages.Emails);
await mailPMO.UseMailPit();
await mailPMO.FillMailPit();
var rules = await mailPMO.ConfigureEmailRules();
await rules.CreateEmailRule();
await rules.EditRule("SRV-PasswordReset");
await pmo.Fill(new()
{
Trigger = "SRV-PasswordReset",
@@ -1610,11 +1610,10 @@ namespace BTCPayServer.Tests
await s.GoToInvoices();
await s.ClickPagePrimary();
Assert.Contains("To create an invoice, you need to", await s.Page.ContentAsync());
await s.AddDerivationScheme();
await s.GoToInvoices();
var invoiceId = await s.CreateInvoice();
await s.CreateInvoice();
await s.Page.ClickAsync("[data-invoice-state-badge] .dropdown-toggle");
await s.Page.ClickAsync("[data-invoice-state-badge] .dropdown-menu button:first-child");
await TestUtils.EventuallyAsync(async () => Assert.Contains("Invalid (marked)", await s.Page.ContentAsync()));

View File

@@ -20,6 +20,7 @@ using NBitpayClient;
using NBXplorer;
using BTCPayServer.Abstractions.Contracts;
using System.Diagnostics.Metrics;
using System.Threading;
using BTCPayServer.Events;
namespace BTCPayServer.Tests
@@ -49,7 +50,7 @@ namespace BTCPayServer.Tests
GetEnvironment("TESTS_MAILPIT_HOST", "127.0.0.1"),
int.Parse(GetEnvironment("TESTS_MAILPIT_SMTP", "34219")),
int.Parse(GetEnvironment("TESTS_MAILPIT_HTTP", "34218")));
TestLogs.LogInformation($"MailPit settings: http://{MailPitSettings.Hostname}:{MailPitSettings.HttpPort} (SMTP: {MailPitSettings.SmtpPort})");
_NetworkProvider = networkProvider;
ExplorerNode = new RPCClient(RPCCredentialString.Parse(GetEnvironment("TESTS_BTCRPCCONNECTION", "server=http://127.0.0.1:43782;ceiwHEbqWI83:DwubwWsoo3")), NetworkProvider.GetNetwork<BTCPayNetwork>("BTC").NBitcoinNetwork);
ExplorerNode.ScanRPCCapabilities();
@@ -198,6 +199,8 @@ namespace BTCPayServer.Tests
public async Task<T> WaitForEvent<T>(Func<Task> action, Func<T, bool> correctEvent = null)
{
var tcs = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await using var register = cts.Token.Register(() => tcs.TrySetCanceled());
var sub = PayTester.GetService<EventAggregator>().SubscribeAny<T>(evt =>
{
if (correctEvent is null)

View File

@@ -3352,14 +3352,17 @@ namespace BTCPayServer.Tests
Assert.Equal("admin@admin.com", (await Assert.IsType<ServerEmailSender>(await emailSenderFactory.GetEmailSender()).GetEmailSettings()).Login);
Assert.Null(await Assert.IsType<StoreEmailSender>(await emailSenderFactory.GetEmailSender(acc.StoreId)).GetEmailSettings());
Assert.IsType<RedirectToActionResult>(await acc.GetController<UIServerEmailController>().ServerEmailSettings(new (new EmailSettings
Assert.IsType<RedirectToActionResult>(await acc.GetController<UIStoresEmailController>().StoreEmailSettings(acc.StoreId, new(new()
{
From = "store@store.com",
Login = "store@store.com",
Password = "store@store.com",
Port = tester.MailPitSettings.SmtpPort,
Server = tester.MailPitSettings.Hostname
}), ""));
})
{
IsCustomSMTP = true
}, ""));
Assert.Equal("store@store.com", (await Assert.IsType<StoreEmailSender>(await emailSenderFactory.GetEmailSender(acc.StoreId)).GetEmailSettings()).Login);
@@ -3370,6 +3373,26 @@ namespace BTCPayServer.Tests
});
Assert.Equal("test", message.Subject);
Assert.Equal("hello world", message.Text);
// Configure at server level
Assert.IsType<RedirectToActionResult>(await acc.GetController<UIServerEmailController>().ServerEmailSettings(new(new()
{
From = "server@server.com",
Login = "server@server.com",
Password = "server@server.com",
Port = tester.MailPitSettings.SmtpPort,
Server = tester.MailPitSettings.Hostname
})
{
EnableStoresToUseServerEmailSettings = true
}, ""));
// The store should now use it
Assert.IsType<RedirectToActionResult>(await acc.GetController<UIStoresEmailController>().StoreEmailSettings(acc.StoreId, new(new())
{
IsCustomSMTP = false
}, ""));
Assert.Equal("server@server.com", (await Assert.IsType<StoreEmailSender>(await emailSenderFactory.GetEmailSender(acc.StoreId)).GetEmailSettings()).Login);
}
[Fact(Timeout = TestUtils.TestTimeout)]

View File

@@ -0,0 +1,13 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
namespace BTCPayServer.Data;
public abstract class MigrationBase<TDbContext>(string migrationId)
where TDbContext : DbContext
{
public string MigrationId { get; } = migrationId;
public abstract Task MigrateAsync(TDbContext dbContext, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,48 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.Extensions.Logging;
namespace BTCPayServer.Data;
public interface IMigrationExecutor
{
Task Execute(CancellationToken cancellationToken);
}
public class MigrationExecutor<TDbContext>(
ILoggerFactory loggerFactory,
IDbContextFactory<TDbContext> dbContextFactory,
IEnumerable<MigrationBase<TDbContext>> migrations) : IMigrationExecutor
where TDbContext : DbContext
{
ILogger logger = loggerFactory.CreateLogger($"BTCPayServer.Migrations.{typeof(TDbContext).Name}");
public async Task Execute(CancellationToken cancellationToken)
{
await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
var history = dbContext.Database.GetService<IHistoryRepository>();
var appliedMigrations = (await history.GetAppliedMigrationsAsync(cancellationToken)).Select(m => m.MigrationId).ToHashSet();
var insertedRows = new List<HistoryRow>();
foreach (var migration in migrations
.Where(m => !appliedMigrations.Contains(m.MigrationId))
.OrderBy(m => m.MigrationId))
{
logger.LogInformation("Applying migration '{MigrationId}'", migration.MigrationId);
await migration.MigrateAsync(dbContext, cancellationToken);
insertedRows.Add(new HistoryRow(migration.MigrationId, ProductInfo.GetVersion()));
}
if (insertedRows.Count > 0)
{
await dbContext.SaveChangesAsync(cancellationToken);
var insertMigrations =
string.Concat(insertedRows
.Select(r => history.GetInsertScript(r))
.ToArray());
await dbContext.Database.ExecuteSqlRawAsync(insertMigrations, cancellationToken);
}
}
}

View File

@@ -36,6 +36,7 @@ using BTCPayServer.Services.Wallets;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
@@ -454,6 +455,15 @@ namespace BTCPayServer
return services;
}
public static IServiceCollection AddMigration<TDbContext, TMigration>(this IServiceCollection services)
where TDbContext : DbContext
where TMigration : MigrationBase<TDbContext>
{
services.TryAddSingleton<IMigrationExecutor, MigrationExecutor<TDbContext>>();
services.TryAddSingleton<MigrationBase<TDbContext>, TMigration>();
return services;
}
public static async Task CloseSocket(this WebSocket webSocket)
{
try

View File

@@ -74,6 +74,7 @@ using ExchangeSharp;
using Microsoft.Extensions.Localization;
using Microsoft.AspNetCore.Mvc.Localization;
using System.Reflection;
using Microsoft.EntityFrameworkCore;
namespace BTCPayServer.Hosting
{
@@ -96,6 +97,9 @@ namespace BTCPayServer.Hosting
services.AddSingleton<MvcNewtonsoftJsonOptions>(o => o.GetRequiredService<IOptions<MvcNewtonsoftJsonOptions>>().Value);
services.AddSingleton<JsonSerializerSettings>(o => o.GetRequiredService<IOptions<MvcNewtonsoftJsonOptions>>().Value.SerializerSettings);
services.AddSingleton<IDbContextFactory<ApplicationDbContext>, ApplicationDbContextFactory>((provider) => provider.GetRequiredService<ApplicationDbContextFactory>());
services.AddSingleton<IMigrationExecutor, MigrationExecutor<ApplicationDbContext>>();
services.AddDbContext<ApplicationDbContext>((provider, o) =>
{
var factory = provider.GetRequiredService<ApplicationDbContextFactory>();

View File

@@ -41,6 +41,7 @@ namespace BTCPayServer.Hosting
private readonly ApplicationDbContextFactory _DBContextFactory;
private readonly StoreRepository _StoreRepository;
private readonly IEnumerable<IMigrationExecutor> _migrationExecutors;
private readonly PaymentMethodHandlerDictionary _handlers;
private readonly SettingsRepository _Settings;
private readonly AppService _appService;
@@ -54,6 +55,7 @@ namespace BTCPayServer.Hosting
public IOptions<LightningNetworkOptions> LightningOptions { get; }
public MigrationStartupTask(
IEnumerable<IMigrationExecutor> migrationExecutors,
PaymentMethodHandlerDictionary handlers,
StoreRepository storeRepository,
ApplicationDbContextFactory dbContextFactory,
@@ -67,6 +69,7 @@ namespace BTCPayServer.Hosting
IFileService fileService,
LightningClientFactoryService lightningClientFactoryService)
{
_migrationExecutors = migrationExecutors;
_handlers = handlers;
_DBContextFactory = dbContextFactory;
_StoreRepository = storeRepository;
@@ -230,6 +233,11 @@ namespace BTCPayServer.Hosting
settings.MigrateOldDerivationSchemes = true;
await _Settings.UpdateSetting(settings);
}
foreach (var executor in _migrationExecutors)
{
await executor.Execute(cancellationToken);
}
}
catch (Exception ex)
{

View File

@@ -0,0 +1,37 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Plugins.Emails.Views;
namespace BTCPayServer.Plugins.Emails;
public class DefaultServerEmailRulesMigration(IEnumerable<EmailTriggerViewModel> vms) : MigrationBase<ApplicationDbContext>("20251109_defaultserverrules")
{
public override Task MigrateAsync(ApplicationDbContext dbContext, CancellationToken cancellationToken)
{
var defaultRules = new string[] {
ServerMailTriggers.PasswordReset,
ServerMailTriggers.InvitePending,
ServerMailTriggers.ApprovalConfirmed,
ServerMailTriggers.ApprovalPending,
ServerMailTriggers.EmailConfirm,
ServerMailTriggers.ApprovalRequest,
ServerMailTriggers.InviteConfirmed
}.ToHashSet();
foreach (var vm in vms.Where(v => defaultRules.Contains(v.Trigger)))
{
dbContext.EmailRules.Add(new()
{
To = vm.DefaultEmail.To,
CC = vm.DefaultEmail.CC,
BCC = vm.DefaultEmail.BCC,
Trigger = vm.Trigger,
Subject = vm.DefaultEmail.Subject,
Body = vm.DefaultEmail.Body,
});
}
return Task.CompletedTask;
}
}

View File

@@ -1,5 +1,6 @@
using System.Collections.Generic;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Data;
using BTCPayServer.Plugins.Emails.HostedServices;
using BTCPayServer.Plugins.Emails.Views;
using BTCPayServer.Plugins.Webhooks;
@@ -21,6 +22,7 @@ public class EmailsPlugin : BaseBTCPayServerPlugin
services.AddSingleton<IDefaultTranslationProvider, EmailsTranslationProvider>();
services.AddSingleton<IHostedService, StoreEmailRuleProcessorSender>();
services.AddSingleton<IHostedService, UserEventHostedService>();
services.AddMigration<ApplicationDbContext, DefaultServerEmailRulesMigration>();
RegisterServerEmailTriggers(services);
}
private static string BODY_STYLE = "font-family: Open Sans, Helvetica Neue,Arial,sans-serif; font-color: #292929;";

View File

@@ -81,7 +81,7 @@ else
<tbody>
@foreach (var rule in Model.Rules)
{
<tr>
<tr data-trigger="@rule.Trigger">
<td>@rule.Trigger</td>
@if (Model.ShowCustomerEmailColumn)
{
@@ -94,12 +94,12 @@ else
@if (storeId is not null)
{
<a asp-action="StoreEmailRulesEdit" asp-route-storeId="@storeId" asp-route-ruleId="@rule.Data.Id">Edit</a>
<a asp-action="StoreEmailRulesEdit" asp-route-storeId="@storeId" asp-route-ruleId="@rule.Data.Id" text-translate="true">Edit</a>
<a asp-action="StoreEmailRulesDelete" asp-route-storeId="@storeId" asp-route-ruleId="@rule.Data.Id" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-description="@ViewLocalizer["This action will remove the rule with the trigger <b>{0}</b>.", Html.Encode(rule.Trigger)]" data-confirm-input="@StringLocalizer["Delete"]" text-translate="true">Remove</a>
}
else
{
<a asp-action="ServerEmailRulesEdit" asp-route-ruleId="@rule.Data.Id">Edit</a>
<a asp-action="ServerEmailRulesEdit" asp-route-ruleId="@rule.Data.Id" text-translate="true">Edit</a>
<a asp-action="ServerEmailRulesDelete" asp-route-ruleId="@rule.Data.Id" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-description="@ViewLocalizer["This action will remove the rule with the trigger <b>{0}</b>.", Html.Encode(rule.Trigger)]" data-confirm-input="@StringLocalizer["Delete"]" text-translate="true">Remove</a>
}
</div >

View File

@@ -180,7 +180,6 @@
var body = triggersByType[selectedTrigger].defaultEmail.body;
if ($(bodyTextarea).summernote) {
console.log(body);
$(bodyTextarea).summernote('reset');
$(bodyTextarea).summernote('code', body.replace(/\n/g, '<br/>'));
} else {
@@ -213,7 +212,7 @@
const customerEmailContainer = document.querySelector('.customer-email-container');
const customerEmailCheckbox = document.querySelector('.customer-email-checkbox');
const selectedTrigger = triggerSelect.value;
if (triggersByType[selectedTrigger].canIncludeCustomerEmail) {
if (triggersByType[selectedTrigger].defaultEmail.canIncludeCustomerEmail) {
customerEmailContainer.style.display = 'block';
} else {
customerEmailContainer.style.display = 'none';

View File

@@ -34,7 +34,7 @@
Go to email rules
</a>
}
<button id="page-primary" type="submit" class="btn btn-primary" name="command" value="Save">Save</button>
<button id="page-primary" type="submit" class="btn btn-primary" name="command" value="Save" permission="@Model.ModifyPermission">Save</button>
</div>
</div>
<partial name="_StatusMessage" />

View File

@@ -58,6 +58,7 @@ namespace BTCPayServer
// Uncomment this to see EF queries
//l.AddFilter("Microsoft.EntityFrameworkCore.Database.Command", LogLevel.Trace);
l.AddFilter("Microsoft.EntityFrameworkCore.Migrations", LogLevel.Information);
l.AddFilter("BTCPayServer.Migrations", LogLevel.Information);
l.AddFilter("System.Net.Http.HttpClient", LogLevel.Critical);
l.AddFilter("Microsoft.AspNetCore.Antiforgery.Internal", LogLevel.Critical);
l.AddFilter("Fido2NetLib.DistributedCacheMetadataService", LogLevel.Error);

View File

@@ -47,11 +47,7 @@ namespace BTCPayServer.Services.Mails
EmailSettings? GetCustomSettings(StoreData store)
{
var emailSettings = store.GetStoreBlob().EmailSettings;
if (emailSettings?.IsComplete() is true)
{
return emailSettings;
}
return null;
return emailSettings?.IsComplete() is true ? emailSettings : null;
}
}
}