mirror of
https://github.com/aljazceru/btcpayserver.git
synced 2025-12-18 22:44:29 +01:00
Adding endpoint to set server email settings (#6601)
* Adding endpoint in Greenfield to allow server email settings * Adding related swagger file * Refactoring EmailSettingsData to be more readable * Adding server email masking * Adding tests * Update BTCPayServer/wwwroot/swagger/v1/swagger.template.serveremail.json Co-authored-by: d11n <mail@dennisreimann.de> * Masking smtp server email returned over greenfield api and test * Retaining password if password mask is used * Remove magic string ***** * Flatten request for server's settings. Fix bug on shared setting instances * Remove useless doc * Simplify code * Fix Store Email settings page --------- Co-authored-by: d11n <mail@dennisreimann.de> Co-authored-by: nicolas.dorier <nicolas.dorier@gmail.com>
This commit is contained in:
20
BTCPayServer.Client/BTCPayServerClient.ServerEmail.cs
Normal file
20
BTCPayServer.Client/BTCPayServerClient.ServerEmail.cs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using BTCPayServer.Client.Models;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Client;
|
||||||
|
|
||||||
|
public partial class BTCPayServerClient
|
||||||
|
{
|
||||||
|
public virtual async Task<ServerEmailSettingsData> GetServerEmailSettings(CancellationToken token = default)
|
||||||
|
{
|
||||||
|
return await SendHttpRequest<ServerEmailSettingsData>("api/v1/server/email", null, HttpMethod.Get, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual async Task<bool> UpdateServerEmailSettings(ServerEmailSettingsData request, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
return await SendHttpRequest<bool>("api/v1/server/email", request, HttpMethod.Put, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,29 +4,11 @@ namespace BTCPayServer.Client.Models;
|
|||||||
|
|
||||||
public class EmailSettingsData
|
public class EmailSettingsData
|
||||||
{
|
{
|
||||||
public string Server
|
public string Server { get; set; }
|
||||||
{
|
public int? Port { get; set; }
|
||||||
get; set;
|
public string Login { get; set; }
|
||||||
}
|
public string Password { get; set; }
|
||||||
|
public string From { get; set; }
|
||||||
public int? Port
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
}
|
|
||||||
|
|
||||||
public string Login
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
}
|
|
||||||
|
|
||||||
public string Password
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
}
|
|
||||||
public string From
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
}
|
|
||||||
public bool DisableCertificateCheck { get; set; }
|
public bool DisableCertificateCheck { get; set; }
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
|
|||||||
7
BTCPayServer.Client/Models/ServerEmailSettingsData.cs
Normal file
7
BTCPayServer.Client/Models/ServerEmailSettingsData.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
namespace BTCPayServer.Client.Models
|
||||||
|
{
|
||||||
|
public class ServerEmailSettingsData : EmailSettingsData
|
||||||
|
{
|
||||||
|
public bool EnableStoresToUseServerEmailSettings { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ using BTCPayServer.Rating;
|
|||||||
using BTCPayServer.Services;
|
using BTCPayServer.Services;
|
||||||
using BTCPayServer.Services.Apps;
|
using BTCPayServer.Services.Apps;
|
||||||
using BTCPayServer.Services.Invoices;
|
using BTCPayServer.Services.Invoices;
|
||||||
|
using BTCPayServer.Services.Mails;
|
||||||
using BTCPayServer.Services.Notifications;
|
using BTCPayServer.Services.Notifications;
|
||||||
using BTCPayServer.Services.Notifications.Blobs;
|
using BTCPayServer.Services.Notifications.Blobs;
|
||||||
using BTCPayServer.Services.Stores;
|
using BTCPayServer.Services.Stores;
|
||||||
@@ -1537,7 +1538,7 @@ namespace BTCPayServer.Tests
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact(Timeout = TestTimeout)]
|
||||||
[Trait("Integration", "Integration")]
|
[Trait("Integration", "Integration")]
|
||||||
public async Task CanProcessPayoutsExternally()
|
public async Task CanProcessPayoutsExternally()
|
||||||
{
|
{
|
||||||
@@ -4093,6 +4094,45 @@ namespace BTCPayServer.Tests
|
|||||||
await AssertAPIError("store-user-role-orphaned", async () => await employeeClient.RemoveStoreUser(user.StoreId, employee.UserId));
|
await AssertAPIError("store-user-role-orphaned", async () => await employeeClient.RemoveStoreUser(user.StoreId, employee.UserId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[Fact(Timeout = TestTimeout)]
|
||||||
|
[Trait("Integration", "Integration")]
|
||||||
|
public async Task ServerEmailTests()
|
||||||
|
{
|
||||||
|
using var tester = CreateServerTester();
|
||||||
|
await tester.StartAsync();
|
||||||
|
var admin = tester.NewAccount();
|
||||||
|
await admin.GrantAccessAsync(true);
|
||||||
|
var adminClient = await admin.CreateClient(Policies.Unrestricted);
|
||||||
|
var data = new EmailSettingsData
|
||||||
|
{
|
||||||
|
From = "admin@admin.com",
|
||||||
|
Login = "admin@admin.com",
|
||||||
|
Password = "admin@admin.com",
|
||||||
|
Port = 1234,
|
||||||
|
Server = "admin.com",
|
||||||
|
};
|
||||||
|
var serverEmailSettings = new ServerEmailSettingsData
|
||||||
|
{
|
||||||
|
EnableStoresToUseServerEmailSettings = false
|
||||||
|
};
|
||||||
|
await adminClient.UpdateServerEmailSettings(serverEmailSettings);
|
||||||
|
|
||||||
|
var s = await adminClient.GetServerEmailSettings();
|
||||||
|
// email password is masked and not returned from the server once set
|
||||||
|
serverEmailSettings.Password = null;
|
||||||
|
Assert.Equal(JsonConvert.SerializeObject(s), JsonConvert.SerializeObject(serverEmailSettings));
|
||||||
|
await AssertValidationError(new[] { nameof(EmailSettingsData.From) },
|
||||||
|
async () => await adminClient.UpdateServerEmailSettings(new ServerEmailSettingsData
|
||||||
|
{
|
||||||
|
From = "invalid"
|
||||||
|
}));
|
||||||
|
|
||||||
|
// NOTE: This email test fails silently in EmailSender.cs#31, can't test, but leaving for the future as reminder
|
||||||
|
//await adminClient.SendEmail(admin.StoreId,
|
||||||
|
// new SendEmailRequest { Body = "lol", Subject = "subj", Email = "to@example.org" });
|
||||||
|
}
|
||||||
|
|
||||||
[Fact(Timeout = 60 * 2 * 1000)]
|
[Fact(Timeout = 60 * 2 * 1000)]
|
||||||
[Trait("Integration", "Integration")]
|
[Trait("Integration", "Integration")]
|
||||||
public async Task StoreEmailTests()
|
public async Task StoreEmailTests()
|
||||||
@@ -4115,6 +4155,8 @@ namespace BTCPayServer.Tests
|
|||||||
};
|
};
|
||||||
await adminClient.UpdateStoreEmailSettings(admin.StoreId, data);
|
await adminClient.UpdateStoreEmailSettings(admin.StoreId, data);
|
||||||
var s = await adminClient.GetStoreEmailSettings(admin.StoreId);
|
var s = await adminClient.GetStoreEmailSettings(admin.StoreId);
|
||||||
|
// email password is masked and not returned from the server once set
|
||||||
|
data.Password = null;
|
||||||
Assert.Equal(JsonConvert.SerializeObject(s), JsonConvert.SerializeObject(data));
|
Assert.Equal(JsonConvert.SerializeObject(s), JsonConvert.SerializeObject(data));
|
||||||
await AssertValidationError(new[] { nameof(EmailSettingsData.From) },
|
await AssertValidationError(new[] { nameof(EmailSettingsData.From) },
|
||||||
async () => await adminClient.UpdateStoreEmailSettings(admin.StoreId,
|
async () => await adminClient.UpdateStoreEmailSettings(admin.StoreId,
|
||||||
|
|||||||
@@ -691,7 +691,7 @@ namespace BTCPayServer.Tests
|
|||||||
|
|
||||||
// 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.ElementDoesNotExist(By.Id("IsCustomSMTP"));
|
||||||
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);
|
||||||
|
|
||||||
@@ -706,12 +706,12 @@ 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);
|
Assert.False(s.Driver.FindElement(By.Id("IsCustomSMTP")).Selected);
|
||||||
s.Driver.FindElement(By.Id("ConfigureEmailRules")).Click();
|
s.Driver.FindElement(By.Id("ConfigureEmailRules")).Click();
|
||||||
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);
|
||||||
|
|
||||||
s.GoToStore(StoreNavPages.Emails);
|
s.GoToStore(StoreNavPages.Emails);
|
||||||
s.Driver.FindElement(By.Id("UseCustomSMTP")).Click();
|
s.Driver.FindElement(By.Id("IsCustomSMTP")).Click();
|
||||||
Thread.Sleep(250);
|
Thread.Sleep(250);
|
||||||
CanSetupEmailCore(s);
|
CanSetupEmailCore(s);
|
||||||
|
|
||||||
@@ -732,7 +732,7 @@ namespace BTCPayServer.Tests
|
|||||||
Assert.Contains("Store email rules saved", s.FindAlertMessage().Text);
|
Assert.Contains("Store email rules saved", s.FindAlertMessage().Text);
|
||||||
|
|
||||||
s.GoToStore(StoreNavPages.Emails);
|
s.GoToStore(StoreNavPages.Emails);
|
||||||
Assert.True(s.Driver.FindElement(By.Id("UseCustomSMTP")).Selected);
|
Assert.True(s.Driver.FindElement(By.Id("IsCustomSMTP")).Selected);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(Timeout = TestTimeout)]
|
[Fact(Timeout = TestTimeout)]
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
#nullable enable
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using BTCPayServer.Abstractions.Constants;
|
||||||
|
using BTCPayServer.Abstractions.Extensions;
|
||||||
|
using BTCPayServer.Client;
|
||||||
|
using BTCPayServer.Client.Models;
|
||||||
|
using BTCPayServer.Models.ServerViewModels;
|
||||||
|
using BTCPayServer.Services;
|
||||||
|
using BTCPayServer.Services.Mails;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Cors;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Controllers.GreenField
|
||||||
|
{
|
||||||
|
[ApiController]
|
||||||
|
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||||
|
[EnableCors(CorsPolicies.All)]
|
||||||
|
public class GreenfieldServerEmailController : Controller
|
||||||
|
{
|
||||||
|
private readonly EmailSenderFactory _emailSenderFactory;
|
||||||
|
private readonly PoliciesSettings _policiesSettings;
|
||||||
|
readonly SettingsRepository _settingsRepository;
|
||||||
|
|
||||||
|
public GreenfieldServerEmailController(EmailSenderFactory emailSenderFactory, PoliciesSettings policiesSettings, SettingsRepository settingsRepository)
|
||||||
|
{
|
||||||
|
_emailSenderFactory = emailSenderFactory;
|
||||||
|
_policiesSettings = policiesSettings;
|
||||||
|
_settingsRepository = settingsRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = Policies.CanModifyServerSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||||
|
[HttpGet("~/api/v1/server/email")]
|
||||||
|
public async Task<IActionResult> ServerEmailSettings()
|
||||||
|
{
|
||||||
|
var email = await _emailSenderFactory.GetSettings() ?? new EmailSettings();
|
||||||
|
var model = new ServerEmailSettingsData
|
||||||
|
{
|
||||||
|
EnableStoresToUseServerEmailSettings = !_policiesSettings.DisableStoresToUseServerEmailSettings,
|
||||||
|
From = email.From,
|
||||||
|
Server = email.Server,
|
||||||
|
Port = email.Port,
|
||||||
|
Login = email.Login,
|
||||||
|
DisableCertificateCheck = email.DisableCertificateCheck,
|
||||||
|
// Password is not returned
|
||||||
|
Password = null
|
||||||
|
};
|
||||||
|
return Ok(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize(Policy = Policies.CanModifyServerSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||||
|
[HttpPut("~/api/v1/server/email")]
|
||||||
|
public async Task<IActionResult> ServerEmailSettings(ServerEmailSettingsData request)
|
||||||
|
{
|
||||||
|
if (_policiesSettings.DisableStoresToUseServerEmailSettings == request.EnableStoresToUseServerEmailSettings)
|
||||||
|
{
|
||||||
|
_policiesSettings.DisableStoresToUseServerEmailSettings = !request.EnableStoresToUseServerEmailSettings;
|
||||||
|
await _settingsRepository.UpdateSetting(_policiesSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
// save
|
||||||
|
if (request.From is not null && !MailboxAddressValidator.IsMailboxAddress(request.From))
|
||||||
|
{
|
||||||
|
request.AddModelError(e => e.From,
|
||||||
|
"Invalid email address", this);
|
||||||
|
return this.CreateValidationError(ModelState);
|
||||||
|
}
|
||||||
|
|
||||||
|
var oldSettings = await _emailSenderFactory.GetSettings() ?? new EmailSettings();
|
||||||
|
// retaining the password if it exists and was not provided in request
|
||||||
|
if (string.IsNullOrEmpty(request.Password) &&
|
||||||
|
!string.IsNullOrEmpty(oldSettings?.Password))
|
||||||
|
request.Password = oldSettings.Password;
|
||||||
|
|
||||||
|
// important to save as EmailSettings otherwise it won't be able to be fetched
|
||||||
|
await _settingsRepository.UpdateSetting(new EmailSettings
|
||||||
|
{
|
||||||
|
Server = request.Server,
|
||||||
|
Port = request.Port,
|
||||||
|
Login = request.Login,
|
||||||
|
Password = request.Password,
|
||||||
|
From = request.From,
|
||||||
|
DisableCertificateCheck = request.DisableCertificateCheck
|
||||||
|
});
|
||||||
|
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,7 +55,6 @@ namespace BTCPayServer.Controllers.GreenField
|
|||||||
[HttpGet("~/api/v1/stores/{storeId}/email")]
|
[HttpGet("~/api/v1/stores/{storeId}/email")]
|
||||||
public IActionResult GetStoreEmailSettings()
|
public IActionResult GetStoreEmailSettings()
|
||||||
{
|
{
|
||||||
|
|
||||||
var store = HttpContext.GetStoreData();
|
var store = HttpContext.GetStoreData();
|
||||||
return store == null ? StoreNotFound() : Ok(FromModel(store));
|
return store == null ? StoreNotFound() : Ok(FromModel(store));
|
||||||
}
|
}
|
||||||
@@ -76,7 +75,13 @@ namespace BTCPayServer.Controllers.GreenField
|
|||||||
"Invalid email address", this);
|
"Invalid email address", this);
|
||||||
return this.CreateValidationError(ModelState);
|
return this.CreateValidationError(ModelState);
|
||||||
}
|
}
|
||||||
|
|
||||||
var blob = store.GetStoreBlob();
|
var blob = store.GetStoreBlob();
|
||||||
|
|
||||||
|
// retaining the password if it exists and was not provided in request
|
||||||
|
if (string.IsNullOrEmpty(request.Password) && blob.EmailSettings?.Password != null)
|
||||||
|
request.Password = blob.EmailSettings?.Password;
|
||||||
|
|
||||||
blob.EmailSettings = request;
|
blob.EmailSettings = request;
|
||||||
if (store.SetStoreBlob(blob))
|
if (store.SetStoreBlob(blob))
|
||||||
{
|
{
|
||||||
@@ -87,7 +92,11 @@ namespace BTCPayServer.Controllers.GreenField
|
|||||||
}
|
}
|
||||||
private EmailSettings FromModel(Data.StoreData data)
|
private EmailSettings FromModel(Data.StoreData data)
|
||||||
{
|
{
|
||||||
return data.GetStoreBlob().EmailSettings ?? new();
|
var emailSettings = data.GetStoreBlob().EmailSettings;
|
||||||
|
if (emailSettings == null)
|
||||||
|
return new EmailSettings();
|
||||||
|
emailSettings.Password = null;
|
||||||
|
return emailSettings;
|
||||||
}
|
}
|
||||||
private IActionResult StoreNotFound()
|
private IActionResult StoreNotFound()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1263,10 +1263,8 @@ namespace BTCPayServer.Controllers
|
|||||||
return View(model);
|
return View(model);
|
||||||
}
|
}
|
||||||
var oldSettings = await _emailSenderFactory.GetSettings() ?? new EmailSettings();
|
var oldSettings = await _emailSenderFactory.GetSettings() ?? new EmailSettings();
|
||||||
if (new ServerEmailsViewModel(oldSettings).PasswordSet)
|
if (!string.IsNullOrEmpty(oldSettings.Password))
|
||||||
{
|
|
||||||
model.Settings.Password = oldSettings.Password;
|
model.Settings.Password = oldSettings.Password;
|
||||||
}
|
|
||||||
|
|
||||||
await _SettingsRepository.UpdateSetting(model.Settings);
|
await _SettingsRepository.UpdateSetting(model.Settings);
|
||||||
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email settings saved"].Value;
|
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email settings saved"].Value;
|
||||||
|
|||||||
@@ -174,51 +174,65 @@ public partial class UIStoresController
|
|||||||
if (store == null)
|
if (store == null)
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
|
||||||
var emailSender = await _emailSenderFactory.GetEmailSender(store.Id);
|
var settings = await GetCustomSettings(store.Id);
|
||||||
var data = await emailSender.GetEmailSettings() ?? new EmailSettings();
|
|
||||||
var fallbackSettings = emailSender is StoreEmailSender { FallbackSender: { } fallbackSender }
|
return View(new EmailsViewModel(settings.Custom ?? new())
|
||||||
? await fallbackSender.GetEmailSettings()
|
{
|
||||||
: null;
|
IsFallbackSetup = settings.Fallback is not null,
|
||||||
var settings = data != fallbackSettings ? data : new EmailSettings();
|
IsCustomSMTP = settings.Custom is not null || settings.Fallback is null
|
||||||
return View(new EmailsViewModel(settings, fallbackSettings));
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
record AllEmailSettings(EmailSettings Custom, EmailSettings Fallback);
|
||||||
|
private async Task<AllEmailSettings> GetCustomSettings(string storeId)
|
||||||
|
{
|
||||||
|
var sender = await _emailSenderFactory.GetEmailSender(storeId) as StoreEmailSender;
|
||||||
|
if (sender is null)
|
||||||
|
return new(null, null);
|
||||||
|
var fallback = sender.FallbackSender is { } fb ? await fb.GetEmailSettings() : null;
|
||||||
|
if (fallback?.IsComplete() is not true)
|
||||||
|
fallback = null;
|
||||||
|
return new(await sender.GetCustomSettings(), fallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{storeId}/email-settings")]
|
[HttpPost("{storeId}/email-settings")]
|
||||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||||
public async Task<IActionResult> StoreEmailSettings(string storeId, EmailsViewModel model, string command, [FromForm] bool useCustomSMTP = false)
|
public async Task<IActionResult> StoreEmailSettings(string storeId, EmailsViewModel model, string command)
|
||||||
{
|
{
|
||||||
var store = HttpContext.GetStoreData();
|
var store = HttpContext.GetStoreData();
|
||||||
if (store == null)
|
if (store == null)
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
var settings = await GetCustomSettings(store.Id);
|
||||||
model.FallbackSettings = await _emailSenderFactory.GetEmailSender(store.Id) is StoreEmailSender { FallbackSender: not null } storeSender
|
model.IsFallbackSetup = settings.Fallback is not null;
|
||||||
? await storeSender.FallbackSender.GetEmailSettings()
|
if (!model.IsFallbackSetup)
|
||||||
: null;
|
model.IsCustomSMTP = true;
|
||||||
if (model.FallbackSettings is null) useCustomSMTP = true;
|
if (model.IsCustomSMTP)
|
||||||
ViewBag.UseCustomSMTP = useCustomSMTP;
|
|
||||||
if (useCustomSMTP)
|
|
||||||
{
|
{
|
||||||
model.Settings.Validate("Settings.", ModelState);
|
model.Settings.Validate("Settings.", ModelState);
|
||||||
|
if (model.Settings.From is not null && !MailboxAddressValidator.IsMailboxAddress(model.Settings.From))
|
||||||
|
{
|
||||||
|
ModelState.AddModelError("Settings.From", StringLocalizer["Invalid email"]);
|
||||||
}
|
}
|
||||||
|
if (!ModelState.IsValid)
|
||||||
|
return View(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
var storeBlob = store.GetStoreBlob();
|
||||||
|
var currentSettings = store.GetStoreBlob().EmailSettings;
|
||||||
|
if (model is { IsCustomSMTP: true, Settings: { Password: null } })
|
||||||
|
model.Settings.Password = currentSettings?.Password;
|
||||||
|
|
||||||
if (command == "Test")
|
if (command == "Test")
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (useCustomSMTP)
|
|
||||||
{
|
|
||||||
if (model.PasswordSet)
|
|
||||||
{
|
|
||||||
model.Settings.Password = store.GetStoreBlob().EmailSettings.Password;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(model.TestEmail))
|
if (string.IsNullOrEmpty(model.TestEmail))
|
||||||
ModelState.AddModelError(nameof(model.TestEmail), new RequiredAttribute().FormatErrorMessage(nameof(model.TestEmail)));
|
ModelState.AddModelError(nameof(model.TestEmail), new RequiredAttribute().FormatErrorMessage(nameof(model.TestEmail)));
|
||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
return View(model);
|
return View(model);
|
||||||
var settings = useCustomSMTP ? model.Settings : model.FallbackSettings;
|
var clientSettings = (model.IsCustomSMTP ? model.Settings : settings.Fallback) ?? new();
|
||||||
using var client = await settings.CreateSmtpClient();
|
using var client = await clientSettings.CreateSmtpClient();
|
||||||
var message = settings.CreateMailMessage(MailboxAddress.Parse(model.TestEmail), $"{store.StoreName}: Email test", StringLocalizer["You received it, the BTCPay Server SMTP settings work."], false);
|
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.SendAsync(message);
|
||||||
await client.DisconnectAsync(true);
|
await client.DisconnectAsync(true);
|
||||||
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email sent to {0}. Please verify you received it.", model.TestEmail].Value;
|
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email sent to {0}. Please verify you received it.", model.TestEmail].Value;
|
||||||
@@ -229,29 +243,24 @@ public partial class UIStoresController
|
|||||||
}
|
}
|
||||||
return View(model);
|
return View(model);
|
||||||
}
|
}
|
||||||
if (command == "ResetPassword")
|
else if (command == "ResetPassword")
|
||||||
{
|
{
|
||||||
var storeBlob = store.GetStoreBlob();
|
if (storeBlob.EmailSettings is not null)
|
||||||
storeBlob.EmailSettings.Password = null;
|
storeBlob.EmailSettings.Password = null;
|
||||||
store.SetStoreBlob(storeBlob);
|
store.SetStoreBlob(storeBlob);
|
||||||
await _storeRepo.UpdateStore(store);
|
await _storeRepo.UpdateStore(store);
|
||||||
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email server password reset"].Value;
|
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email server password reset"].Value;
|
||||||
}
|
}
|
||||||
var unsetCustomSMTP = !useCustomSMTP && store.GetStoreBlob().EmailSettings is not null;
|
else if (!model.IsCustomSMTP && currentSettings is not null)
|
||||||
if (useCustomSMTP || unsetCustomSMTP)
|
|
||||||
{
|
{
|
||||||
if (model.Settings.From is not null && !MailboxAddressValidator.IsMailboxAddress(model.Settings.From))
|
storeBlob.EmailSettings = null;
|
||||||
{
|
store.SetStoreBlob(storeBlob);
|
||||||
ModelState.AddModelError("Settings.From", StringLocalizer["Invalid email"]);
|
await _storeRepo.UpdateStore(store);
|
||||||
|
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["You are now using server's email settings"].Value;
|
||||||
}
|
}
|
||||||
if (!ModelState.IsValid)
|
else if (model.IsCustomSMTP)
|
||||||
return View(model);
|
|
||||||
var storeBlob = store.GetStoreBlob();
|
|
||||||
if (storeBlob.EmailSettings != null && new EmailsViewModel(storeBlob.EmailSettings, model.FallbackSettings).PasswordSet)
|
|
||||||
{
|
{
|
||||||
model.Settings.Password = storeBlob.EmailSettings.Password;
|
storeBlob.EmailSettings = model.Settings;
|
||||||
}
|
|
||||||
storeBlob.EmailSettings = unsetCustomSMTP ? null : model.Settings;
|
|
||||||
store.SetStoreBlob(storeBlob);
|
store.SetStoreBlob(storeBlob);
|
||||||
await _storeRepo.UpdateStore(store);
|
await _storeRepo.UpdateStore(store);
|
||||||
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email settings modified"].Value;
|
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email settings modified"].Value;
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ namespace BTCPayServer.Models;
|
|||||||
public class EmailsViewModel
|
public class EmailsViewModel
|
||||||
{
|
{
|
||||||
public EmailSettings Settings { get; set; }
|
public EmailSettings Settings { get; set; }
|
||||||
public EmailSettings FallbackSettings { get; set; }
|
|
||||||
public bool PasswordSet { get; set; }
|
public bool PasswordSet { get; set; }
|
||||||
|
|
||||||
[MailboxAddress]
|
[MailboxAddress]
|
||||||
@@ -18,14 +17,14 @@ public class EmailsViewModel
|
|||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
public EmailsViewModel(EmailSettings settings, EmailSettings fallbackSettings = null)
|
public EmailsViewModel(EmailSettings settings)
|
||||||
{
|
{
|
||||||
Settings = settings;
|
Settings = settings;
|
||||||
FallbackSettings = fallbackSettings;
|
PasswordSet = !string.IsNullOrWhiteSpace(settings?.Password);
|
||||||
PasswordSet = !string.IsNullOrEmpty(settings?.Password);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsSetup() => Settings?.IsComplete() is true;
|
public bool IsSetup() => Settings?.IsComplete() is true;
|
||||||
public bool IsFallbackSetup() => FallbackSettings?.IsComplete() is true;
|
|
||||||
public bool UsesFallback() => IsFallbackSetup() && Settings == FallbackSettings;
|
public bool IsFallbackSetup { get; set; }
|
||||||
|
public bool IsCustomSMTP { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,14 +29,27 @@ namespace BTCPayServer.Services.Mails
|
|||||||
var store = await StoreRepository.FindStore(StoreId);
|
var store = await StoreRepository.FindStore(StoreId);
|
||||||
if (store is null)
|
if (store is null)
|
||||||
return null;
|
return null;
|
||||||
|
var emailSettings = GetCustomSettings(store);
|
||||||
|
if (emailSettings is not null)
|
||||||
|
return emailSettings;
|
||||||
|
if (FallbackSender is not null)
|
||||||
|
return await FallbackSender.GetEmailSettings();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
public async Task<EmailSettings?> GetCustomSettings()
|
||||||
|
{
|
||||||
|
var store = await StoreRepository.FindStore(StoreId);
|
||||||
|
if (store is null)
|
||||||
|
return null;
|
||||||
|
return GetCustomSettings(store);
|
||||||
|
}
|
||||||
|
EmailSettings? GetCustomSettings(StoreData store)
|
||||||
|
{
|
||||||
var emailSettings = store.GetStoreBlob().EmailSettings;
|
var emailSettings = store.GetStoreBlob().EmailSettings;
|
||||||
if (emailSettings?.IsComplete() is true)
|
if (emailSettings?.IsComplete() is true)
|
||||||
{
|
{
|
||||||
return emailSettings;
|
return emailSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (FallbackSender is not null)
|
|
||||||
return await FallbackSender.GetEmailSettings();
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,12 +26,13 @@ namespace BTCPayServer.Services
|
|||||||
public async Task<T?> GetSettingAsync<T>(string? name = null) where T : class
|
public async Task<T?> GetSettingAsync<T>(string? name = null) where T : class
|
||||||
{
|
{
|
||||||
name ??= typeof(T).FullName ?? string.Empty;
|
name ??= typeof(T).FullName ?? string.Empty;
|
||||||
return await _memoryCache.GetOrCreateAsync(GetCacheKey(name), async entry =>
|
var data = await _memoryCache.GetOrCreateAsync(GetCacheKey(name), async entry =>
|
||||||
{
|
{
|
||||||
await using var ctx = _ContextFactory.CreateContext();
|
await using var ctx = _ContextFactory.CreateContext();
|
||||||
var data = await ctx.Settings.Where(s => s.Id == name).FirstOrDefaultAsync();
|
var data = await ctx.Settings.Where(s => s.Id == name).FirstOrDefaultAsync();
|
||||||
return data == null ? default : Deserialize<T>(data.Value);
|
return data?.Value;
|
||||||
});
|
});
|
||||||
|
return data is string ? Deserialize<T>(data) : null;
|
||||||
}
|
}
|
||||||
public async Task UpdateSetting<T>(T obj, string? name = null) where T : class
|
public async Task UpdateSetting<T>(T obj, string? name = null) where T : class
|
||||||
{
|
{
|
||||||
@@ -49,7 +50,7 @@ namespace BTCPayServer.Services
|
|||||||
await ctx.SaveChangesAsync();
|
await ctx.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_memoryCache.Set(GetCacheKey(name), obj);
|
_memoryCache.Remove(GetCacheKey(name));
|
||||||
_EventAggregator.Publish(new SettingsChanged<T>()
|
_EventAggregator.Publish(new SettingsChanged<T>()
|
||||||
{
|
{
|
||||||
Settings = obj,
|
Settings = obj,
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
<label asp-for="Settings.Password" class="form-label" text-translate="true">Password</label>
|
<label asp-for="Settings.Password" class="form-label" text-translate="true">Password</label>
|
||||||
@if (!Model.PasswordSet)
|
@if (!Model.PasswordSet)
|
||||||
{
|
{
|
||||||
<input asp-for="Settings.Password" type="password" class="form-control"/>
|
<input asp-for="Settings.Password" type="password" value="@Model.Settings.Password" class="form-control" />
|
||||||
<span asp-validation-for="Settings.Password" class="text-danger"></span>
|
<span asp-validation-for="Settings.Password" class="text-danger"></span>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
@model BTCPayServer.Models.EmailsViewModel
|
@model BTCPayServer.Models.EmailsViewModel
|
||||||
@{
|
@{
|
||||||
var storeId = Context.GetStoreData().Id;
|
var storeId = Context.GetStoreData().Id;
|
||||||
var hasCustomSettings = (Model.IsSetup() && !Model.UsesFallback()) || ViewBag.UseCustomSMTP ?? false;
|
|
||||||
ViewData.SetActivePage(StoreNavPages.Emails, StringLocalizer["Email Rules"], storeId);
|
ViewData.SetActivePage(StoreNavPages.Emails, StringLocalizer["Email Rules"], storeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,23 +12,23 @@
|
|||||||
<button id="page-primary" type="submit" class="btn btn-primary" name="command" value="Save">Save</button>
|
<button id="page-primary" type="submit" class="btn btn-primary" name="command" value="Save">Save</button>
|
||||||
</div>
|
</div>
|
||||||
<partial name="_StatusMessage" />
|
<partial name="_StatusMessage" />
|
||||||
@if (Model.IsFallbackSetup())
|
@if (Model.IsFallbackSetup)
|
||||||
{
|
{
|
||||||
<label class="d-flex align-items-center mb-4">
|
<label class="d-flex align-items-center mb-4">
|
||||||
<input type="checkbox" id="UseCustomSMTP" name="UseCustomSMTP" value="true" checked="@hasCustomSettings" class="btcpay-toggle me-3" data-bs-toggle="collapse" data-bs-target="#SmtpSettings" aria-expanded="@hasCustomSettings" aria-controls="SmtpSettings" />
|
<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>
|
<div>
|
||||||
<span text-translate="true">Use custom SMTP settings for this store</span>
|
<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 class="form-text" text-translate="true">Otherwise, the server's SMTP settings will be used to send emails.</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="collapse @(hasCustomSettings ? "show" : "")" id="SmtpSettings">
|
<div class="collapse @(Model.IsCustomSMTP ? "show" : "")" id="SmtpSettings">
|
||||||
<partial name="EmailsBody" model="Model" />
|
<partial name="EmailsBody" model="Model" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<input type="hidden" name="UseCustomSMTP" value="true" />
|
<input type="hidden" id="IsCustomSMTPHidden" asp-for="IsCustomSMTP" />
|
||||||
<partial name="EmailsBody" model="Model" />
|
<partial name="EmailsBody" model="Model" />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
{
|
||||||
|
"paths": {
|
||||||
|
"/api/v1/server/email": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["ServerEmail"],
|
||||||
|
"summary": "Get server email settings",
|
||||||
|
"description": "Retrieve the email settings configured for the server. The password field will be masked if present.",
|
||||||
|
"operationId": "ServerEmail_GetSettings",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Server email settings",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ServerEmailSettingsData"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden - Insufficient permissions"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"API_Key": ["btcpay.server.canmodifyserversettings"],
|
||||||
|
"Basic": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"put": {
|
||||||
|
"tags": ["ServerEmail"],
|
||||||
|
"summary": "Update server email settings",
|
||||||
|
"description": "Update server's email settings.",
|
||||||
|
"operationId": "ServerEmail_UpdateSettings",
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ServerEmailSettingsData"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Email settings updated successfully"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid request or email format",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden - Insufficient permissions"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"API_Key": ["btcpay.server.canmodifyserversettings"],
|
||||||
|
"Basic": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"components": {
|
||||||
|
"schemas": {
|
||||||
|
"ServerEmailSettingsData": {
|
||||||
|
"allOf": [
|
||||||
|
{ "$ref": "#/components/schemas/EmailSettings" },
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"enableStoresToUseServerEmailSettings": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Indicates if stores can use server email settings"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"EmailSettings": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"from": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The sender email address"
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "SMTP server host"
|
||||||
|
},
|
||||||
|
"port": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "SMTP server port"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "SMTP username"
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "SMTP password, masked in responses and retained if not updated",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"disableCertificateCheck": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Use SSL for SMTP connection"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"name": "ServerEmail",
|
||||||
|
"description": "Server Email Settings operations"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
"$ref": "#/components/parameters/StoreId"
|
"$ref": "#/components/parameters/StoreId"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "View email settings of the specified store",
|
"description": "Retrieve the email settings configured for specific store. The password field will be masked if present.",
|
||||||
"operationId": "Stores_GetStoreEmailSettings",
|
"operationId": "Stores_GetStoreEmailSettings",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
|
|||||||
Reference in New Issue
Block a user