What changed, and why it matters
This commit refactors BTCPay Server's email handling into a new 'Server Email Rules' plugin. It moves server and store email settings into shared base controllers, adds server-wide email rules (for example password-reset emails), and changes how password-reset emails are generated. The changes are mostly architectural and feature-adding; there is no clear evidence in the diff of a security vulnerability being fixed or introduced. However, the new code does involve email templating, SQL queries, and authorization boundaries, so it warrants normal review rather than being dismissed as purely cosmetic.
Treat as a normal feature/refactor commit. Reviewers should verify that server email rules cannot be accessed or modified by non-server admins, that store rules remain scoped to their store, that the password-reset template cannot be abused to leak or redirect reset links, and that the jsonb_path_exists condition validation prevents SQL/JSONPath injection. No immediate security patch action is indicated by the diff alone.
Security signals we found
Refactor of email settings controllers changes authorization scope (server vs store) and permission checks
Password reset email generation moved from hard-coded helper to configurable template with placeholders including {ResetLink}
New server-level email rules can target server-wide events and are stored with StoreId == null
SQL query in GetMatches uses FromSqlInterpolated and jsonb_path_exists with user-provided condition string
Email body/subject are rendered through TextTemplate against a JObject model that includes user and branding data
New package reference JetBrains.Annotations.Sources added
Tests added for password-reset email rule using HTML body with anchor link placeholder
Evidence from the diff
The commit is a large refactor/feature addition for the Emails plugin. Key changes: (1) Extracts common email-settings logic into UIEmailControllerBase and common email-rule CRUD into UIEmailRuleControllerBase. (2) Adds server-level email rules via UIServerEmailRulesController and server-level settings via UIServerEmailController. (3) Replaces the hard-coded password-reset email (SendResetPassword) with a new TriggerEvent-based flow using ServerMailTriggers.PasswordReset, rendering a configurable HTML template. (4) Updates GetMatches in ApplicationDbContextExtensions to handle null storeId for server rules. (5) Adds tests for the new server password-reset rule. (6) Adds a JetBrains.Annotations.Sources package reference. No explicit security bug or CVE is mentioned in the commit message or diff. The SQL uses FromSqlInterpolated (parameterized), and the controllers carry [Authorize] attributes, but the refactor changes authorization/scope boundaries and introduces template rendering of user-controlled placeholders, which are typical areas to verify.
Changed components
BTCPayServer.Plugins.EmailsBTCPayServer.Controllers.UIServerController (email actions removed)BTCPayServer.Plugins.Emails.Controllers.UIServerEmailControllerBTCPayServer.Plugins.Emails.Controllers.UIServerEmailRulesControllerBTCPayServer.Plugins.Emails.Controllers.UIStoresEmailControllerBTCPayServer.Plugins.Emails.Controllers.UIStoreEmailRulesControllerBTCPayServer.HostedServices.UserEventHostedServiceBTCPayServer.Data.ApplicationDbContextExtensions (email rule queries)BTCPayServer.Services.EmailSenderExtensionsBTCPayServer.Plugins.Emails.EmailRuleProcessorSenderInspect captured patch +1415 / −965
diff --git a/BTCPayServer.Abstractions/Extensions/ViewsRazor.cs b/BTCPayServer.Abstractions/Extensions/ViewsRazor.cs
index 68fc291..a182fa9 100644
--- a/BTCPayServer.Abstractions/Extensions/ViewsRazor.cs
+++ b/BTCPayServer.Abstractions/Extensions/ViewsRazor.cs
@@ -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
{
diff --git a/BTCPayServer.Data/Data/EmailRuleData.cs b/BTCPayServer.Data/Data/EmailRuleData.cs
index b0f37f0..cf83667 100644
--- a/BTCPayServer.Data/Data/EmailRuleData.cs
+++ b/BTCPayServer.Data/Data/EmailRuleData.cs
@@ -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
- .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))
- """)
- .ToArrayAsync();
+ =>
+ storeId is null
+ ? set
+ .FromSqlInterpolated($"""
+ SELECT * FROM email_rules
+ 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();
}
diff --git a/BTCPayServer.Tests/PMO/ConfigureEmailPMO.cs b/BTCPayServer.Tests/PMO/ConfigureEmailPMO.cs
index 27ffa81..b0746f1 100644
--- a/BTCPayServer.Tests/PMO/ConfigureEmailPMO.cs
+++ b/BTCPayServer.Tests/PMO/ConfigureEmailPMO.cs
@@ -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()
diff --git a/BTCPayServer.Tests/PMO/EmailRulePMO.cs b/BTCPayServer.Tests/PMO/EmailRulePMO.cs
index 4aabe8a..30cac29 100644
--- a/BTCPayServer.Tests/PMO/EmailRulePMO.cs
+++ b/BTCPayServer.Tests/PMO/EmailRulePMO.cs
@@ -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();
diff --git a/BTCPayServer.Tests/PlaywrightTester.cs b/BTCPayServer.Tests/PlaywrightTester.cs
index d4add1f..97cc0eb 100644
--- a/BTCPayServer.Tests/PlaywrightTester.cs
+++ b/BTCPayServer.Tests/PlaywrightTester.cs
@@ -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}");
}
diff --git a/BTCPayServer.Tests/PlaywrightTests.cs b/BTCPayServer.Tests/PlaywrightTests.cs
index 2284459..66efb41 100644
--- a/BTCPayServer.Tests/PlaywrightTests.cs
+++ b/BTCPayServer.Tests/PlaywrightTests.cs
@@ -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]
diff --git a/BTCPayServer.Tests/ServerTester.cs b/BTCPayServer.Tests/ServerTester.cs
index 9d0bd51..82590a1 100644
--- a/BTCPayServer.Tests/ServerTester.cs
+++ b/BTCPayServer.Tests/ServerTester.cs
@@ -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");
diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs
index 6807604..0e89feb 100644
--- a/BTCPayServer.Tests/UnitTest1.cs
+++ b/BTCPayServer.Tests/UnitTest1.cs
@@ -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 sender = await emailSenderFactory.GetEmailSender(acc.StoreId);
- sender.SendEmail(MailboxAddress.Parse("destination@test.com"), "test", "hello world");
- });
- var message = await tester.AssertHasEmail(sent);
+ var message = await tester.AssertHasEmail(async () =>
+ {
+ var sender = await emailSenderFactory.GetEmailSender(acc.StoreId);
+ sender.SendEmail(MailboxAddress.Parse("destination@test.com"), "test", "hello world");
+ });
Assert.Equal("test", message.Subject);
Assert.Equal("hello world", message.Text);
}
diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj
index 2c1f1b0..e504a43 100644
--- a/BTCPayServer/BTCPayServer.csproj
+++ b/BTCPayServer/BTCPayServer.csproj
@@ -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>
diff --git a/BTCPayServer/Components/MainNav/Default.cshtml b/BTCPayServer/Components/MainNav/Default.cshtml
index afae344..23c8745 100644
--- a/BTCPayServer/Components/MainNav/Default.cshtml
+++ b/BTCPayServer/Components/MainNav/Default.cshtml
@@ -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>
diff --git a/BTCPayServer/Controllers/UIServerController.cs b/BTCPayServer/Controllers/UIServerController.cs
index d922379..6208432 100644
--- a/BTCPayServer/Controllers/UIServerController.cs
+++ b/BTCPayServer/Controllers/UIServerController.cs
@@ -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)
{
diff --git a/BTCPayServer/Extensions/EmailSenderExtensions.cs b/BTCPayServer/Extensions/EmailSenderExtensions.cs
index cd5466a..caa4e9e 100644
--- a/BTCPayServer/Extensions/EmailSenderExtensions.cs
+++ b/BTCPayServer/Extensions/EmailSenderExtensions.cs
@@ -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(
diff --git a/BTCPayServer/HostedServices/UserEventHostedService.cs b/BTCPayServer/HostedServices/UserEventHostedService.cs
index 6cff41d..178663e 100644
--- a/BTCPayServer/HostedServices/UserEventHostedService.cs
+++ b/BTCPayServer/HostedServices/UserEventHostedService.cs
@@ -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,
@@ -59,7 +63,7 @@ public class UserEventHostedService(
Logs.PayServer.LogInformation(newUserInfo);
// send notification if the user does not require email confirmation.
- // inform admins only about qualified users and not annoy them with bot registrations.
+ // inform admins only about qualified users and not annoy them with bot registrations.
if (requiresApproval && !requiresEmailConfirmation)
{
await NotifyAdminsAboutUserRequiringApproval(user, ev.ApprovalLink, newUserInfo);
@@ -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;
diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs
index 4d89d68..1d000a4 100644
--- a/BTCPayServer/Hosting/BTCPayServerServices.cs
+++ b/BTCPayServer/Hosting/BTCPayServerServices.cs
@@ -159,6 +159,7 @@ namespace BTCPayServer.Hosting
//
AddSettingsAccessor<PoliciesSettings>(services);
AddSettingsAccessor<ThemeSettings>(services);
+ AddSettingsAccessor<ServerSettings>(services);
//
AddOnchainWalletParsers(services);
diff --git a/BTCPayServer/Hosting/Startup.cs b/BTCPayServer/Hosting/Startup.cs
index 7747abb..037460f 100644
--- a/BTCPayServer/Hosting/Startup.cs
+++ b/BTCPayServer/Hosting/Startup.cs
@@ -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()
diff --git a/BTCPayServer/Models/EmailsViewModel.cs b/BTCPayServer/Models/EmailsViewModel.cs
deleted file mode 100644
index 4c516c0..0000000
--- a/BTCPayServer/Models/EmailsViewModel.cs
+++ /dev/null
@@ -1,30 +0,0 @@
-using System.ComponentModel.DataAnnotations;
-using BTCPayServer.Services.Mails;
-using BTCPayServer.Validation;
-
-namespace BTCPayServer.Models;
-
-public class EmailsViewModel
-{
- public EmailSettings Settings { get; set; }
- public bool PasswordSet { get; set; }
-
- [MailboxAddress]
- [Display(Name = "Test Email")]
- public string TestEmail { get; set; }
-
- public EmailsViewModel()
- {
- }
-
- public EmailsViewModel(EmailSettings settings)
- {
- Settings = settings;
- PasswordSet = !string.IsNullOrWhiteSpace(settings?.Password);
- }
-
- public bool IsSetup() => Settings?.IsComplete() is true;
-
- public bool IsFallbackSetup { get; set; }
- public bool IsCustomSMTP { get; set; }
-}
diff --git a/BTCPayServer/Models/ServerViewModels/EmailsViewModel.cs b/BTCPayServer/Models/ServerViewModels/EmailsViewModel.cs
deleted file mode 100644
index f710e69..0000000
--- a/BTCPayServer/Models/ServerViewModels/EmailsViewModel.cs
+++ /dev/null
@@ -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)
- {
- }
-}
diff --git a/BTCPayServer/Plugins/Emails/Controllers/UIEmailControllerBase.cs b/BTCPayServer/Plugins/Emails/Controllers/UIEmailControllerBase.cs
new file mode 100644
index 0000000..a1761fd
--- /dev/null
+++ b/BTCPayServer/Plugins/Emails/Controllers/UIEmailControllerBase.cs
@@ -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);
+ }
+}
diff --git a/BTCPayServer/Plugins/Emails/Controllers/UIEmailRuleControllerBase.cs b/BTCPayServer/Plugins/Emails/Controllers/UIEmailRuleControllerBase.cs
new file mode 100644
index 0000000..997aead
--- /dev/null
+++ b/BTCPayServer/Plugins/Emails/Controllers/UIEmailRuleControllerBase.cs
@@ -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})");
+ }
+ }
+ }
+}
diff --git a/BTCPayServer/Plugins/Emails/Controllers/UIServerEmailController.cs b/BTCPayServer/Plugins/Emails/Controllers/UIServerEmailController.cs
new file mode 100644
index 0000000..9b25092
--- /dev/null
+++ b/BTCPayServer/Plugins/Emails/Controllers/UIServerEmailController.cs
@@ -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);
+}
diff --git a/BTCPayServer/Plugins/Emails/Controllers/UIServerEmailRulesController.cs b/BTCPayServer/Plugins/Emails/Controllers/UIServerEmailRulesController.cs
new file mode 100644
index 0000000..f1ba896
--- /dev/null
+++ b/BTCPayServer/Plugins/Emails/Controllers/UIServerEmailRulesController.cs
@@ -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);
+}
diff --git a/BTCPayServer/Plugins/Emails/Controllers/UIStoreEmailRulesController.cs b/BTCPayServer/Plugins/Emails/Controllers/UIStoreEmailRulesController.cs
index 7f689f9..f2b4d44 100644
--- a/BTCPayServer/Plugins/Emails/Controllers/UIStoreEmailRulesController.cs
+++ b/BTCPayServer/Plugins/Emails/Controllers/UIStoreEmailRulesController.cs
@@ -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())
+ public Task<IActionResult> StoreEmailRulesList(string storeId)
+ => EmailRulesListCore(CreateContext(storeId));
+
+ private EmailsRuleControllerContext CreateContext(string storeId)
+ => new()
{
- TempData.SetStatusMessageModel(new StatusMessageModel
+ StoreId = storeId,
+ EmailSettingsLink = linkGenerator.GetStoreEmailSettingsLink(storeId, Request.GetRequestBaseUrl()),
+ Rules = (ctx) => ctx.EmailRules.GetRules(storeId).ToListAsync(),
+ Triggers = triggers.Where(t => !t.ServerTrigger).ToList(),
+ ModifyViewModel = (vm) =>
{
- 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>."
- });
- }
-
- var rules = await ctx.EmailRules.GetRules(storeId).ToListAsync();
- return View("StoreEmailRulesList", rules.Select(r => new StoreEmailRuleViewModel(r, triggers)).ToList());
- }
+ 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);
-
- 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);
- }
+ public Task<IActionResult> StoreEmailRulesCreate(string storeId, StoreEmailRuleViewModel model)
+ => EmailRulesCreateCore(CreateContext(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);
}
diff --git a/BTCPayServer/Plugins/Emails/Controllers/UIStoresEmailController.cs b/BTCPayServer/Plugins/Emails/Controllers/UIStoresEmailController.cs
index 7a7c0b3..065566c 100644
--- a/BTCPayServer/Plugins/Emails/Controllers/UIStoresEmailController.cs
+++ b/BTCPayServer/Plugins/Emails/Controllers/UIStoresEmailController.cs
@@ -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,25 +24,34 @@ 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()
{
- IsFallbackSetup = settings.Fallback is not null,
- IsCustomSMTP = settings.Custom is not null || settings.Fallback is null
- });
+ StoreId = storeId,
+ CreateEmailViewModel = (email) => new EmailsViewModel(email)
+ {
+ IsFallbackSetup = settings.Fallback is not 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()
- {
- 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 });
+ protected override async Task<(string Subject, string Body)> GetTestMessage(Context ctx)
+ {
+ var store = await storeRepository.FindStore(ctx.StoreId);
+ return ($"{store?.StoreName}: Email test", "You received it, the BTCPay Server SMTP settings work.");
}
}
diff --git a/BTCPayServer/Plugins/Emails/EmailRuleProcessorSender.cs b/BTCPayServer/Plugins/Emails/EmailRuleProcessorSender.cs
new file mode 100644
index 0000000..68dc707
--- /dev/null
+++ b/BTCPayServer/Plugins/Emails/EmailRuleProcessorSender.cs
@@ -0,0 +1,87 @@
+#nullable enable
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using BTCPayServer.Data;
+using BTCPayServer.HostedServices;
+using BTCPayServer.Services.Mails;
+using Microsoft.Extensions.Logging;
+using MimeKit;
+using Newtonsoft.Json.Linq;
+
+namespace BTCPayServer.Plugins.Emails;
+
+public interface ITriggerOwner
+{
+ Task BeforeSending(EmailRuleMatchContext context);
+}
+
+public record TriggerEvent(string? StoreId, string Trigger, JObject Model, ITriggerOwner? Owner)
+{
+ public override string ToString()
+ => $"Trigger event '{Trigger}'";
+}
+
+public class EmailRuleMatchContext(
+ TriggerEvent triggerEvent,
+ EmailRuleData matchedRule)
+{
+ public TriggerEvent TriggerEvent { get; } = triggerEvent;
+ public EmailRuleData MatchedRule { get; } = matchedRule;
+
+ public List<MailboxAddress> Recipients { get; set; } = new();
+ public List<MailboxAddress> Cc { get; set; } = new();
+ public List<MailboxAddress> Bcc { get; set; } = new();
+}
+
+public class StoreEmailRuleProcessorSender(
+ ApplicationDbContextFactory dbContextFactory,
+ EventAggregator eventAggregator,
+ ILogger<StoreEmailRuleProcessorSender> logger,
+ EmailSenderFactory emailSenderFactory)
+ : EventHostedServiceBase(eventAggregator, logger)
+{
+ protected override void SubscribeToEvents()
+ {
+ Subscribe<TriggerEvent>();
+ }
+
+ protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
+ {
+ if (evt is TriggerEvent triggEvent)
+ {
+ await using var ctx = dbContextFactory.CreateContext();
+ var actionableRules = await ctx.EmailRules.GetMatches(triggEvent.StoreId, triggEvent.Trigger, triggEvent.Model);
+
+ if (actionableRules.Length > 0)
+ {
+ var sender = await emailSenderFactory.GetEmailSender(triggEvent.StoreId);
+ foreach (var actionableRule in actionableRules)
+ {
+ var matchedContext = new EmailRuleMatchContext(triggEvent, actionableRule);
+
+ var body = new TextTemplate(actionableRule.Body ?? "");
+ var subject = new TextTemplate(actionableRule.Subject ?? "");
+ matchedContext.Recipients.AddRange(
+ actionableRule.To
+ .Select(o =>
+ {
+ if (!MailboxAddressValidator.TryParse(o, out var oo))
+ {
+ MailboxAddressValidator.TryParse(new TextTemplate(o).Render(triggEvent.Model), out oo);
+ }
+ return oo;
+ })
+ .Where(o => o != null)!);
+
+ if (triggEvent.Owner is not null)
+ await triggEvent.Owner.BeforeSending(matchedContext);
+ if (matchedContext.Recipients.Count == 0)
+ continue;
+ sender.SendEmail(matchedContext.Recipients.ToArray(), matchedContext.Cc.ToArray(), matchedContext.Bcc.ToArray(), subject.Render(triggEvent.Model), body.Render(triggEvent.Model));
+ }
+ }
+ }
+ }
+}
diff --git a/BTCPayServer/Plugins/Emails/EmailsPlugin.cs b/BTCPayServer/Plugins/Emails/EmailsPlugin.cs
index 0b86775..73537c7 100644
--- a/BTCPayServer/Plugins/Emails/EmailsPlugin.cs
+++ b/BTCPayServer/Plugins/Emails/EmailsPlugin.cs
@@ -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);
+ }
}
}
diff --git a/BTCPayServer/Plugins/Emails/ServerMailTriggers.cs b/BTCPayServer/Plugins/Emails/ServerMailTriggers.cs
new file mode 100644
index 0000000..c5ee029
--- /dev/null
+++ b/BTCPayServer/Plugins/Emails/ServerMailTriggers.cs
@@ -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";
+}
diff --git a/BTCPayServer/Plugins/Emails/StoreEmailRuleProcessorSender.cs b/BTCPayServer/Plugins/Emails/StoreEmailRuleProcessorSender.cs
deleted file mode 100644
index 2523753..0000000
--- a/BTCPayServer/Plugins/Emails/StoreEmailRuleProcessorSender.cs
+++ /dev/null
@@ -1,88 +0,0 @@
-#nullable enable
-using System.Collections.Generic;
-using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
-using BTCPayServer.Data;
-using BTCPayServer.HostedServices;
-using BTCPayServer.Services.Mails;
-using Microsoft.Extensions.Logging;
-using MimeKit;
-using Newtonsoft.Json.Linq;
-
-namespace BTCPayServer.Plugins.Emails;
-
-public interface ITriggerOwner
-{
- Task BeforeSending(EmailRuleMatchContext context);
-}
-
-public record TriggerEvent(string? StoreId, string Trigger, JObject Model, ITriggerOwner? Owner)
-{
- public override string ToString()
- => $"Trigger event '{Trigger}'";
-}
-
-public class EmailRuleMatchContext(
- TriggerEvent triggerEvent,
- EmailRuleData matchedRule)
-{
- public TriggerEvent TriggerEvent { get; } = triggerEvent;
- public EmailRuleData MatchedRule { get; } = matchedRule;
-
- public List<MailboxAddress> Recipients { get; set; } = new();
- public List<MailboxAddress> Cc { get; set; } = new();
- public List<MailboxAddress> Bcc { get; set; } = new();
-}
-
-public class StoreEmailRuleProcessorSender(
- ApplicationDbContextFactory dbContextFactory,
- EventAggregator eventAggregator,
- ILogger<StoreEmailRuleProcessorSender> logger,
- EmailSenderFactory emailSenderFactory)
- : EventHostedServiceBase(eventAggregator, logger)
-{
- protected override void SubscribeToEvents()
- {
- Subscribe<TriggerEvent>();
- }
-
- protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
- {
- if (evt is TriggerEvent triggEvent)
- {
- await using var ctx = dbContextFactory.CreateContext();
- var actionableRules = await ctx.EmailRules
- .GetMatches(triggEvent.StoreId, triggEvent.Trigger, triggEvent.Model);
-
- if (actionableRules.Length > 0)
- {
- var sender = await emailSenderFactory.GetEmailSender(triggEvent.StoreId);
- foreach (var actionableRule in actionableRules)
- {
- var matchedContext = new EmailRuleMatchContext(triggEvent, actionableRule);
-
- var body = new TextTemplate(actionableRule.Body ?? "");
- var subject = new TextTemplate(actionableRule.Subject ?? "");
- matchedContext.Recipients.AddRange(
- actionableRule.To
- .Select(o =>
- {
- if (!MailboxAddressValidator.TryParse(o, out var oo))
- {
- MailboxAddressValidator.TryParse(new TextTemplate(o).Render(triggEvent.Model), out oo);
- }
- return oo;
- })
- .Where(o => o != null)!);
-
- if (triggEvent.Owner is not null)
- await triggEvent.Owner.BeforeSending(matchedContext);
- if (matchedContext.Recipients.Count == 0)
- continue;
- sender.SendEmail(matchedContext.Recipients.ToArray(), matchedContext.Cc.ToArray(), matchedContext.Bcc.ToArray(), subject.Render(triggEvent.Model), body.Render(triggEvent.Model));
- }
- }
- }
- }
-}
diff --git a/BTCPayServer/Plugins/Emails/Views/EmailTriggerViewModel.cs b/BTCPayServer/Plugins/Emails/Views/EmailTriggerViewModel.cs
index 02b03a6..66a81e6 100644
--- a/BTCPayServer/Plugins/Emails/Views/EmailTriggerViewModel.cs
+++ b/BTCPayServer/Plugins/Emails/Views/EmailTriggerViewModel.cs
@@ -20,4 +20,6 @@ public class EmailTriggerViewModel
}
public List<PlaceHolder> PlaceHolders { get; set; } = new();
+ public bool ServerTrigger { get; set; }
+ public string RecipientExample { get; set; }
}
diff --git a/BTCPayServer/Plugins/Emails/Views/EmailsViewModel.cs b/BTCPayServer/Plugins/Emails/Views/EmailsViewModel.cs
new file mode 100644
index 0000000..3a435c9
--- /dev/null
+++ b/BTCPayServer/Plugins/Emails/Views/EmailsViewModel.cs
@@ -0,0 +1,36 @@
+using System.ComponentModel.DataAnnotations;
+using BTCPayServer.Services.Mails;
+using BTCPayServer.Validation;
+
+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; }
+
+ public EmailsViewModel()
+ {
+ }
+
+ public EmailsViewModel(EmailSettings settings)
+ {
+ Settings = settings;
+ PasswordSet = !string.IsNullOrWhiteSpace(settings?.Password);
+ }
+
+ public bool IsSetup() => Settings?.IsComplete() is true;
+
+ public bool IsFallbackSetup { get; set; }
+ public bool IsCustomSMTP { get; set; }
+}
diff --git a/BTCPayServer/Plugins/Emails/Views/Shared/EmailRulesList.cshtml b/BTCPayServer/Plugins/Emails/Views/Shared/EmailRulesList.cshtml
new file mode 100644
index 0000000..9f6d5fb
--- /dev/null
+++ b/BTCPayServer/Plugins/Emails/Views/Shared/EmailRulesList.cshtml
@@ -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" />
diff --git a/BTCPayServer/Plugins/Emails/Views/Shared/EmailRulesListViewModel.cs b/BTCPayServer/Plugins/Emails/Views/Shared/EmailRulesListViewModel.cs
new file mode 100644
index 0000000..9bf6fab
--- /dev/null
+++ b/BTCPayServer/Plugins/Emails/Views/Shared/EmailRulesListViewModel.cs
@@ -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; }
+}
diff --git a/BTCPayServer/Plugins/Emails/Views/Shared/EmailRulesManage.cshtml b/BTCPayServer/Plugins/Emails/Views/Shared/EmailRulesManage.cshtml
new file mode 100644
index 0000000..2b3a456
--- /dev/null
+++ b/BTCPayServer/Plugins/Emails/Views/Shared/EmailRulesManage.cshtml
@@ -0,0 +1,212 @@
+@using BTCPayServer.Views.Server
+@model StoreEmailRuleViewModel
+
+@{
+ 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 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.GetTitle()</li>
+ </ol>
+ <h2>@ViewData.GetTitle()</h2>
+ </nav>
+ <div>
+ <button id="page-primary" type="submit" class="btn btn-primary" text-translate="true">Save</button>
+ <a asp-action="StoreEmailRulesList" asp-route-storeId="@storeId" class="btn btn-secondary" text-translate="true">Cancel</a>
+ </div>
+ </div>
+ <partial name="_StatusMessage" />
+
+ <input type="hidden" asp-for="OfferingId"></input>
+ <input type="hidden" asp-for="RedirectUrl"></input>
+ <div class="form-group">
+ <label asp-for="Trigger" class="form-label" data-required></label>
+ <input type="hidden" asp-for="CanChangeTrigger"></input>
+ @if (Model.CanChangeTrigger)
+ {
+ <select asp-for="Trigger"
+ asp-items="@Model.Triggers.Select(t => new SelectListItem(StringLocalizer[t.Description], t.Trigger))"
+ class="form-select email-rule-trigger" required></select>
+ <span asp-validation-for="Trigger" class="text-danger"></span>
+ <div class="form-text" text-translate="true">Choose what event sends the email.</div>
+ }
+ else
+ {
+ <input type="hidden" asp-for="Trigger"></input>
+ <input asp-for="Trigger" class="form-control email-rule-trigger-hidden" disabled></input>
+ }
+ </div>
+
+ <div class="form-group">
+ <label asp-for="Condition" class="form-label"></label>
+ <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. $.Customer.Name == \"john\")"]" />
+ }
+ else
+ {
+ <input type="hidden" asp-for="Condition"></input>
+ <input asp-for="Condition" class="form-control" disabled></input>
+ }
+ <span asp-validation-for="Condition" class="text-danger"></span>
+ <div class="form-text" text-translate="true">Only send email when the specified JSON Path exists</div>
+ </div>
+
+ <div class="form-group">
+ <label asp-for="To" class="form-label" text-translate="true">Recipients</label>
+ <input type="text" asp-for="To" class="form-control email-rule-to" />
+ <span asp-validation-for="To" class="text-danger"></span>
+ <div class="form-text" text-translate="true">Who to send the email to. For multiple emails, separate with a comma.</div>
+ </div>
+
+ <div class="form-check mb-4 customer-email-container">
+ <input asp-for="AdditionalData.CustomerEmail" type="checkbox" class="form-check-input email-rule-customer-email customer-email-checkbox" />
+ <label asp-for="AdditionalData.CustomerEmail" class="form-check-label" text-translate="true">Send the email to the buyer, if email was provided to the invoice</label>
+ <span asp-validation-for="AdditionalData.CustomerEmail" class="text-danger"></span>
+ </div>
+
+ <div class="form-group">
+ <label asp-for="Subject" class="form-label" data-required></label>
+ <input type="text" asp-for="Subject" class="form-control email-rule-subject" />
+ <span asp-validation-for="Subject" class="text-danger"></span>
+ </div>
+
+ <div class="form-group">
+ <label asp-for="Body" class="form-label" data-required></label>
+ <textarea asp-for="Body" class="form-control richtext email-rule-body" rows="4"></textarea>
+ <span asp-validation-for="Body" class="text-danger"></span>
+ <div class="form-text rounded bg-light p-2">
+ <h6 class="text-muted p-0" text-translate="true">Placeholders</h6>
+ <table id="placeholders" class="table">
+ </table>
+ <span>* These fields are JSON objects. You can access properties within them using <a href="https://www.newtonsoft.com/json/help/html/SelectToken.htm#SelectTokenJSONPath" rel="noreferrer noopener" target="_blank">this syntax</a>. One example is <code>{Invoice.Metadata.itemCode}</code></span>
+ </div>
+ </div>
+
+</form>
+
+@section PageFootContent {
+ <partial name="_ValidationScriptsPartial" />
+ <script src="~/vendor/summernote/summernote-bs5.js" asp-append-version="true"></script>
+ <script>
+ var triggers = @Safe.Json(Model.Triggers);
+ var triggersByType = {};
+ for (var i = 0; i < triggers.length; i++) {
+ triggersByType[triggers[i].trigger] = triggers[i];
+ }
+
+ document.addEventListener('DOMContentLoaded', () => {
+
+ 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');
+
+ const isEmptyOrDefault = (value, type) => {
+ const val = value.replace(/<.*?>/gi, '').trim();
+ if (!val) return true;
+ return Object.values(triggersByType).some(t => t[type] === val);
+ };
+
+ function applyTemplate() {
+ const selectedTrigger = triggerSelect.value;
+ console.log(selectedTrigger);
+ if (triggersByType[selectedTrigger]) {
+ 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')) {
+ if ($(bodyTextarea).summernote) {
+ $(bodyTextarea).summernote('reset');
+ $(bodyTextarea).summernote('code', body.replace(/\n/g, '<br/>'));
+ } else {
+ bodyTextarea.value = body;
+ }
+ }
+
+ placeholdersTd.innerHTML = '';
+ triggersByType[selectedTrigger].placeHolders.forEach(p => {
+ const tr = document.createElement('tr');
+ const td1 = document.createElement('td');
+ const td2 = document.createElement('td');
+ const code = document.createElement('code');
+ td1.appendChild(code);
+ code.innerText = p.name;
+ td2.innerText = p.description;
+ tr.appendChild(td1);
+ tr.appendChild(td2);
+ placeholdersTd.appendChild(tr);
+ });
+
+ }
+ }
+
+ function toggleCustomerEmailVisibility() {
+ const customerEmailContainer = document.querySelector('.customer-email-container');
+ const customerEmailCheckbox = document.querySelector('.customer-email-checkbox');
+ const selectedTrigger = triggerSelect.value;
+ if (triggersByType[selectedTrigger].canIncludeCustomerEmail) {
+ customerEmailContainer.style.display = 'block';
+ } else {
+ customerEmailContainer.style.display = 'none';
+ customerEmailCheckbox.checked = false;
+ }
+ }
+
+ triggerSelect.addEventListener('change', applyTemplate);
+ triggerSelect.addEventListener('change', toggleCustomerEmailVisibility);
+
+ // Apply template on page load if a trigger is selected
+ if (triggerSelect.value) {
+ applyTemplate();
+ toggleCustomerEmailVisibility();
+ }
+ });
+ </script>
+
+}
diff --git a/BTCPayServer/Plugins/Emails/Views/Shared/EmailSettings.cshtml b/BTCPayServer/Plugins/Emails/Views/Shared/EmailSettings.cshtml
new file mode 100644
index 0000000..4f83727
--- /dev/null
+++ b/BTCPayServer/Plugins/Emails/Views/Shared/EmailSettings.cshtml
@@ -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>
+}
diff --git a/BTCPayServer/Plugins/Emails/Views/StoreEmailRuleViewModel.cs b/BTCPayServer/Plugins/Emails/Views/StoreEmailRuleViewModel.cs
index 1fd81ec..f78f413 100644
--- a/BTCPayServer/Plugins/Emails/Views/StoreEmailRuleViewModel.cs
+++ b/BTCPayServer/Plugins/Emails/Views/StoreEmailRuleViewModel.cs
@@ -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)
diff --git a/BTCPayServer/Plugins/Emails/Views/UIStoreEmailRules/StoreEmailRulesList.cshtml b/BTCPayServer/Plugins/Emails/Views/UIStoreEmailRules/StoreEmailRulesList.cshtml
deleted file mode 100644
index 4b2a2e6..0000000
--- a/BTCPayServer/Plugins/Emails/Views/UIStoreEmailRules/StoreEmailRulesList.cshtml
+++ /dev/null
@@ -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" />
diff --git a/BTCPayServer/Plugins/Emails/Views/UIStoreEmailRules/StoreEmailRulesManage.cshtml b/BTCPayServer/Plugins/Emails/Views/UIStoreEmailRules/StoreEmailRulesManage.cshtml
deleted file mode 100644
index a13088d..0000000
--- a/BTCPayServer/Plugins/Emails/Views/UIStoreEmailRules/StoreEmailRulesManage.cshtml
+++ /dev/null
@@ -1,185 +0,0 @@
-@model StoreEmailRuleViewModel
-
-@{
- var storeId = Context.GetStoreData().Id;
- bool isEdit = Model.Trigger != null;
- ViewData.SetLayoutModel(new LayoutModel(nameof(StoreNavPages.Emails), StringLocalizer[isEdit ? "Edit Email Rule" : "Create Email Rule"])
- .SetCategory(WellKnownCategories.Store));
-}
-
-@section PageHeadContent {
- <link href="~/vendor/summernote/summernote-bs5.css" rel="stylesheet" asp-append-version="true" />
-}
-
-<form method="post">
- <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">
- <a asp-action="StoreEmailRulesList" asp-route-storeId="@storeId" text-translate="true">Email Rules</a>
- </li>
- <li class="breadcrumb-item active" aria-current="page">@ViewData["Title"]</li>
- </ol>
- <h2>@ViewData["Title"]</h2>
- </nav>
- <div>
- <button id="page-primary" type="submit" class="btn btn-primary" text-translate="true">Save</button>
- <a asp-action="StoreEmailRulesList" asp-route-storeId="@storeId" class="btn btn-secondary" text-translate="true">Cancel</a>
- </div>
- </div>
- <partial name="_StatusMessage" />
-
- <input type="hidden" asp-for="OfferingId"></input>
- <input type="hidden" asp-for="RedirectUrl"></input>
- <div class="form-group">
- <label asp-for="Trigger" class="form-label" data-required></label>
- <input type="hidden" asp-for="CanChangeTrigger"></input>
- @if (Model.CanChangeTrigger)
- {
- <select asp-for="Trigger"
- asp-items="@Model.Triggers.Select(t => new SelectListItem(StringLocalizer[t.Description], t.Trigger))"
- class="form-select email-rule-trigger" required></select>
- <span asp-validation-for="Trigger" class="text-danger"></span>
- <div class="form-text" text-translate="true">Choose what event sends the email.</div>
- }
- else
- {
- <input type="hidden" asp-for="Trigger"></input>
- <input asp-for="Trigger" class="form-control email-rule-trigger-hidden" disabled></input>
- }
- </div>
-
- <div class="form-group">
- <label asp-for="Condition" class="form-label"></label>
- <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\")"]" />
- }
- else
- {
- <input type="hidden" asp-for="Condition"></input>
- <input asp-for="Condition" class="form-control" disabled></input>
- }
- <span asp-validation-for="Condition" class="text-danger"></span>
- <div class="form-text" text-translate="true">Only send email when the specified JSON Path exists</div>
- </div>
-
- <div class="form-group">
- <label asp-for="To" class="form-label" text-translate="true">Recipients</label>
- <input type="text" asp-for="To" class="form-control email-rule-to" />
- <span asp-validation-for="To" class="text-danger"></span>
- <div class="form-text" text-translate="true">Who to send the email to. For multiple emails, separate with a comma.</div>
- </div>
-
- <div class="form-check mb-4 customer-email-container">
- <input asp-for="AdditionalData.CustomerEmail" type="checkbox" class="form-check-input email-rule-customer-email customer-email-checkbox" />
- <label asp-for="AdditionalData.CustomerEmail" class="form-check-label" text-translate="true">Send the email to the buyer, if email was provided to the invoice</label>
- <span asp-validation-for="AdditionalData.CustomerEmail" class="text-danger"></span>
- </div>
-
- <div class="form-group">
- <label asp-for="Subject" class="form-label" data-required></label>
- <input type="text" asp-for="Subject" class="form-control email-rule-subject" />
- <span asp-validation-for="Subject" class="text-danger"></span>
- </div>
-
- <div class="form-group">
- <label asp-for="Body" class="form-label" data-required></label>
- <textarea asp-for="Body" class="form-control richtext email-rule-body" rows="4"></textarea>
- <span asp-validation-for="Body" class="text-danger"></span>
- <div class="form-text rounded bg-light p-2">
- <h6 class="text-muted p-0" text-translate="true">Placeholders</h6>
- <table id="placeholders" class="table">
- </table>
- <span>* These fields are JSON objects. You can access properties within them using <a href="https://www.newtonsoft.com/json/help/html/SelectToken.htm#SelectTokenJSONPath" rel="noreferrer noopener" target="_blank">this syntax</a>. One example is <code>{Invoice.Metadata.itemCode}</code></span>
- </div>
- </div>
-
-</form>
-
-@section PageFootContent {
- <partial name="_ValidationScriptsPartial" />
- <script src="~/vendor/summernote/summernote-bs5.js" asp-append-version="true"></script>
- <script>
- var triggers = @Safe.Json(Model.Triggers);
- var triggersByType = {};
- for (var i = 0; i < triggers.length; i++) {
- triggersByType[triggers[i].trigger] = triggers[i];
- }
-
- document.addEventListener('DOMContentLoaded', () => {
-
- const triggerSelect = document.querySelector('.email-rule-trigger') ?? document.querySelector('.email-rule-trigger-hidden');
- const subjectInput = document.querySelector('.email-rule-subject');
- const bodyTextarea = document.querySelector('.email-rule-body');
- const placeholdersTd = document.querySelector('#placeholders');
-
- const isEmptyOrDefault = (value, type) => {
- const val = value.replace(/<.*?>/gi, '').trim();
- if (!val) return true;
- return Object.values(triggersByType).some(t => t[type] === val);
- };
-
- function applyTemplate() {
- const selectedTrigger = triggerSelect.value;
- console.log(selectedTrigger);
- if (triggersByType[selectedTrigger]) {
- if (isEmptyOrDefault(subjectInput.value, 'subjectExample')) {
- subjectInput.value = triggersByType[selectedTrigger].subjectExample;
- }
- var body = triggersByType[selectedTrigger].bodyExample;
-
- if (isEmptyOrDefault(bodyTextarea.value, 'bodyExample')) {
- if ($(bodyTextarea).summernote) {
- $(bodyTextarea).summernote('reset');
- $(bodyTextarea).summernote('code', body.replace(/\n/g, '<br/>'));
- } else {
- bodyTextarea.value = body;
- }
- }
-
- placeholdersTd.innerHTML = '';
- triggersByType[selectedTrigger].placeHolders.forEach(p => {
- const tr = document.createElement('tr');
- const td1 = document.createElement('td');
- const td2 = document.createElement('td');
- const code = document.createElement('code');
- td1.appendChild(code);
- code.innerText = p.name;
- td2.innerText = p.description;
- tr.appendChild(td1);
- tr.appendChild(td2);
- placeholdersTd.appendChild(tr);
- });
-
- }
- }
-
- function toggleCustomerEmailVisibility() {
- const customerEmailContainer = document.querySelector('.customer-email-container');
- const customerEmailCheckbox = document.querySelector('.customer-email-checkbox');
- const selectedTrigger = triggerSelect.value;
- if (triggersByType[selectedTrigger].canIncludeCustomerEmail) {
- customerEmailContainer.style.display = 'block';
- } else {
- customerEmailContainer.style.display = 'none';
- customerEmailCheckbox.checked = false;
- }
- }
-
- triggerSelect.addEventListener('change', applyTemplate);
- triggerSelect.addEventListener('change', toggleCustomerEmailVisibility);
-
- // Apply template on page load if a trigger is selected
- if (triggerSelect.value) {
- applyTemplate();
- toggleCustomerEmailVisibility();
- }
- });
- </script>
-
-}
diff --git a/BTCPayServer/Plugins/Emails/Views/UIStoresEmail/StoreEmailSettings.cshtml b/BTCPayServer/Plugins/Emails/Views/UIStoresEmail/StoreEmailSettings.cshtml
deleted file mode 100644
index c6411a9..0000000
--- a/BTCPayServer/Plugins/Emails/Views/UIStoresEmail/StoreEmailSettings.cshtml
+++ /dev/null
@@ -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" />
-}
diff --git a/BTCPayServer/Program.cs b/BTCPayServer/Program.cs
index cc183db..04e8363 100644
--- a/BTCPayServer/Program.cs
+++ b/BTCPayServer/Program.cs
@@ -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
diff --git a/BTCPayServer/Views/Shared/EmailsBody.cshtml b/BTCPayServer/Views/Shared/EmailsBody.cshtml
deleted file mode 100644
index ceca7f7..0000000
--- a/BTCPayServer/Views/Shared/EmailsBody.cshtml
+++ /dev/null
@@ -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>
diff --git a/BTCPayServer/Views/Shared/EmailsTest.cshtml b/BTCPayServer/Views/Shared/EmailsTest.cshtml
deleted file mode 100644
index 0385228..0000000
--- a/BTCPayServer/Views/Shared/EmailsTest.cshtml
+++ /dev/null
@@ -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>
diff --git a/BTCPayServer/Views/UIAccount/ForgotPassword.cshtml b/BTCPayServer/Views/UIAccount/ForgotPassword.cshtml
index dba8c16..d5f50c3 100644
--- a/BTCPayServer/Views/UIAccount/ForgotPassword.cshtml
+++ b/BTCPayServer/Views/UIAccount/ForgotPassword.cshtml
@@ -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>
}
diff --git a/BTCPayServer/Views/UIServer/Emails.cshtml b/BTCPayServer/Views/UIServer/Emails.cshtml
deleted file mode 100644
index f1b3d9c..0000000
--- a/BTCPayServer/Views/UIServer/Emails.cshtml
+++ /dev/null
@@ -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" />
-}
Why this scored 28/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.