Refactor email endpoints

This commit is contained in:
nicolas.dorier
2025-02-27 16:50:32 +09:00
committed by rockstardev
parent 78f33f0ca4
commit be8ecb823e
10 changed files with 185 additions and 159 deletions

View File

@@ -13,8 +13,8 @@ public partial class BTCPayServerClient
return await SendHttpRequest<ServerEmailSettingsData>("api/v1/server/email", null, HttpMethod.Get, token); return await SendHttpRequest<ServerEmailSettingsData>("api/v1/server/email", null, HttpMethod.Get, token);
} }
public virtual async Task<bool> UpdateServerEmailSettings(ServerEmailSettingsData request, CancellationToken token = default) public virtual async Task<ServerEmailSettingsData> UpdateServerEmailSettings(ServerEmailSettingsData request, CancellationToken token = default)
{ {
return await SendHttpRequest<bool>("api/v1/server/email", request, HttpMethod.Put, token); return await SendHttpRequest<ServerEmailSettingsData>("api/v1/server/email", request, HttpMethod.Put, token);
} }
} }

View File

@@ -1,4 +1,6 @@
using System.Collections.Generic;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Client.Models; namespace BTCPayServer.Client.Models;
@@ -7,14 +9,11 @@ public class EmailSettingsData
public string Server { get; set; } public string Server { get; set; }
public int? Port { get; set; } public int? Port { get; set; }
public string Login { get; set; } public string Login { get; set; }
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
public string Password { get; set; } public string Password { get; set; }
public bool? PasswordSet { get; set; }
public string From { get; set; } public string From { get; set; }
public bool DisableCertificateCheck { get; set; } public bool DisableCertificateCheck { get; set; }
[JsonExtensionData]
[JsonIgnore] public IDictionary<string, JToken> AdditionalData { get; set; } = new Dictionary<string, JToken>();
public bool EnabledCertificateCheck
{
get => !DisableCertificateCheck;
set { DisableCertificateCheck = !value; }
}
} }

View File

@@ -4104,24 +4104,23 @@ namespace BTCPayServer.Tests
var admin = tester.NewAccount(); var admin = tester.NewAccount();
await admin.GrantAccessAsync(true); await admin.GrantAccessAsync(true);
var adminClient = await admin.CreateClient(Policies.Unrestricted); var adminClient = await admin.CreateClient(Policies.Unrestricted);
var data = new EmailSettingsData var data = new ServerEmailSettingsData
{ {
From = "admin@admin.com", From = "admin@admin.com",
Login = "admin@admin.com", Login = "admin@admin.com",
Password = "admin@admin.com", Password = "admin@admin.com",
Port = 1234, Port = 1234,
Server = "admin.com", Server = "admin.com",
};
var serverEmailSettings = new ServerEmailSettingsData
{
EnableStoresToUseServerEmailSettings = false EnableStoresToUseServerEmailSettings = false
}; };
await adminClient.UpdateServerEmailSettings(serverEmailSettings); var actualUpdated = await adminClient.UpdateServerEmailSettings(data);
var s = await adminClient.GetServerEmailSettings(); var actualUpdated2 = await adminClient.GetServerEmailSettings();
// email password is masked and not returned from the server once set // email password is masked and not returned from the server once set
serverEmailSettings.Password = null; data.Password = null;
Assert.Equal(JsonConvert.SerializeObject(s), JsonConvert.SerializeObject(serverEmailSettings)); data.PasswordSet = true;
Assert.Equal(JsonConvert.SerializeObject(actualUpdated2), JsonConvert.SerializeObject(data));
Assert.Equal(JsonConvert.SerializeObject(actualUpdated2), JsonConvert.SerializeObject(actualUpdated));
await AssertValidationError(new[] { nameof(EmailSettingsData.From) }, await AssertValidationError(new[] { nameof(EmailSettingsData.From) },
async () => await adminClient.UpdateServerEmailSettings(new ServerEmailSettingsData async () => await adminClient.UpdateServerEmailSettings(new ServerEmailSettingsData
{ {
@@ -4157,6 +4156,7 @@ namespace BTCPayServer.Tests
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 // email password is masked and not returned from the server once set
data.Password = null; data.Password = null;
data.PasswordSet = true;
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

@@ -1,4 +1,5 @@
#nullable enable #nullable enable
using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants; using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions; using BTCPayServer.Abstractions.Extensions;
@@ -10,6 +11,7 @@ using BTCPayServer.Services.Mails;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Controllers.GreenField namespace BTCPayServer.Controllers.GreenField
{ {
@@ -34,56 +36,36 @@ namespace BTCPayServer.Controllers.GreenField
public async Task<IActionResult> ServerEmailSettings() public async Task<IActionResult> ServerEmailSettings()
{ {
var email = await _emailSenderFactory.GetSettings() ?? new EmailSettings(); var email = await _emailSenderFactory.GetSettings() ?? new EmailSettings();
var model = new ServerEmailSettingsData return Ok(FromModel(email));
}
private ServerEmailSettingsData FromModel(EmailSettings email)
{ {
EnableStoresToUseServerEmailSettings = !_policiesSettings.DisableStoresToUseServerEmailSettings, var data = email.ToData<ServerEmailSettingsData>();
From = email.From, data.EnableStoresToUseServerEmailSettings = !_policiesSettings.DisableStoresToUseServerEmailSettings;
Server = email.Server, return data;
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)] [Authorize(Policy = Policies.CanModifyServerSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpPut("~/api/v1/server/email")] [HttpPut("~/api/v1/server/email")]
public async Task<IActionResult> ServerEmailSettings(ServerEmailSettingsData request) public async Task<IActionResult> ServerEmailSettings(ServerEmailSettingsData request)
{ {
request.Validate(ModelState);
if (!ModelState.IsValid)
return this.CreateValidationError(ModelState);
if (_policiesSettings.DisableStoresToUseServerEmailSettings == request.EnableStoresToUseServerEmailSettings) if (_policiesSettings.DisableStoresToUseServerEmailSettings == request.EnableStoresToUseServerEmailSettings)
{ {
_policiesSettings.DisableStoresToUseServerEmailSettings = !request.EnableStoresToUseServerEmailSettings; _policiesSettings.DisableStoresToUseServerEmailSettings = !request.EnableStoresToUseServerEmailSettings;
await _settingsRepository.UpdateSetting(_policiesSettings); await _settingsRepository.UpdateSetting(_policiesSettings);
} }
// save var settings = await _emailSenderFactory.GetSettings();
if (request.From is not null && !MailboxAddressValidator.IsMailboxAddress(request.From)) settings = EmailSettings.FromData(request, settings?.Password);
{
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 // important to save as EmailSettings otherwise it won't be able to be fetched
await _settingsRepository.UpdateSetting(new EmailSettings await _settingsRepository.UpdateSetting(settings);
{ return Ok(FromModel(settings));
Server = request.Server,
Port = request.Port,
Login = request.Login,
Password = request.Password,
From = request.From,
DisableCertificateCheck = request.DisableCertificateCheck
});
return Ok(true);
} }
} }
} }

View File

@@ -61,43 +61,25 @@ namespace BTCPayServer.Controllers.GreenField
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpPut("~/api/v1/stores/{storeId}/email")] [HttpPut("~/api/v1/stores/{storeId}/email")]
public async Task<IActionResult> UpdateStoreEmailSettings(string storeId, EmailSettings request) public async Task<IActionResult> UpdateStoreEmailSettings(string storeId, EmailSettingsData request)
{ {
var store = HttpContext.GetStoreData(); var store = HttpContext.GetStoreData();
if (store == null) request ??= new();
{ request.Validate(this.ModelState);
return StoreNotFound(); if (!ModelState.IsValid)
}
if (!string.IsNullOrEmpty(request.From) && !MailboxAddressValidator.IsMailboxAddress(request.From))
{
request.AddModelError(e => e.From,
"Invalid email address", this);
return this.CreateValidationError(ModelState); return this.CreateValidationError(ModelState);
}
var blob = store.GetStoreBlob(); var blob = store.GetStoreBlob();
var settings = EmailSettings.FromData(request, blob.EmailSettings?.Password);
// retaining the password if it exists and was not provided in request blob.EmailSettings = settings;
if (string.IsNullOrEmpty(request.Password) && blob.EmailSettings?.Password != null)
request.Password = blob.EmailSettings?.Password;
blob.EmailSettings = request;
if (store.SetStoreBlob(blob)) if (store.SetStoreBlob(blob))
{
await _storeRepository.UpdateStore(store); await _storeRepository.UpdateStore(store);
}
return Ok(FromModel(store)); return Ok(FromModel(store));
} }
private EmailSettings FromModel(Data.StoreData data) private EmailSettingsData FromModel(Data.StoreData data)
{ => (data.GetStoreBlob().EmailSettings ?? new()).ToData();
var emailSettings = data.GetStoreBlob().EmailSettings;
if (emailSettings == null)
return new EmailSettings();
emailSettings.Password = null;
return emailSettings;
}
private IActionResult StoreNotFound() private IActionResult StoreNotFound()
{ {
return this.CreateAPIError(404, "store-not-found", "The store was not found"); return this.CreateAPIError(404, "store-not-found", "The store was not found");

View File

@@ -977,7 +977,7 @@ namespace BTCPayServer.Controllers.Greenfield
{ {
return GetFromActionResult<EmailSettingsData>( return GetFromActionResult<EmailSettingsData>(
await GetController<GreenfieldStoreEmailController>().UpdateStoreEmailSettings(storeId, await GetController<GreenfieldStoreEmailController>().UpdateStoreEmailSettings(storeId,
JObject.FromObject(request).ToObject<EmailSettings>())); JObject.FromObject(request).ToObject<EmailSettingsData>()));
} }
public override async Task<ApplicationUserData[]> GetUsers(CancellationToken token = default) public override async Task<ApplicationUserData[]> GetUsers(CancellationToken token = default)

View File

@@ -6,11 +6,48 @@ using BTCPayServer.Validation;
using MailKit.Net.Smtp; using MailKit.Net.Smtp;
using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding;
using MimeKit; using MimeKit;
using Newtonsoft.Json;
namespace BTCPayServer.Services.Mails namespace BTCPayServer.Services.Mails
{ {
public class EmailSettings : EmailSettingsData public class EmailSettings
{ {
#nullable enable
public static EmailSettings FromData(EmailSettingsData data, string? existingPassword)
=> new EmailSettings()
{
Server = data.Server,
Port = data.Port,
Login = data.Login,
// Retaining the password if it exists and was not provided in request
Password = string.IsNullOrEmpty(data.Password) ? existingPassword : data.Password,
From = data.From,
DisableCertificateCheck = data.DisableCertificateCheck
};
public EmailSettingsData ToData() => ToData<EmailSettingsData>();
public T ToData<T>() where T : EmailSettingsData, new()
=> new T()
{
Server = Server,
Port = Port,
Login = Login,
PasswordSet = !string.IsNullOrEmpty(Password),
From = From,
DisableCertificateCheck = DisableCertificateCheck
};
#nullable restore
public string Server { 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; }
[JsonIgnore]
public bool EnabledCertificateCheck
{
get => !DisableCertificateCheck;
set { DisableCertificateCheck = !value; }
}
public bool IsComplete() public bool IsComplete()
{ {
return MailboxAddressValidator.IsMailboxAddress(From) return MailboxAddressValidator.IsMailboxAddress(From)

View File

@@ -0,0 +1,14 @@
#nullable enable
using BTCPayServer.Client.Models;
namespace BTCPayServer
{
public static class ValidationExtensions
{
public static void Validate(this EmailSettingsData request, Microsoft.AspNetCore.Mvc.ModelBinding.ModelStateDictionary modelState)
{
if (!string.IsNullOrEmpty(request.From) && !MailboxAddressValidator.IsMailboxAddress(request.From))
modelState.AddModelError(nameof(request.From), "Invalid email address");
}
}
}

View File

@@ -12,7 +12,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/ServerEmailSettingsData" "$ref": "#/components/schemas/GetServerEmailSettings"
} }
} }
} }
@@ -38,14 +38,21 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/ServerEmailSettingsData" "$ref": "#/components/schemas/UpdateServerEmailSettings"
} }
} }
} }
}, },
"responses": { "responses": {
"200": { "200": {
"description": "Email settings updated successfully" "description": "Server email settings",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GetServerEmailSettings"
}
}
}
}, },
"400": { "400": {
"description": "Invalid request or email format", "description": "Invalid request or email format",
@@ -72,9 +79,9 @@
}, },
"components": { "components": {
"schemas": { "schemas": {
"ServerEmailSettingsData": { "UpdateServerEmailSettings": {
"allOf": [ "allOf": [
{ "$ref": "#/components/schemas/EmailSettings" }, { "$ref": "#/components/schemas/UpdateEmailSettings" },
{ {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -86,36 +93,20 @@
} }
] ]
}, },
"EmailSettings": { "GetServerEmailSettings": {
"allOf": [
{ "$ref": "#/components/schemas/GetEmailSettings" },
{
"type": "object", "type": "object",
"properties": { "properties": {
"from": { "enableStoresToUseServerEmailSettings": {
"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", "type": "boolean",
"description": "Use SSL for SMTP connection" "description": "Indicates if stores can use server email settings"
} }
} }
} }
]
}
} }
}, },
"tags": [ "tags": [

View File

@@ -19,7 +19,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/EmailSettingsData" "$ref": "#/components/schemas/GetEmailSettings"
} }
} }
} }
@@ -57,7 +57,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/EmailSettingsData" "$ref": "#/components/schemas/UpdateEmailSettings"
} }
} }
}, },
@@ -70,7 +70,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/EmailSettingsData" "$ref": "#/components/schemas/GetEmailSettings"
} }
} }
} }
@@ -117,7 +117,22 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/EmailData" "type": "object",
"additionalProperties": false,
"properties": {
"email": {
"type": "string",
"description": "Email of the recipient"
},
"subject": {
"type": "string",
"description": "Subject of the email"
},
"body": {
"type": "string",
"description": "Body of the email to send as plain text."
}
}
} }
} }
}, },
@@ -151,57 +166,63 @@
}, },
"components": { "components": {
"schemas": { "schemas": {
"EmailData": { "UpdateEmailSettings": {
"allOf": [
{ "$ref": "#/components/schemas/EmailSettingsBase" },
{
"type": "object", "type": "object",
"additionalProperties": false,
"properties": { "properties": {
"email": { "password": {
"type": "string", "type": "string",
"description": "Email of the recipient" "description": "SMTP password. Keep null or empty to not update it.",
}, "nullable": true,
"subject": { "example": "MyS3cr3t"
"type": "string",
"description": "Subject of the email"
},
"body": {
"type": "string",
"description": "Body of the email to send as plain text."
} }
} }
}
]
}, },
"EmailSettingsData": { "GetEmailSettings": {
"allOf": [
{ "$ref": "#/components/schemas/EmailSettingsBase" },
{
"type": "object", "type": "object",
"additionalProperties": false,
"properties": { "properties": {
"passwordSet": {
"type": "boolean",
"description": "`true` if the password has been set."
}
}
}
]
},
"EmailSettingsBase": {
"type": "object",
"properties": {
"from": {
"type": "string",
"description": "The sender email address",
"example": "sender@gmail.com"
},
"server": { "server": {
"type": "string", "type": "string",
"description": "Smtp server host" "description": "SMTP server host",
"example": "smtp.gmail.com"
}, },
"port": { "port": {
"type": "number", "type": "integer",
"description": "Smtp server port" "description": "SMTP server port",
"example": 587
}, },
"login": { "login": {
"type": "string", "type": "string",
"description": "Smtp server username" "description": "SMTP username",
}, "example": "John.Smith"
"password": {
"type": "string",
"description": "Smtp server password"
},
"from": {
"type": "string",
"format": "email",
"description": "Email to send from"
},
"fromDisplay": {
"type": "string",
"description": "The name of the sender"
}, },
"disableCertificateCheck": { "disableCertificateCheck": {
"type": "boolean", "type": "boolean",
"default": false, "description": "Use SSL for SMTP connection",
"description": "Disable TLS certificate security checks" "example": false
} }
} }
} }