Add invite and confirmation emails

This commit is contained in:
Nicolas Dorier
2025-11-07 13:09:58 +09:00
parent 88d3e7ad55
commit d7fcd55707
17 changed files with 183 additions and 136 deletions

View File

@@ -220,7 +220,7 @@ namespace BTCPayServer.Controllers
}
var callbackUrl = await _callbackGenerator.ForEmailConfirmation(user, Request);
(await _EmailSenderFactory.GetEmailSender()).SendEmailConfirmation(user.GetMailboxAddress(), callbackUrl);
_eventAggregator.Publish(new UserEvent.ConfirmationEmailRequested(user, callbackUrl));
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Verification email sent. Please check your email."].Value;
return RedirectToAction(nameof(Index));
}

View File

@@ -405,9 +405,7 @@ namespace BTCPayServer.Controllers
}
var callbackUrl = await _callbackGenerator.ForEmailConfirmation(user, Request);
(await _emailSenderFactory.GetEmailSender()).SendEmailConfirmation(user.GetMailboxAddress(), callbackUrl);
_eventAggregator.Publish(new UserEvent.ConfirmationEmailRequested(user, callbackUrl));
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Verification email sent"].Value;
return RedirectToAction(nameof(ListUsers));
}

View File

@@ -24,6 +24,12 @@ public class UserEvent(ApplicationUser user)
{
public string ResetLink { get; } = resetLink;
}
public class ConfirmationEmailRequested(ApplicationUser user, string confirmLink) : UserEvent(user)
{
public string ConfirmLink { get; } = confirmLink;
}
public class Registered(ApplicationUser user, string approvalLink, string confirmationEmail) : UserEvent(user)
{
public string ApprovalLink { get; } = approvalLink;

View File

@@ -9,56 +9,16 @@ namespace BTCPayServer.Services
{
private static string BODY_STYLE = "font-family: Open Sans, Helvetica Neue,Arial,sans-serif; font-color: #292929;";
private static string HEADER_HTML = "<h1 style='font-size:1.2rem'>BTCPay Server</h1><br/>";
private static string BUTTON_HTML = "<a href='{button_link}' type='submit' style='min-width: 2em;min-height: 20px;text-decoration-line: none;cursor: pointer;display: inline-block;font-weight: 400;color: #fff;text-align: center;vertical-align: middle;user-select: none;background-color: #51b13e;border-color: #51b13e;border: 1px solid transparent;padding: 0.375rem 0.75rem;font-size: 1rem;line-height: 1.5;border-radius: 0.25rem;transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;'>{button_description}</a>";
private static string CallToAction(string actionName, string actionLink)
{
var button = $"{BUTTON_HTML}".Replace("{button_description}", actionName, System.StringComparison.InvariantCulture);
return button.Replace("{button_link}", HtmlEncoder.Default.Encode(actionLink), System.StringComparison.InvariantCulture);
}
private static string CreateEmailBody(string body)
{
return $"<html><body style='{BODY_STYLE}'>{HEADER_HTML}{body}</body></html>";
}
public static void SendEmailConfirmation(this IEmailSender emailSender, MailboxAddress address, string link)
{
emailSender.SendEmail(address, "Confirm your email", CreateEmailBody(
$"Please confirm your account.<br/><br/>{CallToAction("Confirm Email", link)}"));
}
public static void SendApprovalConfirmation(this IEmailSender emailSender, MailboxAddress address, string link)
{
emailSender.SendEmail(address, "Your account has been approved", CreateEmailBody(
$"Your account has been approved and you can now <a href='{HtmlEncoder.Default.Encode(link)}'>login here</a>."));
}
public static void SendInvitation(this IEmailSender emailSender, MailboxAddress address, string link)
{
emailSender.SendEmail(address, "Invitation", CreateEmailBody(
$"<p>Please complete your account setup by clicking <a href='{HtmlEncoder.Default.Encode(link)}'>this link</a>.</p><p>You can also use the BTCPay Server app and scan this QR code when connecting:</p>{GetQrCodeImg(link)}"));
}
public static void SendNewUserInfo(this IEmailSender emailSender, MailboxAddress address, string newUserInfo, string link)
{
emailSender.SendEmail(address, newUserInfo, CreateEmailBody(
$"{newUserInfo}. You can verify and approve the account here: <a href='{HtmlEncoder.Default.Encode(link)}'>User details</a>"));
}
public static void SendUserInviteAcceptedInfo(this IEmailSender emailSender, MailboxAddress address, string userInfo, string link)
{
emailSender.SendEmail(address, userInfo, CreateEmailBody(
$"{userInfo}. You can view the store users here: <a href='{HtmlEncoder.Default.Encode(link)}'>Store users</a>"));
}
private static string GetQrCodeImg(string data)
{
using var qrGenerator = new QRCodeGenerator();
using var qrCodeData = qrGenerator.CreateQrCode(data, QRCodeGenerator.ECCLevel.Q);
using var qrCode = new Base64QRCode(qrCodeData);
var base64 = qrCode.GetGraphic(20);
return $"<img src='data:image/png;base64,{base64}' alt='{data}' width='320' height='320'/>";
}
}
}

View File

@@ -436,7 +436,6 @@ o.GetRequiredService<IEnumerable<IPaymentLinkExtension>>().ToDictionary(o => o.P
services.AddSingleton<IHostedService, AppInventoryUpdaterHostedService>();
services.AddSingleton<IHostedService, TransactionLabelMarkerHostedService>();
services.AddSingleton<IHostedService, OnChainRateTrackerHostedService>();
services.AddSingleton<IHostedService, UserEventHostedService>();
services.AddSingleton<IHostedService, DynamicDnsHostedService>();
services.AddSingleton<PaymentRequestStreamer>();
services.AddSingleton<IHostedService>(s => s.GetRequiredService<PaymentRequestStreamer>());

View File

@@ -1,5 +1,6 @@
using System.Collections.Generic;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Plugins.Emails.HostedServices;
using BTCPayServer.Plugins.Emails.Views;
using BTCPayServer.Plugins.Webhooks;
using BTCPayServer.Services;
@@ -19,6 +20,7 @@ public class EmailsPlugin : BaseBTCPayServerPlugin
{
services.AddSingleton<IDefaultTranslationProvider, EmailsTranslationProvider>();
services.AddSingleton<IHostedService, StoreEmailRuleProcessorSender>();
services.AddSingleton<IHostedService, UserEventHostedService>();
RegisterServerEmailTriggers(services);
}
private static string BODY_STYLE = "font-family: Open Sans, Helvetica Neue,Arial,sans-serif; font-color: #292929;";
@@ -50,8 +52,67 @@ public class EmailsPlugin : BaseBTCPayServerPlugin
Description = "Password Reset Requested",
};
vms.Add(vm);
vm = new EmailTriggerViewModel()
{
Trigger = ServerMailTriggers.EmailConfirm,
RecipientExample = "{User.MailboxAddress}",
SubjectExample = "Confirm your email",
BodyExample = CreateEmailBody($"Please confirm your account.<br/><br/>{CallToAction("Confirm Email", "{ConfirmLink}")}"),
PlaceHolders = new()
{
new ("{ConfirmLink}", "The link used to confirm the user's email address")
},
Description = "Confirm new user's email",
};
vms.Add(vm);
vm = new EmailTriggerViewModel()
{
Trigger = ServerMailTriggers.InvitePending,
RecipientExample = "{User.MailboxAddress}",
SubjectExample = "Invitation to join {Branding.ServerName}",
BodyExample = CreateEmailBody($"<p>Please complete your account setup by clicking <a href='{{InvitationLink}}'>this link</a>.</p><p>You can also use the BTCPay Server app and scan this QR code when connecting:</p>{{InvitationLinkQR}}"),
PlaceHolders = new()
{
new ("{InvitationLink}", "The link where the invited user can set up their account"),
new ("{InvitationLinkQR}", "The QR code representation of the invitation link")
},
Description = "User invitation email",
};
vms.Add(vm);
vm = new EmailTriggerViewModel()
{
Trigger = ServerMailTriggers.ApprovalConfirmed,
RecipientExample = "{User.MailboxAddress}",
SubjectExample = "Your account has been approved",
BodyExample = CreateEmailBody($"Your account has been approved and you can now.<br/><br/>{CallToAction("Login here", "{LoginLink}")}"),
PlaceHolders = new()
{
new ("{LoginLink}", "The link that the user can use to login"),
},
Description = "User account approved",
};
vms.Add(vm);
vm = new EmailTriggerViewModel()
{
Trigger = ServerMailTriggers.ApprovalRequest,
RecipientExample = "{Admin.MailboxAddresses}",
SubjectExample = "Approval request to access the server for {User.Email}",
BodyExample = CreateEmailBody($"A new user ({{User.MailboxAddress}}), is awaiting approval to access the server.<br/><br/>{CallToAction("Approve", "{ApprovalLink}")}"),
PlaceHolders = new()
{
new ("{ApprovalLink}", "The link that the admin needs to use to approve the user"),
},
Description = "Approval request to administrators",
};
vms.Add(vm);
var commonPlaceholders = new List<EmailTriggerViewModel.PlaceHolder>()
{
new("{Admins.MailboxAddresses}", "The email addresses of the admins separated by a comma (eg. ,)"),
new("{User.Name}", "The name of the user (eg. John Doe)"),
new("{User.Email}", "The email of the user (eg. john.doe@example.com)"),
new("{User.MailboxAddress}", "The formatted mailbox address to use when sending an email. (eg. \"John Doe\" <john.doe@example.com>)"),

View File

@@ -1,6 +1,8 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Mail;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Data;
@@ -10,7 +12,7 @@ using Microsoft.Extensions.Logging;
using MimeKit;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Plugins.Emails;
namespace BTCPayServer.Plugins.Emails.HostedServices;
public interface ITriggerOwner
{
@@ -65,14 +67,30 @@ public class StoreEmailRuleProcessorSender(
var subject = new TextTemplate(actionableRule.Subject ?? "");
matchedContext.Recipients.AddRange(
actionableRule.To
.Select(o =>
.SelectMany(o =>
{
if (!MailboxAddressValidator.TryParse(o, out var oo))
if (MailboxAddressValidator.TryParse(o, out var oo))
return new[] { oo };
var emails = new TextTemplate(o).Render(triggEvent.Model);
MailAddressCollection mailCollection = new();
try
{
MailboxAddressValidator.TryParse(new TextTemplate(o).Render(triggEvent.Model), out oo);
mailCollection.Add(emails);
}
catch (FormatException)
{
return Array.Empty<MailboxAddress>();
}
return mailCollection.Select(a =>
{
MailboxAddressValidator.TryParse(a.ToString(), out oo);
return oo;
})
.Where(a => a != null)
.ToArray();
})
.Where(o => o != null)!);
if (triggEvent.Owner is not null)

View File

@@ -1,10 +1,12 @@
#nullable enable
using System.Linq;
using System.Text.Encodings.Web;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.HostedServices;
using BTCPayServer.Logging;
using BTCPayServer.Plugins.Emails;
using BTCPayServer.Services;
using BTCPayServer.Services.Mails;
using BTCPayServer.Services.Notifications;
@@ -13,13 +15,13 @@ using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json.Linq;
using QRCoder;
namespace BTCPayServer.HostedServices;
namespace BTCPayServer.Plugins.Emails.HostedServices;
public class UserEventHostedService(
EventAggregator eventAggregator,
UserManager<ApplicationUser> userManager,
CallbackGenerator callbackGenerator,
ISettingsAccessor<ServerSettings> serverSettings,
EmailSenderFactory emailSenderFactory,
NotificationSender notificationSender,
@@ -28,62 +30,64 @@ public class UserEventHostedService(
: EventHostedServiceBase(eventAggregator, logs)
{
public UserManager<ApplicationUser> UserManager { get; } = userManager;
public CallbackGenerator CallbackGenerator { get; } = callbackGenerator;
protected override void SubscribeToEvents()
{
Subscribe<UserEvent.Registered>();
Subscribe<UserEvent.Invited>();
Subscribe<UserEvent.Approved>();
Subscribe<UserEvent.ConfirmedEmail>();
Subscribe<UserEvent.PasswordResetRequested>();
Subscribe<UserEvent.InviteAccepted>();
SubscribeAny<UserEvent>();
}
public static string GetQrCodeImg(string data)
{
using var qrGenerator = new QRCodeGenerator();
using var qrCodeData = qrGenerator.CreateQrCode(data, QRCodeGenerator.ECCLevel.Q);
using var qrCode = new Base64QRCode(qrCodeData);
var base64 = qrCode.GetGraphic(20);
return $"<img src='data:image/png;base64,{base64}' alt='{data}' width='320' height='320'/>";
}
protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
{
ApplicationUser user = (evt as UserEvent).User;
IEmailSender emailSender;
var user = (evt as UserEvent)?.User;
if (user is null) return;
switch (evt)
{
case UserEvent.Registered ev:
// can be either a self-registration or by invite from another user
var type = await UserManager.IsInRoleAsync(user, Roles.ServerAdmin) ? "admin" : "user";
var info = ev switch
{
UserEvent.Invited { InvitedByUser: { } invitedBy } => $"invited by {invitedBy.Email}",
UserEvent.Invited => "invited",
_ => "registered"
};
var requiresApproval = user.RequiresApproval && !user.Approved;
var requiresEmailConfirmation = user.RequiresEmailConfirmation && !user.EmailConfirmed;
// log registration info
var newUserInfo = $"New {type} {user.Email} {info}";
Logs.PayServer.LogInformation(newUserInfo);
var requiresApproval = user is { RequiresApproval: true, Approved: false };
var requiresEmailConfirmation = user is { RequiresEmailConfirmation: true, EmailConfirmed: false };
// send notification if the user does not require email confirmation.
// inform admins only about qualified users and not annoy them with bot registrations.
if (requiresApproval && !requiresEmailConfirmation)
{
await NotifyAdminsAboutUserRequiringApproval(user, ev.ApprovalLink, newUserInfo);
await NotifyAdminsAboutUserRequiringApproval(user, ev.ApprovalLink);
}
// set callback result and send email to user
emailSender = await emailSenderFactory.GetEmailSender();
if (ev is UserEvent.Invited invited)
{
if (invited.SendInvitationEmail)
emailSender.SendInvitation(user.GetMailboxAddress(), invited.InvitationLink);
EventAggregator.Publish(await CreateTriggerEvent(ServerMailTriggers.InvitePending,
new JObject()
{
["InvitationLink"] = HtmlEncoder.Default.Encode(invited.InvitationLink),
["InvitationLinkQR"] = GetQrCodeImg(invited.InvitationLink)
}, user));
}
else if (requiresEmailConfirmation)
{
emailSender.SendEmailConfirmation(user.GetMailboxAddress(), ev.ConfirmationEmailLink);
EventAggregator.Publish(new UserEvent.ConfirmationEmailRequested(user, ev.ConfirmationEmailLink));
}
break;
case UserEvent.ConfirmationEmailRequested confReq:
EventAggregator.Publish(await CreateTriggerEvent(ServerMailTriggers.EmailConfirm,
new JObject()
{
["ConfirmLink"] = HtmlEncoder.Default.Encode(confReq.ConfirmLink)
}, user));
break;
case UserEvent.PasswordResetRequested pwResetEvent:
EventAggregator.Publish(CreateTriggerEvent(ServerMailTriggers.PasswordReset,
EventAggregator.Publish(await CreateTriggerEvent(ServerMailTriggers.PasswordReset,
new JObject()
{
["ResetLink"] = HtmlEncoder.Default.Encode(pwResetEvent.ResetLink)
@@ -92,15 +96,16 @@ public class UserEventHostedService(
case UserEvent.Approved approvedEvent:
if (!user.Approved) break;
emailSender = await emailSenderFactory.GetEmailSender();
emailSender.SendApprovalConfirmation(user.GetMailboxAddress(), approvedEvent.LoginLink);
EventAggregator.Publish(await CreateTriggerEvent(ServerMailTriggers.ApprovalConfirmed,
new JObject()
{
["LoginLink"] = approvedEvent.LoginLink
}, user));
break;
case UserEvent.ConfirmedEmail confirmedEvent:
if (!user.EmailConfirmed) break;
var confirmedUserInfo = $"User {user.Email} confirmed their email address";
Logs.PayServer.LogInformation(confirmedUserInfo);
await NotifyAdminsAboutUserRequiringApproval(user, confirmedEvent.ApprovalLink, confirmedUserInfo);
case UserEvent.ConfirmedEmail confirmedEvent when user is { RequiresApproval: true, Approved: false, EmailConfirmed: true }:
await NotifyAdminsAboutUserRequiringApproval(user, confirmedEvent.ApprovalLink);
break;
case UserEvent.InviteAccepted inviteAcceptedEvent:
@@ -110,8 +115,24 @@ public class UserEventHostedService(
}
}
private TriggerEvent CreateTriggerEvent(string trigger, JObject model, ApplicationUser user)
private async Task NotifyAdminsAboutUserRequiringApproval(ApplicationUser user, string approvalLink)
{
await notificationSender.SendNotification(new AdminScope(), new NewUserRequiresApprovalNotification(user));
EventAggregator.Publish(await CreateTriggerEvent(ServerMailTriggers.ApprovalRequest,
new JObject()
{
["ApprovalLink"] = approvalLink
}, user));
}
private async Task<TriggerEvent> CreateTriggerEvent(string trigger, JObject model, ApplicationUser user)
{
var admins = await UserManager.GetUsersInRoleAsync(Roles.ServerAdmin);
var adminMailboxes = string.Join(", ", admins.Select(a => a.GetMailboxAddress().ToString()).ToArray());
model["Admins"] = new JObject()
{
["MailboxAddresses"] = adminMailboxes,
};
model["User"] = new JObject()
{
["Name"] = user.UserName,
@@ -126,21 +147,6 @@ public class UserEventHostedService(
var evt = new TriggerEvent(null, trigger, model, null);
return evt;
}
private async Task NotifyAdminsAboutUserRequiringApproval(ApplicationUser user, string approvalLink, string newUserInfo)
{
if (!user.RequiresApproval || user.Approved) return;
// notification
await notificationSender.SendNotification(new AdminScope(), new NewUserRequiresApprovalNotification(user));
// email
var admins = await UserManager.GetUsersInRoleAsync(Roles.ServerAdmin);
var emailSender = await emailSenderFactory.GetEmailSender();
foreach (var admin in admins)
{
emailSender.SendNewUserInfo(admin.GetMailboxAddress(), newUserInfo, approvalLink);
}
}
private async Task NotifyAboutUserAcceptingInvite(ApplicationUser user, string storeUsersLink)
{
var stores = await storeRepository.GetStoresByUserId(user.Id);

View File

@@ -8,4 +8,5 @@ public class ServerMailTriggers
public const string ApprovalConfirmed = "SRV-ApprovalConfirmed";
public const string ApprovalPending = "SRV-ApprovalPending";
public const string EmailConfirm = "SRV-EmailConfirmation";
public const string ApprovalRequest = "SRV-ApprovalRequest";
}

View File

@@ -7,11 +7,18 @@ namespace BTCPayServer.Plugins.Emails.Views;
/// </summary>
public class EmailTriggerViewModel
{
public string Trigger { get; set; }
public string Description { get; set; }
public class Default
{
public string SubjectExample { get; set; }
public string BodyExample { get; set; }
public bool CanIncludeCustomerEmail { get; set; }
public string RecipientExample { get; set; }
}
public string Trigger { get; set; }
public string Description { get; set; }
public class PlaceHolder(string name, string description)
{
@@ -21,5 +28,4 @@ public class EmailTriggerViewModel
public List<PlaceHolder> PlaceHolders { get; set; } = new();
public bool ServerTrigger { get; set; }
public string RecipientExample { get; set; }
}

View File

@@ -152,32 +152,20 @@
const bodyTextarea = document.querySelector('.email-rule-body');
const placeholdersTd = document.querySelector('#placeholders');
const isEmptyOrDefault = (value, type) => {
const val = value.replace(/<.*?>/gi, '').trim();
if (!val) return true;
return Object.values(triggersByType).some(t => t[type] === val);
};
function applyTemplate() {
const selectedTrigger = triggerSelect.value;
console.log(selectedTrigger);
if (triggersByType[selectedTrigger]) {
if (isEmptyOrDefault(subjectInput.value, 'subjectExample')) {
subjectInput.value = triggersByType[selectedTrigger].subjectExample;
}
if (isEmptyOrDefault(recipientInput.value, 'recipientExample')) {
recipientInput.value = triggersByType[selectedTrigger].recipientExample;
}
var body = triggersByType[selectedTrigger].bodyExample;
if (isEmptyOrDefault(bodyTextarea.value, 'bodyExample')) {
if ($(bodyTextarea).summernote) {
console.log(body);
$(bodyTextarea).summernote('reset');
$(bodyTextarea).summernote('code', body.replace(/\n/g, '<br/>'));
} else {
bodyTextarea.value = body;
}
}
placeholdersTd.innerHTML = '';
triggersByType[selectedTrigger].placeHolders.forEach(p => {
@@ -213,7 +201,10 @@
// Apply template on page load if a trigger is selected
if (triggerSelect.value) {
if (@Safe.Json(!isEdit))
{
applyTemplate();
}
toggleCustomerEmailVisibility();
}
});

View File

@@ -7,6 +7,7 @@ using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.Plugins.Emails;
using BTCPayServer.Plugins.Emails.HostedServices;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using StoreData = BTCPayServer.Data.StoreData;

View File

@@ -4,6 +4,7 @@ using BTCPayServer.Client.Models;
using BTCPayServer.Controllers.Greenfield;
using BTCPayServer.Events;
using BTCPayServer.Plugins.Emails;
using BTCPayServer.Plugins.Emails.HostedServices;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Newtonsoft.Json.Linq;

View File

@@ -6,6 +6,7 @@ using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.Plugins.Emails;
using BTCPayServer.Plugins.Emails.HostedServices;
using BTCPayServer.Services.PaymentRequests;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;

View File

@@ -3,6 +3,7 @@ using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.HostedServices;
using BTCPayServer.Plugins.Emails;
using BTCPayServer.Plugins.Emails.HostedServices;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using StoreData = BTCPayServer.Data.StoreData;

View File

@@ -48,10 +48,7 @@ namespace BTCPayServer.Services.Notifications
}
await db.SaveChangesAsync();
}
foreach (string user in users)
{
_notificationManager.InvalidateNotificationCache(user);
}
_notificationManager.InvalidateNotificationCache(users);
}
public BaseNotification GetBaseNotification(NotificationData notificationData)

View File

@@ -8,7 +8,7 @@ using MimeKit;
namespace BTCPayServer
{
/// <summary>
/// Validate address in the format "Firstname Lastname <blah@example.com>" See rfc822
/// Validate address in the format "Firstname Lastname <blah@example.com>" See rfc5322
/// </summary>
public class MailboxAddressValidator
{
@@ -25,7 +25,7 @@ namespace BTCPayServer
public static MailboxAddress Parse(string? str)
{
if (!TryParse(str, out var mb))
throw new FormatException("Invalid mailbox address (rfc822)");
throw new FormatException("Invalid mailbox address (rfc5322)");
return mb;
}
public static bool TryParse(string? str, [MaybeNullWhen(false)] out MailboxAddress mailboxAddress)