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:
rockstardev
2025-02-27 01:59:17 -05:00
committed by GitHub
parent 8b5c5895f0
commit a7e3cbb105
16 changed files with 412 additions and 117 deletions

View 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);
}
}

View File

@@ -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]

View File

@@ -0,0 +1,7 @@
namespace BTCPayServer.Client.Models
{
public class ServerEmailSettingsData : EmailSettingsData
{
public bool EnableStoresToUseServerEmailSettings { get; set; }
}
}

View File

@@ -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()
{ {
@@ -4092,6 +4093,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")]
@@ -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,

View File

@@ -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)]

View File

@@ -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);
}
}
}

View File

@@ -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()
{ {

View File

@@ -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;

View File

@@ -47,7 +47,7 @@ public partial class UIStoresController
{ {
vm.Rules ??= []; vm.Rules ??= [];
int commandIndex = 0; int commandIndex = 0;
var indSep = command.Split(':', StringSplitOptions.RemoveEmptyEntries); var indSep = command.Split(':', StringSplitOptions.RemoveEmptyEntries);
if (indSep.Length > 1) if (indSep.Length > 1)
{ {
@@ -154,15 +154,15 @@ public partial class UIStoresController
{ {
[Required] [Required]
public string Trigger { get; set; } public string Trigger { get; set; }
public bool CustomerEmail { get; set; } public bool CustomerEmail { get; set; }
public string To { get; set; } public string To { get; set; }
[Required] [Required]
public string Subject { get; set; } public string Subject { get; set; }
[Required] [Required]
public string Body { get; set; } public string Body { get; set; }
} }
@@ -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) }
return View(model); else if (model.IsCustomSMTP)
var storeBlob = store.GetStoreBlob(); {
if (storeBlob.EmailSettings != null && new EmailsViewModel(storeBlob.EmailSettings, model.FallbackSettings).PasswordSet) storeBlob.EmailSettings = model.Settings;
{
model.Settings.Password = storeBlob.EmailSettings.Password;
}
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;

View File

@@ -7,9 +7,8 @@ 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]
[Display(Name = "Test Email")] [Display(Name = "Test Email")]
public string TestEmail { get; set; } public string TestEmail { get; set; }
@@ -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; }
} }

View File

@@ -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;
} }

View File

@@ -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,

View File

@@ -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

View File

@@ -2,34 +2,33 @@
@using Microsoft.AspNetCore.Mvc.TagHelpers @using Microsoft.AspNetCore.Mvc.TagHelpers
@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);
} }
<form method="post" autocomplete="off" permissioned="@Policies.CanModifyStoreSettings"> <form method="post" autocomplete="off" permissioned="@Policies.CanModifyStoreSettings">
<div class="sticky-header"> <div class="sticky-header">
<h2 text-translate="true">Email Server</h2> <h2 text-translate="true">Email Server</h2>
<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" />
} }

View File

@@ -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"
}
]
}

View File

@@ -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": {