Add CC and BCC to emails

This commit is contained in:
Nicolas Dorier
2025-11-08 22:42:45 +09:00
parent d7fcd55707
commit dcf60e20b9
16 changed files with 442 additions and 177 deletions

View File

@@ -10,6 +10,9 @@ namespace BTCPayServer;
public class TextTemplate(string template)
{
static readonly Regex _interpolationRegex = new Regex(@"\{([^}]+)\}", RegexOptions.Compiled | RegexOptions.CultureInvariant);
public Func<string, string> NotFoundReplacement { get; set; } = path => $"[NotFound({path})]";
public Func<string, string> ParsingErrorReplacement { get; set; } = path => $"[ParsingError({path})]";
public string Render(JObject model)
{
model = (JObject)ToLowerCase(model);
@@ -23,11 +26,11 @@ public class TextTemplate(string template)
try
{
var token = model.SelectToken(path);
return token?.ToString() ?? $"<NotFound({initial})>";
return token?.ToString() ?? NotFoundReplacement(initial);
}
catch
{
return $"<ParsingError({initial})>";
return ParsingErrorReplacement(initial);
}
});
}

View File

@@ -39,6 +39,12 @@ public class EmailRuleData : BaseEntityData
[Required]
[Column("to")]
public string[] To { get; set; } = null!;
[Required]
[Column("cc")]
public string[] CC { get; set; } = null!;
[Required]
[Column("bcc")]
public string[] BCC { get; set; } = null!;
[Required]
[Column("subject")]

View File

@@ -0,0 +1,43 @@
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BTCPayServer.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20251107131717_emailccbcc")]
public partial class emailccbcc : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string[]>(
name: "bcc",
table: "email_rules",
type: "text[]",
nullable: false,
defaultValue: new string[0]);
migrationBuilder.AddColumn<string[]>(
name: "cc",
table: "email_rules",
type: "text[]",
nullable: false,
defaultValue: new string[0]);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "bcc",
table: "email_rules");
migrationBuilder.DropColumn(
name: "cc",
table: "email_rules");
}
}
}

View File

@@ -280,11 +280,21 @@ namespace BTCPayServer.Migrations
.HasColumnName("additional_data")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<string[]>("BCC")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("bcc");
b.Property<string>("Body")
.IsRequired()
.HasColumnType("text")
.HasColumnName("body");
b.Property<string[]>("CC")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("cc");
b.Property<string>("Condition")
.HasColumnType("text")
.HasColumnName("condition");

View File

@@ -20,6 +20,7 @@ using BTCPayServer.HostedServices;
using BTCPayServer.Hosting;
using BTCPayServer.JsonConverters;
using BTCPayServer.Payments;
using BTCPayServer.Plugins.Emails.Views;
using BTCPayServer.Rating;
using BTCPayServer.Services;
using BTCPayServer.Services.Apps;
@@ -2305,6 +2306,16 @@ bc1qfzu57kgu5jthl934f9xrdzzx8mmemx7gn07tf0grnvz504j6kzusu2v0ku
Assert.Equal("1-of-xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL-xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL-[legacy]", parsedDescriptor.AccountDerivation.ToString());
}
[Fact]
[Trait("FastTest", "FastTest")]
public void CanParseEmailDestination()
{
var vm = new StoreEmailRuleViewModel();
var actual = vm.AsArray("\"Nicolas, The, Great\" <emperor@btc.pay>,{SomeTemplate} ,\"Madd,Test\" <madd@example.com>");
string[] expected = ["\"Nicolas, The, Great\" <emperor@btc.pay>", "{SomeTemplate}", "\"Madd,Test\" <madd@example.com>"];
Assert.Equal(expected, actual);
}
[Fact]
[Trait("Altcoins", "Altcoins")]
public void CanCalculateCryptoDue2()

View File

@@ -314,7 +314,7 @@
<a layout-menu-item="@nameof(ServerNavPages.Roles)" asp-controller="UIServer" asp-action="ListRoles" text-translate="true">Roles</a>
</li>
<li class="nav-item nav-item-sub" permission="@Policies.CanModifyServerSettings">
<a layout-menu-item="Server-@nameof(ServerNavPages.Emails)" asp-area="@EmailsPlugin.Area" asp-controller="UIServerEmail" asp-action="ServerEmailSettings" text-translate="true">Email</a>
<a layout-menu-item="Server-@nameof(ServerNavPages.Emails)" asp-area="@EmailsPlugin.Area" asp-controller="UIServerEmail" asp-action="ServerEmailSettings" text-translate="true">Emails</a>
</li>
<li class="nav-item nav-item-sub" permission="@Policies.CanModifyServerSettings">
<a layout-menu-item="@nameof(ServerNavPages.Services)" asp-controller="UIServer" asp-action="Services" text-translate="true">Services</a>

View File

@@ -106,7 +106,9 @@ public class UIEmailRuleControllerBase(
Subject = model.Subject,
Condition = string.IsNullOrWhiteSpace(model.Condition) ? null : model.Condition,
OfferingId = model.OfferingId,
To = model.ToAsArray()
To = model.AsArray(model.To),
CC = model.AsArray(model.CC),
BCC = model.AsArray(model.BCC),
};
c.SetBTCPayAdditionalData(model.AdditionalData);
ctx.EmailRules.Add(c);
@@ -143,7 +145,9 @@ public class UIEmailRuleControllerBase(
rule.Trigger = model.Trigger;
rule.SetBTCPayAdditionalData(model.AdditionalData);
rule.To = model.ToAsArray();
rule.To = model.AsArray(model.To);
rule.CC = model.AsArray(model.CC);
rule.BCC = model.AsArray(model.BCC);
rule.Subject = model.Subject;
rule.Condition = model.Condition;
rule.Body = model.Body;
@@ -170,6 +174,20 @@ public class UIEmailRuleControllerBase(
protected async Task ValidateCondition(StoreEmailRuleViewModel model)
{
string[] modelKeys = [nameof(model.To), nameof(model.CC), nameof(model.BCC)];
string[] values = [model.To, model.CC, model.BCC];
for (int i = 0; i < modelKeys.Length; i++)
{
try
{
model.AsArray(values[i]);
}
catch (FormatException)
{
ModelState.AddModelError(modelKeys[i], StringLocalizer["Invalid email address or placeholder detected"]);
}
}
model.Condition = model.Condition?.Trim() ?? "";
if (model.Condition.Length == 0)
model.Condition = null;

View File

@@ -42,77 +42,92 @@ public class EmailsPlugin : BaseBTCPayServerPlugin
var vm = new EmailTriggerViewModel()
{
Trigger = ServerMailTriggers.PasswordReset,
RecipientExample = "{User.MailboxAddress}",
SubjectExample = "Update Password",
BodyExample = CreateEmailBody($"A request has been made to reset your {{Branding.ServerName}} password. Please set your password by clicking below.<br/><br/>{CallToAction("Update Password", "{ResetLink}")}"),
DefaultEmail = new()
{
To = ["{User.MailboxAddress}"],
Subject = "Update Password",
Body = CreateEmailBody($"A request has been made to reset your {{Branding.ServerName}} password. Please set your password by clicking below.<br/><br/>{CallToAction("Update Password", "{ResetLink}")}"),
},
PlaceHolders = new()
{
new ("{ResetLink}", "The link to the password reset page")
},
Description = "Password Reset Requested",
Description = "User: 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}")}"),
DefaultEmail = new()
{
To = ["{User.MailboxAddress}"],
Subject = "Confirm your email address",
Body = 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",
Description = "User: Email confirmation",
};
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}}"),
DefaultEmail = new()
{
To = ["{User.MailboxAddress}"],
Subject = "Invitation to join {Branding.ServerName}",
Body = 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",
Description = "User: Invitation",
};
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}")}"),
DefaultEmail = new()
{
To = ["{User.MailboxAddress}"],
Subject = "Your account has been approved",
Body = 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",
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}")}"),
DefaultEmail = new()
{
To = ["{Admin.MailboxAddresses}"],
Subject = "Approval request to access the server for {User.Email}",
Body = 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",
Description = "Admin: Approval request",
};
vms.Add(vm);
var commonPlaceholders = new List<EmailTriggerViewModel.PlaceHolder>()
{
new("{Admins.MailboxAddresses}", "The email addresses of the admins separated by a comma (eg. ,)"),
new("{Admins.MailboxAddresses}", "The email addresses of the admins separated by a comma"),
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

@@ -32,9 +32,9 @@ public class EmailRuleMatchContext(
public TriggerEvent TriggerEvent { get; } = triggerEvent;
public EmailRuleData MatchedRule { get; } = matchedRule;
public List<MailboxAddress> Recipients { get; set; } = new();
public List<MailboxAddress> Cc { get; set; } = new();
public List<MailboxAddress> Bcc { get; set; } = new();
public List<MailboxAddress> To { get; set; } = new();
public List<MailboxAddress> CC { get; set; } = new();
public List<MailboxAddress> BCC { get; set; } = new();
}
public class StoreEmailRuleProcessorSender(
@@ -65,41 +65,45 @@ public class StoreEmailRuleProcessorSender(
var body = new TextTemplate(actionableRule.Body ?? "");
var subject = new TextTemplate(actionableRule.Subject ?? "");
matchedContext.Recipients.AddRange(
actionableRule.To
.SelectMany(o =>
{
if (MailboxAddressValidator.TryParse(o, out var oo))
return new[] { oo };
var emails = new TextTemplate(o).Render(triggEvent.Model);
MailAddressCollection mailCollection = new();
try
{
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)!);
AddToMatchedContext(triggEvent.Model, matchedContext.To, actionableRule.To);
AddToMatchedContext(triggEvent.Model, matchedContext.CC, actionableRule.CC);
AddToMatchedContext(triggEvent.Model, matchedContext.BCC, actionableRule.BCC);
if (triggEvent.Owner is not null)
await triggEvent.Owner.BeforeSending(matchedContext);
if (matchedContext.Recipients.Count == 0)
if (matchedContext.To.Count == 0)
continue;
sender.SendEmail(matchedContext.Recipients.ToArray(), matchedContext.Cc.ToArray(), matchedContext.Bcc.ToArray(), subject.Render(triggEvent.Model), body.Render(triggEvent.Model));
sender.SendEmail(matchedContext.To.ToArray(), matchedContext.CC.ToArray(), matchedContext.BCC.ToArray(), subject.Render(triggEvent.Model), body.Render(triggEvent.Model));
}
}
}
}
private void AddToMatchedContext(JObject model, List<MailboxAddress> mailboxAddresses, string[] rulesAddresses)
{
mailboxAddresses.AddRange(
rulesAddresses
.SelectMany(o =>
{
var emails = new TextTemplate(o).Render(model);
MailAddressCollection mailCollection = new();
try
{
mailCollection.Add(emails);
}
catch (FormatException)
{
return Array.Empty<MailboxAddress>();
}
return mailCollection.Select(a =>
{
MailboxAddressValidator.TryParse(a.ToString(), out var oo);
return oo;
})
.Where(a => a != null)
.ToArray();
})
.Where(o => o != null)!);
}
}

View File

@@ -1,4 +1,6 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
namespace BTCPayServer.Plugins.Emails.Views;
@@ -9,16 +11,21 @@ public class EmailTriggerViewModel
{
public class Default
{
public string SubjectExample { get; set; }
public string BodyExample { get; set; }
public string Subject { get; set; }
public string Body { get; set; }
public string[] To { get; set; } = Array.Empty<string>();
[JsonProperty("cc")]
public string[] CC { get; set; } = Array.Empty<string>();
[JsonProperty("bcc")]
public string[] BCC { get; set; } = Array.Empty<string>();
public bool CanIncludeCustomerEmail { get; set; }
public string RecipientExample { get; set; }
}
public string Trigger { get; set; }
public string Description { get; set; }
public Default DefaultEmail { get; set; }
public class PlaceHolder(string name, string description)
{

View File

@@ -91,12 +91,25 @@
<div class="form-text" text-translate="true">Only send email when the specified JSON Path exists</div>
</div>
@{
var placeholder = "\"John Smith\" <john.smith@example.com>, john.smith@example.com, {Placeholder}";
}
<div class="form-group">
<label asp-for="To" class="form-label" text-translate="true">Recipients</label>
<input type="text" asp-for="To" class="form-control email-rule-to" />
<label asp-for="To" class="form-label" text-translate="true">To</label>
<input type="text" asp-for="To" placeholder="@placeholder" class="form-control email-rule-to" />
<span asp-validation-for="To" class="text-danger"></span>
<div class="form-text" text-translate="true">Who to send the email to. For multiple emails, separate with a comma.</div>
</div>
<div class="form-group">
<label asp-for="CC" class="form-label" text-translate="true">CC</label>
<input type="text" asp-for="CC" placeholder="@placeholder" class="form-control email-rule-cc" />
<span asp-validation-for="CC" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="BCC" class="form-label" text-translate="true">BCC</label>
<input type="text" asp-for="BCC" placeholder="@placeholder" class="form-control email-rule-bcc" />
<span asp-validation-for="BCC" class="text-danger"></span>
</div>
<div class="form-check mb-4 customer-email-container">
<input asp-for="AdditionalData.CustomerEmail" type="checkbox" class="form-check-input email-rule-customer-email customer-email-checkbox" />
@@ -148,16 +161,23 @@
const triggerSelect = document.querySelector('.email-rule-trigger') ?? document.querySelector('.email-rule-trigger-hidden');
const subjectInput = document.querySelector('.email-rule-subject');
const recipientInput = document.querySelector('.email-rule-to');
const toInput = document.querySelector('.email-rule-to');
const ccInput = document.querySelector('.email-rule-cc');
const bccInput = document.querySelector('.email-rule-bcc');
const bodyTextarea = document.querySelector('.email-rule-body');
const placeholdersTd = document.querySelector('#placeholders');
function join(arr) {
return arr ? arr.join(', ') : '';
}
function applyTemplate() {
const selectedTrigger = triggerSelect.value;
if (triggersByType[selectedTrigger]) {
subjectInput.value = triggersByType[selectedTrigger].subjectExample;
recipientInput.value = triggersByType[selectedTrigger].recipientExample;
var body = triggersByType[selectedTrigger].bodyExample;
subjectInput.value = triggersByType[selectedTrigger].defaultEmail.subject;
toInput.value = join(triggersByType[selectedTrigger].defaultEmail.to);
ccInput.value = join(triggersByType[selectedTrigger].defaultEmail.cc);
bccInput.value = join(triggersByType[selectedTrigger].defaultEmail.bcc);
var body = triggersByType[selectedTrigger].defaultEmail.body;
if ($(bodyTextarea).summernote) {
console.log(body);
@@ -166,7 +186,13 @@
} else {
bodyTextarea.value = body;
}
applyPlaceholders();
}
}
function applyPlaceholders()
{
const selectedTrigger = triggerSelect.value;
if (triggersByType[selectedTrigger]) {
placeholdersTd.innerHTML = '';
triggersByType[selectedTrigger].placeHolders.forEach(p => {
const tr = document.createElement('tr');
@@ -180,7 +206,6 @@
tr.appendChild(td2);
placeholdersTd.appendChild(tr);
});
}
}
@@ -205,6 +230,10 @@
{
applyTemplate();
}
else
{
applyPlaceholders();
}
toggleCustomerEmailVisibility();
}
});

View File

@@ -2,7 +2,9 @@ using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Net.Mail;
using BTCPayServer.Data;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Plugins.Emails.Views;
@@ -25,6 +27,8 @@ public class StoreEmailRuleViewModel
Condition = data.Condition ?? "";
Body = data.Body;
To = string.Join(",", data.To);
CC = string.Join(",", data.CC);
BCC = string.Join(",", data.BCC);
}
else
{
@@ -46,6 +50,8 @@ public class StoreEmailRuleViewModel
public EmailRuleData Data { get; set; }
public EmailRuleData.BTCPayAdditionalData AdditionalData { get; set; }
public string To { get; set; }
public string CC { get; set; }
public string BCC { get; set; }
public List<EmailTriggerViewModel> Triggers { get; set; }
public string RedirectUrl { get; set; }
@@ -54,8 +60,29 @@ public class StoreEmailRuleViewModel
public string OfferingId { get; set; }
public string StoreId { get; set; }
public string[] ToAsArray()
=> (To ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(t => t.Trim())
.ToArray();
public string[] AsArray(string values)
{
// This replace the placeholders with random email addresses
// We can't just split input by comma, because display names of people can contain commas.
// "John, Jr. Smith" <jon@example.com>,{User.Email},"Nicolas D." <nico@example.com>
values ??= "";
// We replace the placeholders with dummy email addresses
var template = new TextTemplate(values);
var dummy = $"{Random.Shared.Next()}@example.com";
template.NotFoundReplacement = o => $"\"{o}\" <{dummy}>";
values = template.Render(new JObject());
if (string.IsNullOrWhiteSpace(values))
return Array.Empty<string>();
// We use MailAddressCollection to parse the addresses
MailAddressCollection mailCollection = new();
mailCollection.Add(values);
foreach (var mail in mailCollection)
{
// Let it throw if the address is invalid
MailboxAddressValidator.Parse(mail.ToString());
}
// We replace the dummies with the former placeholders
return mailCollection.Select(a => a.Address == dummy ? $"{{{a.DisplayName}}}" : a.ToString()).ToArray();
}
}

View File

@@ -43,18 +43,17 @@ public class SubscriptionsPlugin : BaseBTCPayServerPlugin
var placeHolders = new List<EmailTriggerViewModel.PlaceHolder>()
{
new ("{Plan.Id}", "Plan ID"),
new ("{Plan.Name}", "Plan name"),
new ("{Offering.Name}", "Offering name"),
new ("{Offering.Id}", "Offering ID"),
new ("{Offering.AppId}", "Offering app ID"),
new ("{Offering.Metadata}*", "Offering metadata"),
new ("{Subscriber.Phase}", "Subscriber phase"),
new ("{Subscriber.Email}", "Subscriber email"),
new ("{Customer.ExternalRef}", "Customer external reference"),
new ("{Customer.Name}", "Customer name"),
new ("{Customer.Metadata}*", "Customer metadata")
new("{Plan.Id}", "Plan ID"),
new("{Plan.Name}", "Plan name"),
new("{Offering.Name}", "Offering name"),
new("{Offering.Id}", "Offering ID"),
new("{Offering.AppId}", "Offering app ID"),
new("{Offering.Metadata}*", "Offering metadata"),
new("{Subscriber.Phase}", "Subscriber phase"),
new("{Subscriber.Email}", "Subscriber email"),
new("{Customer.ExternalRef}", "Customer external reference"),
new("{Customer.Name}", "Customer name"),
new("{Customer.Metadata}*", "Customer metadata")
}.AddStoresPlaceHolders();
var viewModels = new List<EmailTriggerViewModel>()
@@ -63,72 +62,99 @@ public class SubscriptionsPlugin : BaseBTCPayServerPlugin
{
Trigger = WebhookSubscriptionEvent.SubscriberCreated,
Description = "Subscription - New subscriber",
SubjectExample = "Welcome {Customer.Name}!",
BodyExample = "Hello {Customer.Name},\n\nThank you for subscribing to our service.\n\nRegards,\n{Store.Name}",
DefaultEmail = new()
{
Subject = "Welcome {Customer.Name}!",
Body = "Hello {Customer.Name},\n\nThank you for subscribing to our service.\n\nRegards,\n{Store.Name}"
},
PlaceHolders = placeHolders
},
new()
{
Trigger = WebhookSubscriptionEvent.SubscriberCredited,
Description = "Subscription - Subscriber credited",
SubjectExample = "Your subscription has been credited",
BodyExample = "Hello {Customer.Name},\n\nYour subscription has been credited.\n\nRegards,\n{Store.Name}",
DefaultEmail = new()
{
Subject = "Your subscription has been credited",
Body = "Hello {Customer.Name},\n\nYour subscription has been credited.\n\nRegards,\n{Store.Name}"
},
PlaceHolders = placeHolders
},
new()
{
Trigger = WebhookSubscriptionEvent.SubscriberCharged,
Description = "Subscription - Subscriber charged",
SubjectExample = "Your subscription payment has been processed",
BodyExample = "Hello {Customer.Name},\n\nYour subscription payment for {Plan.Name} has been processed.\n\nRegards,\n{Store.Name}",
DefaultEmail = new()
{
Subject = "Your subscription payment has been processed",
Body = "Hello {Customer.Name},\n\nYour subscription payment for {Plan.Name} has been processed.\n\nRegards,\n{Store.Name}"
},
PlaceHolders = placeHolders
},
new()
{
Trigger = WebhookSubscriptionEvent.SubscriberActivated,
Description = "Subscription - Subscriber activated",
SubjectExample = "Your subscription is now active",
BodyExample = "Hello {Customer.Name},\n\nYour subscription to {Plan.Name} is now active.\n\nRegards,\n{Store.Name}",
DefaultEmail = new()
{
Subject = "Your subscription is now active",
Body = "Hello {Customer.Name},\n\nYour subscription to {Plan.Name} is now active.\n\nRegards,\n{Store.Name}"
},
PlaceHolders = placeHolders
},
new()
{
Trigger = WebhookSubscriptionEvent.SubscriberPhaseChanged,
Description = "Subscription - Subscriber phase changed",
SubjectExample = "Your subscription phase has changed",
BodyExample = "Hello {Customer.Name},\n\nYour subscription phase has been updated to {Subscriber.Phase}.\n\nRegards,\n{Store.Name}",
DefaultEmail = new()
{
Subject = "Your subscription phase has changed",
Body = "Hello {Customer.Name},\n\nYour subscription phase has been updated to {Subscriber.Phase}.\n\nRegards,\n{Store.Name}"
},
PlaceHolders = placeHolders
},
new()
{
Trigger = WebhookSubscriptionEvent.SubscriberDisabled,
Description = "Subscription - Subscriber disabled",
SubjectExample = "Your subscription has been disabled",
BodyExample = "Hello {Customer.Name},\n\nYour subscription has been disabled.\n\nRegards,\n{Store.Name}",
DefaultEmail = new()
{
Subject = "Your subscription has been disabled",
Body = "Hello {Customer.Name},\n\nYour subscription has been disabled.\n\nRegards,\n{Store.Name}"
},
PlaceHolders = placeHolders
},
new()
{
Trigger = WebhookSubscriptionEvent.PaymentReminder,
Description = "Subscription - Payment reminder",
SubjectExample = "Payment reminder for your subscription",
BodyExample = "Hello {Customer.Name},\n\nThis is a reminder about your upcoming subscription payment.\n\nRegards,\n{Store.Name}",
DefaultEmail = new()
{
Subject = "Payment reminder for your subscription",
Body = "Hello {Customer.Name},\n\nThis is a reminder about your upcoming subscription payment.\n\nRegards,\n{Store.Name}"
},
PlaceHolders = placeHolders
},
new()
{
Trigger = WebhookSubscriptionEvent.PlanStarted,
Description = "Subscription - Plan started",
SubjectExample = "Your subscription plan has started",
BodyExample = "Hello {Customer.Name},\n\nYour subscription plan {Plan.Name} has started.\n\nRegards,\n{Store.Name}",
DefaultEmail = new()
{
Subject = "Your subscription plan has started",
Body = "Hello {Customer.Name},\n\nYour subscription plan {Plan.Name} has started.\n\nRegards,\n{Store.Name}"
},
PlaceHolders = placeHolders
},
new()
{
Trigger = WebhookSubscriptionEvent.SubscriberNeedUpgrade,
Description = "Subscription - Need upgrade",
SubjectExample = "Your subscription needs to be upgraded",
BodyExample = "Hello {Customer.Name},\n\nYour subscription needs to be upgraded to continue using our service.\n\nRegards,\n{Store.Name}",
DefaultEmail = new()
{
Subject = "Your subscription needs to be upgraded",
Body = "Hello {Customer.Name},\n\nYour subscription needs to be upgraded to continue using our service.\n\nRegards,\n{Store.Name}"
},
PlaceHolders = placeHolders
},
};
@@ -141,6 +167,7 @@ public class SubscriptionsAppType(
IOptions<BTCPayServerOptions> btcPayServerOptions) : AppBaseType(AppType)
{
public const string AppType = "Subscriptions";
public class AppConfig
{
public string OfferingId { get; set; } = null!;

View File

@@ -42,7 +42,7 @@ public class InvoiceTriggerProvider(LinkGenerator linkGenerator)
context.MatchedRule.GetBTCPayAdditionalData()?.CustomerEmail is true &&
MailboxAddressValidator.TryParse(email, out var mb))
{
context.Recipients.Insert(0, mb);
context.To.Insert(0, mb);
}
return Task.CompletedTask;
}

View File

@@ -43,7 +43,7 @@ public class PaymentRequestTriggerProvider(LinkGenerator linkGenerator)
context.MatchedRule.GetBTCPayAdditionalData()?.CustomerEmail is true &&
MailboxAddressValidator.TryParse(email, out var mb))
{
context.Recipients.Insert(0, mb);
context.To.Insert(0, mb);
}
return Task.CompletedTask;
}

View File

@@ -1,4 +1,4 @@
#nullable enable
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
@@ -53,7 +53,7 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
AddPendingTransactionWebhooks(services);
}
private static void AddPendingTransactionWebhooks(IServiceCollection services)
private static void AddPendingTransactionWebhooks(IServiceCollection services)
{
services.AddWebhookTriggerProvider<PendingTransactionTriggerProvider>();
var pendingTransactionsPlaceholders = new List<EmailTriggerViewModel.PlaceHolder>()
@@ -73,32 +73,45 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
{
Trigger = PendingTransactionTriggerProvider.PendingTransactionCreated,
Description = "Pending Transaction - Created",
SubjectExample = "Pending Transaction {PendingTransaction.TrimmedId} Created",
BodyExample = "Review the transaction {PendingTransaction.Id} and sign it on: {PendingTransaction.Link}",
DefaultEmail = new()
{
Subject = "Pending Transaction {PendingTransaction.TrimmedId} Created",
Body = "Review the transaction {PendingTransaction.Id} and sign it on: {PendingTransaction.Link}"
},
PlaceHolders = pendingTransactionsPlaceholders
},
new()
{
Trigger = PendingTransactionTriggerProvider.PendingTransactionSignatureCollected,
Description = "Pending Transaction - Signature Collected",
SubjectExample = "Signature Collected for Pending Transaction {PendingTransaction.TrimmedId}",
BodyExample = "So far {PendingTransaction.SignaturesCollected} signatures collected out of {PendingTransaction.SignaturesNeeded} signatures needed. ",
DefaultEmail = new()
{
Subject = "Signature Collected for Pending Transaction {PendingTransaction.TrimmedId}",
Body =
"So far {PendingTransaction.SignaturesCollected} signatures collected out of {PendingTransaction.SignaturesNeeded} signatures needed. "
},
PlaceHolders = pendingTransactionsPlaceholders
},
new()
{
Trigger = PendingTransactionTriggerProvider.PendingTransactionBroadcast,
Description = "Pending Transaction - Broadcast",
SubjectExample = "Transaction {PendingTransaction.TrimmedId} has been Broadcast",
BodyExample = "Transaction is visible in mempool on: https://mempool.space/tx/{PendingTransaction.Id}. ",
DefaultEmail = new()
{
Subject = "Transaction {PendingTransaction.TrimmedId} has been Broadcast",
Body = "Transaction is visible in mempool on: https://mempool.space/tx/{PendingTransaction.Id}. "
},
PlaceHolders = pendingTransactionsPlaceholders
},
new()
{
Trigger = PendingTransactionTriggerProvider.PendingTransactionCancelled,
Description = "Pending Transaction - Cancelled",
SubjectExample = "Pending Transaction {PendingTransaction.TrimmedId} Cancelled",
BodyExample = "Transaction {PendingTransaction.Id} is cancelled and signatures are no longer being collected. ",
DefaultEmail = new()
{
Subject = "Pending Transaction {PendingTransaction.TrimmedId} Cancelled",
Body = "Transaction {PendingTransaction.Id} is cancelled and signatures are no longer being collected. "
},
PlaceHolders = pendingTransactionsPlaceholders
}
};
@@ -130,46 +143,61 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
{
Trigger = WebhookEventType.PaymentRequestCreated,
Description = "Payment Request - Created",
SubjectExample = "Payment Request {PaymentRequest.Id} created",
BodyExample = "Payment Request {PaymentRequest.Id} ({PaymentRequest.Title}) created.",
PlaceHolders = paymentRequestPlaceholders,
CanIncludeCustomerEmail = true
DefaultEmail = new()
{
Subject = "Payment Request {PaymentRequest.Id} created",
Body = "Payment Request {PaymentRequest.Id} ({PaymentRequest.Title}) created.",
CanIncludeCustomerEmail = true
},
PlaceHolders = paymentRequestPlaceholders
},
new()
{
Trigger = WebhookEventType.PaymentRequestUpdated,
Description = "Payment Request - Updated",
SubjectExample = "Payment Request {PaymentRequest.Id} updated",
BodyExample = "Payment Request {PaymentRequest.Id} ({PaymentRequest.Title}) updated.",
PlaceHolders = paymentRequestPlaceholders,
CanIncludeCustomerEmail = true
DefaultEmail = new()
{
Subject = "Payment Request {PaymentRequest.Id} updated",
Body = "Payment Request {PaymentRequest.Id} ({PaymentRequest.Title}) updated.",
CanIncludeCustomerEmail = true
},
PlaceHolders = paymentRequestPlaceholders
},
new()
{
Trigger = WebhookEventType.PaymentRequestArchived,
Description = "Payment Request - Archived",
SubjectExample = "Payment Request {PaymentRequest.Id} archived",
BodyExample = "Payment Request {PaymentRequest.Id} ({PaymentRequest.Title}) archived.",
PlaceHolders = paymentRequestPlaceholders,
CanIncludeCustomerEmail = true
DefaultEmail = new()
{
Subject = "Payment Request {PaymentRequest.Id} archived",
Body = "Payment Request {PaymentRequest.Id} ({PaymentRequest.Title}) archived.",
CanIncludeCustomerEmail = true
},
PlaceHolders = paymentRequestPlaceholders
},
new()
{
Trigger = WebhookEventType.PaymentRequestStatusChanged,
Description = "Payment Request - Status Changed",
SubjectExample = "Payment Request {PaymentRequest.Id} status changed",
BodyExample = "Payment Request {PaymentRequest.Id} ({PaymentRequest.Title}) status changed to {PaymentRequest.Status}.",
PlaceHolders = paymentRequestPlaceholders,
CanIncludeCustomerEmail = true
DefaultEmail = new()
{
Subject = "Payment Request {PaymentRequest.Id} status changed",
Body = "Payment Request {PaymentRequest.Id} ({PaymentRequest.Title}) status changed to {PaymentRequest.Status}.",
CanIncludeCustomerEmail = true
},
PlaceHolders = paymentRequestPlaceholders
},
new()
{
Trigger = WebhookEventType.PaymentRequestCompleted,
Description = "Payment Request - Completed",
SubjectExample = "Payment Request {PaymentRequest.Title} {PaymentRequest.ReferenceId} Completed",
BodyExample = "The total of {PaymentRequest.Amount} {PaymentRequest.Currency} has been received and Payment Request {PaymentRequest.Id} is completed.\nReview the payment request: {PaymentRequest.Link}",
PlaceHolders = paymentRequestPlaceholders,
CanIncludeCustomerEmail = true
DefaultEmail = new()
{
Subject = "Payment Request {PaymentRequest.Title} {PaymentRequest.ReferenceId} Completed",
Body = "The total of {PaymentRequest.Amount} {PaymentRequest.Currency} has been received and Payment Request {PaymentRequest.Id} is completed.\nReview the payment request: {PaymentRequest.Link}",
CanIncludeCustomerEmail = true
},
PlaceHolders = paymentRequestPlaceholders
}
};
services.AddWebhookTriggerViewModels(paymentRequestTriggers);
@@ -192,24 +220,33 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
{
Trigger = WebhookEventType.PayoutCreated,
Description = "Payout - Created",
SubjectExample = "Payout {Payout.Id} created",
BodyExample = "Payout {Payout.Id} (Pull Payment Id: {Payout.PullPaymentId}) created.",
DefaultEmail = new()
{
Subject = "Payout {Payout.Id} created",
Body = "Payout {Payout.Id} (Pull Payment Id: {Payout.PullPaymentId}) created."
},
PlaceHolders = payoutPlaceholders
},
new()
{
Trigger = WebhookEventType.PayoutApproved,
Description = "Payout - Approved",
SubjectExample = "Payout {Payout.Id} approved",
BodyExample = "Payout {Payout.Id} (Pull Payment Id: {Payout.PullPaymentId}) approved.",
DefaultEmail = new()
{
Subject = "Payout {Payout.Id} approved",
Body = "Payout {Payout.Id} (Pull Payment Id: {Payout.PullPaymentId}) approved."
},
PlaceHolders = payoutPlaceholders
},
new()
{
Trigger = WebhookEventType.PayoutUpdated,
Description = "Payout - Updated",
SubjectExample = "Payout {Payout.Id} updated",
BodyExample = "Payout {Payout.Id} (Pull Payment Id: {Payout.PullPaymentId}) updated.",
DefaultEmail = new()
{
Subject = "Payout {Payout.Id} updated",
Body = "Payout {Payout.Id} (Pull Payment Id: {Payout.PullPaymentId}) updated."
},
PlaceHolders = payoutPlaceholders
}
};
@@ -238,82 +275,110 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
{
Trigger = WebhookEventType.InvoiceCreated,
Description = "Invoice - Created",
SubjectExample = "Invoice {Invoice.Id} created",
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) created.",
PlaceHolders = invoicePlaceholders,
CanIncludeCustomerEmail = true
DefaultEmail = new()
{
Subject = "Invoice {Invoice.Id} created",
Body = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) created.",
CanIncludeCustomerEmail = true
},
PlaceHolders = invoicePlaceholders
},
new()
{
Trigger = WebhookEventType.InvoiceReceivedPayment,
Description = "Invoice - Received Payment",
SubjectExample = "Invoice {Invoice.Id} received payment",
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) received payment.",
PlaceHolders = invoicePlaceholders,
CanIncludeCustomerEmail = true
DefaultEmail = new()
{
Subject = "Invoice {Invoice.Id} received payment",
Body = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) received payment.",
CanIncludeCustomerEmail = true
},
PlaceHolders = invoicePlaceholders
},
new()
{
Trigger = WebhookEventType.InvoiceProcessing,
Description = "Invoice - Is Processing",
SubjectExample = "Invoice {Invoice.Id} processing",
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) is processing.",
PlaceHolders = invoicePlaceholders,
CanIncludeCustomerEmail = true
DefaultEmail = new()
{
Subject = "Invoice {Invoice.Id} processing",
Body = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) is processing.",
CanIncludeCustomerEmail = true
},
PlaceHolders = invoicePlaceholders
},
new()
{
Trigger = WebhookEventType.InvoiceExpired,
Description = "Invoice - Expired",
SubjectExample = "Invoice {Invoice.Id} expired",
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) expired.",
PlaceHolders = invoicePlaceholders,
CanIncludeCustomerEmail = true
DefaultEmail = new()
{
Subject = "Invoice {Invoice.Id} expired",
Body = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) expired.",
CanIncludeCustomerEmail = true
},
PlaceHolders = invoicePlaceholders
},
new()
{
Trigger = WebhookEventType.InvoiceSettled,
Description = "Invoice - Is Settled",
SubjectExample = "Invoice {Invoice.Id} settled",
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) is settled.",
PlaceHolders = invoicePlaceholders,
CanIncludeCustomerEmail = true
DefaultEmail = new()
{
Subject = "Invoice {Invoice.Id} settled",
Body = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) is settled.",
CanIncludeCustomerEmail = true
},
PlaceHolders = invoicePlaceholders
},
new()
{
Trigger = WebhookEventType.InvoiceInvalid,
Description = "Invoice - Became Invalid",
SubjectExample = "Invoice {Invoice.Id} invalid",
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) invalid.",
PlaceHolders = invoicePlaceholders,
CanIncludeCustomerEmail = true
DefaultEmail = new()
{
Subject = "Invoice {Invoice.Id} invalid",
Body = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) invalid.",
CanIncludeCustomerEmail = true
},
PlaceHolders = invoicePlaceholders
},
new()
{
Trigger = WebhookEventType.InvoicePaymentSettled,
Description = "Invoice - Payment Settled",
SubjectExample = "Invoice {Invoice.Id} payment settled",
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) payment settled.",
PlaceHolders = invoicePlaceholders,
CanIncludeCustomerEmail = true
DefaultEmail = new()
{
Subject = "Invoice {Invoice.Id} payment settled",
Body = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) payment settled.",
CanIncludeCustomerEmail = true
},
PlaceHolders = invoicePlaceholders
},
new()
{
Trigger = WebhookEventType.InvoiceExpiredPaidPartial,
Description = "Invoice - Expired Paid Partial",
SubjectExample = "Invoice {Invoice.Id} expired with partial payment",
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) expired with partial payment. \nPlease review and take appropriate action: {Invoice.Link}",
DefaultEmail = new()
{
Subject = "Invoice {Invoice.Id} expired with partial payment",
Body = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) expired with partial payment. \nPlease review and take appropriate action: {Invoice.Link}",
CanIncludeCustomerEmail = true
},
PlaceHolders = invoicePlaceholders,
CanIncludeCustomerEmail = true
},
new()
{
Trigger = WebhookEventType.InvoicePaidAfterExpiration,
Description = "Invoice - Expired Paid Late",
SubjectExample = "Invoice {Invoice.Id} paid after expiration",
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) paid after expiration.",
PlaceHolders = invoicePlaceholders,
CanIncludeCustomerEmail = true
DefaultEmail = new()
{
Subject = "Invoice {Invoice.Id} paid after expiration",
Body = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) paid after expiration.",
CanIncludeCustomerEmail = true
},
PlaceHolders = invoicePlaceholders
}
};
services.AddWebhookTriggerViewModels(emailTriggers);