Store Email Settings: Improve configuration (#5629)

* Store Email Settings: Improve configuration

This works with the existing settings and provides better guidance about the different store email cases. Closes #5623.

* Split email and notification settings
This commit is contained in:
d11n 2024-01-26 10:28:50 +01:00 committed by GitHub
parent 2111b67e2c
commit b174977bc7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 117 additions and 71 deletions

View file

@ -468,8 +468,16 @@ namespace BTCPayServer.Tests
s.RegisterNewUser(true); s.RegisterNewUser(true);
s.CreateNewStore(); s.CreateNewStore();
// Ensure empty server settings
s.Driver.Navigate().GoToUrl(s.Link("/server/emails"));
s.Driver.FindElement(By.Id("Settings_Login")).Clear();
s.Driver.FindElement(By.Id("Settings_Password")).Clear();
s.Driver.FindElement(By.Id("Settings_From")).Clear();
s.Driver.FindElement(By.Id("Save")).Submit();
// Store Emails without server fallback // Store Emails without server fallback
s.GoToStore(StoreNavPages.Emails); s.GoToStore(StoreNavPages.Emails);
s.Driver.ElementDoesNotExist(By.Id("UseCustomSMTP"));
s.Driver.FindElement(By.Id("ConfigureEmailRules")).Click(); s.Driver.FindElement(By.Id("ConfigureEmailRules")).Click();
Assert.Contains("You need to configure email settings before this feature works", s.Driver.PageSource); Assert.Contains("You need to configure email settings before this feature works", s.Driver.PageSource);
@ -484,10 +492,13 @@ namespace BTCPayServer.Tests
// Store Emails with server fallback // Store Emails with server fallback
s.GoToStore(StoreNavPages.Emails); s.GoToStore(StoreNavPages.Emails);
Assert.False(s.Driver.FindElement(By.Id("UseCustomSMTP")).Selected);
s.Driver.FindElement(By.Id("ConfigureEmailRules")).Click(); s.Driver.FindElement(By.Id("ConfigureEmailRules")).Click();
Assert.Contains("Emails will be sent with the email settings of the server", s.Driver.PageSource); Assert.DoesNotContain("You need to configure email settings before this feature works", s.Driver.PageSource);
s.GoToStore(StoreNavPages.Emails); s.GoToStore(StoreNavPages.Emails);
s.Driver.FindElement(By.Id("UseCustomSMTP")).Click();
Thread.Sleep(250);
CanSetupEmailCore(s); CanSetupEmailCore(s);
// Store Email Rules // Store Email Rules
@ -495,7 +506,6 @@ namespace BTCPayServer.Tests
Assert.Contains("There are no rules yet.", s.Driver.PageSource); Assert.Contains("There are no rules yet.", s.Driver.PageSource);
Assert.DoesNotContain("id=\"SaveEmailRules\"", s.Driver.PageSource); Assert.DoesNotContain("id=\"SaveEmailRules\"", s.Driver.PageSource);
Assert.DoesNotContain("You need to configure email settings before this feature works", s.Driver.PageSource); Assert.DoesNotContain("You need to configure email settings before this feature works", s.Driver.PageSource);
Assert.DoesNotContain("Emails will be sent with the email settings of the server", s.Driver.PageSource);
s.Driver.FindElement(By.Id("CreateEmailRule")).Click(); s.Driver.FindElement(By.Id("CreateEmailRule")).Click();
var select = new SelectElement(s.Driver.FindElement(By.Id("Rules_0__Trigger"))); var select = new SelectElement(s.Driver.FindElement(By.Id("Rules_0__Trigger")));
@ -506,6 +516,9 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.ClassName("note-editable")).SendKeys("Your invoice is settled"); s.Driver.FindElement(By.ClassName("note-editable")).SendKeys("Your invoice is settled");
s.Driver.FindElement(By.Id("SaveEmailRules")).Click(); s.Driver.FindElement(By.Id("SaveEmailRules")).Click();
Assert.Contains("Store email rules saved", s.FindAlertMessage().Text); Assert.Contains("Store email rules saved", s.FindAlertMessage().Text);
s.GoToStore(StoreNavPages.Emails);
Assert.True(s.Driver.FindElement(By.Id("UseCustomSMTP")).Selected);
} }
[Fact(Timeout = TestTimeout)] [Fact(Timeout = TestTimeout)]
@ -3159,15 +3172,13 @@ retry:
private static void CanSetupEmailCore(SeleniumTester s) private static void CanSetupEmailCore(SeleniumTester s)
{ {
s.Driver.ScrollTo(By.Id("QuickFillDropdownToggle"));
s.Driver.FindElement(By.Id("QuickFillDropdownToggle")).Click(); s.Driver.FindElement(By.Id("QuickFillDropdownToggle")).Click();
s.Driver.FindElement(By.CssSelector("#quick-fill .dropdown-menu .dropdown-item:first-child")).Click(); s.Driver.FindElement(By.CssSelector("#quick-fill .dropdown-menu .dropdown-item:first-child")).Click();
s.Driver.FindElement(By.Id("Settings_Login")).Clear(); s.Driver.FindElement(By.Id("Settings_Login")).Clear();
s.Driver.FindElement(By.Id("Settings_Login")).SendKeys("test@gmail.com"); s.Driver.FindElement(By.Id("Settings_Login")).SendKeys("test@gmail.com");
s.Driver.FindElement(By.CssSelector("button[value=\"Save\"]")).Submit();
s.FindAlertMessage();
s.Driver.FindElement(By.Id("Settings_Password")).Clear(); s.Driver.FindElement(By.Id("Settings_Password")).Clear();
s.Driver.FindElement(By.Id("Settings_Password")).SendKeys("mypassword"); s.Driver.FindElement(By.Id("Settings_Password")).SendKeys("mypassword");
s.Driver.FindElement(By.Id("Settings_From")).Clear(); s.Driver.FindElement(By.Id("Settings_From")).Clear();
s.Driver.FindElement(By.Id("Settings_From")).SendKeys("Firstname Lastname <email@example.com>"); s.Driver.FindElement(By.Id("Settings_From")).SendKeys("Firstname Lastname <email@example.com>");
s.Driver.FindElement(By.Id("Save")).SendKeys(Keys.Enter); s.Driver.FindElement(By.Id("Save")).SendKeys(Keys.Enter);

View file

@ -27,22 +27,20 @@ namespace BTCPayServer.Controllers
return NotFound(); return NotFound();
var blob = store.GetStoreBlob(); var blob = store.GetStoreBlob();
var storeSetupComplete = blob.EmailSettings?.IsComplete() is true; if (blob.EmailSettings?.IsComplete() is not true && !TempData.HasStatusMessage())
if (!storeSetupComplete && !TempData.HasStatusMessage())
{ {
var emailSender = await _emailSenderFactory.GetEmailSender(store.Id) as StoreEmailSender; var emailSender = await _emailSenderFactory.GetEmailSender(store.Id) as StoreEmailSender;
var hasServerFallback = await IsSetupComplete(emailSender?.FallbackSender); if (!await IsSetupComplete(emailSender?.FallbackSender))
var message = hasServerFallback {
? "Emails will be sent with the email settings of the server"
: "You need to configure email settings before this feature works";
TempData.SetStatusMessageModel(new StatusMessageModel TempData.SetStatusMessageModel(new StatusMessageModel
{ {
Severity = hasServerFallback ? StatusMessageModel.StatusSeverity.Info : StatusMessageModel.StatusSeverity.Warning, Severity = StatusMessageModel.StatusSeverity.Warning,
Html = $"{message}. <a class='alert-link' href='{Url.Action("StoreEmailSettings", new { storeId })}'>Configure store email settings</a>." Html = $"You need to configure email settings before this feature works. <a class='alert-link' href='{Url.Action("StoreEmailSettings", new { storeId })}'>Configure store email settings</a>."
}); });
} }
}
var vm = new StoreEmailRuleViewModel { Rules = blob.EmailRules ?? new List<StoreEmailRule>() }; var vm = new StoreEmailRuleViewModel { Rules = blob.EmailRules ?? [] };
return View(vm); return View(vm);
} }
@ -172,13 +170,20 @@ namespace BTCPayServer.Controllers
} }
[HttpGet("{storeId}/email-settings")] [HttpGet("{storeId}/email-settings")]
public IActionResult StoreEmailSettings() public async Task<IActionResult> StoreEmailSettings(string storeId)
{ {
var store = HttpContext.GetStoreData(); var store = HttpContext.GetStoreData();
if (store == null) if (store == null)
return NotFound(); return NotFound();
var data = store.GetStoreBlob().EmailSettings ?? new EmailSettings();
return View(new EmailsViewModel(data)); var blob = store.GetStoreBlob();
var data = blob.EmailSettings ?? new EmailSettings();
var fallbackSettings = await _emailSenderFactory.GetEmailSender(store.Id) is StoreEmailSender { FallbackSender: not null } storeSender
? await storeSender.FallbackSender.GetEmailSettings()
: null;
var vm = new EmailsViewModel(data, fallbackSettings);
return View(vm);
} }
[HttpPost("{storeId}/email-settings")] [HttpPost("{storeId}/email-settings")]
@ -188,6 +193,12 @@ namespace BTCPayServer.Controllers
if (store == null) if (store == null)
return NotFound(); return NotFound();
var emailSender = await _emailSenderFactory.GetEmailSender(store.Id) as StoreEmailSender;
var fallbackSettings = await _emailSenderFactory.GetEmailSender(store.Id) is StoreEmailSender { FallbackSender: not null } storeSender
? await storeSender.FallbackSender.GetEmailSettings()
: null;
model.FallbackSettings = fallbackSettings;
if (command == "Test") if (command == "Test")
{ {
try try
@ -230,7 +241,7 @@ namespace BTCPayServer.Controllers
return View(model); return View(model);
} }
var storeBlob = store.GetStoreBlob(); var storeBlob = store.GetStoreBlob();
if (new EmailsViewModel(storeBlob.EmailSettings).PasswordSet && storeBlob.EmailSettings != null) if (storeBlob.EmailSettings != null && new EmailsViewModel(storeBlob.EmailSettings, fallbackSettings).PasswordSet)
{ {
model.Settings.Password = storeBlob.EmailSettings.Password; model.Settings.Password = storeBlob.EmailSettings.Password;
} }

View file

@ -6,25 +6,27 @@ namespace BTCPayServer.Models.ServerViewModels
{ {
public class EmailsViewModel public class EmailsViewModel
{ {
public EmailSettings Settings { get; set; }
public EmailSettings FallbackSettings { get; set; }
public bool PasswordSet { get; set; }
[MailboxAddress]
[Display(Name = "Test Email")]
public string TestEmail { get; set; }
public EmailsViewModel() public EmailsViewModel()
{ {
} }
public EmailsViewModel(EmailSettings settings)
public EmailsViewModel(EmailSettings settings, EmailSettings fallbackSettings = null)
{ {
Settings = settings; Settings = settings;
FallbackSettings = fallbackSettings;
PasswordSet = !string.IsNullOrEmpty(settings?.Password); PasswordSet = !string.IsNullOrEmpty(settings?.Password);
} }
public EmailSettings Settings
{ public bool IsSetup() => Settings?.IsComplete() is true;
get; set; public bool IsFallbackSetup() => FallbackSettings?.IsComplete() is true;
} public bool UsesFallback() => IsFallbackSetup() && Settings == FallbackSettings;
public bool PasswordSet { get; set; }
[MailboxAddressAttribute]
[Display(Name = "Test Email")]
public string TestEmail
{
get; set;
}
} }
} }

View file

@ -30,6 +30,14 @@ namespace BTCPayServer.Services
public bool DisableInstantNotifications { get; set; } public bool DisableInstantNotifications { get; set; }
[Display(Name = "Disable stores from using the server's email settings as backup")] [Display(Name = "Disable stores from using the server's email settings as backup")]
public bool DisableStoresToUseServerEmailSettings { get; set; } public bool DisableStoresToUseServerEmailSettings { get; set; }
[JsonIgnore]
[Display(Name = "Allow stores to use the server's SMTP email settings as a default")]
public bool EnableStoresToUseServerEmailSettings
{
get => !DisableStoresToUseServerEmailSettings;
set { DisableStoresToUseServerEmailSettings = !value; }
}
[Display(Name = "Disable non-admins access to the user creation API endpoint")] [Display(Name = "Disable non-admins access to the user creation API endpoint")]
public bool DisableNonAdminCreateUserApi { get; set; } public bool DisableNonAdminCreateUserApi { get; set; }

View file

@ -1,12 +1,17 @@
@model BTCPayServer.Models.ServerViewModels.EmailsViewModel @model BTCPayServer.Models.ServerViewModels.EmailsViewModel
<div class="row"> <form method="post" autocomplete="off">
<div class="row">
<div class="col-xl-10 col-xxl-constrain"> <div class="col-xl-10 col-xxl-constrain">
<div class="d-flex flex-wrap gap-3 align-items-center justify-content-between mt-n1 mb-4"> @if (!ViewContext.ModelState.IsValid)
<h3 class="mb-0">Email Server</h3> {
<div class="d-flex"> <div asp-validation-summary="All"></div>
<div class="dropdown only-for-js" id="quick-fill"> }
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" id="QuickFillDropdownToggle"> <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">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">
Quick Fill Quick Fill
</button> </button>
<div class="dropdown-menu" aria-labelledby="QuickFillDropdownToggle"> <div class="dropdown-menu" aria-labelledby="QuickFillDropdownToggle">
@ -18,20 +23,7 @@
</div> </div>
</div> </div>
</div> </div>
</div> <input asp-for="Settings.Server" data-fill="server" class="form-control" />
</div>
</div>
<form method="post" autocomplete="off">
<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">
<label asp-for="Settings.Server" class="form-label">SMTP Server</label>
<input asp-for="Settings.Server" data-fill="server" class="form-control"/>
<span asp-validation-for="Settings.Server" class="text-danger"></span> <span asp-validation-for="Settings.Server" class="text-danger"></span>
</div> </div>
<div class="form-group"> <div class="form-group">
@ -53,7 +45,6 @@
<div class="form-group"> <div class="form-group">
@if (!Model.PasswordSet) @if (!Model.PasswordSet)
{ {
<label asp-for="Settings.Password" class="form-label"></label> <label asp-for="Settings.Password" class="form-label"></label>
<input asp-for="Settings.Password" type="password" class="form-control"/> <input asp-for="Settings.Password" type="password" class="form-control"/>
<span asp-validation-for="Settings.Password" class="text-danger"></span> <span asp-validation-for="Settings.Password" class="text-danger"></span>

View file

@ -3,6 +3,7 @@
ViewData.SetActivePage(ServerNavPages.Emails, "Emails"); ViewData.SetActivePage(ServerNavPages.Emails, "Emails");
} }
<h3 class="mb-4">Email Server</h3>
<partial name="EmailsBody" model="Model" /> <partial name="EmailsBody" model="Model" />
@section PageFootContent { @section PageFootContent {

View file

@ -113,6 +113,18 @@
</div> </div>
</div> </div>
<div class="form-group mb-5">
<h4 class="mb-3">Email Settings</h4>
<div class="form-check my-3">
<input asp-for="EnableStoresToUseServerEmailSettings" type="checkbox" class="form-check-input"/>
<label asp-for="EnableStoresToUseServerEmailSettings" class="form-check-label"></label>
<a href="https://docs.btcpayserver.org/Notifications/#server-emails" target="_blank" rel="noreferrer noopener">
<vc:icon symbol="info" />
</a>
<span asp-validation-for="EnableStoresToUseServerEmailSettings" class="text-danger"></span>
</div>
</div>
<div class="form-group mb-5"> <div class="form-group mb-5">
<h4 class="mb-3">Notification Settings</h4> <h4 class="mb-3">Notification Settings</h4>
<div class="form-check my-3"> <div class="form-check my-3">
@ -123,14 +135,6 @@
</a> </a>
<span asp-validation-for="DisableInstantNotifications" class="text-danger"></span> <span asp-validation-for="DisableInstantNotifications" class="text-danger"></span>
</div> </div>
<div class="form-check my-3">
<input asp-for="DisableStoresToUseServerEmailSettings" type="checkbox" class="form-check-input"/>
<label asp-for="DisableStoresToUseServerEmailSettings" class="form-check-label"></label>
<a href="https://docs.btcpayserver.org/Notifications/#server-emails" target="_blank" rel="noreferrer noopener">
<vc:icon symbol="info" />
</a>
<span asp-validation-for="DisableStoresToUseServerEmailSettings" class="text-danger"></span>
</div>
</div> </div>
<div class="form-group mb-5"> <div class="form-group mb-5">

View file

@ -2,6 +2,7 @@
@{ @{
Layout = "../Shared/_NavLayout.cshtml"; Layout = "../Shared/_NavLayout.cshtml";
ViewData.SetActivePage(StoreNavPages.Emails, "Emails", Context.GetStoreData().Id); ViewData.SetActivePage(StoreNavPages.Emails, "Emails", Context.GetStoreData().Id);
var hasCustomSettings = Model.IsSetup() && !Model.UsesFallback();
} }
<div class="row mb-4"> <div class="row mb-4">
@ -19,7 +20,25 @@
</div> </div>
</div> </div>
<partial name="EmailsBody" model="Model" /> <h3 class="mb-4">Email Server</h3>
@if (Model.IsFallbackSetup())
{
<label class="d-flex align-items-center mb-4">
<input type="checkbox" id="UseCustomSMTP" checked="@hasCustomSettings" class="btcpay-toggle me-3" data-bs-toggle="collapse" data-bs-target="#SmtpSettings" aria-expanded="@hasCustomSettings" aria-controls="SmtpSettings" />
<div>
<span>Use custom SMTP settings for this store</span>
<div class="form-text">Otherwise, the server's SMTP settings will be used to send emails.</div>
</div>
</label>
<div class="checkout-settings collapse @(hasCustomSettings ? "show" : "")" id="SmtpSettings">
<partial name="EmailsBody" model="Model" />
</div>
}
else
{
<partial name="EmailsBody" model="Model" />
}
@section PageFootContent { @section PageFootContent {
<partial name="_ValidationScriptsPartial" /> <partial name="_ValidationScriptsPartial" />

View file

@ -117,8 +117,7 @@
<code>{Payout.Metadata}*</code> <code>{Payout.Metadata}*</code>
</td> </td>
</tr> </tr>
<tr><td colspan="2">* 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> <tr><td colspan="2">* 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></td></tr>
</td></tr>
</table> </table>
</div> </div>
</div> </div>