Store Emails (#3611)

* Store Emails

* fix test

* Update email rules layout

* Cleanups

* Test cleanups

* Add back comments

* Update view; add test

* Show email rules link even if email settings aren't completed

* Validate email addresses

* No redirect, display warning

* Fix test

* Refactoring: Change email argument types to MailAddress

* Test fix

* Refactoring: Use MailboxAddress

* Parse emails properly in controllers and backend

Co-authored-by: Dennis Reimann <mail@dennisreimann.de>
Co-authored-by: nicolas.dorier <nicolas.dorier@gmail.com>
This commit is contained in:
Andrew Camilleri 2022-06-22 05:05:32 +02:00 committed by GitHub
parent af93cf2adb
commit c2d72e71aa
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 429 additions and 120 deletions

View file

@ -1,7 +1,3 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace BTCPayServer.Client.Models
{
public enum WebhookEventType

View file

@ -22,13 +22,12 @@ namespace BTCPayServer.Tests
[Fact(Timeout = TestTimeout)]
public async Task CanHandleRefundEmailForm()
{
using var s = CreateSeleniumTester();
await s.StartAsync();
s.GoToRegister();
s.RegisterNewUser();
s.CreateNewStore();
s.AddDerivationScheme("BTC");
s.AddDerivationScheme();
s.GoToStore(StoreNavPages.CheckoutAppearance);
s.Driver.FindElement(By.Id("RequiresRefundEmail")).Click();
s.Driver.FindElement(By.Name("command")).Click();
@ -70,14 +69,13 @@ namespace BTCPayServer.Tests
[Fact(Timeout = TestTimeout)]
public async Task CanHandleRefundEmailForm2()
{
using var s = CreateSeleniumTester();
// Prepare user account and store
await s.StartAsync();
s.GoToRegister();
s.RegisterNewUser();
s.CreateNewStore();
s.AddDerivationScheme("BTC");
s.AddDerivationScheme();
// Now create an invoice that requires a refund email
var invoice = s.CreateInvoice(100, "USD", "", null, true);
@ -135,7 +133,7 @@ namespace BTCPayServer.Tests
s.GoToRegister();
s.RegisterNewUser();
s.CreateNewStore();
s.AddDerivationScheme("BTC");
s.AddDerivationScheme();
var invoiceId = s.CreateInvoice();
s.GoToInvoiceCheckout(invoiceId);
@ -166,7 +164,7 @@ namespace BTCPayServer.Tests
s.RegisterNewUser(true);
s.CreateNewStore();
s.AddLightningNode();
s.AddDerivationScheme("BTC");
s.AddDerivationScheme();
var invoiceId = s.CreateInvoice(defaultPaymentMethod: "BTC_LightningLike");
s.GoToInvoiceCheckout(invoiceId);

View file

@ -2353,10 +2353,8 @@ namespace BTCPayServer.Tests
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await client.RemoveStoreUser(user.StoreId, user.UserId));
await AssertAPIError("store-user-role-orphaned", async () => await user2Client.RemoveStoreUser(user.StoreId, user2.UserId));
}
[Fact(Timeout = 60 * 2 * 1000)]
[Trait("Integration", "Integration")]
public async Task StoreEmailTests()
@ -2369,7 +2367,7 @@ namespace BTCPayServer.Tests
await adminClient.UpdateStoreEmailSettings(admin.StoreId,
new EmailSettingsData());
var data = new EmailSettingsData()
var data = new EmailSettingsData
{
From = "admin@admin.com",
Login = "admin@admin.com",
@ -2382,11 +2380,10 @@ namespace BTCPayServer.Tests
Assert.Equal(JsonConvert.SerializeObject(s), JsonConvert.SerializeObject(data));
await AssertValidationError(new[] { nameof(EmailSettingsData.From) },
async () => await adminClient.UpdateStoreEmailSettings(admin.StoreId,
new EmailSettingsData() { From = "ass" }));
new EmailSettingsData { From = "invalid" }));
await adminClient.SendEmail(admin.StoreId,
new SendEmailRequest() { Body = "lol", Subject = "subj", Email = "sdasdas" });
new SendEmailRequest { Body = "lol", Subject = "subj", Email = "to@example.org" });
}
[Fact(Timeout = 60 * 2 * 1000)]

View file

@ -319,6 +319,8 @@ namespace BTCPayServer.Tests
using var s = CreateSeleniumTester();
await s.StartAsync();
s.RegisterNewUser(true);
// Server Emails
s.Driver.Navigate().GoToUrl(s.Link("/server/emails"));
if (s.Driver.PageSource.Contains("Configured"))
{
@ -327,8 +329,30 @@ namespace BTCPayServer.Tests
}
CanSetupEmailCore(s);
s.CreateNewStore();
s.GoToUrl($"stores/{s.StoreId}/emails");
// Store Emails
s.GoToStore(StoreNavPages.Emails);
s.Driver.FindElement(By.Id("ConfigureEmailRules")).Click();
Assert.Contains("You need to configure email settings before this feature works", s.Driver.PageSource);
s.GoToStore(StoreNavPages.Emails);
CanSetupEmailCore(s);
// Store Email Rules
s.Driver.FindElement(By.Id("ConfigureEmailRules")).Click();
Assert.Contains("There are no rules yet.", s.Driver.PageSource);
Assert.DoesNotContain("id=\"SaveEmailRules\"", s.Driver.PageSource);
Assert.DoesNotContain("You need to configure email settings before this feature works", s.Driver.PageSource);
s.Driver.FindElement(By.Id("CreateEmailRule")).Click();
var select = new SelectElement(s.Driver.FindElement(By.Id("Rules_0__Trigger")));
select.SelectByText("InvoiceSettled", true);
s.Driver.FindElement(By.Id("Rules_0__To")).SendKeys("test@gmail.com");
s.Driver.FindElement(By.Id("Rules_0__CustomerEmail")).Click();
s.Driver.FindElement(By.Id("Rules_0__Subject")).SendKeys("Thanks!");
s.Driver.FindElement(By.Id("Rules_0__Body")).SendKeys("Your invoice is settled");
s.Driver.FindElement(By.Id("SaveEmailRules")).Click();
Assert.Contains("Store email rules saved", s.FindAlertMessage().Text);
}
[Fact(Timeout = TestTimeout)]

View file

@ -2628,7 +2628,7 @@ namespace BTCPayServer.Tests
Assert.Equal("admin@admin.com", (await Assert.IsType<ServerEmailSender>(await emailSenderFactory.GetEmailSender()).GetEmailSettings()).Login);
Assert.Null(await Assert.IsType<StoreEmailSender>(await emailSenderFactory.GetEmailSender(acc.StoreId)).GetEmailSettings());
Assert.IsType<RedirectToActionResult>(await acc.GetController<UIStoresController>().Emails(acc.StoreId, new EmailsViewModel(new EmailSettings()
Assert.IsType<RedirectToActionResult>(await acc.GetController<UIStoresController>().StoreEmailSettings(acc.StoreId, new EmailsViewModel(new EmailSettings()
{
From = "store@store.com",
Login = "store@store.com",

View file

@ -1,5 +1,4 @@
#nullable enable
using System;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
@ -12,6 +11,7 @@ using BTCPayServer.Validation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using MimeKit;
namespace BTCPayServer.Controllers.GreenField
{
@ -39,13 +39,17 @@ namespace BTCPayServer.Controllers.GreenField
{
return this.CreateAPIError(404, "store-not-found", "The store was not found");
}
if (!MailboxAddress.TryParse(request.Email, out MailboxAddress to))
{
ModelState.AddModelError(nameof(request.Email), "Invalid email");
return this.CreateValidationError(ModelState);
}
var emailSender = await _emailSenderFactory.GetEmailSender(storeId);
if (emailSender is null )
if (emailSender is null)
{
return this.CreateAPIError(404,"smtp-not-configured", "Store does not have an SMTP server configured.");
}
emailSender.SendEmail(request.Email, request.Subject, request.Body);
emailSender.SendEmail(to, request.Subject, request.Body);
return Ok();
}

View file

@ -18,6 +18,7 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Logging;
using MimeKit;
namespace BTCPayServer.Controllers
{
@ -164,8 +165,8 @@ namespace BTCPayServer.Controllers
var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
var callbackUrl = _linkGenerator.EmailConfirmationLink(user.Id, code, Request.Scheme, Request.Host, Request.PathBase);
var email = user.Email;
(await _EmailSenderFactory.GetEmailSender()).SendEmailConfirmation(email, callbackUrl);
var address = new MailboxAddress(user.UserName, user.Email);
(await _EmailSenderFactory.GetEmailSender()).SendEmailConfirmation(address, callbackUrl);
TempData[WellKnownTempData.SuccessMessage] = "Verification email sent. Please check your email.";
return RedirectToAction(nameof(Index));
}

View file

@ -15,6 +15,7 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Routing;
using MimeKit;
namespace BTCPayServer.Controllers
{
@ -302,7 +303,8 @@ namespace BTCPayServer.Controllers
var code = await _UserManager.GenerateEmailConfirmationTokenAsync(user);
var callbackUrl = _linkGenerator.EmailConfirmationLink(user.Id, code, Request.Scheme, Request.Host, Request.PathBase);
(await _emailSenderFactory.GetEmailSender()).SendEmailConfirmation(user.Email, callbackUrl);
var address = new MailboxAddress(user.UserName, user.Email);
(await _emailSenderFactory.GetEmailSender()).SendEmailConfirmation(address, callbackUrl);
TempData[WellKnownTempData.SuccessMessage] = "Verification email sent";
return RedirectToAction(nameof(ListUsers));

View file

@ -1006,7 +1006,6 @@ namespace BTCPayServer.Controllers
[HttpPost]
public async Task<IActionResult> Emails(EmailsViewModel model, string command)
{
if (command == "Test")
{
try
@ -1021,13 +1020,18 @@ namespace BTCPayServer.Controllers
TempData[WellKnownTempData.ErrorMessage] = "Required fields missing";
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(new MailboxAddress(model.TestEmail, model.TestEmail), "BTCPay test", "BTCPay test", false))
using (var message = model.Settings.CreateMailMessage(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";
TempData[WellKnownTempData.SuccessMessage] = $"Email sent to {model.TestEmail}. Please verify you received it.";
}
catch (Exception ex)
{
@ -1035,7 +1039,7 @@ namespace BTCPayServer.Controllers
}
return View(model);
}
else if (command == "ResetPassword")
if (command == "ResetPassword")
{
var settings = await _SettingsRepository.GetSettingAsync<EmailSettings>() ?? new EmailSettings();
settings.Password = null;
@ -1043,7 +1047,7 @@ namespace BTCPayServer.Controllers
TempData[WellKnownTempData.SuccessMessage] = "Email server password reset";
return RedirectToAction(nameof(Emails));
}
else // if(command == "Save")
else // if (command == "Save")
{
var oldSettings = await _SettingsRepository.GetSettingAsync<EmailSettings>() ?? new EmailSettings();
if (new EmailsViewModel(oldSettings).PasswordSet)

View file

@ -1,7 +1,11 @@
using System;
using System.Net.Mail;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Models.ServerViewModels;
using BTCPayServer.Services.Mails;
@ -12,9 +16,84 @@ namespace BTCPayServer.Controllers
{
public partial class UIStoresController
{
[HttpGet("{storeId}/emails")]
public IActionResult StoreEmails(string storeId)
{
var store = HttpContext.GetStoreData();
if (store == null)
return NotFound();
var blob = store.GetStoreBlob();
var data = blob.EmailSettings;
if (data?.IsComplete() is not true)
{
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Warning,
Html = $"You need to configure email settings before this feature works. <a class='alert-link' href='{Url.Action("StoreEmailSettings", new {storeId})}'>Configure now</a>."
});
}
[Route("{storeId}/emails")]
public IActionResult Emails()
var vm = new StoreEmailRuleViewModel { Rules = blob.EmailRules ?? new List<StoreEmailRule>() };
return View(vm);
}
[HttpPost("{storeId}/emails")]
public async Task<IActionResult> StoreEmails(string storeId, StoreEmailRuleViewModel vm, string command)
{
vm.Rules ??= new List<StoreEmailRule>();
if (command.StartsWith("remove", StringComparison.InvariantCultureIgnoreCase))
{
var item = command[(command.IndexOf(":", StringComparison.InvariantCultureIgnoreCase) + 1)..];
var index = int.Parse(item);
vm.Rules.RemoveAt(index);
return View(vm);
}
if (command == "add")
{
vm.Rules.Add(new StoreEmailRule());
return View(vm);
}
if (!ModelState.IsValid)
{
return View(vm);
}
var store = HttpContext.GetStoreData();
if (store == null)
return NotFound();
var blob = store.GetStoreBlob();
blob.EmailRules = vm.Rules;
store.SetStoreBlob(blob);
await _Repo.UpdateStore(store);
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Success,
Message = "Store email rules saved"
});
return RedirectToAction("StoreEmails", new {storeId});
}
public class StoreEmailRuleViewModel
{
public List<StoreEmailRule> Rules { get; set; }
}
public class StoreEmailRule
{
[Required]
public WebhookEventType Trigger { get; set; }
public bool CustomerEmail { get; set; }
public string To { get; set; }
public string Body { get; set; }
public string Subject { get; set; }
}
[HttpGet("{storeId}/email-settings")]
public IActionResult StoreEmailSettings()
{
var store = HttpContext.GetStoreData();
if (store == null)
@ -23,13 +102,13 @@ namespace BTCPayServer.Controllers
return View(new EmailsViewModel(data));
}
[Route("{storeId}/emails")]
[HttpPost]
public async Task<IActionResult> Emails(string storeId, EmailsViewModel model, string command)
[HttpPost("{storeId}/email-settings")]
public async Task<IActionResult> StoreEmailSettings(string storeId, EmailsViewModel model, string command)
{
var store = HttpContext.GetStoreData();
if (store == null)
return NotFound();
if (command == "Test")
{
try
@ -43,11 +122,16 @@ namespace BTCPayServer.Controllers
TempData[WellKnownTempData.ErrorMessage] = "Required fields missing";
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(new MailboxAddress(model.TestEmail, model.TestEmail), "BTCPay test", "BTCPay test", false);
var message = model.Settings.CreateMailMessage(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";
TempData[WellKnownTempData.SuccessMessage] = $"Email sent to {model.TestEmail}. Please verify you received it.";
}
catch (Exception ex)
{
@ -55,23 +139,19 @@ namespace BTCPayServer.Controllers
}
return View(model);
}
else if (command == "ResetPassword")
if (command == "ResetPassword")
{
var storeBlob = store.GetStoreBlob();
storeBlob.EmailSettings.Password = null;
store.SetStoreBlob(storeBlob);
await _Repo.UpdateStore(store);
TempData[WellKnownTempData.SuccessMessage] = "Email server password reset";
return RedirectToAction(nameof(Emails), new
{
storeId
});
return RedirectToAction(nameof(StoreEmailSettings), new { storeId });
}
else // if(command == "Save")
else // if (command == "Save")
{
var storeBlob = store.GetStoreBlob();
var oldPassword = storeBlob.EmailSettings?.Password;
if (new EmailsViewModel(storeBlob.EmailSettings).PasswordSet)
if (new EmailsViewModel(storeBlob.EmailSettings).PasswordSet && storeBlob.EmailSettings != null)
{
model.Settings.Password = storeBlob.EmailSettings.Password;
}
@ -79,10 +159,7 @@ namespace BTCPayServer.Controllers
store.SetStoreBlob(storeBlob);
await _Repo.UpdateStore(store);
TempData[WellKnownTempData.SuccessMessage] = "Email settings modified";
return RedirectToAction(nameof(Emails), new
{
storeId
});
return RedirectToAction(nameof(StoreEmailSettings), new { storeId });
}
}
}

View file

@ -6,6 +6,7 @@ using System.Linq;
using System.Text;
using BTCPayServer.Client.JsonConverters;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers;
using BTCPayServer.JsonConverters;
using BTCPayServer.Payments;
using BTCPayServer.Rating;
@ -179,6 +180,8 @@ namespace BTCPayServer.Data
[JsonConverter(typeof(TimeSpanJsonConverter.Days))]
public TimeSpan RefundBOLT11Expiration { get; set; }
public List<UIStoresController.StoreEmailRule> EmailRules { get; set; }
public IPaymentFilter GetExcludedPaymentMethods()
{
#pragma warning disable CS0618 // Type or member is obsolete

View file

@ -1,5 +1,6 @@
using System.Text.Encodings.Web;
using BTCPayServer.Services.Mails;
using MimeKit;
namespace BTCPayServer.Services
{
@ -16,19 +17,17 @@ namespace BTCPayServer.Services
return button;
}
public static void SendEmailConfirmation(this IEmailSender emailSender, string email, string link)
public static void SendEmailConfirmation(this IEmailSender emailSender, MailboxAddress address, string link)
{
emailSender.SendEmail(email, "Confirm your email",
emailSender.SendEmail(address, "Confirm your email",
$"Please confirm your account by clicking this link: <a href='{HtmlEncoder.Default.Encode(link)}'>link</a>");
}
public static void SendSetPasswordConfirmation(this IEmailSender emailSender, string email, string link, bool newPassword)
public static void SendSetPasswordConfirmation(this IEmailSender emailSender, MailboxAddress address, string link, bool newPassword)
{
var subject = $"{(newPassword ? "Set" : "Update")} Password";
var body = $"A request has been made to {(newPassword ? "set" : "update")} your BTCPay Server password. Please confirm your password by clicking below. <br/><br/> {CallToAction(subject, HtmlEncoder.Default.Encode(link))}";
emailSender.SendEmail(email,
subject,
$"<html><body style='{BODY_STYLE}'>{HEADER_HTML}{body}</body></html>");
emailSender.SendEmail(address, subject, $"<html><body style='{BODY_STYLE}'>{HEADER_HTML}{body}</body></html>");
}
}
}

View file

@ -5,16 +5,14 @@ using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Logging;
using BTCPayServer.Payments;
using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Mails;
using BTCPayServer.Services.Stores;
using Microsoft.Extensions.Hosting;
using MimeKit;
using NBitpayClient;
using NBXplorer;
using Newtonsoft.Json;
@ -122,7 +120,9 @@ namespace BTCPayServer.HostedServices
#pragma warning restore CS0618
}
if (sendMail && !String.IsNullOrEmpty(invoice.NotificationEmail))
if (sendMail &&
invoice.NotificationEmail is String e &&
MailboxAddress.TryParse(e, out MailboxAddress notificationEmail))
{
var json = NBitcoin.JsonConverters.Serializer.ToString(notification);
var store = await _StoreRepository.FindStore(invoice.StoreId);
@ -134,7 +134,7 @@ namespace BTCPayServer.HostedServices
$"<br><details><summary>Details</summary><pre>{json}</pre></details>";
(await _EmailSenderFactory.GetEmailSender(invoice.StoreId)).SendEmail(
invoice.NotificationEmail,
notificationEmail,
$"{storeName} Invoice Notification - ${invoice.StoreId}",
emailBody);
}

View file

@ -0,0 +1,72 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Controllers;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Services.Mails;
using BTCPayServer.Services.Stores;
using Microsoft.Extensions.Logging;
using MimeKit;
namespace BTCPayServer.HostedServices;
public class StoreEmailRuleProcessorSender : EventHostedServiceBase
{
private readonly StoreRepository _storeRepository;
private readonly EmailSenderFactory _emailSenderFactory;
public StoreEmailRuleProcessorSender(StoreRepository storeRepository, EventAggregator eventAggregator,
ILogger<InvoiceEventSaverService> logger,
EmailSenderFactory emailSenderFactory) : base(
eventAggregator, logger)
{
_storeRepository = storeRepository;
_emailSenderFactory = emailSenderFactory;
}
protected override void SubscribeToEvents()
{
Subscribe<InvoiceEvent>();
}
protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
{
if (evt is InvoiceEvent invoiceEvent)
{
var type = WebhookSender.GetWebhookEvent(invoiceEvent);
if (type is null)
{
return;
}
var store = await _storeRepository.FindStore(invoiceEvent.Invoice.StoreId);
var blob = store.GetStoreBlob();
if (blob.EmailRules?.Any() is true)
{
var actionableRules = blob.EmailRules.Where(rule => rule.Trigger == type.Type).ToList();
if (actionableRules.Any())
{
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))
{
dest = dest.Append(invoiceEvent.Invoice.Metadata.BuyerEmail);
}
var recipients = dest.Select(address => new MailboxAddress(address, address)).ToArray();
sender.SendEmail(recipients, null, null, actionableRule.Subject, actionableRule.Body);
}
}
}
}
}
private bool IsValidEmailAddress(string address) =>
!string.IsNullOrEmpty(address) && MailboxAddress.TryParse(address, out _);
}

View file

@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Logging;
using MimeKit;
namespace BTCPayServer.HostedServices
{
@ -39,6 +40,7 @@ namespace BTCPayServer.HostedServices
{
string code;
string callbackUrl;
MailboxAddress address;
UserPasswordResetRequestedEvent userPasswordResetRequestedEvent;
switch (evt)
{
@ -53,8 +55,9 @@ namespace BTCPayServer.HostedServices
new HostString(userRegisteredEvent.RequestUri.Host, userRegisteredEvent.RequestUri.Port),
userRegisteredEvent.RequestUri.PathAndQuery);
userRegisteredEvent.CallbackUrlGenerated?.SetResult(new Uri(callbackUrl));
(await _emailSenderFactory.GetEmailSender())
.SendEmailConfirmation(userRegisteredEvent.User.Email, callbackUrl);
address = new MailboxAddress(userRegisteredEvent.User.Email,
userRegisteredEvent.User.Email);
(await _emailSenderFactory.GetEmailSender()).SendEmailConfirmation(address, callbackUrl);
}
else if (!await _userManager.HasPasswordAsync(userRegisteredEvent.User))
{
@ -83,9 +86,10 @@ passwordSetter:
userPasswordResetRequestedEvent.RequestUri.Port),
userPasswordResetRequestedEvent.RequestUri.PathAndQuery);
userPasswordResetRequestedEvent.CallbackUrlGenerated?.SetResult(new Uri(callbackUrl));
address = new MailboxAddress(userPasswordResetRequestedEvent.User.Email,
userPasswordResetRequestedEvent.User.Email);
(await _emailSenderFactory.GetEmailSender())
.SendSetPasswordConfirmation(userPasswordResetRequestedEvent.User.Email, callbackUrl,
newPassword);
.SendSetPasswordConfirmation(address, callbackUrl, newPassword);
break;
}
}

View file

@ -169,7 +169,7 @@ namespace BTCPayServer.HostedServices
_processingQueue.Enqueue(context.WebhookId, (cancellationToken) => Process(context, cancellationToken));
}
private WebhookInvoiceEvent GetWebhookEvent(WebhookEventType webhookEventType)
public static WebhookInvoiceEvent GetWebhookEvent(WebhookEventType webhookEventType)
{
switch (webhookEventType)
{
@ -192,7 +192,7 @@ namespace BTCPayServer.HostedServices
}
}
private WebhookInvoiceEvent? GetWebhookEvent(InvoiceEvent invoiceEvent)
public static WebhookInvoiceEvent? GetWebhookEvent(InvoiceEvent invoiceEvent)
{
var eventCode = invoiceEvent.EventCode;
switch (eventCode)

View file

@ -332,6 +332,7 @@ namespace BTCPayServer.Hosting
services.AddSingleton<IHostedService, HostedServices.CheckConfigurationHostedService>(o => o.GetRequiredService<CheckConfigurationHostedService>());
services.AddSingleton<HostedServices.WebhookSender>();
services.AddSingleton<IHostedService, WebhookSender>(o => o.GetRequiredService<WebhookSender>());
services.AddSingleton<IHostedService, StoreEmailRuleProcessorSender>();
services.AddHttpClient(WebhookSender.OnionNamedClient)
.ConfigurePrimaryHttpMessageHandler<Socks5HttpClientHandler>();

View file

@ -1,10 +1,9 @@
using System;
using System.Net.Mail;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Logging;
using Microsoft.Extensions.Logging;
using MimeKit;
using NBitcoin;
namespace BTCPayServer.Services.Mails
{
@ -20,8 +19,13 @@ namespace BTCPayServer.Services.Mails
_JobClient = jobClient ?? throw new ArgumentNullException(nameof(jobClient));
}
public void SendEmail(string email, string subject, string message)
public void SendEmail(MailboxAddress email, string subject, string message)
{
SendEmail(new[] {email}, Array.Empty<MailboxAddress>(), Array.Empty<MailboxAddress>(), subject, message);
}
public void SendEmail(MailboxAddress[] email, MailboxAddress[] cc, MailboxAddress[] bcc, string subject, string message)
{
_JobClient.Schedule(async (cancellationToken) =>
{
var emailSettings = await GetEmailSettings();
@ -30,8 +34,9 @@ namespace BTCPayServer.Services.Mails
Logs.Configuration.LogWarning("Should have sent email, but email settings are not configured");
return;
}
using var smtp = await emailSettings.CreateSmtpClient();
var mail = emailSettings.CreateMailMessage(new MailboxAddress(email, email), subject, message, true);
var mail = emailSettings.CreateMailMessage(email, cc, bcc, subject, message, true);
await smtp.SendAsync(mail, cancellationToken);
await smtp.DisconnectAsync(true, cancellationToken);
}, TimeSpan.Zero);

View file

@ -13,7 +13,9 @@ namespace BTCPayServer.Services.Mails
return !string.IsNullOrWhiteSpace(Server) && Port is int;
}
public MimeMessage CreateMailMessage(MailboxAddress to, string subject, string message, bool isHtml)
public MimeMessage CreateMailMessage(MailboxAddress to, string subject, string message, bool isHtml) =>
CreateMailMessage(new[] {to}, null, null, subject, message, isHtml);
public MimeMessage CreateMailMessage(MailboxAddress[] to, MailboxAddress[] cc, MailboxAddress[] bcc, string subject, string message, bool isHtml)
{
var bodyBuilder = new BodyBuilder();
if (isHtml)
@ -25,11 +27,15 @@ namespace BTCPayServer.Services.Mails
bodyBuilder.TextBody = message;
}
return new MimeMessage(
from: new[] { new MailboxAddress(From, !string.IsNullOrWhiteSpace(FromDisplay) ? From : FromDisplay) },
to: new[] { to },
subject,
bodyBuilder.ToMessageBody());
var mm = new MimeMessage();
mm.Body = bodyBuilder.ToMessageBody();
mm.Subject = subject;
mm.From.Add(new MailboxAddress(From, !string.IsNullOrWhiteSpace(FromDisplay) ? From : FromDisplay));
mm.To.AddRange(to);
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,7 +1,10 @@
using MimeKit;
namespace BTCPayServer.Services.Mails
{
public interface IEmailSender
{
void SendEmail(string email, string subject, string message);
void SendEmail(MailboxAddress email, string subject, string message);
void SendEmail(MailboxAddress[] email, MailboxAddress[] cc, MailboxAddress[] bcc, string subject, string message);
}
}

View file

@ -1,19 +1,21 @@
@model BTCPayServer.Models.ServerViewModels.EmailsViewModel
<div class="row">
<div class="col-xl-8 col-xxl-constrain">
<div class="d-flex align-items-center justify-content-between mb-4">
<h3 class="mb-0">@ViewData["Title"]</h3>
<div class="dropdown only-for-js" id="quick-fill">
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" id="QuickFillDropdownToggle">
Quick Fill
</button>
<div class="dropdown-menu" aria-labelledby="QuickFillDropdownToggle">
<a class="dropdown-item" href="" data-server="smtp.gmail.com" data-port="587">Gmail.com</a>
<a class="dropdown-item" href="" data-server="mail.yahoo.com" data-port="587">Yahoo.com</a>
<a class="dropdown-item" href="" data-server="smtp.mailgun.org" data-port="587">Mailgun</a>
<a class="dropdown-item" href="" data-server="smtp.office365.com" data-port="587">Office365</a>
<a class="dropdown-item" href="" data-server="smtp.sendgrid.net" data-port="587">SendGrid</a>
<div class="col-xl-10 col-xxl-constrain">
<div class="d-flex flex-wrap gap-3 align-items-center justify-content-between mt-n1 mb-4">
<h3 class="mb-0">Email Server</h3>
<div class="d-flex">
<div class="dropdown only-for-js" id="quick-fill">
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" id="QuickFillDropdownToggle">
Quick Fill
</button>
<div class="dropdown-menu" aria-labelledby="QuickFillDropdownToggle">
<a class="dropdown-item" href="" data-server="smtp.gmail.com" data-port="587">Gmail.com</a>
<a class="dropdown-item" href="" data-server="mail.yahoo.com" data-port="587">Yahoo.com</a>
<a class="dropdown-item" href="" data-server="smtp.mailgun.org" data-port="587">Mailgun</a>
<a class="dropdown-item" href="" data-server="smtp.office365.com" data-port="587">Office365</a>
<a class="dropdown-item" href="" data-server="smtp.sendgrid.net" data-port="587">SendGrid</a>
</div>
</div>
</div>
</div>
@ -22,7 +24,7 @@
<form method="post" autocomplete="off">
<div class="row">
<div class="col-xl-8 col-xxl-constrain">
<div class="col-xl-10 col-xxl-constrain">
@if (!ViewContext.ModelState.IsValid)
{
<div asp-validation-summary="All" class="text-danger"></div>

View file

@ -2,5 +2,5 @@
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close">
<vc:icon symbol="close" />
</button>
The Email settings have not been configured on this server or store yet. Setting this field will not send emails until then. <a asp-action="Emails" asp-controller="UIStores" asp-route-storeId="@Model" class="alert-link">Configure store email settings</a>
The Email settings have not been configured on this server or store yet. Setting this field will not send emails until then. <a asp-action="StoreEmailSettings" asp-controller="UIStores" asp-route-storeId="@Model" class="alert-link">Configure store email settings</a>
</div>

View file

@ -1,6 +1,6 @@
@model EmailsViewModel
@{
ViewData.SetActivePage(ServerNavPages.Emails, "Email Server");
ViewData.SetActivePage(ServerNavPages.Emails, "Emails");
}
<partial name="EmailsBody" model="Model" />

View file

@ -87,30 +87,6 @@
<button name="command" type="submit" class="btn btn-primary mt-2" value="Save" id="Save">Save</button>
</form>
<h3 class="mt-5 mb-3">Services</h3>
<div class="table-responsive-md">
<table class="table table-hover mt-1 mb-5">
<thead>
<tr>
<th>Service</th>
<th class="text-end w-100px">Actions</th>
</tr>
</thead>
<tbody>
<tr>
<td>
Email
</td>
<td class="text-end">
<a asp-action="Emails" asp-route-storeId="@Context.GetRouteValue("storeId")">
Setup
</a>
</td>
</tr>
</tbody>
</table>
</div>
@if (Model.CanDelete)
{
<h3 class="mt-5 mb-3">Additional Actions</h3>

View file

@ -0,0 +1,26 @@
@model BTCPayServer.Models.ServerViewModels.EmailsViewModel
@{
Layout = "../Shared/_NavLayout.cshtml";
ViewData.SetActivePage(StoreNavPages.Emails, "Emails", Context.GetStoreData().Id);
}
<div class="row mb-4">
<div class="col-xl-10 col-xxl-constrain">
<div class="d-flex flex-wrap gap-3 align-items-center justify-content-between mt-n1 mb-4">
<h3 class="mb-0">Email Rules</h3>
<a class="btn btn-secondary" asp-action="StoreEmails" asp-controller="UIStores" asp-route-storeId="@Context.GetStoreData().Id" id="ConfigureEmailRules">
Configure
</a>
</div>
<p>
<a asp-action="StoreEmails" asp-controller="UIStores" asp-route-storeId="@Context.GetStoreData().Id">Email rules</a>
allow BTCPay Server to send customized emails from your store based on events.
</p>
</div>
</div>
<partial name="EmailsBody" model="Model" />
@section PageFootContent {
<partial name="_ValidationScriptsPartial" />
}

View file

@ -1,7 +1,7 @@
@model BTCPayServer.Models.ServerViewModels.EmailsViewModel
@{
Layout = "../Shared/_NavLayout.cshtml";
ViewData.SetActivePage(StoreNavPages.General, "Email Server", Context.GetStoreData().Id);
ViewData.SetActivePage(StoreNavPages.Emails, "Email Server", Context.GetStoreData().Id);
}
<partial name="EmailsBody" model="Model" />

View file

@ -0,0 +1,27 @@
@using Microsoft.AspNetCore.Server.Kestrel.Core
@model BTCPayServer.Models.ServerViewModels.EmailsViewModel
@{
Layout = "../Shared/_NavLayout.cshtml";
ViewData.SetActivePage(StoreNavPages.Emails, "Emails", Context.GetStoreData().Id);
}
<div class="row mb-4">
<div class="col-xl-10 col-xxl-constrain">
<div class="d-flex flex-wrap gap-3 align-items-center justify-content-between mt-n1 mb-4">
<h3 class="mb-0">Email Rules</h3>
<a class="btn btn-secondary" asp-action="StoreEmails" asp-controller="UIStores" asp-route-storeId="@Context.GetStoreData().Id" id="ConfigureEmailRules">
Configure
</a>
</div>
<p>
<a asp-action="StoreEmails" asp-controller="UIStores" asp-route-storeId="@Context.GetStoreData().Id">Email rules</a>
allow BTCPay Server to send customized emails from your store based on events.
</p>
</div>
</div>
<partial name="EmailsBody" model="Model" />
@section PageFootContent {
<partial name="_ValidationScriptsPartial" />
}

View file

@ -0,0 +1,80 @@
@using BTCPayServer.Client.Models
@model BTCPayServer.Controllers.UIStoresController.StoreEmailRuleViewModel
@{
Layout = "../Shared/_NavLayout.cshtml";
ViewData.SetActivePage(StoreNavPages.Emails, "Email Rules", Context.GetStoreData().Id);
}
<form class="row" asp-action="StoreEmails" asp-route-storeId="@Context.GetStoreData().Id">
<div class="col-xxl-constrain">
<div class="d-flex align-items-center justify-content-between mt-n1 mb-3">
<h3 class="mb-0">@ViewData["Title"]</h3>
<div class="d-flex gap-3">
@if (Model.Rules.Any())
{
<button class="btn btn-primary" name="command" type="submit" value="save" id="SaveEmailRules">
Save
</button>
}
<button class="btn btn-primary" name="command" type="submit" value="add" id="CreateEmailRule">
<span class="fa fa-plus"></span>
Create
</button>
</div>
</div>
<p class="mb-0">Email rules allow BTCPay Server to send customized emails from your store based on events.</p>
@if (Model.Rules.Any())
{
<ul class="list-group list-group-flush">
@for (var index = 0; index < Model.Rules.Count; index++)
{
<li class="list-group-item py-4 px-0">
<div class="form-group">
<div class="d-flex align-items-center justify-content-between gap-3">
<label asp-for="Rules[index].Trigger" class="form-label" data-required></label>
<button name="command" type="submit" value="remove:@index" class="d-inline-block btn text-danger btn-link p-0">
<span class="fa fa-times"></span> Remove this email rule
</button>
</div>
<select asp-for="Rules[index].Trigger" asp-items="@Html.GetEnumSelectList<WebhookEventType>()" class="form-select" required></select>
<small class="form-text text-muted">Choose what event sends the email.</small>
<span asp-validation-for="Rules[index].Trigger" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Rules[index].To" class="form-label">Recipients</label>
<input type="text" asp-for="Rules[index].To" class="form-control"/>
<small class="form-text text-muted">Who to send the email to. For multiple emails, separate with a comma.</small>
<span asp-validation-for="Rules[index].To" class="text-danger"></span>
</div>
<div class="form-check mb-4">
<input asp-for="Rules[index].CustomerEmail" type="checkbox" class="form-check-input" />
<label asp-for="Rules[index].CustomerEmail" class="form-check-label">Send the email to the buyer, if email was provided to the invoice</label>
<span asp-validation-for="Rules[index].CustomerEmail" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Rules[index].Subject" class="form-label" ></label>
<input type="text" asp-for="Rules[index].Subject" class="form-control"/>
<span asp-validation-for="Rules[index].Subject" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Rules[index].Body" class="form-label" ></label>
<textarea asp-for="Rules[index].Body" class="form-control" rows="4"></textarea>
<span asp-validation-for="Rules[index].Body" class="text-danger"></span>
</div>
</li>
}
</ul>
}
else
{
<p class="text-secondary mt-3">
There are no rules yet.
</p>
}
</div>
</form>
@section PageFootContent {
<partial name="_ValidationScriptsPartial"/>
}

View file

@ -20,6 +20,7 @@ namespace BTCPayServer.Views.Stores
PullPayments,
Payouts,
[Obsolete("Use StoreNavPages.Plugins instead")]
Integrations
Integrations,
Emails
}
}

View file

@ -18,6 +18,7 @@
<a permission="@Policies.CanModifyStoreSettings" id="SectionNav-@(nameof(StoreNavPages.Plugins))" class="nav-link @ViewData.IsActivePage(StoreNavPages.Plugins) || @ViewData.IsActivePage(StoreNavPages.Integrations)" asp-controller="UIStores" asp-action="Plugins" asp-route-storeId="@storeId">Plugins</a>
<a permission="@Policies.CanModifyStoreSettings" id="SectionNav-@(nameof(StoreNavPages.Webhooks))" class="nav-link @ViewData.IsActivePage(StoreNavPages.Webhooks)" asp-controller="UIStores" asp-action="Webhooks" asp-route-storeId="@storeId">Webhooks</a>
<a permission="@Policies.CanModifyStoreSettings" id="SectionNav-PayoutProcessors" class="nav-link @ViewData.IsActivePage("PayoutProcessors")" asp-controller="UIPayoutProcessors" asp-action="ConfigureStorePayoutProcessors" asp-route-storeId="@storeId">Payout Processors</a>
<a permission="@Policies.CanModifyStoreSettings" id="SectionNav-@(nameof(StoreNavPages.Emails))" class="nav-link @ViewData.IsActivePage(StoreNavPages.Emails)" asp-controller="UIStores" asp-action="StoreEmailSettings" asp-route-storeId="@storeId">Emails</a>
<vc:ui-extension-point location="store-nav" model="@Model"/>
</div>
</nav>