mirror of
https://github.com/aljazceru/btcpayserver.git
synced 2025-12-17 05:54:26 +01:00
Add default server email rules in migration
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)]
|
||||
|
||||
13
BTCPayServer/Data/MigrationBase.cs
Normal file
13
BTCPayServer/Data/MigrationBase.cs
Normal 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);
|
||||
}
|
||||
48
BTCPayServer/Data/MigrationExecutor.cs
Normal file
48
BTCPayServer/Data/MigrationExecutor.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;";
|
||||
|
||||
@@ -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 >
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user