mirror of
https://github.com/aljazceru/btcpayserver.git
synced 2025-12-17 05:54:26 +01:00
Add Server Email Rules
This commit is contained in:
@@ -97,6 +97,7 @@ namespace BTCPayServer.Abstractions.Extensions
|
||||
return categoryAndPageMatch && idMatch;
|
||||
}
|
||||
|
||||
[Obsolete()]
|
||||
public static bool IsPageActive<T>(this ViewDataDictionary viewData, IEnumerable<T> pages, object id = null)
|
||||
where T : IConvertible
|
||||
{
|
||||
|
||||
@@ -69,16 +69,29 @@ public static partial class ApplicationDbContextExtensions
|
||||
public static IQueryable<EmailRuleData> GetRules(this IQueryable<EmailRuleData> query, string storeId)
|
||||
=> query.Where(o => o.StoreId == storeId)
|
||||
.OrderBy(o => o.Id);
|
||||
public static IQueryable<EmailRuleData> GetServerRules(this IQueryable<EmailRuleData> query)
|
||||
=> query.Where(o => o.StoreId == null).OrderBy(o => o.Id);
|
||||
|
||||
public static Task<EmailRuleData[]> GetMatches(this DbSet<EmailRuleData> set, string? storeId, string trigger, JObject model)
|
||||
=> set
|
||||
=>
|
||||
storeId is null
|
||||
? set
|
||||
.FromSqlInterpolated($"""
|
||||
SELECT * FROM email_rules
|
||||
WHERE store_id IS NOT DISTINCT FROM {storeId} AND trigger = {trigger} AND (condition IS NULL OR jsonb_path_exists({model.ToString()}::JSONB, condition::JSONPATH))
|
||||
WHERE store_id IS NULL AND trigger = {trigger} AND (condition IS NULL OR jsonb_path_exists({model.ToString()}::JSONB, condition::JSONPATH))
|
||||
""")
|
||||
.ToArrayAsync()
|
||||
: set
|
||||
.FromSqlInterpolated($"""
|
||||
SELECT * FROM email_rules
|
||||
WHERE store_id = {storeId} AND trigger = {trigger} AND (condition IS NULL OR jsonb_path_exists({model.ToString()}::JSONB, condition::JSONPATH))
|
||||
""")
|
||||
.ToArrayAsync();
|
||||
|
||||
public static Task<EmailRuleData?> GetRule(this IQueryable<EmailRuleData> query, string storeId, long id)
|
||||
=> query.Where(o => o.StoreId == storeId && o.Id == id)
|
||||
.FirstOrDefaultAsync();
|
||||
public static Task<EmailRuleData?> GetServerRule(this IQueryable<EmailRuleData> query, long id)
|
||||
=> query.Where(o => o.StoreId == null && o.Id == id)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#nullable enable
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Abstractions.Models;
|
||||
|
||||
namespace BTCPayServer.Tests.PMO;
|
||||
|
||||
@@ -14,6 +15,17 @@ 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()
|
||||
{
|
||||
await s.Page.ClickAsync("#ConfigureEmailRules");
|
||||
return new EmailRulesPMO(s);
|
||||
}
|
||||
|
||||
public Task FillMailPit(Form form)
|
||||
=> Fill(new()
|
||||
|
||||
@@ -3,6 +3,15 @@ using System.Threading.Tasks;
|
||||
|
||||
namespace BTCPayServer.Tests.PMO;
|
||||
|
||||
public class EmailRulesPMO(PlaywrightTester s)
|
||||
{
|
||||
public async Task<EmailRulePMO> CreateEmailRule()
|
||||
{
|
||||
await s.Page.ClickAsync("#CreateEmailRule");
|
||||
return new EmailRulePMO(s);
|
||||
}
|
||||
}
|
||||
|
||||
public class EmailRulePMO(PlaywrightTester s)
|
||||
{
|
||||
public class Form
|
||||
@@ -13,6 +22,7 @@ public class EmailRulePMO(PlaywrightTester s)
|
||||
public string? Body { get; set; }
|
||||
public bool? CustomerEmail { get; set; }
|
||||
public string? Condition { get; set; }
|
||||
public bool HtmlBody { get; set; }
|
||||
}
|
||||
|
||||
public async Task Fill(Form form)
|
||||
@@ -26,7 +36,18 @@ public class EmailRulePMO(PlaywrightTester s)
|
||||
if (form.Subject is not null)
|
||||
await s.Page.FillAsync("#Subject", form.Subject);
|
||||
if (form.Body is not null)
|
||||
await s.Page.Locator(".note-editable").FillAsync(form.Body);
|
||||
{
|
||||
if (form.HtmlBody)
|
||||
{
|
||||
await s.Page.ClickAsync(".btn-codeview");
|
||||
await s.Page.FillAsync(".note-codable", form.Body);
|
||||
}
|
||||
else
|
||||
{
|
||||
await s.Page.FillAsync(".note-editable", form.Body);
|
||||
}
|
||||
}
|
||||
|
||||
if (form.CustomerEmail is {} v)
|
||||
await s.Page.SetCheckedAsync("#AdditionalData_CustomerEmail", v);
|
||||
await s.ClickPagePrimary();
|
||||
|
||||
@@ -25,7 +25,7 @@ namespace BTCPayServer.Tests
|
||||
public class PlaywrightTester : IAsyncDisposable
|
||||
{
|
||||
public Uri ServerUri;
|
||||
private string CreatedUser;
|
||||
public string CreatedUser;
|
||||
internal string InvoiceId;
|
||||
public Logging.ILog TestLogs => Server.TestLogs;
|
||||
public IPage Page { get; set; }
|
||||
@@ -369,7 +369,11 @@ namespace BTCPayServer.Tests
|
||||
public async Task GoToServer(ServerNavPages navPages = ServerNavPages.Policies)
|
||||
{
|
||||
await Page.ClickAsync("#menu-item-Policies");
|
||||
if (navPages != ServerNavPages.Policies)
|
||||
if (navPages == ServerNavPages.Emails)
|
||||
{
|
||||
await Page.ClickAsync($"#menu-item-Server-{navPages}");
|
||||
}
|
||||
else if (navPages != ServerNavPages.Policies)
|
||||
{
|
||||
await Page.ClickAsync($"#menu-item-{navPages}");
|
||||
}
|
||||
|
||||
@@ -1496,8 +1496,7 @@ namespace BTCPayServer.Tests
|
||||
|
||||
await s.AddDerivationScheme();
|
||||
await s.GoToInvoices();
|
||||
var sent = await s.Server.WaitForEvent<EmailSentEvent>(() => s.CreateInvoice(amount: 10m, currency: "USD"));
|
||||
var message = await s.Server.AssertHasEmail(sent);
|
||||
var message = await s.Server.AssertHasEmail(() => s.CreateInvoice(amount: 10m, currency: "USD"));
|
||||
Assert.Equal("Invoice has been created in USD for 10!", message.Text);
|
||||
|
||||
await s.GoToUrl(rulesUrl);
|
||||
@@ -1529,11 +1528,27 @@ namespace BTCPayServer.Tests
|
||||
});
|
||||
|
||||
await s.GoToInvoices();
|
||||
sent = await s.Server.WaitForEvent<EmailSentEvent>(() => s.CreateInvoice(amount: 10m, currency: "USD", refundEmail: "john@test.com"));
|
||||
message = await s.Server.AssertHasEmail(sent);
|
||||
message = await s.Server.AssertHasEmail(() => s.CreateInvoice(amount: 10m, currency: "USD", refundEmail: "john@test.com"));
|
||||
Assert.Equal("Invoice Created in USD for " + storeName + "!", message.Subject);
|
||||
Assert.Equal("Invoice has been created in USD for 10!", message.Text);
|
||||
Assert.Equal("john@test.com", message.To[0].Address);
|
||||
|
||||
await s.GoToServer(ServerNavPages.Emails);
|
||||
|
||||
await mailPMO.UseMailPit();
|
||||
var rules = await mailPMO.ConfigureEmailRules();
|
||||
await rules.CreateEmailRule();
|
||||
await pmo.Fill(new()
|
||||
{
|
||||
Trigger = "SRV-PasswordReset",
|
||||
HtmlBody = true,
|
||||
Body = "<p>Hello, <a id=\"reset-link\" href=\"{ResetLink}\">click here</a> to reset the password</p>"
|
||||
});
|
||||
await s.Logout();
|
||||
await s.Page.GetByRole(AriaRole.Link, new() { Name = "Forgot password?" }).ClickAsync();
|
||||
await s.Page.FillAsync("#Email", s.CreatedUser);
|
||||
message = await s.Server.AssertHasEmail(() => s.ClickPagePrimary());
|
||||
Assert.Contains("<p>Hello, <a id=\"reset-link\" href=\"http://", message.Html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -300,6 +300,12 @@ namespace BTCPayServer.Tests
|
||||
return await mailPitClient.GetMessage(sent.ServerResponse.Split(' ').Last());
|
||||
}
|
||||
|
||||
public async Task<MailPitClient.Message> AssertHasEmail(Func<Task> action)
|
||||
{
|
||||
var sent = await WaitForEvent<EmailSentEvent>(action);
|
||||
return await AssertHasEmail(sent);
|
||||
}
|
||||
|
||||
public MailPitClient GetMailPitClient()
|
||||
{
|
||||
var http = PayTester.GetService<IHttpClientFactory>().CreateClient("MAIL_PIT");
|
||||
|
||||
@@ -3352,7 +3352,7 @@ 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<UIStoresEmailController>().StoreEmailSettings(acc.StoreId, new EmailsViewModel(new EmailSettings
|
||||
Assert.IsType<RedirectToActionResult>(await acc.GetController<UIServerEmailController>().ServerEmailSettings(new (new EmailSettings
|
||||
{
|
||||
From = "store@store.com",
|
||||
Login = "store@store.com",
|
||||
@@ -3363,13 +3363,11 @@ namespace BTCPayServer.Tests
|
||||
|
||||
Assert.Equal("store@store.com", (await Assert.IsType<StoreEmailSender>(await emailSenderFactory.GetEmailSender(acc.StoreId)).GetEmailSettings()).Login);
|
||||
|
||||
var sent = await tester.WaitForEvent<Events.EmailSentEvent>(
|
||||
async () =>
|
||||
var message = await tester.AssertHasEmail(async () =>
|
||||
{
|
||||
var sender = await emailSenderFactory.GetEmailSender(acc.StoreId);
|
||||
sender.SendEmail(MailboxAddress.Parse("destination@test.com"), "test", "hello world");
|
||||
});
|
||||
var message = await tester.AssertHasEmail(sent);
|
||||
Assert.Equal("test", message.Subject);
|
||||
Assert.Equal("hello world", message.Text);
|
||||
}
|
||||
|
||||
@@ -51,6 +51,10 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BTCPayServer.NTag424" Version="1.0.25" />
|
||||
<PackageReference Include="JetBrains.Annotations.Sources" Version="2025.2.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="NBitcoin" Version="9.0.0" />
|
||||
<PackageReference Include="YamlDotNet" Version="8.0.0" />
|
||||
<PackageReference Include="BIP78.Sender" Version="0.2.5" />
|
||||
@@ -193,5 +197,12 @@
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<_ContentIncludedByDefault Remove="Plugins\Emails\Views\UIStoreEmailRules\StoreEmailRulesList.cshtml" />
|
||||
<_ContentIncludedByDefault Remove="Plugins\Emails\Views\UIStoreEmailRules\StoreEmailRulesManage.cshtml" />
|
||||
<_ContentIncludedByDefault Remove="Plugins\Emails\Views\UIServerEmail\ServerEmailSettings.cshtml" />
|
||||
<_ContentIncludedByDefault Remove="Plugins\Emails\Views\UIStoresEmail\StoreEmailSettings.cshtml" />
|
||||
</ItemGroup>
|
||||
|
||||
<ProjectExtensions><VisualStudio><UserProperties wwwroot_4swagger_4v1_4swagger_1template_1invoices_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1misc_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1pull-payments_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1serverinfo_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1stores_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1stores-payment-methods_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1stores-users_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1users_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1webhooks_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" /></VisualStudio></ProjectExtensions>
|
||||
</Project>
|
||||
|
||||
@@ -314,7 +314,7 @@
|
||||
<a layout-menu-item="@nameof(ServerNavPages.Roles)" asp-controller="UIServer" asp-action="ListRoles" text-translate="true">Roles</a>
|
||||
</li>
|
||||
<li class="nav-item nav-item-sub" permission="@Policies.CanModifyServerSettings">
|
||||
<a layout-menu-item="Server-@nameof(ServerNavPages.Emails)" asp-controller="UIServer" asp-action="Emails" text-translate="true">Email</a>
|
||||
<a layout-menu-item="Server-@nameof(ServerNavPages.Emails)" asp-area="@EmailsPlugin.Area" asp-controller="UIServerEmail" asp-action="ServerEmailSettings" text-translate="true">Email</a>
|
||||
</li>
|
||||
<li class="nav-item nav-item-sub" permission="@Policies.CanModifyServerSettings">
|
||||
<a layout-menu-item="@nameof(ServerNavPages.Services)" asp-controller="UIServer" asp-action="Services" text-translate="true">Services</a>
|
||||
|
||||
@@ -66,9 +66,9 @@ namespace BTCPayServer.Controllers
|
||||
private readonly IEnumerable<IStorageProviderService> _StorageProviderServices;
|
||||
private readonly CallbackGenerator _callbackGenerator;
|
||||
private readonly UriResolver _uriResolver;
|
||||
private readonly EmailSenderFactory _emailSenderFactory;
|
||||
private readonly TransactionLinkProviders _transactionLinkProviders;
|
||||
private readonly LocalizerService _localizer;
|
||||
private readonly EmailSenderFactory _emailSenderFactory;
|
||||
public IStringLocalizer StringLocalizer { get; }
|
||||
|
||||
public UIServerController(
|
||||
@@ -76,6 +76,7 @@ namespace BTCPayServer.Controllers
|
||||
UserService userService,
|
||||
StoredFileRepository storedFileRepository,
|
||||
IFileService fileService,
|
||||
EmailSenderFactory emailSenderFactory,
|
||||
IEnumerable<IStorageProviderService> storageProviderServices,
|
||||
BTCPayServerOptions options,
|
||||
SettingsRepository settingsRepository,
|
||||
@@ -92,7 +93,6 @@ namespace BTCPayServer.Controllers
|
||||
Logs logs,
|
||||
CallbackGenerator callbackGenerator,
|
||||
UriResolver uriResolver,
|
||||
EmailSenderFactory emailSenderFactory,
|
||||
IHostApplicationLifetime applicationLifetime,
|
||||
IHtmlHelper html,
|
||||
TransactionLinkProviders transactionLinkProviders,
|
||||
@@ -119,9 +119,9 @@ namespace BTCPayServer.Controllers
|
||||
_eventAggregator = eventAggregator;
|
||||
_externalServiceOptions = externalServiceOptions;
|
||||
Logs = logs;
|
||||
_emailSenderFactory = emailSenderFactory;
|
||||
_callbackGenerator = callbackGenerator;
|
||||
_uriResolver = uriResolver;
|
||||
_emailSenderFactory = emailSenderFactory;
|
||||
ApplicationLifetime = applicationLifetime;
|
||||
Html = html;
|
||||
_transactionLinkProviders = transactionLinkProviders;
|
||||
@@ -1224,99 +1224,6 @@ namespace BTCPayServer.Controllers
|
||||
return View(vm);
|
||||
}
|
||||
|
||||
[HttpGet("server/emails")]
|
||||
public async Task<IActionResult> Emails()
|
||||
{
|
||||
var email = await _emailSenderFactory.GetSettings() ?? new EmailSettings();
|
||||
var vm = new ServerEmailsViewModel(email)
|
||||
{
|
||||
EnableStoresToUseServerEmailSettings = !_policiesSettings.DisableStoresToUseServerEmailSettings
|
||||
};
|
||||
return View(vm);
|
||||
}
|
||||
|
||||
[HttpPost("server/emails")]
|
||||
public async Task<IActionResult> Emails(ServerEmailsViewModel model, string command)
|
||||
{
|
||||
if (command == "Test")
|
||||
{
|
||||
try
|
||||
{
|
||||
if (model.PasswordSet)
|
||||
{
|
||||
var settings = await _emailSenderFactory.GetSettings() ?? new EmailSettings();
|
||||
model.Settings.Password = settings.Password;
|
||||
}
|
||||
model.Settings.Validate("Settings.", ModelState);
|
||||
if (string.IsNullOrEmpty(model.TestEmail))
|
||||
ModelState.AddModelError(nameof(model.TestEmail), new RequiredAttribute().FormatErrorMessage(nameof(model.TestEmail)));
|
||||
if (!ModelState.IsValid)
|
||||
return View(model);
|
||||
var serverSettings = await _SettingsRepository.GetSettingAsync<ServerSettings>();
|
||||
var serverName = string.IsNullOrEmpty(serverSettings?.ServerName) ? "BTCPay Server" : serverSettings.ServerName;
|
||||
using (var client = await model.Settings.CreateSmtpClient())
|
||||
using (var message = model.Settings.CreateMailMessage(MailboxAddress.Parse(model.TestEmail), $"{serverName}: Email test", "You received it, the BTCPay Server SMTP settings work.", false))
|
||||
{
|
||||
await client.SendAsync(message);
|
||||
await client.DisconnectAsync(true);
|
||||
}
|
||||
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email sent to {0}. Please verify you received it.", model.TestEmail].Value;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TempData[WellKnownTempData.ErrorMessage] = ex.Message;
|
||||
}
|
||||
return View(model);
|
||||
}
|
||||
|
||||
if (_policiesSettings.DisableStoresToUseServerEmailSettings == model.EnableStoresToUseServerEmailSettings)
|
||||
{
|
||||
_policiesSettings.DisableStoresToUseServerEmailSettings = !model.EnableStoresToUseServerEmailSettings;
|
||||
await _SettingsRepository.UpdateSetting(_policiesSettings);
|
||||
}
|
||||
|
||||
if (command == "ResetPassword")
|
||||
{
|
||||
var settings = await _SettingsRepository.GetSettingAsync<EmailSettings>() ?? new EmailSettings();
|
||||
settings.Password = null;
|
||||
await _SettingsRepository.UpdateSetting(settings);
|
||||
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email server password reset"].Value;
|
||||
}
|
||||
else if (command == "mailpit")
|
||||
{
|
||||
model.Settings.Server = "localhost";
|
||||
model.Settings.Port = 34219;
|
||||
model.Settings.EnabledCertificateCheck = false;
|
||||
model.Settings.Login ??= "store@example.com";
|
||||
model.Settings.From ??= "store@example.com";
|
||||
model.Settings.Password ??= "password";
|
||||
await _SettingsRepository.UpdateSetting<EmailSettings>(model.Settings);
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel()
|
||||
{
|
||||
Severity = StatusMessageModel.StatusSeverity.Info,
|
||||
AllowDismiss = true,
|
||||
Html = "Mailpit is now running on <a href=\"http://localhost:34218\" target=\"_blank\" class=\"alert-link\">localhost</a>. You can use it to test your SMTP settings."
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
// save if user provided valid email; this will also clear settings if no model.Settings.From
|
||||
if (model.Settings.From is not null && !MailboxAddressValidator.IsMailboxAddress(model.Settings.From))
|
||||
{
|
||||
ModelState.AddModelError("Settings.From", StringLocalizer["Invalid email"]);
|
||||
return View(model);
|
||||
}
|
||||
|
||||
var oldSettings = await _emailSenderFactory.GetSettings() ?? new EmailSettings();
|
||||
if (!string.IsNullOrEmpty(oldSettings.Password))
|
||||
model.Settings.Password = oldSettings.Password;
|
||||
|
||||
await _SettingsRepository.UpdateSetting(model.Settings);
|
||||
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email settings saved"].Value;
|
||||
}
|
||||
return RedirectToAction(nameof(Emails));
|
||||
}
|
||||
|
||||
[Route("server/logs/{file?}")]
|
||||
public async Task<IActionResult> LogsView(string? file = null, int offset = 0, bool download = false)
|
||||
{
|
||||
|
||||
@@ -34,12 +34,6 @@ namespace BTCPayServer.Services
|
||||
$"Your account has been approved and you can now <a href='{HtmlEncoder.Default.Encode(link)}'>login here</a>."));
|
||||
}
|
||||
|
||||
public static void SendResetPassword(this IEmailSender emailSender, MailboxAddress address, string link)
|
||||
{
|
||||
emailSender.SendEmail(address, "Update Password", CreateEmailBody(
|
||||
$"A request has been made to reset your BTCPay Server password. Please set your password by clicking below.<br/><br/>{CallToAction("Update Password", link)}"));
|
||||
}
|
||||
|
||||
public static void SendInvitation(this IEmailSender emailSender, MailboxAddress address, string link)
|
||||
{
|
||||
emailSender.SendEmail(address, "Invitation", CreateEmailBody(
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Events;
|
||||
using BTCPayServer.Logging;
|
||||
using BTCPayServer.Plugins.Emails;
|
||||
using BTCPayServer.Services;
|
||||
using BTCPayServer.Services.Mails;
|
||||
using BTCPayServer.Services.Notifications;
|
||||
@@ -10,6 +12,7 @@ using BTCPayServer.Services.Notifications.Blobs;
|
||||
using BTCPayServer.Services.Stores;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace BTCPayServer.HostedServices;
|
||||
|
||||
@@ -17,6 +20,7 @@ public class UserEventHostedService(
|
||||
EventAggregator eventAggregator,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
CallbackGenerator callbackGenerator,
|
||||
ISettingsAccessor<ServerSettings> serverSettings,
|
||||
EmailSenderFactory emailSenderFactory,
|
||||
NotificationSender notificationSender,
|
||||
StoreRepository storeRepository,
|
||||
@@ -79,9 +83,11 @@ public class UserEventHostedService(
|
||||
break;
|
||||
|
||||
case UserEvent.PasswordResetRequested pwResetEvent:
|
||||
Logs.PayServer.LogInformation("User {Email} requested a password reset", user.Email);
|
||||
emailSender = await emailSenderFactory.GetEmailSender();
|
||||
emailSender.SendResetPassword(user.GetMailboxAddress(), pwResetEvent.ResetLink);
|
||||
EventAggregator.Publish(CreateTriggerEvent(ServerMailTriggers.PasswordReset,
|
||||
new JObject()
|
||||
{
|
||||
["ResetLink"] = HtmlEncoder.Default.Encode(pwResetEvent.ResetLink)
|
||||
}, user));
|
||||
break;
|
||||
|
||||
case UserEvent.Approved approvedEvent:
|
||||
@@ -104,6 +110,23 @@ public class UserEventHostedService(
|
||||
}
|
||||
}
|
||||
|
||||
private TriggerEvent CreateTriggerEvent(string trigger, JObject model, ApplicationUser user)
|
||||
{
|
||||
model["User"] = new JObject()
|
||||
{
|
||||
["Name"] = user.UserName,
|
||||
["Email"] = user.Email,
|
||||
["MailboxAddress"] = user.GetMailboxAddress().ToString(),
|
||||
};
|
||||
model["Branding"] = new JObject()
|
||||
{
|
||||
["ServerName"] = serverSettings.Settings.ServerName ?? "BTCPay Server",
|
||||
["ContactUrl"] = serverSettings.Settings.ContactUrl,
|
||||
};
|
||||
var evt = new TriggerEvent(null, trigger, model, null);
|
||||
return evt;
|
||||
}
|
||||
|
||||
private async Task NotifyAdminsAboutUserRequiringApproval(ApplicationUser user, string approvalLink, string newUserInfo)
|
||||
{
|
||||
if (!user.RequiresApproval || user.Approved) return;
|
||||
|
||||
@@ -159,6 +159,7 @@ namespace BTCPayServer.Hosting
|
||||
//
|
||||
AddSettingsAccessor<PoliciesSettings>(services);
|
||||
AddSettingsAccessor<ThemeSettings>(services);
|
||||
AddSettingsAccessor<ServerSettings>(services);
|
||||
//
|
||||
|
||||
AddOnchainWalletParsers(services);
|
||||
|
||||
@@ -45,16 +45,14 @@ namespace BTCPayServer.Hosting
|
||||
{
|
||||
public class Startup
|
||||
{
|
||||
public Startup(IConfiguration conf, IWebHostEnvironment env, ILoggerFactory loggerFactory)
|
||||
public Startup(IConfiguration conf, ILoggerFactory loggerFactory)
|
||||
{
|
||||
Configuration = conf;
|
||||
_Env = env;
|
||||
LoggerFactory = loggerFactory;
|
||||
Logs = new Logs();
|
||||
Logs.Configure(loggerFactory);
|
||||
}
|
||||
|
||||
readonly IWebHostEnvironment _Env;
|
||||
public IConfiguration Configuration
|
||||
{
|
||||
get; set;
|
||||
@@ -178,7 +176,6 @@ namespace BTCPayServer.Hosting
|
||||
o.AreaViewLocationFormats.Add("/Plugins/{2}/Views/{0}.cshtml");
|
||||
o.AreaViewLocationFormats.Add("/Plugins/{2}/Views/Shared/{0}.cshtml");
|
||||
|
||||
|
||||
o.AreaViewLocationFormats.Add("/{0}.cshtml");
|
||||
})
|
||||
.AddNewtonsoftJson()
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using BTCPayServer.Services.Mails;
|
||||
|
||||
namespace BTCPayServer.Models.ServerViewModels;
|
||||
|
||||
public class ServerEmailsViewModel : EmailsViewModel
|
||||
{
|
||||
[Display(Name = "Allow Stores use the Server's SMTP email settings as their default")]
|
||||
public bool EnableStoresToUseServerEmailSettings { get; set; }
|
||||
|
||||
public ServerEmailsViewModel()
|
||||
{
|
||||
}
|
||||
|
||||
public ServerEmailsViewModel(EmailSettings settings) : base(settings)
|
||||
{
|
||||
}
|
||||
}
|
||||
127
BTCPayServer/Plugins/Emails/Controllers/UIEmailControllerBase.cs
Normal file
127
BTCPayServer/Plugins/Emails/Controllers/UIEmailControllerBase.cs
Normal file
@@ -0,0 +1,127 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Abstractions.Constants;
|
||||
using BTCPayServer.Abstractions.Extensions;
|
||||
using BTCPayServer.Abstractions.Models;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Plugins.Emails.Views;
|
||||
using BTCPayServer.Services;
|
||||
using BTCPayServer.Services.Mails;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using MimeKit;
|
||||
|
||||
namespace BTCPayServer.Plugins.Emails.Controllers;
|
||||
|
||||
public abstract class UIEmailControllerBase(IStringLocalizer stringLocalizer) : Controller
|
||||
{
|
||||
public IStringLocalizer StringLocalizer { get; } = stringLocalizer;
|
||||
|
||||
protected class Context
|
||||
{
|
||||
public Func<EmailSettings, EmailsViewModel> CreateEmailViewModel { get; set; } = null!;
|
||||
public string StoreId { get; set; } = null!;
|
||||
}
|
||||
|
||||
protected abstract Task<EmailSettings> GetEmailSettings(Context ctx);
|
||||
|
||||
protected virtual Task<EmailSettings> GetEmailSettingsForTest(Context ctx, EmailsViewModel viewModel)
|
||||
=> Task.FromResult(viewModel.Settings);
|
||||
|
||||
protected abstract Task SaveEmailSettings(Context ctx, EmailSettings settings, EmailsViewModel? viewModel = null);
|
||||
protected abstract IActionResult RedirectToEmailSettings(Context ctx);
|
||||
protected abstract Task<(string Subject, string Body)> GetTestMessage(Context ctx);
|
||||
|
||||
protected async Task<IActionResult> EmailSettingsCore(Context ctx)
|
||||
{
|
||||
var email = await GetEmailSettings(ctx);
|
||||
var vm = ctx.CreateEmailViewModel(email);
|
||||
return View("EmailSettings", vm);
|
||||
}
|
||||
protected async Task<IActionResult> EmailSettingsCore(Context ctx, EmailsViewModel model, string command)
|
||||
{
|
||||
if (command == "Test")
|
||||
{
|
||||
try
|
||||
{
|
||||
if (model.PasswordSet)
|
||||
{
|
||||
var settings = await GetEmailSettings(ctx);
|
||||
model.Settings.Password = settings.Password;
|
||||
}
|
||||
|
||||
model.Settings.Validate("Settings.", ModelState);
|
||||
if (string.IsNullOrEmpty(model.TestEmail))
|
||||
ModelState.AddModelError(nameof(model.TestEmail), new RequiredAttribute().FormatErrorMessage(nameof(model.TestEmail)));
|
||||
if (!ModelState.IsValid)
|
||||
return await EmailSettingsCore(ctx);
|
||||
var mess = await GetTestMessage(ctx);
|
||||
var settingsForTest = await GetEmailSettingsForTest(ctx, model);
|
||||
if (!settingsForTest.IsComplete())
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.TestEmail), "Email settings are not complete.");
|
||||
return await EmailSettingsCore(ctx);
|
||||
}
|
||||
using (var client = await settingsForTest.CreateSmtpClient())
|
||||
using (var message = settingsForTest.CreateMailMessage(MailboxAddress.Parse(model.TestEmail), mess.Subject,
|
||||
mess.Body, false))
|
||||
{
|
||||
await client.SendAsync(message);
|
||||
await client.DisconnectAsync(true);
|
||||
}
|
||||
|
||||
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email sent to {0}. Please verify you received it.", model.TestEmail].Value;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TempData[WellKnownTempData.ErrorMessage] = ex.Message;
|
||||
}
|
||||
|
||||
return await EmailSettingsCore(ctx);
|
||||
}
|
||||
else if (command == "ResetPassword")
|
||||
{
|
||||
var settings = await GetEmailSettings(ctx);
|
||||
settings.Password = null;
|
||||
await SaveEmailSettings(ctx, settings);
|
||||
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email server password reset"].Value;
|
||||
}
|
||||
else if (command == "mailpit")
|
||||
{
|
||||
model.Settings.Server = "localhost";
|
||||
model.Settings.Port = 34219;
|
||||
model.Settings.EnabledCertificateCheck = false;
|
||||
model.Settings.Login ??= "store@example.com";
|
||||
model.Settings.From ??= "store@example.com";
|
||||
model.Settings.Password ??= "password";
|
||||
await SaveEmailSettings(ctx, model.Settings);
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel()
|
||||
{
|
||||
Severity = StatusMessageModel.StatusSeverity.Info,
|
||||
AllowDismiss = true,
|
||||
Html =
|
||||
"Mailpit is now running on <a href=\"http://localhost:34218\" target=\"_blank\" class=\"alert-link\">localhost</a>. You can use it to test your SMTP settings."
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
// save if user provided valid email; this will also clear settings if no model.Settings.From
|
||||
if (model.Settings.From is not null && !MailboxAddressValidator.IsMailboxAddress(model.Settings.From))
|
||||
{
|
||||
ModelState.AddModelError("Settings.From", StringLocalizer["Invalid email"]);
|
||||
return await EmailSettingsCore(ctx);
|
||||
}
|
||||
|
||||
var oldSettings = await GetEmailSettings(ctx);
|
||||
if (!string.IsNullOrEmpty(oldSettings.Password))
|
||||
model.Settings.Password = oldSettings.Password;
|
||||
|
||||
await SaveEmailSettings(ctx, model.Settings, model);
|
||||
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email settings saved"].Value;
|
||||
}
|
||||
|
||||
return RedirectToEmailSettings(ctx);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Abstractions.Extensions;
|
||||
using BTCPayServer.Abstractions.Models;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Plugins.Emails.Views;
|
||||
using BTCPayServer.Plugins.Emails.Views.Shared;
|
||||
using BTCPayServer.Services.Mails;
|
||||
using Dapper;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using Npgsql;
|
||||
|
||||
namespace BTCPayServer.Plugins.Emails.Controllers;
|
||||
|
||||
public class UIEmailRuleControllerBase(
|
||||
ApplicationDbContextFactory dbContextFactory,
|
||||
IStringLocalizer stringLocalizer,
|
||||
EmailSenderFactory emailSenderFactory) : Controller
|
||||
{
|
||||
public class EmailsRuleControllerContext
|
||||
{
|
||||
public string? StoreId { get; set; }
|
||||
public string EmailSettingsLink { get; set; } = "";
|
||||
public List<EmailTriggerViewModel> Triggers { get; set; } = new();
|
||||
public Func<ApplicationDbContext, Task<List<EmailRuleData>>>? Rules { get; set; }
|
||||
public Action<EmailRulesListViewModel>? ModifyViewModel { get; set; }
|
||||
|
||||
public Func<ApplicationDbContext, long, Task<EmailRuleData?>>? GetRule { get; set; }
|
||||
|
||||
public Func<string?, IActionResult> RedirectToRuleList = null!;
|
||||
}
|
||||
|
||||
public ApplicationDbContextFactory DbContextFactory { get; } = dbContextFactory;
|
||||
public IStringLocalizer StringLocalizer { get; } = stringLocalizer;
|
||||
|
||||
protected async Task<IActionResult> EmailRulesListCore(EmailsRuleControllerContext emailCtx)
|
||||
{
|
||||
await using var ctx = DbContextFactory.CreateContext();
|
||||
var store = HttpContext.GetStoreData();
|
||||
var configured = await emailSenderFactory.IsComplete(store.Id);
|
||||
if (!configured && !TempData.HasStatusMessage())
|
||||
{
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel
|
||||
{
|
||||
Severity = StatusMessageModel.StatusSeverity.Warning,
|
||||
Html = "You need to configure email settings before this feature works." +
|
||||
$" <a class='alert-link configure-email' href='{emailCtx.EmailSettingsLink}'>Configure email settings</a>."
|
||||
});
|
||||
}
|
||||
|
||||
var rules = emailCtx.Rules is null ? new() : await emailCtx.Rules(ctx);
|
||||
var vm = new EmailRulesListViewModel()
|
||||
{
|
||||
StoreId = emailCtx.StoreId,
|
||||
ModifyPermission = Policies.CanModifyStoreSettings,
|
||||
Rules = rules.Select(r => new StoreEmailRuleViewModel(r, emailCtx.Triggers)).ToList()
|
||||
};
|
||||
if (emailCtx.ModifyViewModel is not null)
|
||||
emailCtx.ModifyViewModel(vm);
|
||||
return View("EmailRulesList", vm);
|
||||
}
|
||||
|
||||
protected IActionResult EmailRulesCreateCore(
|
||||
EmailsRuleControllerContext ctx,
|
||||
string? offeringId = null,
|
||||
string? trigger = null,
|
||||
string? condition = null,
|
||||
string? to = null,
|
||||
string? redirectUrl = null)
|
||||
{
|
||||
return View("EmailRulesManage", new StoreEmailRuleViewModel(null, ctx.Triggers)
|
||||
{
|
||||
StoreId = ctx.StoreId,
|
||||
CanChangeTrigger = trigger is null,
|
||||
CanChangeCondition = offeringId is null,
|
||||
Condition = condition,
|
||||
Trigger = trigger,
|
||||
OfferingId = offeringId,
|
||||
RedirectUrl = redirectUrl,
|
||||
To = to
|
||||
});
|
||||
}
|
||||
|
||||
protected async Task<IActionResult> EmailRulesCreateCore(
|
||||
EmailsRuleControllerContext emailCtx,
|
||||
StoreEmailRuleViewModel model)
|
||||
{
|
||||
await ValidateCondition(model);
|
||||
if (!ModelState.IsValid)
|
||||
return EmailRulesCreateCore(emailCtx,
|
||||
model.OfferingId,
|
||||
model.CanChangeTrigger ? null : model.Trigger);
|
||||
|
||||
await using var ctx = DbContextFactory.CreateContext();
|
||||
var c = new EmailRuleData()
|
||||
{
|
||||
StoreId = emailCtx.StoreId,
|
||||
Trigger = model.Trigger,
|
||||
Body = model.Body,
|
||||
Subject = model.Subject,
|
||||
Condition = string.IsNullOrWhiteSpace(model.Condition) ? null : model.Condition,
|
||||
OfferingId = model.OfferingId,
|
||||
To = model.ToAsArray()
|
||||
};
|
||||
c.SetBTCPayAdditionalData(model.AdditionalData);
|
||||
ctx.EmailRules.Add(c);
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
this.TempData.SetStatusSuccess(StringLocalizer["Email rule successfully created"]);
|
||||
return emailCtx.RedirectToRuleList(model.RedirectUrl);
|
||||
}
|
||||
|
||||
public async Task<IActionResult> EmailRulesEditCore(EmailsRuleControllerContext emailCtx, long ruleId, string? redirectUrl = null)
|
||||
{
|
||||
await using var ctx = DbContextFactory.CreateContext();
|
||||
var r = emailCtx.GetRule is null ? null : await emailCtx.GetRule(ctx, ruleId);
|
||||
if (r is null)
|
||||
return NotFound();
|
||||
return View("EmailRulesManage", new StoreEmailRuleViewModel(r, emailCtx.Triggers)
|
||||
{
|
||||
OfferingId = emailCtx.StoreId,
|
||||
CanChangeTrigger = r.OfferingId is null,
|
||||
CanChangeCondition = r.OfferingId is null,
|
||||
RedirectUrl = redirectUrl
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<IActionResult> EmailRulesEditCore(EmailsRuleControllerContext emailCtx, long ruleId, StoreEmailRuleViewModel model)
|
||||
{
|
||||
await ValidateCondition(model);
|
||||
if (!ModelState.IsValid)
|
||||
return await EmailRulesEditCore(emailCtx, ruleId);
|
||||
|
||||
await using var ctx = DbContextFactory.CreateContext();
|
||||
var rule = emailCtx.GetRule is null ? null : await emailCtx.GetRule(ctx, ruleId);
|
||||
if (rule is null) return NotFound();
|
||||
|
||||
rule.Trigger = model.Trigger;
|
||||
rule.SetBTCPayAdditionalData(model.AdditionalData);
|
||||
rule.To = model.ToAsArray();
|
||||
rule.Subject = model.Subject;
|
||||
rule.Condition = model.Condition;
|
||||
rule.Body = model.Body;
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
this.TempData.SetStatusSuccess(StringLocalizer["Email rule successfully updated"]);
|
||||
return emailCtx.RedirectToRuleList(model.RedirectUrl);
|
||||
}
|
||||
|
||||
protected async Task<IActionResult> EmailRulesDeleteCore(EmailsRuleControllerContext emailCtx, long ruleId, string? redirectUrl)
|
||||
{
|
||||
await using var ctx = DbContextFactory.CreateContext();
|
||||
var r = emailCtx.GetRule is null ? null : await emailCtx.GetRule(ctx, ruleId);
|
||||
if (r is not null)
|
||||
{
|
||||
ctx.EmailRules.Remove(r);
|
||||
await ctx.SaveChangesAsync();
|
||||
this.TempData.SetStatusSuccess(StringLocalizer["Email rule successfully deleted"]);
|
||||
}
|
||||
|
||||
return emailCtx.RedirectToRuleList(redirectUrl);
|
||||
}
|
||||
|
||||
|
||||
protected async Task ValidateCondition(StoreEmailRuleViewModel model)
|
||||
{
|
||||
model.Condition = model.Condition?.Trim() ?? "";
|
||||
if (model.Condition.Length == 0)
|
||||
model.Condition = null;
|
||||
else
|
||||
{
|
||||
await using var ctx = DbContextFactory.CreateContext();
|
||||
try
|
||||
{
|
||||
ctx.Database
|
||||
.GetDbConnection()
|
||||
.ExecuteScalar<bool>("SELECT jsonb_path_exists('{}'::JSONB, @path::jsonpath)", new { path = model.Condition });
|
||||
}
|
||||
catch(PostgresException ex)
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.Condition), $"Invalid condition ({ex.MessageText})");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Abstractions.Constants;
|
||||
using BTCPayServer.Abstractions.Extensions;
|
||||
using BTCPayServer.Abstractions.Models;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Plugins.Emails.Views;
|
||||
using BTCPayServer.Services;
|
||||
using BTCPayServer.Services.Mails;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using MimeKit;
|
||||
|
||||
namespace BTCPayServer.Plugins.Emails.Controllers;
|
||||
|
||||
[Area(EmailsPlugin.Area)]
|
||||
[Authorize(Policy = Client.Policies.CanModifyServerSettings,
|
||||
AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
[AutoValidateAntiforgeryToken]
|
||||
public class UIServerEmailController(
|
||||
EmailSenderFactory emailSenderFactory,
|
||||
PoliciesSettings policiesSettings,
|
||||
SettingsRepository settingsRepository,
|
||||
IStringLocalizer stringLocalizer
|
||||
) : UIEmailControllerBase(stringLocalizer)
|
||||
{
|
||||
protected override async Task<EmailSettings> GetEmailSettings(Context ctx)
|
||||
=> await emailSenderFactory.GetSettings() ?? new EmailSettings();
|
||||
|
||||
protected override async Task SaveEmailSettings(Context ctx, EmailSettings settings, EmailsViewModel viewModel = null)
|
||||
{
|
||||
await settingsRepository.UpdateSetting(settings);
|
||||
if (viewModel is not null)
|
||||
{
|
||||
if (policiesSettings.DisableStoresToUseServerEmailSettings == viewModel.EnableStoresToUseServerEmailSettings)
|
||||
{
|
||||
policiesSettings.DisableStoresToUseServerEmailSettings = !viewModel.EnableStoresToUseServerEmailSettings;
|
||||
await settingsRepository.UpdateSetting(policiesSettings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override IActionResult RedirectToEmailSettings(Context ctx)
|
||||
=> RedirectToAction(nameof(ServerEmailSettings));
|
||||
|
||||
protected override async Task<(string Subject, string Body)> GetTestMessage(Context ctx)
|
||||
{
|
||||
var serverSettings = await settingsRepository.GetSettingAsync<ServerSettings>();
|
||||
var serverName = string.IsNullOrEmpty(serverSettings?.ServerName) ? "BTCPay Server" : serverSettings.ServerName;
|
||||
return ($"{serverName}: Email test",
|
||||
"You received it, the BTCPay Server SMTP settings work.");
|
||||
}
|
||||
|
||||
private Context CreateContext()
|
||||
=> new()
|
||||
{
|
||||
CreateEmailViewModel = (email) => new EmailsViewModel(email)
|
||||
{
|
||||
EnableStoresToUseServerEmailSettings = !policiesSettings.DisableStoresToUseServerEmailSettings,
|
||||
ModifyPermission = Policies.CanModifyServerSettings,
|
||||
ViewPermission = Policies.CanModifyServerSettings
|
||||
}
|
||||
};
|
||||
|
||||
[HttpGet("server/emails")]
|
||||
public Task<IActionResult> ServerEmailSettings()
|
||||
=> EmailSettingsCore(CreateContext());
|
||||
|
||||
[HttpPost("server/emails")]
|
||||
public Task<IActionResult> ServerEmailSettings(EmailsViewModel model, string command)
|
||||
=> EmailSettingsCore(CreateContext(), model, command);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Abstractions.Constants;
|
||||
using BTCPayServer.Abstractions.Extensions;
|
||||
using BTCPayServer.Abstractions.Models;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Controllers;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Plugins.Emails.Views;
|
||||
using BTCPayServer.Plugins.Emails.Views.Shared;
|
||||
using BTCPayServer.Services.Mails;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Localization;
|
||||
|
||||
namespace BTCPayServer.Plugins.Emails.Controllers;
|
||||
|
||||
[Area(EmailsPlugin.Area)]
|
||||
[Route("server/rules")]
|
||||
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
[Authorize(Policy = Policies.CanModifyServerSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
[AutoValidateAntiforgeryToken]
|
||||
public class UIServerEmailRulesController(
|
||||
EmailSenderFactory emailSenderFactory,
|
||||
IEnumerable<EmailTriggerViewModel> triggers,
|
||||
ApplicationDbContextFactory dbContextFactory,
|
||||
IStringLocalizer stringLocalizer
|
||||
) : UIEmailRuleControllerBase(dbContextFactory, stringLocalizer, emailSenderFactory)
|
||||
{
|
||||
|
||||
[HttpGet("")]
|
||||
public Task<IActionResult> ServerEmailRulesList()
|
||||
=> EmailRulesListCore(CreateContext());
|
||||
|
||||
private EmailsRuleControllerContext CreateContext()
|
||||
=> new()
|
||||
{
|
||||
EmailSettingsLink = Url.Action(nameof(UIServerEmailController.ServerEmailSettings), "UIServerEmail") ?? throw new InvalidOperationException("Bug 1928"),
|
||||
Rules = (ctx) => ctx.EmailRules.GetServerRules().ToListAsync(),
|
||||
Triggers = triggers.Where(t => t.ServerTrigger).ToList(),
|
||||
ModifyViewModel = (vm) =>
|
||||
{
|
||||
vm.ShowCustomerEmailColumn = false;
|
||||
},
|
||||
GetRule = (ctx, ruleId) => ctx.EmailRules.GetServerRule(ruleId),
|
||||
RedirectToRuleList = GoToStoreServerRulesList
|
||||
};
|
||||
|
||||
private IActionResult GoToStoreServerRulesList(string redirectUrl)
|
||||
{
|
||||
if (redirectUrl != null)
|
||||
return LocalRedirect(redirectUrl);
|
||||
return RedirectToAction(nameof(ServerEmailRulesList));
|
||||
}
|
||||
|
||||
[HttpGet("create")]
|
||||
[Authorize(Policy = Policies.CanModifyServerSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
public IActionResult ServerEmailRulesCreate(
|
||||
string trigger = null,
|
||||
string condition = null,
|
||||
string to = null,
|
||||
string redirectUrl = null)
|
||||
=> EmailRulesCreateCore(CreateContext(), null, trigger, condition, to, redirectUrl);
|
||||
|
||||
|
||||
[HttpPost("create")]
|
||||
[Authorize(Policy = Policies.CanModifyServerSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
public Task<IActionResult> ServerEmailRulesCreate(StoreEmailRuleViewModel model)
|
||||
=> EmailRulesCreateCore(CreateContext(), model);
|
||||
|
||||
[HttpGet("{ruleId}/edit")]
|
||||
[Authorize(Policy = Policies.CanModifyServerSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
public Task<IActionResult> ServerEmailRulesEdit(long ruleId, string redirectUrl = null)
|
||||
=> EmailRulesEditCore(CreateContext(), ruleId, redirectUrl);
|
||||
|
||||
[HttpPost("{ruleId}/edit")]
|
||||
[Authorize(Policy = Policies.CanModifyServerSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
public Task<IActionResult> ServerEmailRulesEdit(long ruleId, StoreEmailRuleViewModel model)
|
||||
=> EmailRulesEditCore(CreateContext(), ruleId, model);
|
||||
|
||||
[HttpPost("{ruleId}/delete")]
|
||||
[Authorize(Policy = Policies.CanModifyServerSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
public Task<IActionResult> ServerEmailRulesDelete(long ruleId, string redirectUrl = null)
|
||||
=> EmailRulesDeleteCore(CreateContext(), ruleId, redirectUrl);
|
||||
}
|
||||
@@ -3,19 +3,15 @@ using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Abstractions.Constants;
|
||||
using BTCPayServer.Abstractions.Extensions;
|
||||
using BTCPayServer.Abstractions.Models;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Plugins.Emails.Views;
|
||||
using BTCPayServer.Plugins.Subscriptions.Controllers;
|
||||
using BTCPayServer.Services.Mails;
|
||||
using Dapper;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using Npgsql;
|
||||
|
||||
namespace BTCPayServer.Plugins.Emails.Controllers;
|
||||
|
||||
@@ -29,28 +25,26 @@ public class UIStoreEmailRulesController(
|
||||
LinkGenerator linkGenerator,
|
||||
ApplicationDbContextFactory dbContextFactory,
|
||||
IEnumerable<EmailTriggerViewModel> triggers,
|
||||
IStringLocalizer stringLocalizer) : Controller
|
||||
IStringLocalizer stringLocalizer) : UIEmailRuleControllerBase(dbContextFactory, stringLocalizer, emailSenderFactory)
|
||||
{
|
||||
public IStringLocalizer StringLocalizer { get; set; } = stringLocalizer;
|
||||
[HttpGet("")]
|
||||
public async Task<IActionResult> StoreEmailRulesList(string storeId)
|
||||
{
|
||||
await using var ctx = dbContextFactory.CreateContext();
|
||||
var store = HttpContext.GetStoreData();
|
||||
var configured = await emailSenderFactory.IsComplete(store.Id);
|
||||
if (!configured && !TempData.HasStatusMessage())
|
||||
{
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel
|
||||
{
|
||||
Severity = StatusMessageModel.StatusSeverity.Warning,
|
||||
Html = "You need to configure email settings before this feature works." +
|
||||
$" <a class='alert-link configure-email' href='{linkGenerator.GetStoreEmailSettingsLink(storeId, Request.GetRequestBaseUrl())}'>Configure store email settings</a>."
|
||||
});
|
||||
}
|
||||
public Task<IActionResult> StoreEmailRulesList(string storeId)
|
||||
=> EmailRulesListCore(CreateContext(storeId));
|
||||
|
||||
var rules = await ctx.EmailRules.GetRules(storeId).ToListAsync();
|
||||
return View("StoreEmailRulesList", rules.Select(r => new StoreEmailRuleViewModel(r, triggers)).ToList());
|
||||
}
|
||||
private EmailsRuleControllerContext CreateContext(string storeId)
|
||||
=> new()
|
||||
{
|
||||
StoreId = storeId,
|
||||
EmailSettingsLink = linkGenerator.GetStoreEmailSettingsLink(storeId, Request.GetRequestBaseUrl()),
|
||||
Rules = (ctx) => ctx.EmailRules.GetRules(storeId).ToListAsync(),
|
||||
Triggers = triggers.Where(t => !t.ServerTrigger).ToList(),
|
||||
ModifyViewModel = (vm) =>
|
||||
{
|
||||
vm.ShowCustomerEmailColumn = true;
|
||||
},
|
||||
GetRule = (ctx, ruleId) => ctx.EmailRules.GetRule(storeId, ruleId),
|
||||
RedirectToRuleList = (redirectUrl) => GoToStoreEmailRulesList(storeId, redirectUrl)
|
||||
};
|
||||
|
||||
[HttpGet("create")]
|
||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
@@ -61,50 +55,13 @@ public class UIStoreEmailRulesController(
|
||||
string condition = null,
|
||||
string to = null,
|
||||
string redirectUrl = null)
|
||||
{
|
||||
return View("StoreEmailRulesManage", new StoreEmailRuleViewModel(null, triggers)
|
||||
{
|
||||
CanChangeTrigger = trigger is null,
|
||||
CanChangeCondition = offeringId is null,
|
||||
Condition = condition,
|
||||
Trigger = trigger,
|
||||
OfferingId = offeringId,
|
||||
RedirectUrl = redirectUrl,
|
||||
To = to
|
||||
});
|
||||
}
|
||||
=> EmailRulesCreateCore(CreateContext(storeId), offeringId, trigger, condition, to, redirectUrl);
|
||||
|
||||
[HttpPost("create")]
|
||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
public async Task<IActionResult> StoreEmailRulesCreate(string storeId, StoreEmailRuleViewModel model)
|
||||
{
|
||||
await ValidateCondition(model);
|
||||
if (!ModelState.IsValid)
|
||||
return StoreEmailRulesCreate(storeId,
|
||||
model.OfferingId,
|
||||
model.CanChangeTrigger ? null : model.Trigger);
|
||||
public Task<IActionResult> StoreEmailRulesCreate(string storeId, StoreEmailRuleViewModel model)
|
||||
=> EmailRulesCreateCore(CreateContext(storeId), model);
|
||||
|
||||
await using var ctx = dbContextFactory.CreateContext();
|
||||
var c = new EmailRuleData()
|
||||
{
|
||||
StoreId = storeId,
|
||||
Trigger = model.Trigger,
|
||||
Body = model.Body,
|
||||
Subject = model.Subject,
|
||||
Condition = string.IsNullOrWhiteSpace(model.Condition) ? null : model.Condition,
|
||||
OfferingId = model.OfferingId,
|
||||
To = model.ToAsArray()
|
||||
};
|
||||
c.SetBTCPayAdditionalData(model.AdditionalData);
|
||||
ctx.EmailRules.Add(c);
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
this.TempData.SetStatusSuccess(StringLocalizer["Email rule successfully created"]);
|
||||
return GoToStoreEmailRulesList(storeId, model);
|
||||
}
|
||||
|
||||
private IActionResult GoToStoreEmailRulesList(string storeId, StoreEmailRuleViewModel model)
|
||||
=> GoToStoreEmailRulesList(storeId, model.RedirectUrl);
|
||||
private IActionResult GoToStoreEmailRulesList(string storeId, string redirectUrl)
|
||||
{
|
||||
if (redirectUrl != null)
|
||||
@@ -114,78 +71,16 @@ public class UIStoreEmailRulesController(
|
||||
|
||||
[HttpGet("{ruleId}/edit")]
|
||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
public async Task<IActionResult> StoreEmailRulesEdit(string storeId, long ruleId, string redirectUrl = null)
|
||||
{
|
||||
await using var ctx = dbContextFactory.CreateContext();
|
||||
var r = await ctx.EmailRules.GetRule(storeId, ruleId);
|
||||
if (r is null)
|
||||
return NotFound();
|
||||
return View("StoreEmailRulesManage", new StoreEmailRuleViewModel(r, triggers)
|
||||
{
|
||||
CanChangeTrigger = r.OfferingId is null,
|
||||
CanChangeCondition = r.OfferingId is null,
|
||||
RedirectUrl = redirectUrl
|
||||
});
|
||||
}
|
||||
public Task<IActionResult> StoreEmailRulesEdit(string storeId, long ruleId, string redirectUrl = null)
|
||||
=> EmailRulesEditCore(CreateContext(storeId), ruleId, redirectUrl);
|
||||
|
||||
[HttpPost("{ruleId}/edit")]
|
||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
public async Task<IActionResult> StoreEmailRulesEdit(string storeId, long ruleId, StoreEmailRuleViewModel model)
|
||||
{
|
||||
await ValidateCondition(model);
|
||||
if (!ModelState.IsValid)
|
||||
return await StoreEmailRulesEdit(storeId, ruleId);
|
||||
|
||||
await using var ctx = dbContextFactory.CreateContext();
|
||||
var rule = await ctx.EmailRules.GetRule(storeId, ruleId);
|
||||
if (rule is null) return NotFound();
|
||||
|
||||
rule.Trigger = model.Trigger;
|
||||
rule.SetBTCPayAdditionalData(model.AdditionalData);
|
||||
rule.To = model.ToAsArray();
|
||||
rule.Subject = model.Subject;
|
||||
rule.Condition = model.Condition;
|
||||
rule.Body = model.Body;
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
this.TempData.SetStatusSuccess(StringLocalizer["Email rule successfully updated"]);
|
||||
return GoToStoreEmailRulesList(storeId, model);
|
||||
}
|
||||
|
||||
private async Task ValidateCondition(StoreEmailRuleViewModel model)
|
||||
{
|
||||
model.Condition = model.Condition?.Trim() ?? "";
|
||||
if (model.Condition.Length == 0)
|
||||
model.Condition = null;
|
||||
else
|
||||
{
|
||||
await using var ctx = dbContextFactory.CreateContext();
|
||||
try
|
||||
{
|
||||
ctx.Database
|
||||
.GetDbConnection()
|
||||
.ExecuteScalar<bool>("SELECT jsonb_path_exists('{}'::JSONB, @path::jsonpath)", new { path = model.Condition });
|
||||
}
|
||||
catch(PostgresException ex)
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.Condition), $"Invalid condition ({ex.MessageText})");
|
||||
}
|
||||
}
|
||||
}
|
||||
public Task<IActionResult> StoreEmailRulesEdit(string storeId, long ruleId, StoreEmailRuleViewModel model)
|
||||
=> EmailRulesEditCore(CreateContext(storeId), ruleId, model);
|
||||
|
||||
[HttpPost("{ruleId}/delete")]
|
||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
public async Task<IActionResult> StoreEmailRulesDelete(string storeId, long ruleId, string redirectUrl = null)
|
||||
{
|
||||
await using var ctx = dbContextFactory.CreateContext();
|
||||
var r = await ctx.EmailRules.GetRule(storeId, ruleId);
|
||||
if (r is not null)
|
||||
{
|
||||
ctx.EmailRules.Remove(r);
|
||||
await ctx.SaveChangesAsync();
|
||||
this.TempData.SetStatusSuccess(StringLocalizer["Email rule successfully deleted"]);
|
||||
}
|
||||
|
||||
return GoToStoreEmailRulesList(storeId, redirectUrl);
|
||||
}
|
||||
public Task<IActionResult> StoreEmailRulesDelete(string storeId, long ruleId, string redirectUrl = null)
|
||||
=> EmailRulesDeleteCore(CreateContext(storeId), ruleId, redirectUrl);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Abstractions.Constants;
|
||||
using BTCPayServer.Abstractions.Extensions;
|
||||
using BTCPayServer.Abstractions.Models;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Models;
|
||||
using BTCPayServer.Plugins.Emails;
|
||||
using BTCPayServer.Plugins.Emails.Views;
|
||||
using BTCPayServer.Services.Mails;
|
||||
using BTCPayServer.Services.Stores;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using MimeKit;
|
||||
|
||||
@@ -30,24 +24,33 @@ namespace BTCPayServer.Plugins.Emails.Controllers;
|
||||
public class UIStoresEmailController(
|
||||
EmailSenderFactory emailSenderFactory,
|
||||
StoreRepository storeRepository,
|
||||
IStringLocalizer stringLocalizer) : Controller
|
||||
IStringLocalizer stringLocalizer) : UIEmailControllerBase(stringLocalizer)
|
||||
{
|
||||
public IStringLocalizer StringLocalizer { get; set; } = stringLocalizer;
|
||||
[HttpGet("email-settings")]
|
||||
public async Task<IActionResult> StoreEmailSettings(string storeId)
|
||||
private async Task<Context> CreateContext(string storeId)
|
||||
{
|
||||
var store = HttpContext.GetStoreData();
|
||||
if (store == null)
|
||||
return NotFound();
|
||||
|
||||
var settings = await GetCustomSettings(store.Id);
|
||||
|
||||
return View(new EmailsViewModel(settings.Custom ?? new())
|
||||
var settings = await GetCustomSettings(storeId);
|
||||
return new()
|
||||
{
|
||||
StoreId = storeId,
|
||||
CreateEmailViewModel = (email) => new EmailsViewModel(email)
|
||||
{
|
||||
IsFallbackSetup = settings.Fallback is not null,
|
||||
IsCustomSMTP = settings.Custom is not null || settings.Fallback is null
|
||||
});
|
||||
IsCustomSMTP = settings.Custom is not null || settings.Fallback is null,
|
||||
StoreId = storeId,
|
||||
ModifyPermission = Policies.CanModifyStoreSettings,
|
||||
ViewPermission = Policies.CanViewStoreSettings,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
[HttpGet("email-settings")]
|
||||
public async Task<IActionResult> StoreEmailSettings(string storeId)
|
||||
=> await EmailSettingsCore(await CreateContext(storeId));
|
||||
|
||||
[HttpPost("email-settings")]
|
||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
public async Task<IActionResult> StoreEmailSettings(string storeId, EmailsViewModel model, string command)
|
||||
=> await EmailSettingsCore(await CreateContext(storeId), model, command);
|
||||
|
||||
record AllEmailSettings(EmailSettings Custom, EmailSettings Fallback);
|
||||
private async Task<AllEmailSettings> GetCustomSettings(string storeId)
|
||||
@@ -61,96 +64,35 @@ public class UIStoresEmailController(
|
||||
return new(await sender.GetCustomSettings(), fallback);
|
||||
}
|
||||
|
||||
[HttpPost("email-settings")]
|
||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
public async Task<IActionResult> StoreEmailSettings(string storeId, EmailsViewModel model, string command)
|
||||
protected override async Task<EmailSettings> GetEmailSettings(Context ctx)
|
||||
{
|
||||
var store = HttpContext.GetStoreData();
|
||||
if (store == null)
|
||||
return NotFound();
|
||||
var settings = await GetCustomSettings(store.Id);
|
||||
model.IsFallbackSetup = settings.Fallback is not null;
|
||||
if (!model.IsFallbackSetup)
|
||||
model.IsCustomSMTP = true;
|
||||
if (model.IsCustomSMTP)
|
||||
{
|
||||
model.Settings.Validate("Settings.", ModelState);
|
||||
if (model.Settings.From is not null && !MailboxAddressValidator.IsMailboxAddress(model.Settings.From))
|
||||
{
|
||||
ModelState.AddModelError("Settings.From", StringLocalizer["Invalid email"]);
|
||||
}
|
||||
var store = await storeRepository.FindStore(ctx.StoreId);
|
||||
return store?.GetStoreBlob().EmailSettings ?? new();
|
||||
}
|
||||
|
||||
var storeBlob = store.GetStoreBlob();
|
||||
var currentSettings = store.GetStoreBlob().EmailSettings;
|
||||
if (model is { IsCustomSMTP: true, Settings: { Password: null } })
|
||||
model.Settings.Password = currentSettings?.Password;
|
||||
protected override async Task<EmailSettings> GetEmailSettingsForTest(Context ctx, EmailsViewModel model)
|
||||
{
|
||||
var settings = await GetCustomSettings(ctx.StoreId);
|
||||
return (model.IsCustomSMTP ? model.Settings : settings.Fallback) ?? new();
|
||||
}
|
||||
|
||||
if (!ModelState.IsValid && command is not ("ResetPassword" or "mailpit"))
|
||||
return View(model);
|
||||
protected override async Task SaveEmailSettings(Context ctx, EmailSettings settings, EmailsViewModel viewModel = null)
|
||||
{
|
||||
var store = await storeRepository.FindStore(ctx.StoreId);
|
||||
var blob = store?.GetStoreBlob();
|
||||
if (blob is null)
|
||||
return;
|
||||
blob.EmailSettings = viewModel?.IsCustomSMTP is false ? null : settings;
|
||||
store.SetStoreBlob(blob);
|
||||
await storeRepository.UpdateStore(store);
|
||||
}
|
||||
|
||||
if (command == "Test")
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(model.TestEmail))
|
||||
ModelState.AddModelError(nameof(model.TestEmail), new RequiredAttribute().FormatErrorMessage(nameof(model.TestEmail)));
|
||||
if (!ModelState.IsValid)
|
||||
return View(model);
|
||||
var clientSettings = (model.IsCustomSMTP ? model.Settings : settings.Fallback) ?? new();
|
||||
using var client = await clientSettings.CreateSmtpClient();
|
||||
var message = clientSettings.CreateMailMessage(MailboxAddress.Parse(model.TestEmail), $"{store.StoreName}: Email test", StringLocalizer["You received it, the BTCPay Server SMTP settings work."], false);
|
||||
await client.SendAsync(message);
|
||||
await client.DisconnectAsync(true);
|
||||
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email sent to {0}. Please verify you received it.", model.TestEmail].Value;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["Error: {0}", ex.Message].Value;
|
||||
}
|
||||
return View(model);
|
||||
}
|
||||
else if (command == "mailpit")
|
||||
{
|
||||
protected override IActionResult RedirectToEmailSettings(Context ctx)
|
||||
=> RedirectToAction(nameof(StoreEmailSettings), new { storeId = ctx.StoreId });
|
||||
|
||||
storeBlob.EmailSettings = model.Settings;
|
||||
storeBlob.EmailSettings.Server = "localhost";
|
||||
storeBlob.EmailSettings.Port = 34219;
|
||||
storeBlob.EmailSettings.EnabledCertificateCheck = false;
|
||||
storeBlob.EmailSettings.Login ??= "store@example.com";
|
||||
storeBlob.EmailSettings.From ??= "store@example.com";
|
||||
storeBlob.EmailSettings.Password ??= "password";
|
||||
store.SetStoreBlob(storeBlob);
|
||||
await storeRepository.UpdateStore(store);
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel()
|
||||
protected override async Task<(string Subject, string Body)> GetTestMessage(Context ctx)
|
||||
{
|
||||
Severity = StatusMessageModel.StatusSeverity.Info,
|
||||
AllowDismiss = true,
|
||||
Html = "Mailpit is now running on <a href=\"http://localhost:34218\" target=\"_blank\" class=\"alert-link\">localhost</a>. You can use it to test your SMTP settings."
|
||||
});
|
||||
}
|
||||
else if (command == "ResetPassword")
|
||||
{
|
||||
if (storeBlob.EmailSettings is not null)
|
||||
storeBlob.EmailSettings.Password = null;
|
||||
store.SetStoreBlob(storeBlob);
|
||||
await storeRepository.UpdateStore(store);
|
||||
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email server password reset"].Value;
|
||||
}
|
||||
else if (!model.IsCustomSMTP && currentSettings is not null)
|
||||
{
|
||||
storeBlob.EmailSettings = null;
|
||||
store.SetStoreBlob(storeBlob);
|
||||
await storeRepository.UpdateStore(store);
|
||||
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["You are now using server's email settings"].Value;
|
||||
}
|
||||
else if (model.IsCustomSMTP)
|
||||
{
|
||||
storeBlob.EmailSettings = model.Settings;
|
||||
store.SetStoreBlob(storeBlob);
|
||||
await storeRepository.UpdateStore(store);
|
||||
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email settings saved"].Value;
|
||||
}
|
||||
return RedirectToAction(nameof(StoreEmailSettings), new { storeId });
|
||||
var store = await storeRepository.FindStore(ctx.StoreId);
|
||||
return ($"{store?.StoreName}: Email test", "You received it, the BTCPay Server SMTP settings work.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,8 +52,7 @@ public class StoreEmailRuleProcessorSender(
|
||||
if (evt is TriggerEvent triggEvent)
|
||||
{
|
||||
await using var ctx = dbContextFactory.CreateContext();
|
||||
var actionableRules = await ctx.EmailRules
|
||||
.GetMatches(triggEvent.StoreId, triggEvent.Trigger, triggEvent.Model);
|
||||
var actionableRules = await ctx.EmailRules.GetMatches(triggEvent.StoreId, triggEvent.Trigger, triggEvent.Model);
|
||||
|
||||
if (actionableRules.Length > 0)
|
||||
{
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using BTCPayServer.Abstractions.Models;
|
||||
using BTCPayServer.Plugins.Emails.Views;
|
||||
using BTCPayServer.Plugins.Webhooks;
|
||||
using BTCPayServer.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -17,5 +19,50 @@ public class EmailsPlugin : BaseBTCPayServerPlugin
|
||||
{
|
||||
services.AddSingleton<IDefaultTranslationProvider, EmailsTranslationProvider>();
|
||||
services.AddSingleton<IHostedService, StoreEmailRuleProcessorSender>();
|
||||
RegisterServerEmailTriggers(services);
|
||||
}
|
||||
private static string BODY_STYLE = "font-family: Open Sans, Helvetica Neue,Arial,sans-serif; font-color: #292929;";
|
||||
private static string HEADER_HTML = "<h1 style='font-size:1.2rem'>{Branding.ServerName}</h1><br/>";
|
||||
private static string BUTTON_HTML = "<a href='{button_link}' type='submit' style='min-width: 2em;min-height: 20px;text-decoration-line: none;cursor: pointer;display: inline-block;font-weight: 400;color: #fff;text-align: center;vertical-align: middle;user-select: none;background-color: #51b13e;border-color: #51b13e;border: 1px solid transparent;padding: 0.375rem 0.75rem;font-size: 1rem;line-height: 1.5;border-radius: 0.25rem;transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;'>{button_description}</a>";
|
||||
|
||||
private static string CallToAction(string actionName, string actionLink)
|
||||
{
|
||||
var button = $"{BUTTON_HTML}".Replace("{button_description}", actionName, System.StringComparison.InvariantCulture);
|
||||
return button.Replace("{button_link}", actionLink, System.StringComparison.InvariantCulture);
|
||||
}
|
||||
|
||||
private static string CreateEmailBody(string body) => $"<html><body style='{BODY_STYLE}'>{HEADER_HTML}{body}</body></html>";
|
||||
private void RegisterServerEmailTriggers(IServiceCollection services)
|
||||
{
|
||||
|
||||
List<EmailTriggerViewModel> vms = new();
|
||||
|
||||
var vm = new EmailTriggerViewModel()
|
||||
{
|
||||
Trigger = ServerMailTriggers.PasswordReset,
|
||||
RecipientExample = "{User.MailboxAddress}",
|
||||
SubjectExample = "Update Password",
|
||||
BodyExample = CreateEmailBody($"A request has been made to reset your {{Branding.ServerName}} password. Please set your password by clicking below.<br/><br/>{CallToAction("Update Password", "{ResetLink}")}"),
|
||||
PlaceHolders = new()
|
||||
{
|
||||
new ("{ResetLink}", "The link to the password reset page")
|
||||
},
|
||||
Description = "Password Reset Requested",
|
||||
};
|
||||
vms.Add(vm);
|
||||
var commonPlaceholders = new List<EmailTriggerViewModel.PlaceHolder>()
|
||||
{
|
||||
new("{User.Name}", "The name of the user (eg. John Doe)"),
|
||||
new("{User.Email}", "The email of the user (eg. john.doe@example.com)"),
|
||||
new("{User.MailboxAddress}", "The formatted mailbox address to use when sending an email. (eg. \"John Doe\" <john.doe@example.com>)"),
|
||||
new("{Branding.ServerName}", "The name of the server (You can configure this in Server Settings ➡ Branding)"),
|
||||
new("{Branding.ContactUrl}", "The contact URL of the server (You can configure this in Server Settings ➡ Branding)"),
|
||||
};
|
||||
foreach (var v in vms)
|
||||
{
|
||||
v.ServerTrigger = true;
|
||||
v.PlaceHolders.InsertRange(0, commonPlaceholders);
|
||||
services.AddSingleton(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
11
BTCPayServer/Plugins/Emails/ServerMailTriggers.cs
Normal file
11
BTCPayServer/Plugins/Emails/ServerMailTriggers.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace BTCPayServer.Plugins.Emails;
|
||||
|
||||
public class ServerMailTriggers
|
||||
{
|
||||
public const string PasswordReset = "SRV-PasswordReset";
|
||||
public const string InvitePending = "SRV-InvitePending";
|
||||
public const string InviteConfirmed = "SRV-InviteConfirmed";
|
||||
public const string ApprovalConfirmed = "SRV-ApprovalConfirmed";
|
||||
public const string ApprovalPending = "SRV-ApprovalPending";
|
||||
public const string EmailConfirm = "SRV-EmailConfirmation";
|
||||
}
|
||||
@@ -20,4 +20,6 @@ public class EmailTriggerViewModel
|
||||
}
|
||||
|
||||
public List<PlaceHolder> PlaceHolders { get; set; } = new();
|
||||
public bool ServerTrigger { get; set; }
|
||||
public string RecipientExample { get; set; }
|
||||
}
|
||||
|
||||
@@ -2,13 +2,19 @@ using System.ComponentModel.DataAnnotations;
|
||||
using BTCPayServer.Services.Mails;
|
||||
using BTCPayServer.Validation;
|
||||
|
||||
namespace BTCPayServer.Models;
|
||||
namespace BTCPayServer.Plugins.Emails.Views;
|
||||
|
||||
public class EmailsViewModel
|
||||
{
|
||||
public string ModifyPermission { get; set; }
|
||||
public string ViewPermission { get; set; }
|
||||
public string StoreId { get; set; }
|
||||
public EmailSettings Settings { get; set; }
|
||||
public bool PasswordSet { get; set; }
|
||||
|
||||
[Display(Name = "Allow Stores use the Server's SMTP email settings as their default")]
|
||||
public bool EnableStoresToUseServerEmailSettings { get; set; }
|
||||
|
||||
[MailboxAddress]
|
||||
[Display(Name = "Test Email")]
|
||||
public string TestEmail { get; set; }
|
||||
120
BTCPayServer/Plugins/Emails/Views/Shared/EmailRulesList.cshtml
Normal file
120
BTCPayServer/Plugins/Emails/Views/Shared/EmailRulesList.cshtml
Normal file
@@ -0,0 +1,120 @@
|
||||
@using BTCPayServer.Views.Server
|
||||
@model EmailRulesListViewModel
|
||||
|
||||
@{
|
||||
var storeId = Model.StoreId;
|
||||
|
||||
if (storeId is null)
|
||||
{
|
||||
ViewData.SetLayoutModel(new LayoutModel("Server-" + nameof(ServerNavPages.Emails), StringLocalizer["Email Rules"])
|
||||
.SetCategory(WellKnownCategories.Server));
|
||||
}
|
||||
else
|
||||
{
|
||||
ViewData.SetLayoutModel(new LayoutModel(nameof(StoreNavPages.Emails), StringLocalizer["Email Rules"])
|
||||
.SetCategory(WellKnownCategories.Store));
|
||||
}
|
||||
}
|
||||
<div class="sticky-header">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item">
|
||||
@if (storeId is not null)
|
||||
{
|
||||
<a asp-controller="UIStoresEmail" asp-action="StoreEmailSettings" asp-route-storeId="@storeId" text-translate="true">Emails</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a asp-controller="UIServerEmail" asp-action="ServerEmailSettings" text-translate="true">Server Emails</a>
|
||||
}
|
||||
</li>
|
||||
<li class="breadcrumb-item active" aria-current="page">@ViewData.GetTitle()</li>
|
||||
</ol>
|
||||
<h2>@ViewData.GetTitle()</h2>
|
||||
</nav>
|
||||
@if (storeId is not null)
|
||||
{
|
||||
<a id="CreateEmailRule" permission="@Model.ModifyPermission" asp-action="StoreEmailRulesCreate" asp-route-storeId="@storeId"
|
||||
class="btn btn-primary" role="button" text-translate="true">
|
||||
Create Email Rule
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a id="CreateEmailRule" permission="@Model.ModifyPermission" asp-action="ServerEmailRulesCreate"
|
||||
class="btn btn-primary" role="button" text-translate="true">
|
||||
Create Email Rule
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
<partial name="_StatusMessage" />
|
||||
@if (storeId is not null)
|
||||
{
|
||||
<p text-translate="true">
|
||||
Email rules allow BTCPay Server to send customized emails from your store based on events.
|
||||
</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p text-translate="true">
|
||||
Email rules allow BTCPay Server to send customized emails from your server based on events.
|
||||
</p>
|
||||
}
|
||||
|
||||
@if (Model.Rules.Any())
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th text-translate="true">Trigger</th>
|
||||
@if (Model.ShowCustomerEmailColumn)
|
||||
{
|
||||
<th text-translate="true">Customer Email</th>
|
||||
}
|
||||
<th text-translate="true">To</th>
|
||||
<th text-translate="true">Subject</th>
|
||||
<th class="actions-col" permission="@Model.ModifyPermission"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var rule in Model.Rules)
|
||||
{
|
||||
<tr>
|
||||
<td>@rule.Trigger</td>
|
||||
@if (Model.ShowCustomerEmailColumn)
|
||||
{
|
||||
<td>@(rule.AdditionalData.CustomerEmail is true ? StringLocalizer["Yes"] : StringLocalizer["No"])</td>
|
||||
}
|
||||
<td>@string.Join(", ", rule.To)</td>
|
||||
<td>@rule.Subject</td>
|
||||
<td class="actions-col" permission="@Model.ModifyPermission">
|
||||
<div class="d-inline-flex align-items-center gap-3">
|
||||
@if (storeId is not null)
|
||||
{
|
||||
|
||||
<a asp-action="StoreEmailRulesEdit" asp-route-storeId="@storeId" asp-route-ruleId="@rule.Data.Id">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="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 >
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-secondary" text-translate="true">
|
||||
There are no rules yet.
|
||||
</p>
|
||||
}
|
||||
|
||||
<partial name="_Confirm" model="@(new ConfirmModel(StringLocalizer["Remove email rule"], StringLocalizer["This action will remove this rule. Are you sure?"], StringLocalizer["Delete"]))" permission="@Model.ModifyPermission" />
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace BTCPayServer.Plugins.Emails.Views.Shared;
|
||||
|
||||
public class EmailRulesListViewModel
|
||||
{
|
||||
public string StoreId { get; set; }
|
||||
public string ModifyPermission { get; set; }
|
||||
public bool ShowCustomerEmailColumn { get; set; }
|
||||
public List<StoreEmailRuleViewModel> Rules { get; set; }
|
||||
}
|
||||
@@ -1,29 +1,52 @@
|
||||
@using BTCPayServer.Views.Server
|
||||
@model StoreEmailRuleViewModel
|
||||
|
||||
@{
|
||||
var storeId = Context.GetStoreData().Id;
|
||||
var storeId = Model.StoreId;
|
||||
bool isEdit = Model.Trigger != null;
|
||||
if (storeId is not null)
|
||||
{
|
||||
ViewData.SetLayoutModel(new LayoutModel(nameof(StoreNavPages.Emails), StringLocalizer[isEdit ? "Edit Email Rule" : "Create Email Rule"])
|
||||
.SetCategory(WellKnownCategories.Store));
|
||||
}
|
||||
else
|
||||
{
|
||||
ViewData.SetLayoutModel(new LayoutModel("Server-" + nameof(ServerNavPages.Emails), StringLocalizer[isEdit ? "Edit Email Rule" : "Create Email Rule"])
|
||||
.SetCategory(WellKnownCategories.Server));
|
||||
}
|
||||
}
|
||||
|
||||
@section PageHeadContent {
|
||||
<link href="~/vendor/summernote/summernote-bs5.css" rel="stylesheet" asp-append-version="true" />
|
||||
}
|
||||
|
||||
<form method="post">
|
||||
<form id="save-form" method="post">
|
||||
<div class="sticky-header">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item">
|
||||
@if (storeId is not null)
|
||||
{
|
||||
<a asp-controller="UIStoresEmail" asp-action="StoreEmailSettings" asp-route-storeId="@storeId" text-translate="true">Emails</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a asp-controller="UIServerEmail" asp-action="ServerEmailSettings" text-translate="true">Server Emails</a>
|
||||
}
|
||||
</li>
|
||||
<li class="breadcrumb-item">
|
||||
@if (storeId is not null)
|
||||
{
|
||||
<a asp-action="StoreEmailRulesList" asp-route-storeId="@storeId" text-translate="true">Email Rules</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a asp-action="ServerEmailRulesList" text-translate="true">Email Rules</a>
|
||||
}
|
||||
</li>
|
||||
<li class="breadcrumb-item active" aria-current="page">@ViewData["Title"]</li>
|
||||
<li class="breadcrumb-item active" aria-current="page">@ViewData.GetTitle()</li>
|
||||
</ol>
|
||||
<h2>@ViewData["Title"]</h2>
|
||||
<h2>@ViewData.GetTitle()</h2>
|
||||
</nav>
|
||||
<div>
|
||||
<button id="page-primary" type="submit" class="btn btn-primary" text-translate="true">Save</button>
|
||||
@@ -57,7 +80,7 @@
|
||||
<input type="hidden" asp-for="CanChangeCondition" ></input>
|
||||
@if (Model.CanChangeCondition)
|
||||
{
|
||||
<input asp-for="Condition" class="form-control" placeholder="@StringLocalizer["A Postgres compatible JSON Path (eg. $.Offering.Id == \"john\")"]" />
|
||||
<input asp-for="Condition" class="form-control" placeholder="@StringLocalizer["A Postgres compatible JSON Path (eg. $.Customer.Name == \"john\")"]" />
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -115,6 +138,7 @@
|
||||
|
||||
const triggerSelect = document.querySelector('.email-rule-trigger') ?? document.querySelector('.email-rule-trigger-hidden');
|
||||
const subjectInput = document.querySelector('.email-rule-subject');
|
||||
const recipientInput = document.querySelector('.email-rule-to');
|
||||
const bodyTextarea = document.querySelector('.email-rule-body');
|
||||
const placeholdersTd = document.querySelector('#placeholders');
|
||||
|
||||
@@ -131,6 +155,9 @@
|
||||
if (isEmptyOrDefault(subjectInput.value, 'subjectExample')) {
|
||||
subjectInput.value = triggersByType[selectedTrigger].subjectExample;
|
||||
}
|
||||
if (isEmptyOrDefault(recipientInput.value, 'recipientExample')) {
|
||||
recipientInput.value = triggersByType[selectedTrigger].recipientExample;
|
||||
}
|
||||
var body = triggersByType[selectedTrigger].bodyExample;
|
||||
|
||||
if (isEmptyOrDefault(bodyTextarea.value, 'bodyExample')) {
|
||||
192
BTCPayServer/Plugins/Emails/Views/Shared/EmailSettings.cshtml
Normal file
192
BTCPayServer/Plugins/Emails/Views/Shared/EmailSettings.cshtml
Normal file
@@ -0,0 +1,192 @@
|
||||
@using BTCPayServer.Views.Server
|
||||
@model EmailsViewModel
|
||||
@{
|
||||
if (Model.StoreId is null)
|
||||
{
|
||||
ViewData.SetLayoutModel(new LayoutModel("Server-" + nameof(ServerNavPages.Emails), StringLocalizer["Emails"]).SetCategory(WellKnownCategories.Server));
|
||||
}
|
||||
else
|
||||
{
|
||||
ViewData.SetLayoutModel(new LayoutModel(nameof(StoreNavPages.Emails), StringLocalizer["Email Rules"])
|
||||
.SetCategory(WellKnownCategories.Store));
|
||||
}
|
||||
}
|
||||
|
||||
<form method="post" autocomplete="off">
|
||||
<div class="sticky-header">
|
||||
<h2>@ViewData.GetTitle()</h2>
|
||||
<div class="d-flex justify-content-end">
|
||||
<button cheat-mode="true" id="mailpit" type="submit" class="btn btn-info" name="command" value="mailpit">Use mailpit</button>
|
||||
@if (Model.StoreId is null)
|
||||
{
|
||||
<a id="ConfigureEmailRules" class="btn btn-secondary" asp-area="@EmailsPlugin.Area" asp-controller="UIServerEmailRules"
|
||||
asp-action="ServerEmailRulesList"
|
||||
permission="@Model.ModifyPermission" text-translate="true">
|
||||
Go to email rules
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a id="ConfigureEmailRules" class="btn btn-secondary" asp-area="@EmailsPlugin.Area" asp-controller="UIStoreEmailRules"
|
||||
asp-action="StoreEmailRulesList"
|
||||
asp-route-storeId="@Model.StoreId"
|
||||
permission="@Model.ViewPermission" text-translate="true">
|
||||
Go to email rules
|
||||
</a>
|
||||
}
|
||||
<button id="page-primary" type="submit" class="btn btn-primary" name="command" value="Save">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
<partial name="_StatusMessage" />
|
||||
<div class="row">
|
||||
<div class="col-xl-10 col-xxl-constrain">
|
||||
@if (!ViewContext.ModelState.IsValid)
|
||||
{
|
||||
<div asp-validation-summary="All"></div>
|
||||
}
|
||||
@if (Model.StoreId is null)
|
||||
{
|
||||
<div class="form-group mb-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<input asp-for="EnableStoresToUseServerEmailSettings" type="checkbox" class="btcpay-toggle me-3" />
|
||||
<div>
|
||||
<label asp-for="EnableStoresToUseServerEmailSettings" class="form-check-label"></label>
|
||||
<div class="text-muted">
|
||||
<span text-translate="true">This can be overridden at the Store level.</span>
|
||||
<a href="https://docs.btcpayserver.org/Notifications/#server-emails" target="_blank" rel="noreferrer noopener">
|
||||
<vc:icon symbol="info" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (Model.IsFallbackSetup)
|
||||
{
|
||||
<label class="d-flex align-items-center mb-4">
|
||||
<input type="checkbox" asp-for="IsCustomSMTP" class="btcpay-toggle me-3" data-bs-toggle="collapse" data-bs-target="#SmtpSettings"
|
||||
aria-expanded="@Model.IsCustomSMTP" aria-controls="SmtpSettings" />
|
||||
<div>
|
||||
<span text-translate="true">Use custom SMTP settings for this store</span>
|
||||
<div class="form-text" text-translate="true">Otherwise, the server's SMTP settings will be used to send emails.</div>
|
||||
</div>
|
||||
</label>
|
||||
}
|
||||
else
|
||||
{
|
||||
<input type="hidden" id="IsCustomSMTPHidden" asp-for="IsCustomSMTP" />
|
||||
}
|
||||
}
|
||||
|
||||
<div class="collapse @(Model.IsCustomSMTP || Model.StoreId is null ? "show" : "")" id="SmtpSettings">
|
||||
<div class="form-group">
|
||||
<div class="d-flex flex-wrap gap-2 align-items-center justify-content-between">
|
||||
<label asp-for="Settings.Server" class="form-label" text-translate="true">SMTP Server</label>
|
||||
<div class="dropdown only-for-js mt-n2" id="quick-fill">
|
||||
<button class="btn btn-link p-0 dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"
|
||||
id="QuickFillDropdownToggle" text-translate="true">
|
||||
Quick Fill
|
||||
</button>
|
||||
<div class="dropdown-menu" aria-labelledby="QuickFillDropdownToggle">
|
||||
<a class="dropdown-item" href="" data-server="smtp.gmail.com" data-port="587">Gmail.com</a>
|
||||
<a class="dropdown-item" href="" data-server="mail.yahoo.com" data-port="587">Yahoo.com</a>
|
||||
<a class="dropdown-item" href="" data-server="smtp.mailgun.org" data-port="587">Mailgun</a>
|
||||
<a class="dropdown-item" href="" data-server="smtp.office365.com" data-port="587">Office365</a>
|
||||
<a class="dropdown-item" href="" data-server="smtp.sendgrid.net" data-port="587">SendGrid</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<input asp-for="Settings.Server" data-fill="server" class="form-control" />
|
||||
<span asp-validation-for="Settings.Server" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label asp-for="Settings.Port" class="form-label" text-translate="true">Port</label>
|
||||
<input asp-for="Settings.Port" data-fill="port" class="form-control" />
|
||||
<span asp-validation-for="Settings.Port" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label asp-for="Settings.From" class="form-label" text-translate="true">Sender's Email Address</label>
|
||||
<input asp-for="Settings.From" class="form-control" placeholder="@StringLocalizer["Firstname Lastname <email@example.com>"]" />
|
||||
<span asp-validation-for="Settings.From" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label asp-for="Settings.Login" class="form-label" text-translate="true">Login</label>
|
||||
<input asp-for="Settings.Login" class="form-control" />
|
||||
<div class="form-text" text-translate="true">For many email providers (like Gmail) your login is your email address.</div>
|
||||
<span asp-validation-for="Settings.Login" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="form-group" permission="@Model.ModifyPermission">
|
||||
<label asp-for="Settings.Password" class="form-label" text-translate="true">Password</label>
|
||||
@if (!Model.PasswordSet)
|
||||
{
|
||||
<input asp-for="Settings.Password" type="password" value="@Model.Settings.Password" class="form-control" />
|
||||
<span asp-validation-for="Settings.Password" class="text-danger"></span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="input-group">
|
||||
<input value="@StringLocalizer["Configured"]" type="text" readonly class="form-control" />
|
||||
<button type="submit" class="btn btn-danger" name="command" value="ResetPassword" id="ResetPassword" text-translate="true">Reset
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<input asp-for="PasswordSet" type="hidden" />
|
||||
<div class="my-4">
|
||||
<button class="d-inline-flex align-items-center btn btn-link text-primary fw-semibold p-0" type="button" id="AdvancedSettingsButton"
|
||||
data-bs-toggle="collapse" data-bs-target="#AdvancedSettings" aria-expanded="false" aria-controls="AdvancedSettings">
|
||||
<vc:icon symbol="caret-down" />
|
||||
<span class="ms-1" text-translate="true">Advanced settings</span>
|
||||
</button>
|
||||
<div id="AdvancedSettings" class="collapse">
|
||||
<div class="pt-3 pb-1">
|
||||
<div class="d-flex">
|
||||
<input asp-for="Settings.EnabledCertificateCheck" type="checkbox" class="btcpay-toggle me-3" />
|
||||
<label asp-for="Settings.EnabledCertificateCheck" class="form-check-label" text-translate="true">TLS certificate security
|
||||
checks</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="my-3" text-translate="true">Testing</h3>
|
||||
<div class="row">
|
||||
<div class="col-xl-10 col-xxl-constrain">
|
||||
<div class="form-group">
|
||||
<label asp-for="TestEmail" class="form-label" text-translate="true">To test your settings, enter an email address</label>
|
||||
<input asp-for="TestEmail" placeholder="@StringLocalizer["Firstname Lastname <email@example.com>"]" class="form-control" />
|
||||
<span asp-validation-for="TestEmail" class="text-danger"></span>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-secondary mt-2" name="command" value="Test" id="Test" text-translate="true">Send Test Email</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@section PageFootContent {
|
||||
<partial name="_ValidationScriptsPartial" />
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
delegate('click', '#quick-fill .dropdown-menu a', function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
const data = e.target.dataset;
|
||||
Object.keys(data).forEach(function (key) {
|
||||
const value = data[key];
|
||||
const input = document.querySelector('input[data-fill="' + key + '"]');
|
||||
if (input) {
|
||||
const type = input.getAttribute('type');
|
||||
if (type === 'checkbox') {
|
||||
input.checked = value;
|
||||
} else {
|
||||
input.value = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
}
|
||||
@@ -16,6 +16,7 @@ public class StoreEmailRuleViewModel
|
||||
{
|
||||
if (data is not null)
|
||||
{
|
||||
StoreId = data.StoreId;
|
||||
Data = data;
|
||||
OfferingId = data.OfferingId;
|
||||
AdditionalData = data.GetBTCPayAdditionalData() ?? new();
|
||||
@@ -51,6 +52,7 @@ public class StoreEmailRuleViewModel
|
||||
public bool CanChangeTrigger { get; set; } = true;
|
||||
public bool CanChangeCondition { get; set; } = true;
|
||||
public string OfferingId { get; set; }
|
||||
public string StoreId { get; set; }
|
||||
|
||||
public string[] ToAsArray()
|
||||
=> (To ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
@model List<StoreEmailRuleViewModel>
|
||||
|
||||
@{
|
||||
var storeId = Context.GetStoreData().Id;
|
||||
ViewData.SetLayoutModel(new LayoutModel(nameof(StoreNavPages.Emails), StringLocalizer["Email Rules"])
|
||||
.SetCategory(WellKnownCategories.Store));
|
||||
}
|
||||
<div class="sticky-header">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item">
|
||||
<a asp-controller="UIStoresEmail" asp-action="StoreEmailSettings" asp-route-storeId="@storeId" text-translate="true">Emails</a>
|
||||
</li>
|
||||
<li class="breadcrumb-item active" aria-current="page">@ViewData["Title"]</li>
|
||||
</ol>
|
||||
<h2>@ViewData["Title"]</h2>
|
||||
</nav>
|
||||
<a id="CreateEmailRule" permission="@Policies.CanModifyStoreSettings" asp-action="StoreEmailRulesCreate" asp-route-storeId="@storeId"
|
||||
class="btn btn-primary" role="button" text-translate="true">
|
||||
Create Email Rule
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<partial name="_StatusMessage" />
|
||||
<p text-translate="true">
|
||||
Email rules allow BTCPay Server to send customized emails from your store based on events.
|
||||
</p>
|
||||
|
||||
@if (Model.Any())
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th text-translate="true">Trigger</th>
|
||||
<th text-translate="true">Customer Email</th>
|
||||
<th text-translate="true">To</th>
|
||||
<th text-translate="true">Subject</th>
|
||||
<th class="actions-col" permission="@Policies.CanModifyStoreSettings"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var rule in Model)
|
||||
{
|
||||
<tr>
|
||||
<td>@rule.Trigger</td>
|
||||
<td>@(rule.AdditionalData.CustomerEmail is true ? StringLocalizer["Yes"] : StringLocalizer["No"])</td>
|
||||
<td>@string.Join(", ", rule.To)</td>
|
||||
<td>@rule.Subject</td>
|
||||
<td class="actions-col" permission="@Policies.CanModifyStoreSettings">
|
||||
<div class="d-inline-flex align-items-center gap-3">
|
||||
<a asp-action="StoreEmailRulesEdit" asp-route-storeId="@storeId" asp-route-ruleId="@rule.Data.Id">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>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-secondary" text-translate="true">
|
||||
There are no rules yet.
|
||||
</p>
|
||||
}
|
||||
|
||||
<partial name="_Confirm" model="@(new ConfirmModel(StringLocalizer["Remove email rule"], StringLocalizer["This action will remove this rule. Are you sure?"], StringLocalizer["Delete"]))" permission="@Policies.CanModifyStoreSettings" />
|
||||
@@ -1,56 +0,0 @@
|
||||
@using BTCPayServer.Client
|
||||
@using BTCPayServer.Plugins.Emails
|
||||
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@model BTCPayServer.Models.EmailsViewModel
|
||||
@{
|
||||
var storeId = Context.GetStoreData().Id;
|
||||
ViewData.SetLayoutModel(new LayoutModel(nameof(StoreNavPages.Emails), StringLocalizer["Email Rules"])
|
||||
.SetCategory(WellKnownCategories.Store));
|
||||
}
|
||||
|
||||
<form method="post" autocomplete="off" permissioned="@Policies.CanModifyStoreSettings">
|
||||
<div class="sticky-header">
|
||||
<h2 text-translate="true">Email Server</h2>
|
||||
<div class="d-flex justify-content-end">
|
||||
<button cheat-mode="true" id="mailpit" type="submit" class="btn btn-info" name="command" value="mailpit">Use mailpit</button>
|
||||
<button id="page-primary" type="submit" class="btn btn-primary" name="command" value="Save">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
<partial name="_StatusMessage" />
|
||||
@if (Model.IsFallbackSetup)
|
||||
{
|
||||
<label class="d-flex align-items-center mb-4">
|
||||
<input type="checkbox" asp-for="IsCustomSMTP" class="btcpay-toggle me-3" data-bs-toggle="collapse" data-bs-target="#SmtpSettings"
|
||||
aria-expanded="@Model.IsCustomSMTP" aria-controls="SmtpSettings" />
|
||||
<div>
|
||||
<span text-translate="true">Use custom SMTP settings for this store</span>
|
||||
<div class="form-text" text-translate="true">Otherwise, the server's SMTP settings will be used to send emails.</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div class="collapse @(Model.IsCustomSMTP ? "show" : "")" id="SmtpSettings">
|
||||
<partial name="EmailsBody" model="Model" />
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<input type="hidden" id="IsCustomSMTPHidden" asp-for="IsCustomSMTP" />
|
||||
<partial name="EmailsBody" model="Model" />
|
||||
}
|
||||
|
||||
<partial name="EmailsTest" model="Model" permission="@Policies.CanModifyStoreSettings" />
|
||||
</form>
|
||||
|
||||
<div class="mt-5" permission="@Policies.CanModifyStoreSettings">
|
||||
<h3 text-translate="true">Email Rules</h3>
|
||||
<p text-translate="true">Email rules allow BTCPay Server to send customized emails from your store based on events.</p>
|
||||
<a id="ConfigureEmailRules" class="btn btn-secondary" asp-area="@EmailsPlugin.Area" asp-controller="UIStoreEmailRules" asp-action="StoreEmailRulesList"
|
||||
asp-route-storeId="@storeId"
|
||||
permission="@Policies.CanViewStoreSettings" text-translate="true">
|
||||
Configure
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@section PageFootContent {
|
||||
<partial name="_ValidationScriptsPartial" />
|
||||
}
|
||||
@@ -16,6 +16,9 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
[assembly: InternalsVisibleTo("BTCPayServer.Tests")]
|
||||
|
||||
// This help JetBrains to find partial views referenced by views in plugins
|
||||
[assembly: JetBrains.Annotations.AspMvcAreaPartialViewLocationFormat("/Plugins/{2}/Views/Shared/{0}.cshtml")]
|
||||
|
||||
namespace BTCPayServer
|
||||
{
|
||||
class Program
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
@using BTCPayServer.Client
|
||||
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@using BTCPayServer.Abstractions.TagHelpers
|
||||
@using BTCPayServer.Models.ServerViewModels
|
||||
@model BTCPayServer.Models.EmailsViewModel
|
||||
|
||||
<div class="row">
|
||||
<div class="col-xl-10 col-xxl-constrain">
|
||||
@if (!ViewContext.ModelState.IsValid)
|
||||
{
|
||||
<div asp-validation-summary="All"></div>
|
||||
}
|
||||
<div class="form-group">
|
||||
<div class="d-flex flex-wrap gap-2 align-items-center justify-content-between">
|
||||
<label asp-for="Settings.Server" class="form-label" text-translate="true">SMTP Server</label>
|
||||
<div class="dropdown only-for-js mt-n2" id="quick-fill">
|
||||
<button class="btn btn-link p-0 dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" id="QuickFillDropdownToggle" text-translate="true">
|
||||
Quick Fill
|
||||
</button>
|
||||
<div class="dropdown-menu" aria-labelledby="QuickFillDropdownToggle">
|
||||
<a class="dropdown-item" href="" data-server="smtp.gmail.com" data-port="587">Gmail.com</a>
|
||||
<a class="dropdown-item" href="" data-server="mail.yahoo.com" data-port="587">Yahoo.com</a>
|
||||
<a class="dropdown-item" href="" data-server="smtp.mailgun.org" data-port="587">Mailgun</a>
|
||||
<a class="dropdown-item" href="" data-server="smtp.office365.com" data-port="587">Office365</a>
|
||||
<a class="dropdown-item" href="" data-server="smtp.sendgrid.net" data-port="587">SendGrid</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<input asp-for="Settings.Server" data-fill="server" class="form-control" />
|
||||
<span asp-validation-for="Settings.Server" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label asp-for="Settings.Port" class="form-label" text-translate="true">Port</label>
|
||||
<input asp-for="Settings.Port" data-fill="port" class="form-control"/>
|
||||
<span asp-validation-for="Settings.Port" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label asp-for="Settings.From" class="form-label" text-translate="true">Sender's Email Address</label>
|
||||
<input asp-for="Settings.From" class="form-control" placeholder="@StringLocalizer["Firstname Lastname <email@example.com>"]" />
|
||||
<span asp-validation-for="Settings.From" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label asp-for="Settings.Login" class="form-label" text-translate="true">Login</label>
|
||||
<input asp-for="Settings.Login" class="form-control"/>
|
||||
<div class="form-text" text-translate="true">For many email providers (like Gmail) your login is your email address.</div>
|
||||
<span asp-validation-for="Settings.Login" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="form-group" permission="@(Model is ServerEmailsViewModel ? Policies.CanModifyServerSettings : Policies.CanModifyStoreSettings)">
|
||||
<label asp-for="Settings.Password" class="form-label" text-translate="true">Password</label>
|
||||
@if (!Model.PasswordSet)
|
||||
{
|
||||
<input asp-for="Settings.Password" type="password" value="@Model.Settings.Password" class="form-control" />
|
||||
<span asp-validation-for="Settings.Password" class="text-danger"></span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="input-group">
|
||||
<input value="@StringLocalizer["Configured"]" type="text" readonly class="form-control"/>
|
||||
<button type="submit" class="btn btn-danger" name="command" value="ResetPassword" id="ResetPassword" text-translate="true">Reset</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<input asp-for="PasswordSet" type="hidden"/>
|
||||
<div class="my-4">
|
||||
<button class="d-inline-flex align-items-center btn btn-link text-primary fw-semibold p-0" type="button" id="AdvancedSettingsButton" data-bs-toggle="collapse" data-bs-target="#AdvancedSettings" aria-expanded="false" aria-controls="AdvancedSettings">
|
||||
<vc:icon symbol="caret-down"/>
|
||||
<span class="ms-1" text-translate="true">Advanced settings</span>
|
||||
</button>
|
||||
<div id="AdvancedSettings" class="collapse">
|
||||
<div class="pt-3 pb-1">
|
||||
<div class="d-flex">
|
||||
<input asp-for="Settings.EnabledCertificateCheck" type="checkbox" class="btcpay-toggle me-3" />
|
||||
<label asp-for="Settings.EnabledCertificateCheck" class="form-check-label" text-translate="true">TLS certificate security checks</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
delegate('click', '#quick-fill .dropdown-menu a', function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
const data = e.target.dataset;
|
||||
Object.keys(data).forEach(function (key) {
|
||||
const value = data[key];
|
||||
const input = document.querySelector('input[data-fill="' + key + '"]');
|
||||
if (input) {
|
||||
const type = input.getAttribute('type');
|
||||
if (type === 'checkbox') {
|
||||
input.checked = value;
|
||||
} else {
|
||||
input.value = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -1,13 +0,0 @@
|
||||
@model BTCPayServer.Models.EmailsViewModel
|
||||
|
||||
<h3 class="my-3" text-translate="true">Testing</h3>
|
||||
<div class="row">
|
||||
<div class="col-xl-10 col-xxl-constrain">
|
||||
<div class="form-group">
|
||||
<label asp-for="TestEmail" class="form-label" text-translate="true">To test your settings, enter an email address</label>
|
||||
<input asp-for="TestEmail" placeholder="@StringLocalizer["Firstname Lastname <email@example.com>"]" class="form-control" />
|
||||
<span asp-validation-for="TestEmail" class="text-danger"></span>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-secondary mt-2" name="command" value="Test" id="Test" text-translate="true">Send Test Email</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -24,7 +24,7 @@
|
||||
<span asp-validation-for="Email" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="form-group mt-4">
|
||||
<button type="submit" class="btn btn-primary btn-lg w-100" text-translate="true">Submit</button>
|
||||
<button id="page-primary" type="submit" class="btn btn-primary btn-lg w-100" text-translate="true">Submit</button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
@model ServerEmailsViewModel
|
||||
@{
|
||||
ViewData.SetLayoutModel(new LayoutModel("Server-" + nameof(ServerNavPages.Emails), StringLocalizer["Emails"]).SetCategory(WellKnownCategories.Server));
|
||||
}
|
||||
|
||||
<form method="post" autocomplete="off">
|
||||
<div class="sticky-header">
|
||||
<h2>@ViewData["Title"]</h2>
|
||||
<div class="d-flex justify-content-end">
|
||||
<button cheat-mode="true" id="mailpit" type="submit" class="btn btn-info" name="command" value="mailpit">Use mailpit</button>
|
||||
<button id="page-primary" type="submit" class="btn btn-primary" name="command" value="Save">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
<partial name="_StatusMessage" />
|
||||
<div class="form-group mb-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<input asp-for="EnableStoresToUseServerEmailSettings" type="checkbox" class="btcpay-toggle me-3"/>
|
||||
<div>
|
||||
<label asp-for="EnableStoresToUseServerEmailSettings" class="form-check-label"></label>
|
||||
<div class="text-muted">
|
||||
<span text-translate="true">This can be overridden at the Store level.</span>
|
||||
<a href="https://docs.btcpayserver.org/Notifications/#server-emails" target="_blank" rel="noreferrer noopener">
|
||||
<vc:icon symbol="info" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<partial name="EmailsBody" model="Model" />
|
||||
<partial name="EmailsTest" model="Model" />
|
||||
</form>
|
||||
|
||||
@section PageFootContent {
|
||||
<partial name="_ValidationScriptsPartial" />
|
||||
}
|
||||
Reference in New Issue
Block a user