Improve email settings validation and UX (#3891)

This commit is contained in:
Nicolas Dorier 2022-06-23 13:41:52 +09:00 committed by GitHub
parent c2d72e71aa
commit c89f7aaaed
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 161 additions and 93 deletions

View file

@ -1,4 +1,4 @@
namespace BTCPayServer.Client.Models;
namespace BTCPayServer.Client.Models;
public class EmailSettingsData
{
@ -21,11 +21,6 @@ public class EmailSettingsData
{
get; set;
}
public string FromDisplay
{
get; set;
}
public string From
{
get; set;

View file

@ -1940,6 +1940,7 @@ retry:
s.Driver.FindElement(By.CssSelector("button[value=\"Save\"]")).Submit();
s.FindAlertMessage();
s.Driver.FindElement(By.Id("Settings_Password")).SendKeys("mypassword");
s.Driver.FindElement(By.Id("Settings_From")).SendKeys("Firstname Lastname <email@example.com>");
s.Driver.FindElement(By.Id("Save")).SendKeys(Keys.Enter);
Assert.Contains("Configured", s.Driver.PageSource);
s.Driver.FindElement(By.Id("Settings_Login")).SendKeys("test_fix@gmail.com");

View file

@ -39,7 +39,7 @@ namespace BTCPayServer.Controllers.GreenField
{
return this.CreateAPIError(404, "store-not-found", "The store was not found");
}
if (!MailboxAddress.TryParse(request.Email, out MailboxAddress to))
if (!MailboxAddressValidator.TryParse(request.Email, out var to))
{
ModelState.AddModelError(nameof(request.Email), "Invalid email");
return this.CreateValidationError(ModelState);
@ -72,7 +72,7 @@ namespace BTCPayServer.Controllers.GreenField
return StoreNotFound();
}
if (!string.IsNullOrEmpty(request.From) && !EmailValidator.IsEmail(request.From))
if (!string.IsNullOrEmpty(request.From) && !MailboxAddressValidator.IsMailboxAddress(request.From))
{
request.AddModelError(e => e.From,
"Invalid email address", this);

View file

@ -118,7 +118,7 @@ namespace BTCPayServer.Controllers.Greenfield
{
if (request.Email is null)
ModelState.AddModelError(nameof(request.Email), "Email is missing");
if (!string.IsNullOrEmpty(request.Email) && !Validation.EmailValidator.IsEmail(request.Email))
if (!string.IsNullOrEmpty(request.Email) && !MailboxAddressValidator.IsMailboxAddress(request.Email))
{
ModelState.AddModelError(nameof(request.Email), "Invalid email");
}

View file

@ -233,7 +233,7 @@ namespace BTCPayServer.Controllers
if (entity.Metadata.BuyerEmail != null)
{
if (!EmailValidator.IsEmail(entity.Metadata.BuyerEmail))
if (!MailboxAddressValidator.IsMailboxAddress(entity.Metadata.BuyerEmail))
throw new BitpayHttpException(400, "Invalid email");
entity.RefundMail = entity.Metadata.BuyerEmail;
}

View file

@ -165,8 +165,7 @@ namespace BTCPayServer.Controllers
var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
var callbackUrl = _linkGenerator.EmailConfirmationLink(user.Id, code, Request.Scheme, Request.Host, Request.PathBase);
var address = new MailboxAddress(user.UserName, user.Email);
(await _EmailSenderFactory.GetEmailSender()).SendEmailConfirmation(address, callbackUrl);
(await _EmailSenderFactory.GetEmailSender()).SendEmailConfirmation(user.GetMailboxAddress(), callbackUrl);
TempData[WellKnownTempData.SuccessMessage] = "Verification email sent. Please check your email.";
return RedirectToAction(nameof(Index));
}

View file

@ -303,8 +303,7 @@ namespace BTCPayServer.Controllers
var code = await _UserManager.GenerateEmailConfirmationTokenAsync(user);
var callbackUrl = _linkGenerator.EmailConfirmationLink(user.Id, code, Request.Scheme, Request.Host, Request.PathBase);
var address = new MailboxAddress(user.UserName, user.Email);
(await _emailSenderFactory.GetEmailSender()).SendEmailConfirmation(address, callbackUrl);
(await _emailSenderFactory.GetEmailSender()).SendEmailConfirmation(user.GetMailboxAddress(), callbackUrl);
TempData[WellKnownTempData.SuccessMessage] = "Verification email sent";
return RedirectToAction(nameof(ListUsers));

View file

@ -1,6 +1,7 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
@ -25,6 +26,7 @@ using BTCPayServer.Services.Stores;
using BTCPayServer.Storage.Models;
using BTCPayServer.Storage.Services;
using BTCPayServer.Storage.Services.Providers;
using BTCPayServer.Validation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
@ -1015,18 +1017,13 @@ namespace BTCPayServer.Controllers
var settings = await _SettingsRepository.GetSettingAsync<EmailSettings>() ?? new EmailSettings();
model.Settings.Password = settings.Password;
}
if (!model.Settings.IsComplete())
{
TempData[WellKnownTempData.ErrorMessage] = "Required fields missing";
model.Settings.Validate("Settings.", ModelState);
if (string.IsNullOrEmpty(model.TestEmail))
ModelState.AddModelError(nameof(model.TestEmail), new RequiredAttribute().FormatErrorMessage(nameof(model.TestEmail)));
if (!ModelState.IsValid)
return View(model);
}
if (!MailboxAddress.TryParse(model.TestEmail, out MailboxAddress testEmail))
{
TempData[WellKnownTempData.ErrorMessage] = "Invalid test email";
return View(model);
}
using (var client = await model.Settings.CreateSmtpClient())
using (var message = model.Settings.CreateMailMessage(testEmail, "BTCPay test", "BTCPay test", false))
using (var message = model.Settings.CreateMailMessage(MailboxAddress.Parse(model.TestEmail), "BTCPay test", "BTCPay test", false))
{
await client.SendAsync(message);
await client.DisconnectAsync(true);
@ -1043,12 +1040,17 @@ namespace BTCPayServer.Controllers
{
var settings = await _SettingsRepository.GetSettingAsync<EmailSettings>() ?? new EmailSettings();
settings.Password = null;
await _SettingsRepository.UpdateSetting(model.Settings);
await _SettingsRepository.UpdateSetting(settings);
TempData[WellKnownTempData.SuccessMessage] = "Email server password reset";
return RedirectToAction(nameof(Emails));
}
else // if (command == "Save")
{
if (model.Settings.From is not null && !MailboxAddressValidator.IsMailboxAddress(model.Settings.From))
{
ModelState.AddModelError("Settings.From", "Invalid email");
return View(model);
}
var oldSettings = await _SettingsRepository.GetSettingAsync<EmailSettings>() ?? new EmailSettings();
if (new EmailsViewModel(oldSettings).PasswordSet)
{

View file

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
@ -45,7 +46,7 @@ namespace BTCPayServer.Controllers
if (command.StartsWith("remove", StringComparison.InvariantCultureIgnoreCase))
{
var item = command[(command.IndexOf(":", StringComparison.InvariantCultureIgnoreCase) + 1)..];
var index = int.Parse(item);
var index = int.Parse(item, CultureInfo.InvariantCulture);
vm.Rules.RemoveAt(index);
return View(vm);
@ -117,18 +118,13 @@ namespace BTCPayServer.Controllers
{
model.Settings.Password = store.GetStoreBlob().EmailSettings.Password;
}
if (!model.Settings.IsComplete())
{
TempData[WellKnownTempData.ErrorMessage] = "Required fields missing";
model.Settings.Validate("Settings.", ModelState);
if (string.IsNullOrEmpty(model.TestEmail))
ModelState.AddModelError(nameof(model.TestEmail), new RequiredAttribute().FormatErrorMessage(nameof(model.TestEmail)));
if (!ModelState.IsValid)
return View(model);
}
if (!MailboxAddress.TryParse(model.TestEmail, out MailboxAddress testEmail))
{
TempData[WellKnownTempData.ErrorMessage] = "Invalid test email";
return View(model);
}
using var client = await model.Settings.CreateSmtpClient();
var message = model.Settings.CreateMailMessage(testEmail, "BTCPay test", "BTCPay test", false);
var message = model.Settings.CreateMailMessage(MailboxAddress.Parse(model.TestEmail), "BTCPay test", "BTCPay test", false);
await client.SendAsync(message);
await client.DisconnectAsync(true);
TempData[WellKnownTempData.SuccessMessage] = $"Email sent to {model.TestEmail}. Please verify you received it.";
@ -150,6 +146,11 @@ namespace BTCPayServer.Controllers
}
else // if (command == "Save")
{
if (model.Settings.From is not null && !MailboxAddressValidator.IsMailboxAddress(model.Settings.From))
{
ModelState.AddModelError("Settings.From", "Invalid email");
return View(model);
}
var storeBlob = store.GetStoreBlob();
if (new EmailsViewModel(storeBlob.EmailSettings).PasswordSet && storeBlob.EmailSettings != null)
{

View file

@ -64,7 +64,7 @@ namespace BTCPayServer.Data.Payouts.LightningLike
try
{
string lnurlTag = null;
var lnurl = EmailValidator.IsEmail(destination)
var lnurl = MailboxAddressValidator.IsMailboxAddress(destination)
? LNURL.LNURL.ExtractUriFromInternetIdentifier(destination)
: LNURL.LNURL.Parse(destination, out lnurlTag);

View file

@ -7,7 +7,6 @@ namespace BTCPayServer
{
public static class ModelStateExtensions
{
public static void AddModelError<TModel, TProperty>(this TModel source,
Expression<Func<TModel, TProperty>> ex,
string message,

View file

@ -1,3 +1,4 @@
using System;
using System.Linq;
using BTCPayServer.Data;
using BTCPayServer.Services.Invoices;
@ -7,6 +8,15 @@ namespace BTCPayServer
{
public static class UserExtensions
{
public static MimeKit.MailboxAddress GetMailboxAddress(this ApplicationUser user)
{
if (user is null)
throw new ArgumentNullException(nameof(user));
var name = user.UserName ?? String.Empty;
if (user.Email == user.UserName)
name = String.Empty;
return new MimeKit.MailboxAddress(name, user.Email);
}
public static UserBlob GetBlob(this ApplicationUser user)
{
var result = user.Blob == null

View file

@ -122,7 +122,7 @@ namespace BTCPayServer.HostedServices
if (sendMail &&
invoice.NotificationEmail is String e &&
MailboxAddress.TryParse(e, out MailboxAddress notificationEmail))
MailboxAddressValidator.TryParse(e, out MailboxAddress notificationEmail))
{
var json = NBitcoin.JsonConverters.Serializer.ToString(notification);
var store = await _StoreRepository.FindStore(invoice.StoreId);

View file

@ -1,4 +1,4 @@
using System;
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@ -53,20 +53,18 @@ public class StoreEmailRuleProcessorSender : EventHostedServiceBase
var sender = await _emailSenderFactory.GetEmailSender(invoiceEvent.Invoice.StoreId);
foreach (UIStoresController.StoreEmailRule actionableRule in actionableRules)
{
var dest = actionableRule.To.Split(",", StringSplitOptions.RemoveEmptyEntries).Where(IsValidEmailAddress);
if (actionableRule.CustomerEmail && IsValidEmailAddress(invoiceEvent.Invoice.Metadata.BuyerEmail))
var recipients = actionableRule.To.Split(",", StringSplitOptions.RemoveEmptyEntries)
.Select(o => { MailboxAddressValidator.TryParse(o, out var mb); return mb; })
.Where(o => o != null)
.ToList();
if (actionableRule.CustomerEmail && MailboxAddressValidator.TryParse(invoiceEvent.Invoice.Metadata.BuyerEmail, out var bmb))
{
dest = dest.Append(invoiceEvent.Invoice.Metadata.BuyerEmail);
recipients.Add(bmb);
}
var recipients = dest.Select(address => new MailboxAddress(address, address)).ToArray();
sender.SendEmail(recipients, null, null, actionableRule.Subject, actionableRule.Body);
sender.SendEmail(recipients.ToArray(), null, null, actionableRule.Subject, actionableRule.Body);
}
}
}
}
}
private bool IsValidEmailAddress(string address) =>
!string.IsNullOrEmpty(address) && MailboxAddress.TryParse(address, out _);
}

View file

@ -55,8 +55,7 @@ namespace BTCPayServer.HostedServices
new HostString(userRegisteredEvent.RequestUri.Host, userRegisteredEvent.RequestUri.Port),
userRegisteredEvent.RequestUri.PathAndQuery);
userRegisteredEvent.CallbackUrlGenerated?.SetResult(new Uri(callbackUrl));
address = new MailboxAddress(userRegisteredEvent.User.Email,
userRegisteredEvent.User.Email);
address = userRegisteredEvent.User.GetMailboxAddress();
(await _emailSenderFactory.GetEmailSender()).SendEmailConfirmation(address, callbackUrl);
}
else if (!await _userManager.HasPasswordAsync(userRegisteredEvent.User))
@ -86,8 +85,7 @@ passwordSetter:
userPasswordResetRequestedEvent.RequestUri.Port),
userPasswordResetRequestedEvent.RequestUri.PathAndQuery);
userPasswordResetRequestedEvent.CallbackUrlGenerated?.SetResult(new Uri(callbackUrl));
address = new MailboxAddress(userPasswordResetRequestedEvent.User.Email,
userPasswordResetRequestedEvent.User.Email);
address = userPasswordResetRequestedEvent.User.GetMailboxAddress();
(await _emailSenderFactory.GetEmailSender())
.SendSetPasswordConfirmation(address, callbackUrl, newPassword);
break;

View file

@ -50,7 +50,7 @@ namespace BTCPayServer.Models.InvoicingModels
get; set;
}
[EmailAddress]
[MailboxAddress]
[DisplayName("Buyer Email")]
public string BuyerEmail
{
@ -76,7 +76,7 @@ namespace BTCPayServer.Models.InvoicingModels
get; set;
}
[EmailAddress]
[MailboxAddress]
[DisplayName("Notification Email")]
public string NotificationEmail
{

View file

@ -1,10 +1,11 @@
using System.ComponentModel.DataAnnotations;
using BTCPayServer.Validation;
namespace BTCPayServer.Models.InvoicingModels
{
public class UpdateCustomerModel
{
[EmailAddress]
[MailboxAddress]
[Required]
public string Email
{

View file

@ -5,6 +5,7 @@ using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates;
using BTCPayServer.Validation;
using Microsoft.AspNetCore.Mvc.Rendering;
using PaymentRequestData = BTCPayServer.Data.PaymentRequestData;
@ -64,7 +65,7 @@ namespace BTCPayServer.Models.PaymentRequestViewModels
[Display(Name = "Store")]
public SelectList Stores { get; set; }
[EmailAddress]
[MailboxAddress]
public string Email { get; set; }
[MaxLength(500)]

View file

@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations;
using BTCPayServer.Services.Mails;
using BTCPayServer.Validation;
namespace BTCPayServer.Models.ServerViewModels
{
@ -19,7 +20,7 @@ namespace BTCPayServer.Models.ServerViewModels
get; set;
}
public bool PasswordSet { get; set; }
[EmailAddress]
[MailboxAddressAttribute]
[Display(Name = "Test Email")]
public string TestEmail
{

View file

@ -3,6 +3,7 @@ using System.ComponentModel.DataAnnotations;
using BTCPayServer.ModelBinders;
using BTCPayServer.Models.AppViewModels;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Validation;
using Microsoft.AspNetCore.Mvc;
namespace BTCPayServer.Plugins.PayButton.Models
@ -34,7 +35,7 @@ namespace BTCPayServer.Plugins.PayButton.Models
public string ServerIpn { get; set; }
[Url]
public string BrowserRedirect { get; set; }
[EmailAddress]
[MailboxAddress]
public string NotifyEmail { get; set; }
public string StoreId { get; set; }

View file

@ -1,7 +1,10 @@
using System.ComponentModel.DataAnnotations;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Validation;
using MailKit.Net.Smtp;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using MimeKit;
namespace BTCPayServer.Services.Mails
@ -10,7 +13,29 @@ namespace BTCPayServer.Services.Mails
{
public bool IsComplete()
{
return !string.IsNullOrWhiteSpace(Server) && Port is int;
return MailboxAddressValidator.IsMailboxAddress(From)
&& !string.IsNullOrWhiteSpace(Server)
&& Port is int;
}
public void Validate(string prefixKey, ModelStateDictionary modelState)
{
if (string.IsNullOrWhiteSpace(From))
{
modelState.AddModelError($"{prefixKey}{nameof(From)}", new RequiredAttribute().FormatErrorMessage(nameof(From)));
}
if (!MailboxAddressValidator.IsMailboxAddress(From))
{
modelState.AddModelError($"{prefixKey}{nameof(From)}", MailboxAddressAttribute.ErrorMessageConst);
}
if (string.IsNullOrWhiteSpace(Server))
{
modelState.AddModelError($"{prefixKey}{nameof(Server)}", new RequiredAttribute().FormatErrorMessage(nameof(Server)));
}
if (Port is null)
{
modelState.AddModelError($"{prefixKey}{nameof(Port)}", new RequiredAttribute().FormatErrorMessage(nameof(Port)));
}
}
public MimeMessage CreateMailMessage(MailboxAddress to, string subject, string message, bool isHtml) =>
@ -30,12 +55,11 @@ namespace BTCPayServer.Services.Mails
var mm = new MimeMessage();
mm.Body = bodyBuilder.ToMessageBody();
mm.Subject = subject;
mm.From.Add(new MailboxAddress(From, !string.IsNullOrWhiteSpace(FromDisplay) ? From : FromDisplay));
mm.From.Add(MailboxAddressValidator.Parse(From));
mm.To.AddRange(to);
mm.Cc.AddRange(cc?? System.Array.Empty<InternetAddress>());
mm.Bcc.AddRange(bcc?? System.Array.Empty<InternetAddress>());
mm.Cc.AddRange(cc ?? System.Array.Empty<InternetAddress>());
mm.Bcc.AddRange(bcc ?? System.Array.Empty<InternetAddress>());
return mm;
}
public async Task<SmtpClient> CreateSmtpClient()

View file

@ -1,18 +0,0 @@
using System;
using System.Text.RegularExpressions;
namespace BTCPayServer.Validation
{
public class EmailValidator
{
static Regex _Email;
public static bool IsEmail(string str)
{
if (String.IsNullOrWhiteSpace(str))
return false;
if (_Email == null)
_Email = new Regex("^((([a-z]|\\d|[!#\\$%&'\\*\\+\\-\\/=\\?\\^_`{\\|}~]|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])+(\\.([a-z]|\\d|[!#\\$%&'\\*\\+\\-\\/=\\?\\^_`{\\|}~]|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])+)*)|((\\x22)((((\\x20|\\x09)*(\\x0d\\x0a))?(\\x20|\\x09)+)?(([\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]|\\x21|[\\x23-\\x5b]|[\\x5d-\\x7e]|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])|(\\\\([\\x01-\\x09\\x0b\\x0c\\x0d-\\x7f]|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF]))))*(((\\x20|\\x09)*(\\x0d\\x0a))?(\\x20|\\x09)+)?(\\x22)))@((([a-z]|\\d|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])|(([a-z]|\\d|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])([a-z]|\\d|-|\\.|_|~|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])*([a-z]|\\d|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])))\\.)+(([a-z]|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])|(([a-z]|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])([a-z]|\\d|-|\\.|_|~|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])*([a-z]|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])))\\.?$", RegexOptions.IgnoreCase | RegexOptions.ExplicitCapture | RegexOptions.Compiled, TimeSpan.FromSeconds(2.0));
return _Email.IsMatch(str);
}
}
}

View file

@ -0,0 +1,25 @@
using System.ComponentModel.DataAnnotations;
namespace BTCPayServer.Validation
{
/// <summary>
/// Validate address in the format "Firstname Lastname <blah@example.com>" See rfc822
/// </summary>
public class MailboxAddressAttribute : ValidationAttribute
{
public MailboxAddressAttribute()
{
ErrorMessage = ErrorMessageConst;
}
public const string ErrorMessageConst = "Invalid mailbox address. Some valid examples are: 'test@example.com' or 'Firstname Lastname <test@example.com>'";
protected override ValidationResult IsValid(object value, ValidationContext validationContext)
{
if (value is null)
return ValidationResult.Success;
var str = value as string;
if (MailboxAddressValidator.IsMailboxAddress(str))
return ValidationResult.Success;
return new ValidationResult(ErrorMessage);
}
}
}

View file

@ -0,0 +1,39 @@
#nullable enable
using System;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics.CodeAnalysis;
using System.Text.RegularExpressions;
using MimeKit;
namespace BTCPayServer
{
/// <summary>
/// Validate address in the format "Firstname Lastname <blah@example.com>" See rfc822
/// </summary>
public class MailboxAddressValidator
{
static ParserOptions _options;
static MailboxAddressValidator()
{
_options = ParserOptions.Default.Clone();
_options.AllowAddressesWithoutDomain = false;
}
public static bool IsMailboxAddress(string? str)
{
return TryParse(str, out _);
}
public static MailboxAddress Parse(string? str)
{
if (!TryParse(str, out var mb))
throw new FormatException("Invalid mailbox address (rfc822)");
return mb;
}
public static bool TryParse(string? str, [MaybeNullWhen(false)] out MailboxAddress mailboxAddress)
{
mailboxAddress = null;
if (String.IsNullOrWhiteSpace(str))
return false;
return MailboxAddress.TryParse(_options, str, out mailboxAddress) && mailboxAddress is not null;
}
}
}

View file

@ -39,17 +39,9 @@
<input asp-for="Settings.Port" data-fill="port" class="form-control"/>
<span asp-validation-for="Settings.Port" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Settings.FromDisplay" class="form-label">Sender's display name</label>
<input asp-for="Settings.FromDisplay" class="form-control"/>
<small class="form-text text-muted">
Some email providers (like Gmail) don't allow you to set your display name, so this setting may not have any effect.
</small>
<span asp-validation-for="Settings.FromDisplay" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Settings.From" class="form-label">Sender's Email Address</label>
<input asp-for="Settings.From" class="form-control"/>
<input asp-for="Settings.From" class="form-control" placeholder="Firstname Lastname <email@example.com>" />
<span asp-validation-for="Settings.From" class="text-danger"></span>
</div>
<div class="form-group">
@ -89,7 +81,7 @@
To test your settings, enter an email address below.
</p>
<label asp-for="TestEmail" class="form-label"></label>
<input asp-for="TestEmail" class="form-control" />
<input asp-for="TestEmail" placeholder="Firstname Lastname <email@example.com>" class="form-control" />
<span asp-validation-for="TestEmail" class="text-danger"></span>
</div>
<button type="submit" class="btn btn-secondary mt-2" name="command" value="Test" id="Test">Send Test Email</button>

View file

@ -73,7 +73,7 @@
</div>
<div class="form-group">
<label asp-for="Email" class="form-label"></label>
<input type="email" asp-for="Email" class="form-control" />
<input type="email" asp-for="Email" placeholder="Firstname Lastname <email@example.com>" class="form-control" />
<span asp-validation-for="Email" class="text-danger"></span>
<p id="PaymentRequestEmailHelpBlock" class="form-text text-muted">
Receive updates for this payment request.