Refactoring of Webhooks and Email Rules (#6954)
What changed, and why it matters
This is a large refactoring commit that moves BTCPay Server's webhook and email-rule features into separate plugin areas and introduces a new database-backed email-rule system with JSONPath-based conditions. The changes are primarily structural: code is relocated, controllers are renamed, and a new TextTemplate helper is added for email interpolation. There is no explicit security fix or vulnerability disclosure in the commit message or diff, and no direct evidence of an exploitable bug is visible in the supplied materials.
Treat as a routine large refactor. Review the new jsonb_path_exists query in EmailRuleData.GetMatches for SQL/JSONPath injection risks, validate that TextTemplate's case-folding does not introduce ambiguity or data leakage, and ensure email rule conditions are properly sanitized before storage. No immediate security response is indicated by the supplied materials.
Security signals we found
New SQL query using jsonb_path_exists with user-supplied JSONPath condition in EmailRuleData.GetMatches
New TextTemplate class performs case-folding on JSON property names before JSONPath selection, which could mask property collisions
Email rule conditions are stored and evaluated against JSON event models; improper JSONPath validation could allow expression injection
Migration moves email rules out of StoreBlob into a dedicated table with cascade delete on Store
No explicit security claim, CVE, or attribution in commit metadata
Evidence from the diff
The commit refactors webhooks and email rules into plugins (BTCPayServer.Plugins.Webhooks and BTCPayServer.Plugins.Emails). It removes StoreBlob-based email rules, adds an EmailRuleData entity with a PostgreSQL jsonb condition evaluated via jsonb_path_exists, introduces a TextTemplate class for case-insensitive JSON token interpolation, and adds RequestBaseUrl tracking for payment requests and pending transactions. Webhook delivery creation is centralized and cleanup is moved. The diff is large (+3895/-2806) but appears to be a feature/architecture refactor rather than a security patch.
Changed components
BTCPayServer.Plugins.EmailsBTCPayServer.Plugins.WebhooksBTCPayServer.Data.EmailRuleDataBTCPayServer.Common.TextTemplateBTCPayServer.Data.PendingTransactionBTCPayServer.Data.PaymentRequestBlobBTCPayServer.Controllers.UIPaymentRequestControllerBTCPayServer.Controllers.UIServerControllerBTCPayServer.HostedServices.PendingTransactionServiceInspect captured patch +3895 / −2806
diff --git a/BTCPayServer.Common/TextTemplate.cs b/BTCPayServer.Common/TextTemplate.cs
new file mode 100644
index 0000000..faddf84
--- /dev/null
+++ b/BTCPayServer.Common/TextTemplate.cs
@@ -0,0 +1,127 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Text;
+using System.Text.RegularExpressions;
+using Newtonsoft.Json.Linq;
+
+namespace BTCPayServer;
+
+public class TextTemplate(string template)
+{
+ static readonly Regex _interpolationRegex = new Regex(@"\{([^}]+)\}", RegexOptions.Compiled | RegexOptions.CultureInvariant);
+ public string Render(JObject model)
+ {
+ model = (JObject)ToLowerCase(model);
+ return _interpolationRegex.Replace(template, match =>
+ {
+ var path = match.Groups[1].Value;
+ var initial = path;
+ if (!path.StartsWith("$."))
+ path = $"$.{path}";
+ path = path.ToLowerInvariant();
+ try
+ {
+ var token = model.SelectToken(path);
+ return token?.ToString() ?? $"<NotFound({initial})>";
+ }
+ catch
+ {
+ return $"<ParsingError({initial})>";
+ }
+ });
+ }
+
+ public List<string> GetPaths(JObject model)
+ {
+ var paths = new List<List<string>>();
+ GetAvailablePaths(model, paths, null);
+
+ List<string> result = new List<string>();
+ foreach (var path in paths)
+ {
+ StringBuilder builder = new StringBuilder();
+ builder.Append('{');
+ int i = 0;
+ foreach (var p in path)
+ {
+ if (i != 0 && !p.StartsWith("["))
+ builder.Append('.');
+
+ builder.Append(p);
+ i++;
+ }
+ builder.Append('}');
+ result.Add(builder.ToString());
+ }
+ return result;
+ }
+
+ private void GetAvailablePaths(JToken tok, List<List<string>> paths, List<string>? currentPath)
+ {
+ if (tok is JProperty prop)
+ {
+ if (currentPath is null)
+ {
+ currentPath = new List<string>();
+ }
+ currentPath.Add(prop.Name);
+ GetAvailablePaths(prop.Value, paths, currentPath);
+ }
+ else if (tok is JValue)
+ {
+ if (currentPath is not null)
+ paths.Add(currentPath);
+ }
+ if (tok is JObject obj)
+ {
+ foreach (var p in obj.Properties())
+ {
+ var newPath = currentPath is null ? new List<string>() : new List<string>(currentPath);
+ GetAvailablePaths(p, paths, newPath);
+ }
+ }
+ if (tok is JArray arr)
+ {
+ int i = 0;
+ foreach (var tokChild in arr)
+ {
+ var newPath = currentPath is null ? new List<string>() : new List<string>(currentPath);
+ newPath.Add($"[{i++}]");
+ GetAvailablePaths(tokChild, paths, newPath);
+ }
+ }
+ }
+
+ private JToken ToLowerCase(JToken model)
+ {
+ if (model is JProperty obj)
+ return new JProperty(obj.Name.ToLowerInvariant(), ToLowerCase(obj.Value));
+ if (model is JArray arr)
+ {
+ var copy = new JArray();
+ foreach (var item in arr)
+ {
+ copy.Add(ToLowerCase(item));
+ }
+ return copy;
+ }
+ if (model is JObject)
+ {
+ var copy = new JObject();
+ foreach (var prop in model.Children<JProperty>())
+ {
+ var newProp = (JProperty)ToLowerCase(prop);
+ if (copy.Property(newProp.Name) is { } existing)
+ {
+ if (existing.Value is JObject exJobj)
+ exJobj.Merge(newProp.Value);
+ }
+ else
+ copy.Add(newProp);
+ }
+ return copy;
+ }
+ return model.DeepClone();
+ }
+}
diff --git a/BTCPayServer.Data/ApplicationDbContext.cs b/BTCPayServer.Data/ApplicationDbContext.cs
index bd3d0e3..8de6923 100644
--- a/BTCPayServer.Data/ApplicationDbContext.cs
+++ b/BTCPayServer.Data/ApplicationDbContext.cs
@@ -65,12 +65,14 @@ namespace BTCPayServer.Data
public DbSet<FormData> Forms { get; set; }
public DbSet<PendingTransaction> PendingTransactions { get; set; }
+ public DbSet<EmailRuleData> EmailRules { get; set; }
+
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
// some of the data models don't have OnModelCreating for now, commenting them
-
+ EmailRuleData.OnModelCreating(builder, Database);
ApplicationUser.OnModelCreating(builder, Database);
AddressInvoiceData.OnModelCreating(builder);
APIKeyData.OnModelCreating(builder, Database);
diff --git a/BTCPayServer.Data/Data/BaseEntityData.cs b/BTCPayServer.Data/Data/BaseEntityData.cs
new file mode 100644
index 0000000..ba77957
--- /dev/null
+++ b/BTCPayServer.Data/Data/BaseEntityData.cs
@@ -0,0 +1,82 @@
+#nullable enable
+
+using System;
+using System.ComponentModel.DataAnnotations.Schema;
+using System.IO;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using NBitcoin;
+using NBitcoin.DataEncoders;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+
+namespace BTCPayServer.Data;
+
+public class BaseEntityData
+{
+ static BaseEntityData()
+ {
+ Settings = new JsonSerializerSettings()
+ {
+ ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver(),
+ DefaultValueHandling = DefaultValueHandling.Ignore
+ };
+ Serializer = JsonSerializer.Create(Settings);
+ }
+ public static readonly JsonSerializerSettings Settings;
+ public static readonly JsonSerializer Serializer;
+
+ /// <summary>
+ /// User-defined custom data
+ /// </summary>
+ [Column("metadata", TypeName = "jsonb")]
+ public string Metadata { get; set; } = "{}";
+
+ /// <summary>
+ /// Data that is not user-defined, but can be used and extended internally by BTCPay Server or plugins.
+ /// </summary>
+ [Column("additional_data", TypeName = "jsonb")]
+ public string AdditionalData { get; set; } = "{}";
+
+ [Column("created_at", TypeName = "timestamptz")]
+ public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
+
+ public T? GetAdditionalData<T>(string key) where T: class
+ => JObject.Parse(AdditionalData)[key]?.ToObject<T>(Serializer);
+
+ public void SetAdditionalData<T>(string key, T? obj)
+ {
+ if (obj is null)
+ {
+ RemoveAdditionalData(key);
+ }
+ else
+ {
+ var w = new StringWriter();
+ Serializer.Serialize(w, obj);
+ w.Flush();
+ var jobj = JObject.Parse(AdditionalData);
+ jobj[key] = JToken.Parse(w.ToString());
+ AdditionalData = jobj.ToString();
+ }
+ }
+ public void RemoveAdditionalData(string key)
+ {
+ var jobj = JObject.Parse(AdditionalData);
+ jobj.Remove(key);
+ AdditionalData = jobj.ToString();
+ }
+
+ public static string GenerateId() => Encoders.Base58.EncodeData(RandomUtils.GetBytes(13));
+
+ protected static void OnModelCreateBase<TEntity>(EntityTypeBuilder<TEntity> b, ModelBuilder builder, DatabaseFacade databaseFacade) where TEntity : BaseEntityData
+ {
+ b.Property(x => x.CreatedAt).HasColumnName("created_at").HasColumnType("timestamptz")
+ .HasDefaultValueSql("now()");
+ b.Property(x => x.Metadata).HasColumnName("metadata").HasColumnType("jsonb")
+ .HasDefaultValueSql("'{}'::jsonb");
+ b.Property(x => x.AdditionalData).HasColumnName("additional_data").HasColumnType("jsonb")
+ .HasDefaultValueSql("'{}'::jsonb");
+ }
+}
diff --git a/BTCPayServer.Data/Data/EmailRuleData.cs b/BTCPayServer.Data/Data/EmailRuleData.cs
new file mode 100644
index 0000000..c874753
--- /dev/null
+++ b/BTCPayServer.Data/Data/EmailRuleData.cs
@@ -0,0 +1,77 @@
+#nullable enable
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+using System.Linq;
+using System.Threading.Tasks;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Newtonsoft.Json.Linq;
+
+namespace BTCPayServer.Data;
+
+[Table("email_rules")]
+public class EmailRuleData : BaseEntityData
+{
+ public static string GetWebhookTriggerName(string webhookType) => $"WH-{webhookType}";
+ [Column("store_id")]
+ public string? StoreId { get; set; }
+
+ [Key]
+ public long Id { get; set; }
+
+ [ForeignKey(nameof(StoreId))]
+ public StoreData? Store { get; set; }
+
+ [Required]
+ [Column("trigger")]
+ public string Trigger { get; set; } = null!;
+
+ [Column("condition")]
+ public string? Condition { get; set; }
+
+ [Required]
+ [Column("to")]
+ public string[] To { get; set; } = null!;
+
+ [Required]
+ [Column("subject")]
+ public string Subject { get; set; } = null!;
+ [Required]
+ [Column("body")]
+ public string Body { get; set; } = null!;
+
+ public class BTCPayAdditionalData
+ {
+ public bool CustomerEmail { get; set; }
+ }
+ public BTCPayAdditionalData? GetBTCPayAdditionalData() => this.GetAdditionalData<BTCPayAdditionalData>("btcpay");
+ public void SetBTCPayAdditionalData(BTCPayAdditionalData? data) => this.SetAdditionalData("btcpay", data);
+
+ internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
+ {
+ var b = builder.Entity<EmailRuleData>();
+ BaseEntityData.OnModelCreateBase(b, builder, databaseFacade);
+ b.Property(o => o.Id).UseIdentityAlwaysColumn();
+ b.HasOne(o => o.Store).WithMany().OnDelete(DeleteBehavior.Cascade);
+ b.HasIndex(o => o.StoreId);
+ }
+}
+public static partial class ApplicationDbContextExtensions
+{
+ public static IQueryable<EmailRuleData> GetRules(this IQueryable<EmailRuleData> query, string storeId)
+ => query.Where(o => o.StoreId == storeId)
+ .OrderBy(o => o.Id);
+
+ public static Task<EmailRuleData[]> GetMatches(this DbSet<EmailRuleData> set, string? storeId, string trigger, JObject model)
+ => set
+ .FromSqlInterpolated($"""
+ SELECT * FROM email_rules
+ WHERE store_id IS NOT DISTINCT FROM {storeId} AND trigger = {trigger} AND (condition IS NULL OR jsonb_path_exists({model.ToString()}::JSONB, condition::JSONPATH))
+ """)
+ .ToArrayAsync();
+
+ public static Task<EmailRuleData?> GetRule(this IQueryable<EmailRuleData> query, string storeId, long id)
+ => query.Where(o => o.StoreId == storeId && o.Id == id)
+ .FirstOrDefaultAsync();
+}
diff --git a/BTCPayServer.Data/Data/PendingTransaction.cs b/BTCPayServer.Data/Data/PendingTransaction.cs
index f968087..ef1e3e8 100644
--- a/BTCPayServer.Data/Data/PendingTransaction.cs
+++ b/BTCPayServer.Data/Data/PendingTransaction.cs
@@ -1,8 +1,11 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations.Schema;
+using System.Text.Json.Serialization;
+using BTCPayServer.Abstractions;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
+using Newtonsoft.Json;
namespace BTCPayServer.Data;
@@ -21,8 +24,8 @@ public class PendingTransaction: IHasBlob<PendingTransactionBlob>
public byte[] Blob { get; set; }
public string Blob2 { get; set; }
-
-
+
+
internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
{
builder.Entity<PendingTransaction>()
@@ -37,7 +40,7 @@ public class PendingTransaction: IHasBlob<PendingTransactionBlob>
builder.Entity<PendingTransaction>()
.Property(o => o.Blob2)
- .HasColumnType("JSONB");
+ .HasColumnType("JSONB");
builder.Entity<PendingTransaction>()
.Property(o => o.OutpointsUsed)
.HasColumnType("text[]");
@@ -56,6 +59,7 @@ public class PendingTransaction: IHasBlob<PendingTransactionBlob>
public class PendingTransactionBlob
{
public string PSBT { get; set; }
+ public string RequestBaseUrl { get; set; }
public List<CollectedSignature> CollectedSignatures { get; set; } = new();
public int? SignaturesCollected { get; set; }
diff --git a/BTCPayServer.Data/Data/WebhookDeliveryData.cs b/BTCPayServer.Data/Data/WebhookDeliveryData.cs
index 977834f..673a1e6 100644
--- a/BTCPayServer.Data/Data/WebhookDeliveryData.cs
+++ b/BTCPayServer.Data/Data/WebhookDeliveryData.cs
@@ -2,11 +2,15 @@ using System;
using System.ComponentModel.DataAnnotations;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
+using NBitcoin;
+using NBitcoin.DataEncoders;
namespace BTCPayServer.Data
{
public class WebhookDeliveryData
{
+ public static WebhookDeliveryData Create(string webhookId)
+ => new WebhookDeliveryData { Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(16)), Timestamp = DateTimeOffset.UtcNow, WebhookId = webhookId };
[Key]
[MaxLength(25)]
public string Id { get; set; }
diff --git a/BTCPayServer.Data/Migrations/20251015142818_emailrules.cs b/BTCPayServer.Data/Migrations/20251015142818_emailrules.cs
new file mode 100644
index 0000000..16a901f
--- /dev/null
+++ b/BTCPayServer.Data/Migrations/20251015142818_emailrules.cs
@@ -0,0 +1,82 @@
+using System;
+using BTCPayServer.Data;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace BTCPayServer.Migrations
+{
+ [DbContext(typeof(ApplicationDbContext))]
+ [Migration("20251015142818_emailrules")]
+ public partial class emailrules : Migration
+ {
+ /// <inheritdoc />
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "email_rules",
+ columns: table => new
+ {
+ Id = table.Column<long>(type: "bigint", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityAlwaysColumn),
+ store_id = table.Column<string>(type: "text", nullable: true),
+ trigger = table.Column<string>(type: "text", nullable: false),
+ condition = table.Column<string>(type: "text", nullable: true),
+ to = table.Column<string[]>(type: "text[]", nullable: false),
+ subject = table.Column<string>(type: "text", nullable: false, defaultValue: ""),
+ body = table.Column<string>(type: "text", nullable: false),
+ metadata = table.Column<string>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
+ additional_data = table.Column<string>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
+ created_at = table.Column<DateTimeOffset>(type: "timestamptz", nullable: false, defaultValueSql: "now()")
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_email_rules", x => x.Id);
+ table.ForeignKey(
+ name: "FK_email_rules_Stores_store_id",
+ column: x => x.store_id,
+ principalTable: "Stores",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_email_rules_store_id",
+ table: "email_rules",
+ column: "store_id");
+
+ migrationBuilder.Sql(
+ """
+ INSERT INTO email_rules (store_id, "to", subject, body, "trigger", additional_data)
+ SELECT
+ s."Id" AS store_id,
+ COALESCE(string_to_array(x."to", ','), ARRAY[]::text[]),
+ COALESCE(x.subject, ''),
+ COALESCE(x.body, ''),
+ CONCAT('WH-', x."trigger"),
+ jsonb_build_object('btcpay', jsonb_build_object('customerEmail', COALESCE(x."customerEmail", false))) AS additional_data
+ FROM "Stores" AS s
+ CROSS JOIN LATERAL jsonb_to_recordset(s."StoreBlob"->'emailRules')
+ AS x("to" text, subject text, body text, "trigger" text, "customerEmail" boolean)
+ WHERE jsonb_typeof(s."StoreBlob"->'emailRules') = 'array' AND x."trigger" IS NOT NULL;
+ """
+ );
+ migrationBuilder.Sql(
+ """
+ UPDATE "Stores"
+ SET "StoreBlob" = "StoreBlob" - 'emailRules'
+ WHERE jsonb_typeof("StoreBlob"->'emailRules') = 'array';
+ """
+ );
+ }
+
+ /// <inheritdoc />
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "email_rules");
+ }
+ }
+}
diff --git a/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs b/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs
index cc6fbd4..9c6f6eb 100644
--- a/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs
+++ b/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs
@@ -194,6 +194,69 @@ namespace BTCPayServer.Migrations
b.ToTable("AspNetUsers", (string)null);
});
+ modelBuilder.Entity("BTCPayServer.Data.EmailRuleData", b =>
+ {
+ b.Property<long>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property<long>("Id"));
+
+ b.Property<string>("AdditionalData")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasColumnName("additional_data")
+ .HasDefaultValueSql("'{}'::jsonb");
+
+ b.Property<string>("Body")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("body");
+
+ b.Property<string>("Condition")
+ .HasColumnType("text")
+ .HasColumnName("condition");
+
+ b.Property<DateTimeOffset>("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamptz")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property<string>("Metadata")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasColumnName("metadata")
+ .HasDefaultValueSql("'{}'::jsonb");
+
+ b.Property<string>("StoreId")
+ .HasColumnType("text")
+ .HasColumnName("store_id");
+
+ b.Property<string>("Subject")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("subject");
+
+ b.Property<string[]>("To")
+ .IsRequired()
+ .HasColumnType("text[]")
+ .HasColumnName("to");
+
+ b.Property<string>("Trigger")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("trigger");
+
+ b.HasKey("Id");
+
+ b.HasIndex("StoreId");
+
+ b.ToTable("email_rules");
+ });
+
modelBuilder.Entity("BTCPayServer.Data.Fido2Credential", b =>
{
b.Property<string>("Id")
@@ -1240,6 +1303,16 @@ namespace BTCPayServer.Migrations
b.Navigation("StoreData");
});
+ modelBuilder.Entity("BTCPayServer.Data.EmailRuleData", b =>
+ {
+ b.HasOne("BTCPayServer.Data.StoreData", "Store")
+ .WithMany()
+ .HasForeignKey("StoreId")
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.Navigation("Store");
+ });
+
modelBuilder.Entity("BTCPayServer.Data.Fido2Credential", b =>
{
b.HasOne("BTCPayServer.Data.ApplicationUser", "ApplicationUser")
diff --git a/BTCPayServer.Tests/GreenfieldAPITests.cs b/BTCPayServer.Tests/GreenfieldAPITests.cs
index 5d0386c..5a8f96a 100644
--- a/BTCPayServer.Tests/GreenfieldAPITests.cs
+++ b/BTCPayServer.Tests/GreenfieldAPITests.cs
@@ -20,6 +20,7 @@ using BTCPayServer.PayoutProcessors;
using BTCPayServer.PayoutProcessors.Lightning;
using BTCPayServer.Plugins.PointOfSale.Controllers;
using BTCPayServer.Plugins.PointOfSale.Models;
+using BTCPayServer.Plugins.Webhooks.HostedServices;
using BTCPayServer.Rating;
using BTCPayServer.Services;
using BTCPayServer.Services.Apps;
@@ -1957,7 +1958,7 @@ namespace BTCPayServer.Tests
TestLogs.LogInformation("Can prune deliveries");
- var cleanup = tester.PayTester.GetService<HostedServices.CleanupWebhookDeliveriesTask>();
+ var cleanup = tester.PayTester.GetService<CleanupWebhookDeliveriesTask>();
cleanup.BatchSize = 1;
cleanup.PruneAfter = TimeSpan.Zero;
await cleanup.Do(default);
diff --git a/BTCPayServer.Tests/MailPitClient.cs b/BTCPayServer.Tests/MailPitClient.cs
new file mode 100644
index 0000000..dd31353
--- /dev/null
+++ b/BTCPayServer.Tests/MailPitClient.cs
@@ -0,0 +1,193 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Net.Http;
+using System.Text.RegularExpressions;
+using System.Threading.Tasks;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+
+namespace BTCPayServer.Tests;
+
+public class MailPitClient
+{
+ private readonly HttpClient _client;
+
+ public MailPitClient(HttpClient client)
+ {
+ _client = client;
+ }
+
+ public async Task<Message> GetMessage(string id)
+ {
+ var result = await _client.GetStringAsync($"api/v1/message/{id}");
+ var settings = new JsonSerializerSettings
+ {
+ DateParseHandling = DateParseHandling.None // let the converter handle "Date"
+ };
+ return JsonConvert.DeserializeObject<Message>(result, settings);
+ }
+
+ public sealed class Message
+ {
+ [JsonProperty("Attachments")]
+ public List<Attachment> Attachments { get; set; }
+
+ [JsonProperty("Bcc")]
+ public List<MailAddress> Bcc { get; set; }
+
+ [JsonProperty("Cc")]
+ public List<MailAddress> Cc { get; set; }
+
+ [JsonProperty("Date")]
+ [JsonConverter(typeof(Rfc3339NanoDateTimeOffsetConverter))]
+ public DateTimeOffset? Date { get; set; }
+
+ [JsonProperty("From")]
+ public MailAddress From { get; set; }
+
+ [JsonProperty("HTML")]
+ public string Html { get; set; }
+
+ [JsonProperty("ID")]
+ public string Id { get; set; }
+
+ [JsonProperty("Inline")]
+ public List<Attachment> Inline { get; set; }
+
+ [JsonProperty("ListUnsubscribe")]
+ public ListUnsubscribeInfo ListUnsubscribe { get; set; }
+
+ [JsonProperty("MessageID")]
+ public string MessageId { get; set; }
+
+ [JsonProperty("ReplyTo")]
+ public List<MailAddress> ReplyTo { get; set; }
+
+ [JsonProperty("ReturnPath")]
+ public string ReturnPath { get; set; }
+
+ [JsonProperty("Size")]
+ public int? Size { get; set; }
+
+ [JsonProperty("Subject")]
+ public string Subject { get; set; }
+
+ [JsonProperty("Tags")]
+ public List<string> Tags { get; set; }
+
+ [JsonProperty("Text")]
+ public string Text { get; set; }
+
+ [JsonProperty("To")]
+ public List<MailAddress> To { get; set; }
+
+ [JsonProperty("Username")]
+ public string Username { get; set; }
+
+ // Capture any unexpected fields without breaking deserialization.
+ [JsonExtensionData]
+ public IDictionary<string, JToken> Extra { get; set; }
+ }
+
+ public sealed class Attachment
+ {
+ [JsonProperty("ContentID")]
+ public string ContentId { get; set; }
+
+ [JsonProperty("ContentType")]
+ public string ContentType { get; set; }
+
+ [JsonProperty("FileName")]
+ public string FileName { get; set; }
+
+ [JsonProperty("PartID")]
+ public string PartId { get; set; }
+
+ [JsonProperty("Size")]
+ public int? Size { get; set; }
+
+ [JsonExtensionData]
+ public IDictionary<string, JToken> Extra { get; set; }
+ }
+
+ public sealed class MailAddress
+ {
+ [JsonProperty("Address")]
+ public string Address { get; set; }
+
+ [JsonProperty("Name")]
+ public string Name { get; set; }
+ }
+
+ public sealed class ListUnsubscribeInfo
+ {
+ [JsonProperty("Errors")]
+ public string Errors { get; set; }
+
+ [JsonProperty("Header")]
+ public string Header { get; set; }
+
+ [JsonProperty("HeaderPost")]
+ public string HeaderPost { get; set; }
+
+ [JsonProperty("Links")]
+ public List<string> Links { get; set; }
+
+ [JsonExtensionData]
+ public IDictionary<string, JToken> Extra { get; set; }
+ }
+
+ /// <summary>
+ /// Permissive RFC3339/RFC3339Nano converter to DateTimeOffset.
+ /// Trims fractional seconds to 7 digits (the .NET limit).
+ /// </summary>
+ public sealed class Rfc3339NanoDateTimeOffsetConverter : JsonConverter<DateTimeOffset?>
+ {
+ // Matches fractional seconds if present, e.g., .123456789
+ private static readonly Regex FractionRegex =
+ new Regex(@"\.(\d+)(?=[Zz]|[+\-]\d{2}:\d{2}$)", RegexOptions.Compiled);
+
+ public override void WriteJson(JsonWriter writer, DateTimeOffset? value, JsonSerializer serializer)
+ {
+ if (value == null)
+ {
+ writer.WriteNull();
+ return;
+ }
+
+ // Use ISO 8601 with offset; keep up to 7 fractional digits if needed.
+ writer.WriteValue(value.Value.ToString("o", CultureInfo.InvariantCulture));
+ }
+
+ public override DateTimeOffset? ReadJson(JsonReader reader, Type objectType, DateTimeOffset? existingValue, bool hasExistingValue,
+ JsonSerializer serializer)
+ {
+ if (reader.TokenType == JsonToken.Null) return null;
+
+ if (reader.TokenType != JsonToken.String)
+ throw new JsonSerializationException($"Unexpected token {reader.TokenType} when parsing DateTimeOffset.");
+
+ var s = (string)reader.Value;
+ if (string.IsNullOrWhiteSpace(s)) return null;
+
+ // Trim fractional seconds to max 7 digits for .NET DateTime parsing.
+ s = FractionRegex.Replace(s, m =>
+ {
+ var frac = m.Groups[1].Value;
+ if (frac.Length <= 7) return m.Value; // unchanged
+ return "." + frac.Substring(0, 7);
+ });
+
+ if (DateTimeOffset.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dto))
+ return dto;
+
+ // Fallback: try without colon in offset (some variants exist)
+ s = s.Replace("Z", "+00:00").Replace("z", "+00:00");
+ if (DateTimeOffset.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out dto))
+ return dto;
+
+ throw new JsonSerializationException($"Unable to parse RFC3339 date-time: '{(string)reader.Value}'.");
+ }
+ }
+}
diff --git a/BTCPayServer.Tests/PMO/ConfigureEmailPMO.cs b/BTCPayServer.Tests/PMO/ConfigureEmailPMO.cs
new file mode 100644
index 0000000..27ffa81
--- /dev/null
+++ b/BTCPayServer.Tests/PMO/ConfigureEmailPMO.cs
@@ -0,0 +1,48 @@
+#nullable enable
+using System.Threading.Tasks;
+
+namespace BTCPayServer.Tests.PMO;
+
+public class ConfigureEmailPMO(PlaywrightTester s)
+{
+ public class Form
+ {
+ public string? Server { get; set; }
+ public int? Port { get; set; }
+ public string? From { get; set; }
+ public string? Login { get; set; }
+ public string? Password { get; set; }
+ public bool? EnabledCertificateCheck { get; set; }
+ }
+
+ public Task FillMailPit(Form form)
+ => Fill(new()
+ {
+ Server = s.Server.MailPitSettings.Hostname,
+ Port = s.Server.MailPitSettings.SmtpPort,
+ From = form.From,
+ Login = form.Login,
+ Password = form.Password,
+ EnabledCertificateCheck = false,
+ });
+
+ public async Task Fill(Form form)
+ {
+ if (form.Server is not null)
+ await s.Page.FillAsync("#Settings_Server", form.Server);
+ if (form.Port is { } p)
+ await s.Page.FillAsync("#Settings_Port", p.ToString());
+ if (form.From is not null)
+ await s.Page.FillAsync("#Settings_From", form.From);
+ if (form.Login is not null)
+ await s.Page.FillAsync("#Settings_Login", form.Login);
+ if (form.Password is not null)
+ await s.Page.FillAsync("#Settings_Password", form.Password);
+ if (form.EnabledCertificateCheck is { } v)
+ {
+ await s.Page.ClickAsync("#AdvancedSettingsButton");
+ await s.Page.SetCheckedAsync("#Settings_EnabledCertificateCheck", v);
+ }
+ await s.ClickPagePrimary();
+ }
+}
diff --git a/BTCPayServer.Tests/PMO/EmailRulePMO.cs b/BTCPayServer.Tests/PMO/EmailRulePMO.cs
new file mode 100644
index 0000000..4aabe8a
--- /dev/null
+++ b/BTCPayServer.Tests/PMO/EmailRulePMO.cs
@@ -0,0 +1,34 @@
+#nullable enable
+using System.Threading.Tasks;
+
+namespace BTCPayServer.Tests.PMO;
+
+public class EmailRulePMO(PlaywrightTester s)
+{
+ public class Form
+ {
+ public string? Trigger { get; set; }
+ public string? To { get; set; }
+ public string? Subject { get; set; }
+ public string? Body { get; set; }
+ public bool? CustomerEmail { get; set; }
+ public string? Condition { get; set; }
+ }
+
+ public async Task Fill(Form form)
+ {
+ if (form.Trigger is not null)
+ await s.Page.SelectOptionAsync("#Trigger", form.Trigger);
+ if (form.Condition is not null)
+ await s.Page.FillAsync("#Condition", form.Condition);
+ if (form.To is not null)
+ await s.Page.FillAsync("#To", form.To);
+ if (form.Subject is not null)
+ await s.Page.FillAsync("#Subject", form.Subject);
+ if (form.Body is not null)
+ await s.Page.Locator(".note-editable").FillAsync(form.Body);
+ if (form.CustomerEmail is {} v)
+ await s.Page.SetCheckedAsync("#AdditionalData_CustomerEmail", v);
+ await s.ClickPagePrimary();
+ }
+}
diff --git a/BTCPayServer.Tests/PlaywrightTests.cs b/BTCPayServer.Tests/PlaywrightTests.cs
index 4d04ee6..53ce837 100644
--- a/BTCPayServer.Tests/PlaywrightTests.cs
+++ b/BTCPayServer.Tests/PlaywrightTests.cs
@@ -18,6 +18,7 @@ using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Wallets;
+using BTCPayServer.Tests.PMO;
using BTCPayServer.Views.Manage;
using BTCPayServer.Views.Server;
using BTCPayServer.Views.Stores;
@@ -390,12 +391,16 @@ namespace BTCPayServer.Tests
Assert.DoesNotContain("You need to configure email settings before this feature works", await s.Page.ContentAsync());
await s.Page.ClickAsync("#CreateEmailRule");
- await s.Page.Locator("#Trigger").SelectOptionAsync(new[] { "InvoicePaymentSettled" });
- await s.Page.FillAsync("#To", "test@gmail.com");
- await s.Page.ClickAsync("#CustomerEmail");
- await s.Page.FillAsync("#Subject", "Thanks!");
- await s.Page.Locator(".note-editable").FillAsync("Your invoice is settled");
- await s.Page.ClickAsync("#SaveEmailRules");
+ var pmo = new EmailRulePMO(s);
+ await pmo.Fill(new()
+ {
+ Trigger = "WH-InvoicePaymentSettled",
+ To = "test@gmail.com",
+ CustomerEmail = true,
+ Subject = "Thanks!",
+ Body = "Your invoice is settled"
+ });
+
await s.FindAlertMessage();
// we now have a rule
Assert.DoesNotContain("There are no rules yet.", await s.Page.ContentAsync());
@@ -1404,34 +1409,55 @@ namespace BTCPayServer.Tests
[Fact]
public async Task CanSetupEmailRules()
{
- await using var s = CreatePlaywrightTester();
+ await using var s = CreatePlaywrightTester(newDb: true);
await s.StartAsync();
await s.RegisterNewUser(true);
- await s.CreateNewStore();
+ var (storeName, _) = await s.CreateNewStore();
await s.GoToStore(StoreNavPages.Emails);
await s.Page.ClickAsync("#ConfigureEmailRules");
Assert.Contains("There are no rules yet.", await s.Page.ContentAsync());
Assert.Contains("You need to configure email settings before this feature works", await s.Page.ContentAsync());
+ await s.Page.ClickAsync(".configure-email");
+
+ var mailPMO = new ConfigureEmailPMO(s);
+ await mailPMO.FillMailPit(new()
+ {
+ From = "store@store.com",
+ Login = "store@store.com",
+ Password = "password"
+ });
+
+ await s.GoToStore(StoreNavPages.Emails);
+ await s.Page.ClickAsync("#ConfigureEmailRules");
+
+ var pmo = new EmailRulePMO(s);
await s.Page.ClickAsync("#CreateEmailRule");
- await s.Page.SelectOptionAsync("#Trigger", "InvoiceCreated");
- await s.Page.FillAsync("#To", "invoicecreated@gmail.com");
- await s.Page.ClickAsync("#CustomerEmail");
- await s.Page.ClickAsync("#SaveEmailRules");
+
+ await pmo.Fill(new() {
+ Trigger = "WH-InvoiceCreated",
+ To = "invoicecreated@gmail.com",
+ Subject = "Invoice Created in {Invoice.Currency}!",
+ Body = "Invoice has been created in {Invoice.Currency} for {Invoice.Price}!",
+ CustomerEmail = true
+ });
await s.FindAlertMessage();
- Assert.DoesNotContain("There are no rules yet.", await s.Page.ContentAsync());
- Assert.Contains("invoicecreated@gmail.com", await s.Page.ContentAsync());
- Assert.Contains("Invoice {Invoice.Id} created", await s.Page.ContentAsync());
- Assert.Contains("Yes", await s.Page.ContentAsync());
+ var page = await s.Page.ContentAsync();
+ Assert.DoesNotContain("There are no rules yet.", page);
+ Assert.Contains("invoicecreated@gmail.com", page);
+ Assert.Contains("Invoice Created in {Invoice.Currency}!", page);
+ Assert.Contains("Yes", page);
await s.Page.ClickAsync("#CreateEmailRule");
- await s.Page.SelectOptionAsync("#Trigger", "PaymentRequestStatusChanged");
- await s.Page.FillAsync("#To", "statuschanged@gmail.com");
- await s.Page.FillAsync("#Subject", "Status changed!");
- await s.Page.Locator(".note-editable").FillAsync("Your Payment Request Status is Changed");
- await s.Page.ClickAsync("#SaveEmailRules");
+
+ await pmo.Fill(new() {
+ Trigger = "WH-PaymentRequestStatusChanged",
+ To = "statuschanged@gmail.com",
+ Subject = "Status changed!",
+ Body = "Your Payment Request Status is Changed"
+ });
await s.FindAlertMessage();
Assert.Contains("statuschanged@gmail.com", await s.Page.ContentAsync());
@@ -1441,14 +1467,23 @@ namespace BTCPayServer.Tests
Assert.True(await editButtons.CountAsync() >= 2);
await editButtons.Nth(1).ClickAsync();
- await s.Page.Locator("#To").ClearAsync();
- await s.Page.FillAsync("#To", "changedagain@gmail.com");
- await s.Page.ClickAsync("#SaveEmailRules");
+ await pmo.Fill(new() {
+ To = "changedagain@gmail.com"
+ });
await s.FindAlertMessage();
Assert.Contains("changedagain@gmail.com", await s.Page.ContentAsync());
Assert.DoesNotContain("statuschanged@gmail.com", await s.Page.ContentAsync());
+ var rulesUrl = s.Page.Url;
+
+ await s.AddDerivationScheme();
+ await s.GoToInvoices();
+ var sent = await s.Server.WaitForEvent<EmailSentEvent>(() => s.CreateInvoice(amount: 10m, currency: "USD"));
+ var message = await s.Server.AssertHasEmail(sent);
+ Assert.Equal("Invoice has been created in USD for 10!", message.Text);
+
+ await s.GoToUrl(rulesUrl);
var deleteLinks = s.Page.GetByRole(AriaRole.Link, new() { Name = "Remove" });
Assert.Equal(2, await deleteLinks.CountAsync());
@@ -1466,6 +1501,24 @@ namespace BTCPayServer.Tests
await s.FindAlertMessage();
Assert.Contains("There are no rules yet.", await s.Page.ContentAsync());
+
+ await s.Page.ClickAsync("#CreateEmailRule");
+
+ await pmo.Fill(new() {
+ Trigger = "WH-InvoiceCreated",
+ To = "invoicecreated@gmail.com",
+ Subject = "Invoice Created in {Invoice.Currency} for {Store.Name}!",
+ Body = "Invoice has been created in {Invoice.Currency} for {Invoice.Price}!",
+ CustomerEmail = true,
+ Condition = "$ ?(@.Invoice.Metadata.buyerEmail == \"john@test.com\")"
+ });
+
+ await s.GoToInvoices();
+ sent = await s.Server.WaitForEvent<EmailSentEvent>(() => s.CreateInvoice(amount: 10m, currency: "USD", refundEmail: "john@test.com"));
+ message = await s.Server.AssertHasEmail(sent);
+ Assert.Equal("Invoice Created in USD for " + storeName + "!", message.Subject);
+ Assert.Equal("Invoice has been created in USD for 10!", message.Text);
+ Assert.Equal("john@test.com", message.To[0].Address);
}
[Fact]
@@ -2034,7 +2087,7 @@ namespace BTCPayServer.Tests
var address = await s.Server.ExplorerNode.GetNewAddressAsync();
await newPage.FillAsync("#Destination", address.ToString());
await newPage.PressAsync("#Destination", "Enter");
-
+
await s.GoToStore(s.StoreId, StoreNavPages.Payouts);
await s.Page.ClickAsync("#InProgress-view");
@@ -2049,7 +2102,7 @@ namespace BTCPayServer.Tests
await s.Page.ClickAsync(".mass-action-select-all[data-payout-state='InProgress']");
await s.Page.ClickAsync("#InProgress-mark-awaiting-payment");
await s.Page.ClickAsync("#AwaitingPayment-view");
-
+
var pageContent = await s.Page.ContentAsync();
Assert.Contains("PP1", pageContent);
}
diff --git a/BTCPayServer.Tests/ServerTester.cs b/BTCPayServer.Tests/ServerTester.cs
index cfff82a..4bb9db0 100644
--- a/BTCPayServer.Tests/ServerTester.cs
+++ b/BTCPayServer.Tests/ServerTester.cs
@@ -20,12 +20,14 @@ using NBitpayClient;
using NBXplorer;
using BTCPayServer.Abstractions.Contracts;
using System.Diagnostics.Metrics;
+using BTCPayServer.Events;
namespace BTCPayServer.Tests
{
public class ServerTester : IDisposable
{
public const string DefaultConnectionString = "User ID=postgres;Include Error Detail=true;Host=127.0.0.1;Port=39372;Database=btcpayserver";
+ public (string Hostname, int SmtpPort, int HttpPort) MailPitSettings { get; set; }
public List<IDisposable> Resources = new List<IDisposable>();
readonly string _Directory;
@@ -43,6 +45,11 @@ namespace BTCPayServer.Tests
if (!Directory.Exists(_Directory))
Directory.CreateDirectory(_Directory);
+ MailPitSettings = (
+ GetEnvironment("TESTS_MAILPIT_HOST", "127.0.0.1"),
+ int.Parse(GetEnvironment("TESTS_MAILPIT_SMTP", "34219")),
+ int.Parse(GetEnvironment("TESTS_MAILPIT_HTTP", "34218")));
+
_NetworkProvider = networkProvider;
ExplorerNode = new RPCClient(RPCCredentialString.Parse(GetEnvironment("TESTS_BTCRPCCONNECTION", "server=http://127.0.0.1:43782;ceiwHEbqWI83:DwubwWsoo3")), NetworkProvider.GetNetwork<BTCPayNetwork>("BTC").NBitcoinNetwork);
ExplorerNode.ScanRPCCapabilities();
@@ -286,5 +293,19 @@ namespace BTCPayServer.Tests
cryptoCode == "LTC" ? NetworkProvider.GetNetwork<BTCPayNetwork>("LTC") :
cryptoCode == "LBTC" ? NetworkProvider.GetNetwork<BTCPayNetwork>("LBTC") :
throw new NotSupportedException();
+
+ public async Task<MailPitClient.Message> AssertHasEmail(EmailSentEvent sent)
+ {
+ var mailPitClient = GetMailPitClient();
+ return await mailPitClient.GetMessage(sent.ServerResponse.Split(' ').Last());
+ }
+
+ public MailPitClient GetMailPitClient()
+ {
+ var http = PayTester.GetService<IHttpClientFactory>().CreateClient("MAIL_PIT");
+ http.BaseAddress = new Uri($"http://{MailPitSettings.Hostname}:{MailPitSettings.HttpPort}");
+ var mailPitClient = new MailPitClient(http);
+ return mailPitClient;
+ }
}
}
diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs
index 63a7810..6807604 100644
--- a/BTCPayServer.Tests/UnitTest1.cs
+++ b/BTCPayServer.Tests/UnitTest1.cs
@@ -76,8 +76,9 @@ using MarkPayoutRequest = BTCPayServer.Client.Models.MarkPayoutRequest;
using RatesViewModel = BTCPayServer.Models.StoreViewModels.RatesViewModel;
using Microsoft.Extensions.Caching.Memory;
using PosViewType = BTCPayServer.Client.Models.PosViewType;
-using BTCPayServer.PaymentRequest;
+using BTCPayServer.Plugins.Emails.Controllers;
using BTCPayServer.Views.Stores;
+using MimeKit;
using NBXplorer.DerivationStrategy;
namespace BTCPayServer.Tests
@@ -1599,6 +1600,107 @@ namespace BTCPayServer.Tests
});
}
+ [Fact]
+ [Trait("UnitTest", "UnitTest")]
+ public void TestMailTemplate()
+ {
+ var template = new TextTemplate("Hello mister {Name.Firstname} {Name.Lastname} !");
+
+ // Happy path
+ JObject model = new()
+ {
+ ["Name"] = new JObject
+ {
+ ["Firstname"] = "John",
+ ["Lastname"] = "Doe"
+ }
+ };
+ var result = template.Render(model);
+
+ // Null values are not rendered
+ Assert.Equal("Hello mister John Doe !", result);
+ model = new()
+ {
+ ["Name"] = new JObject
+ {
+ ["Firstname"] = "John",
+ ["Lastname"] = null
+ }
+ };
+ result = template.Render(model);
+ Assert.Equal("Hello mister John !", result);
+
+ // No crash on missing fields
+ model = new()
+ {
+ ["Name"] = new JObject
+ {
+ ["Firstname"] = "John",
+ }
+ };
+ result = template.Render(model);
+ Assert.Equal("Hello mister John <NotFound(Name.Lastname)> !", result);
+
+ // Is Case insensitive
+ model = new()
+ {
+ ["Name"] = new JObject
+ {
+ ["firstname"] = "John",
+ }
+ };
+ result = template.Render(model);
+ Assert.Equal("Hello mister John <NotFound(Name.Lastname)> !", result);
+
+ model = new()
+ {
+ ["Name"] = new JObject
+ {
+ ["Firstname"] = "John",
+ ["Lastname"] = "Doe",
+ ["NameInner"] = new JObject
+ {
+ ["Ogg"] = 2,
+ ["Arr"] = new JArray {
+ new JObject() { ["ItemName"] = "hello" },
+ 2,
+ new JObject() { ["ItemName"] = "world" } }
+ }
+ }
+ };
+ var paths = template.GetPaths(model);
+ Assert.Equal("{Name.Firstname}", paths[0]);
+ Assert.Equal("{Name.Lastname}", paths[1]);
+ Assert.Equal("{Name.NameInner.Ogg}", paths[2]);
+ Assert.Equal("{Name.NameInner.Arr[0].ItemName}", paths[3]);
+ Assert.Equal("{Name.NameInner.Arr[1]}", paths[4]);
+ Assert.Equal("{Name.NameInner.Arr[2].ItemName}", paths[5]);
+
+ model = new()
+ {
+ ["Name"] = new JObject
+ {
+ ["Firstname"] = "John",
+ ["Lastname"] = "Doe",
+ ["NameInner"] = new JObject
+ {
+ ["Ogg"] = 2,
+ ["Arr"] = new JArray {
+ new JObject() { ["ItemName"] = "hello" },
+ 2,
+ new JObject() { ["ItemName"] = "world" } }
+ },
+ ["nameInner"] = new JObject()
+ {
+ ["Ogg2"] = 3
+ }
+ }
+ };
+ template = new TextTemplate("Hello mister {Name.NameInner.Ogg} {Name.NameInner.Ogg2} !");
+ result = template.Render(model);
+ Assert.Equal("Hello mister 2 3 !", result);
+ }
+
[Fact]
[Trait("Integration", "Integration")]
public async Task CanUseDefaultCurrency()
@@ -3250,16 +3352,26 @@ 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>().StoreEmailSettings(acc.StoreId, new EmailsViewModel(new EmailSettings
+ Assert.IsType<RedirectToActionResult>(await acc.GetController<UIStoresEmailController>().StoreEmailSettings(acc.StoreId, new EmailsViewModel(new EmailSettings
{
From = "store@store.com",
Login = "store@store.com",
Password = "store@store.com",
- Port = 1234,
- Server = "store.com"
+ Port = tester.MailPitSettings.SmtpPort,
+ Server = tester.MailPitSettings.Hostname
}), ""));
Assert.Equal("store@store.com", (await Assert.IsType<StoreEmailSender>(await emailSenderFactory.GetEmailSender(acc.StoreId)).GetEmailSettings()).Login);
+
+ var sent = await tester.WaitForEvent<Events.EmailSentEvent>(
+ async () =>
+ {
+ var sender = await emailSenderFactory.GetEmailSender(acc.StoreId);
+ sender.SendEmail(MailboxAddress.Parse("destination@test.com"), "test", "hello world");
+ });
+ var message = await tester.AssertHasEmail(sent);
+ Assert.Equal("test", message.Subject);
+ Assert.Equal("hello world", message.Text);
}
[Fact(Timeout = TestUtils.TestTimeout)]
diff --git a/BTCPayServer.Tests/docker-compose.altcoins.yml b/BTCPayServer.Tests/docker-compose.altcoins.yml
index beff000..17b1df5 100644
--- a/BTCPayServer.Tests/docker-compose.altcoins.yml
+++ b/BTCPayServer.Tests/docker-compose.altcoins.yml
@@ -18,6 +18,9 @@ services:
TESTS_LTCNBXPLORERURL: http://nbxplorer:32838/
TESTS_POSTGRES: User ID=postgres;Include Error Detail=true;Host=postgres;Port=5432;Database=btcpayserver
TESTS_EXPLORER_POSTGRES: User ID=postgres;Include Error Detail=true;Host=postgres;Port=5432;Database=nbxplorer
+ TESTS_MAILPIT_HOST: mailpit
+ TESTS_MAILPIT_SMTP: 1025
+ TESTS_MAILPIT_HTTP: 8025
TESTS_HOSTNAME: tests
TESTS_RUN_EXTERNAL_INTEGRATION: ${TESTS_RUN_EXTERNAL_INTEGRATION:-"false"}
TESTS_AzureBlobStorageConnectionString: ${TESTS_AzureBlobStorageConnectionString:-none}
@@ -60,6 +63,7 @@ services:
- merchant_lnd
- sshd
- tor
+ - mailpit
sshd:
build:
@@ -98,6 +102,17 @@ services:
default:
custom:
+ mailpit:
+ image: axllent/mailpit:v1.27
+ ports:
+ # Web UI
+ - "34218:8025"
+ # SMTP
+ - "34219:1025"
+ environment:
+ MP_SMTP_AUTH_ACCEPT_ANY: 1
+ MP_SMTP_AUTH_ALLOW_INSECURE: 1
+
nbxplorer:
image: nicolasdorier/nbxplorer:2.5.29
restart: unless-stopped
diff --git a/BTCPayServer.Tests/docker-compose.yml b/BTCPayServer.Tests/docker-compose.yml
index b05b6f1..627b116 100644
--- a/BTCPayServer.Tests/docker-compose.yml
+++ b/BTCPayServer.Tests/docker-compose.yml
@@ -16,6 +16,9 @@ services:
TESTS_BTCNBXPLORERURL: http://nbxplorer:32838/
TESTS_POSTGRES: User ID=postgres;Include Error Detail=true;Host=postgres;Port=5432;Database=btcpayserver
TESTS_EXPLORER_POSTGRES: User ID=postgres;Include Error Detail=true;Host=postgres;Port=5432;Database=nbxplorer
+ TESTS_MAILPIT_HOST: mailpit
+ TESTS_MAILPIT_SMTP: 1025
+ TESTS_MAILPIT_HTTP: 8025
TESTS_HOSTNAME: tests
TESTS_RUN_EXTERNAL_INTEGRATION: ${TESTS_RUN_EXTERNAL_INTEGRATION:-"false"}
TESTS_AzureBlobStorageConnectionString: ${TESTS_AzureBlobStorageConnectionString:-none}
@@ -56,6 +59,7 @@ services:
- merchant_lnd
- sshd
- tor
+ - mailpit
sshd:
build:
@@ -94,6 +98,17 @@ services:
default:
custom:
+ mailpit:
+ image: axllent/mailpit:v1.27
+ ports:
+ # Web UI
+ - "34218:8025"
+ # SMTP
+ - "34219:1025"
+ environment:
+ MP_SMTP_AUTH_ACCEPT_ANY: 1
+ MP_SMTP_AUTH_ALLOW_INSECURE: 1
+
nbxplorer:
image: nicolasdorier/nbxplorer:2.5.29
restart: unless-stopped
diff --git a/BTCPayServer/Components/MainNav/Default.cshtml b/BTCPayServer/Components/MainNav/Default.cshtml
index 2233ce1..f29113a 100644
--- a/BTCPayServer/Components/MainNav/Default.cshtml
+++ b/BTCPayServer/Components/MainNav/Default.cshtml
@@ -11,6 +11,7 @@
@using BTCPayServer.Services
@using BTCPayServer.Views.Apps
@using BTCPayServer.Configuration
+@using BTCPayServer.Plugins.Emails
@inject Microsoft.AspNetCore.Http.IHttpContextAccessor HttpContext;
@inject BTCPayServerOptions BtcPayServerOptions
@inject BTCPayServerEnvironment Env
@@ -61,13 +62,13 @@
<a id="StoreNav-@(nameof(StoreNavPages.Roles))" class="nav-link @ViewData.ActivePageClass(StoreNavPages.Roles)" asp-controller="UIStores" asp-action="ListRoles" asp-route-storeId="@Model.Store.Id" text-translate="true">Roles</a>
</li>
<li class="nav-item nav-item-sub" permission="@Policies.CanViewStoreSettings">
- <a id="StoreNav-@(nameof(StoreNavPages.Webhooks))" class="nav-link @ViewData.ActivePageClass(StoreNavPages.Webhooks)" asp-controller="UIStores" asp-action="Webhooks" asp-route-storeId="@Model.Store.Id" text-translate="true">Webhooks</a>
+ <a id="StoreNav-@(nameof(StoreNavPages.Webhooks))" class="nav-link @ViewData.ActivePageClass(StoreNavPages.Webhooks)" asp-area="Webhooks" asp-controller="UIStoreWebhooks" asp-action="Webhooks" asp-route-storeId="@Model.Store.Id" text-translate="true">Webhooks</a>
</li>
<li class="nav-item nav-item-sub" permission="@Policies.CanViewStoreSettings">
<a id="StoreNav-@(nameof(StoreNavPages.PayoutProcessors))" class="nav-link @ViewData.ActivePageClass(StoreNavPages.PayoutProcessors)" asp-controller="UIPayoutProcessors" asp-action="ConfigureStorePayoutProcessors" asp-route-storeId="@Model.Store.Id" text-translate="true">Payout Processors</a>
</li>
<li class="nav-item nav-item-sub" permission="@Policies.CanViewStoreSettings">
- <a id="StoreNav-@(nameof(StoreNavPages.Emails))" class="nav-link @ViewData.ActivePageClass(StoreNavPages.Emails)" asp-controller="UIStores" asp-action="StoreEmailSettings" asp-route-storeId="@Model.Store.Id" text-translate="true">Emails</a>
+ <a id="StoreNav-@(nameof(StoreNavPages.Emails))" class="nav-link @ViewData.ActivePageClass(StoreNavPages.Emails)" asp-area="@EmailsPlugin.Area" asp-controller="UIStoresEmail" asp-action="StoreEmailSettings" asp-route-storeId="@Model.Store.Id" text-translate="true">Emails</a>
</li>
<li class="nav-item nav-item-sub" permission="@Policies.CanViewStoreSettings">
<a id="StoreNav-@(nameof(StoreNavPages.Forms))" class="nav-link @ViewData.ActivePageClass(StoreNavPages.Forms)" asp-controller="UIForms" asp-action="FormsList" asp-route-storeId="@Model.Store.Id" text-translate="true">Forms</a>
diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldPaymentRequestsController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldPaymentRequestsController.cs
index 96b66a4..5d27e30 100644
--- a/BTCPayServer/Controllers/GreenField/GreenfieldPaymentRequestsController.cs
+++ b/BTCPayServer/Controllers/GreenField/GreenfieldPaymentRequestsController.cs
@@ -182,7 +182,7 @@ namespace BTCPayServer.Controllers.Greenfield
new PaymentRequestQuery() { StoreId = storeId, Ids = new[] { paymentRequestId } })).FirstOrDefault();
if (pr is null)
return PaymentRequestNotFound();
- if ((pr.Amount != request.Amount && request.Amount != 0.0m) ||
+ if ((pr.Amount != request.Amount && request.Amount != 0.0m) ||
(pr.Currency != request.Currency && request.Currency != null))
{
var prWithInvoices = await this.PaymentRequestService.GetPaymentRequest(paymentRequestId, GetUserId());
@@ -226,7 +226,8 @@ namespace BTCPayServer.Controllers.Greenfield
Description = request.Description,
Email = request.Email,
FormId = request.FormId,
- FormResponse = blob.FormId != request.FormId ? null : blob.FormResponse
+ FormResponse = blob.FormId != request.FormId ? null : blob.FormResponse,
+ RequestBaseUrl = Request.GetRequestBaseUrl().ToString()
});
pr = await _paymentRequestRepository.CreateOrUpdatePaymentRequest(pr);
return Ok(FromModel(pr));
diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldStoreWebhooksController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldStoreWebhooksController.cs
deleted file mode 100644
index 7d7d793..0000000
--- a/BTCPayServer/Controllers/GreenField/GreenfieldStoreWebhooksController.cs
+++ /dev/null
@@ -1,202 +0,0 @@
-using System;
-using System.Linq;
-using System.Threading.Tasks;
-using BTCPayServer.Abstractions.Constants;
-using BTCPayServer.Abstractions.Extensions;
-using BTCPayServer.Client;
-using BTCPayServer.Client.Models;
-using BTCPayServer.Data;
-using BTCPayServer.HostedServices.Webhooks;
-using BTCPayServer.Services.Stores;
-using Microsoft.AspNetCore.Authorization;
-using Microsoft.AspNetCore.Cors;
-using Microsoft.AspNetCore.Mvc;
-using Newtonsoft.Json.Linq;
-
-namespace BTCPayServer.Controllers.Greenfield
-{
- [ApiController]
- [Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield,
- Policy = Policies.CanModifyWebhooks)]
- [EnableCors(CorsPolicies.All)]
- public class GreenfieldStoreWebhooksController : ControllerBase
- {
- public GreenfieldStoreWebhooksController(StoreRepository storeRepository, WebhookSender webhookSender)
- {
- StoreRepository = storeRepository;
- WebhookSender = webhookSender;
- }
-
- public StoreRepository StoreRepository { get; }
- public WebhookSender WebhookSender { get; }
-
- [HttpGet("~/api/v1/stores/{storeId}/webhooks/{webhookId?}")]
- public async Task<IActionResult> ListWebhooks(string storeId, string webhookId)
- {
- if (webhookId is null)
- {
- return Ok((await StoreRepository.GetWebhooks(CurrentStoreId))
- .Select(o => FromModel(o, false))
- .ToArray());
- }
- else
- {
- var w = await StoreRepository.GetWebhook(CurrentStoreId, webhookId);
- if (w is null)
- return WebhookNotFound();
- return Ok(FromModel(w, false));
- }
- }
-
- string CurrentStoreId
- {
- get
- {
- return this.HttpContext.GetStoreData()?.Id;
- }
- }
-
- [HttpPost("~/api/v1/stores/{storeId}/webhooks")]
- public async Task<IActionResult> CreateWebhook(string storeId, Client.Models.CreateStoreWebhookRequest create)
- {
- ValidateWebhookRequest(create);
- if (!ModelState.IsValid)
- return this.CreateValidationError(ModelState);
- var webhookId = await StoreRepository.CreateWebhook(CurrentStoreId, ToModel(create));
- var w = await StoreRepository.GetWebhook(CurrentStoreId, webhookId);
- if (w is null)
- return WebhookNotFound();
- return Ok(FromModel(w, true));
- }
-
- private void ValidateWebhookRequest(StoreWebhookBaseData create)
- {
- if (!Uri.TryCreate(create?.Url, UriKind.Absolute, out _))
- ModelState.AddModelError(nameof(Url), "Invalid Url");
- }
-
- [HttpPut("~/api/v1/stores/{storeId}/webhooks/{webhookId}")]
- public async Task<IActionResult> UpdateWebhook(string storeId, string webhookId, Client.Models.UpdateStoreWebhookRequest update)
- {
- ValidateWebhookRequest(update);
- if (!ModelState.IsValid)
- return this.CreateValidationError(ModelState);
- var w = await StoreRepository.GetWebhook(CurrentStoreId, webhookId);
- if (w is null)
- return WebhookNotFound();
- await StoreRepository.UpdateWebhook(storeId, webhookId, ToModel(update));
- return await ListWebhooks(storeId, webhookId);
- }
- [HttpDelete("~/api/v1/stores/{storeId}/webhooks/{webhookId}")]
- public async Task<IActionResult> DeleteWebhook(string storeId, string webhookId)
- {
- var w = await StoreRepository.GetWebhook(CurrentStoreId, webhookId);
- if (w is null)
- return WebhookNotFound();
- await StoreRepository.DeleteWebhook(CurrentStoreId, webhookId);
- return Ok();
- }
-
- IActionResult WebhookNotFound()
- {
- return this.CreateAPIError(404, "webhook-not-found", "The webhook was not found");
- }
- IActionResult WebhookDeliveryNotFound()
- {
- return this.CreateAPIError(404, "webhookdelivery-not-found", "The webhook delivery was not found");
- }
- private WebhookBlob ToModel(StoreWebhookBaseData create)
- {
- return new WebhookBlob()
- {
- Active = create.Enabled,
- Url = create.Url,
- Secret = create.Secret,
- AuthorizedEvents = create.AuthorizedEvents is Client.Models.StoreWebhookBaseData.AuthorizedEventsData aed ?
- new AuthorizedWebhookEvents()
- {
- Everything = aed.Everything,
- SpecificEvents = aed.SpecificEvents
- } :
- new AuthorizedWebhookEvents() { Everything = true },
- AutomaticRedelivery = create.AutomaticRedelivery,
- };
- }
-
-
- [HttpGet("~/api/v1/stores/{storeId}/webhooks/{webhookId}/deliveries/{deliveryId?}")]
- public async Task<IActionResult> ListDeliveries(string storeId, string webhookId, string deliveryId, int? count = null)
- {
- if (deliveryId is null)
- {
- return Ok((await StoreRepository.GetWebhookDeliveries(CurrentStoreId, webhookId, count))
- .Select(o => FromModel(o))
- .ToList());
- }
- else
- {
- var delivery = await StoreRepository.GetWebhookDelivery(CurrentStoreId, webhookId, deliveryId);
- if (delivery is null)
- return WebhookDeliveryNotFound();
- return Ok(FromModel(delivery));
- }
- }
- [HttpPost("~/api/v1/stores/{storeId}/webhooks/{webhookId}/deliveries/{deliveryId}/redeliver")]
- public async Task<IActionResult> RedeliverWebhook(string storeId, string webhookId, string deliveryId)
- {
- var delivery = await StoreRepository.GetWebhookDelivery(CurrentStoreId, webhookId, deliveryId);
- if (delivery is null)
- return WebhookDeliveryNotFound();
- if (delivery.GetBlob().IsPruned())
- return WebhookDeliveryPruned();
- return this.Ok(new JValue(await WebhookSender.Redeliver(deliveryId)));
- }
-
- private IActionResult WebhookDeliveryPruned()
- {
- return this.CreateAPIError(409, "webhookdelivery-pruned", "This webhook delivery has been pruned, so it can't be redelivered");
- }
-
- [HttpGet("~/api/v1/stores/{storeId}/webhooks/{webhookId}/deliveries/{deliveryId}/request")]
- public async Task<IActionResult> GetDeliveryRequest(string storeId, string webhookId, string deliveryId)
- {
- var delivery = await StoreRepository.GetWebhookDelivery(CurrentStoreId, webhookId, deliveryId);
- if (delivery is null)
- return WebhookDeliveryNotFound();
- if (delivery.GetBlob().IsPruned())
- return WebhookDeliveryPruned();
- return File(delivery.GetBlob().Request, "application/json");
- }
-
- private Client.Models.WebhookDeliveryData FromModel(Data.WebhookDeliveryData data)
- {
- var b = data.GetBlob();
- return new Client.Models.WebhookDeliveryData()
- {
- Id = data.Id,
- Timestamp = data.Timestamp,
- Status = b.Status,
- ErrorMessage = b.ErrorMessage,
- HttpCode = b.HttpCode
- };
- }
-
- Client.Models.StoreWebhookData FromModel(Data.WebhookData data, bool includeSecret)
- {
- var b = data.GetBlob();
- return new Client.Models.StoreWebhookData()
- {
- Id = data.Id,
- Url = b.Url,
- Enabled = b.Active,
- Secret = includeSecret ? b.Secret : null,
- AutomaticRedelivery = b.AutomaticRedelivery,
- AuthorizedEvents = new Client.Models.StoreWebhookData.AuthorizedEventsData()
- {
- Everything = b.AuthorizedEvents.Everything,
- SpecificEvents = b.AuthorizedEvents.SpecificEvents
- }
- };
- }
- }
-}
diff --git a/BTCPayServer/Controllers/GreenField/LocalBTCPayServerClient.cs b/BTCPayServer/Controllers/GreenField/LocalBTCPayServerClient.cs
index 1ef6059..125c7d9 100644
--- a/BTCPayServer/Controllers/GreenField/LocalBTCPayServerClient.cs
+++ b/BTCPayServer/Controllers/GreenField/LocalBTCPayServerClient.cs
@@ -12,6 +12,7 @@ using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers.GreenField;
using BTCPayServer.Data;
+using BTCPayServer.Plugins.Webhooks.Controllers;
using BTCPayServer.Security;
using BTCPayServer.Security.Greenfield;
using BTCPayServer.Services.Mails;
diff --git a/BTCPayServer/Controllers/UIInvoiceController.UI.cs b/BTCPayServer/Controllers/UIInvoiceController.UI.cs
index e76ce58..964be9a 100644
--- a/BTCPayServer/Controllers/UIInvoiceController.UI.cs
+++ b/BTCPayServer/Controllers/UIInvoiceController.UI.cs
@@ -20,6 +20,7 @@ using BTCPayServer.Models.PaymentRequestViewModels;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.Payouts;
+using BTCPayServer.Plugins.Webhooks.Views;
using BTCPayServer.Rating;
using BTCPayServer.Services;
using BTCPayServer.Services.Apps;
@@ -154,7 +155,7 @@ namespace BTCPayServer.Controllers
ShowCheckout = invoice.Status == InvoiceStatus.New,
ShowReceipt = invoice.Status == InvoiceStatus.Settled && (invoice.ReceiptOptions?.Enabled ?? receipt.Enabled is true),
Deliveries = (await _InvoiceRepository.GetWebhookDeliveries(invoiceId))
- .Select(c => new Models.StoreViewModels.DeliveryViewModel(c))
+ .Select(c => new DeliveryViewModel(c))
.ToList()
};
diff --git a/BTCPayServer/Controllers/UIInvoiceController.cs b/BTCPayServer/Controllers/UIInvoiceController.cs
index f4f9a3d..b5d7d24 100644
--- a/BTCPayServer/Controllers/UIInvoiceController.cs
+++ b/BTCPayServer/Controllers/UIInvoiceController.cs
@@ -12,10 +12,8 @@ using BTCPayServer.Services;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.HostedServices;
-using BTCPayServer.HostedServices.Webhooks;
using BTCPayServer.Logging;
using BTCPayServer.Payments;
-using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Rating;
using BTCPayServer.Security;
using BTCPayServer.Security.Greenfield;
@@ -29,11 +27,10 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
-using NBitcoin;
using Newtonsoft.Json.Linq;
using StoreData = BTCPayServer.Data.StoreData;
-using Serilog.Filters;
using BTCPayServer.Payouts;
+using BTCPayServer.Plugins.Webhooks;
using Microsoft.Extensions.Localization;
namespace BTCPayServer.Controllers
diff --git a/BTCPayServer/Controllers/UIPaymentRequestController.cs b/BTCPayServer/Controllers/UIPaymentRequestController.cs
index 76d0425..a7e725c 100644
--- a/BTCPayServer/Controllers/UIPaymentRequestController.cs
+++ b/BTCPayServer/Controllers/UIPaymentRequestController.cs
@@ -23,6 +23,7 @@ using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
using PaymentRequestData = BTCPayServer.Data.PaymentRequestData;
using StoreData = BTCPayServer.Data.StoreData;
@@ -38,10 +39,10 @@ namespace BTCPayServer.Controllers
private readonly UserManager<ApplicationUser> _UserManager;
private readonly PaymentRequestRepository _PaymentRequestRepository;
private readonly PaymentRequestService _PaymentRequestService;
- private readonly EventAggregator _EventAggregator;
private readonly CurrencyNameTable _Currencies;
private readonly DisplayFormatter _displayFormatter;
private readonly InvoiceRepository _InvoiceRepository;
+ private readonly ApplicationDbContextFactory _dbContextFactory;
private readonly StoreRepository _storeRepository;
private readonly UriResolver _uriResolver;
private readonly BTCPayNetworkProvider _networkProvider;
@@ -56,7 +57,6 @@ namespace BTCPayServer.Controllers
UserManager<ApplicationUser> userManager,
PaymentRequestRepository paymentRequestRepository,
PaymentRequestService paymentRequestService,
- EventAggregator eventAggregator,
CurrencyNameTable currencies,
DisplayFormatter displayFormatter,
StoreRepository storeRepository,
@@ -65,6 +65,7 @@ namespace BTCPayServer.Controllers
FormComponentProviders formProviders,
FormDataService formDataService,
IStringLocalizer stringLocalizer,
+ ApplicationDbContextFactory dbContextFactory,
BTCPayNetworkProvider networkProvider)
{
_InvoiceController = invoiceController;
@@ -72,12 +73,12 @@ namespace BTCPayServer.Controllers
_UserManager = userManager;
_PaymentRequestRepository = paymentRequestRepository;
_PaymentRequestService = paymentRequestService;
- _EventAggregator = eventAggregator;
_Currencies = currencies;
_displayFormatter = displayFormatter;
_storeRepository = storeRepository;
_uriResolver = uriResolver;
_InvoiceRepository = invoiceRepository;
+ _dbContextFactory = dbContextFactory;
FormProviders = formProviders;
FormDataService = formDataService;
_networkProvider = networkProvider;
@@ -126,11 +127,13 @@ namespace BTCPayServer.Controllers
{
return NotFound();
}
+
var paymentRequest = GetCurrentPaymentRequest();
if (paymentRequest == null && !string.IsNullOrEmpty(payReqId))
{
return NotFound();
}
+
if (!store.AnyPaymentMethodAvailable(_handlers))
{
return NoPaymentMethodResult(storeId);
@@ -145,12 +148,19 @@ namespace BTCPayServer.Controllers
};
vm.Currency ??= storeBlob.DefaultCurrency;
- vm.HasEmailRules = storeBlob.EmailRules?.Any(rule =>
- rule.Trigger.Contains("PaymentRequest", StringComparison.InvariantCultureIgnoreCase));
+ vm.HasEmailRules = await HasEmailRules(store.Id);
return View(nameof(EditPaymentRequest), vm);
}
+ private async Task<bool> HasEmailRules(string storeId)
+ {
+ await using var ctx = _dbContextFactory.CreateContext();
+ return await ctx.Set<EmailRuleData>()
+ .AsNoTracking()
+ .AnyAsync(r => r.StoreId == storeId && EF.Functions.Like(r.Trigger, "WH-PaymentRequest%"));
+ }
+
[HttpPost("/stores/{storeId}/payment-requests/edit/{payReqId?}")]
[Authorize(Policy = Policies.CanModifyPaymentRequests, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> EditPaymentRequest(string payReqId, UpdatePaymentRequestViewModel viewModel)
@@ -168,6 +178,7 @@ namespace BTCPayServer.Controllers
{
return NotFound();
}
+
if (!store.AnyPaymentMethodAvailable(_handlers))
{
return NoPaymentMethodResult(store.Id);
@@ -177,6 +188,7 @@ namespace BTCPayServer.Controllers
{
ModelState.AddModelError(string.Empty, StringLocalizer["You cannot edit an archived payment request."]);
}
+
var data = paymentRequest ?? new PaymentRequestData();
data.StoreDataId = viewModel.StoreId;
data.Archived = viewModel.Archived;
@@ -194,9 +206,7 @@ namespace BTCPayServer.Controllers
if (!ModelState.IsValid)
{
- var storeBlob = store.GetStoreBlob();
- viewModel.HasEmailRules = storeBlob.EmailRules?.Any(rule =>
- rule.Trigger.Contains("PaymentRequest", StringComparison.InvariantCultureIgnoreCase));
+ viewModel.HasEmailRules = await HasEmailRules(store.Id);
return View(nameof(EditPaymentRequest), viewModel);
}
@@ -209,6 +219,8 @@ namespace BTCPayServer.Controllers
data.ReferenceId = viewModel.ReferenceId;
blob.AllowCustomPaymentAmounts = viewModel.AllowCustomPaymentAmounts;
blob.FormId = viewModel.FormId;
+ if (payReqId is null || blob.RequestBaseUrl is null)
+ blob.RequestBaseUrl = Request.GetRequestBaseUrl().ToString();
data.SetBlob(blob);
var isNewPaymentRequest = string.IsNullOrEmpty(payReqId);
@@ -234,6 +246,7 @@ namespace BTCPayServer.Controllers
{
return NotFound();
}
+
var store = await _storeRepository.FindStore(vm.StoreId);
if (store == null)
{
@@ -266,6 +279,7 @@ namespace BTCPayServer.Controllers
{
return RedirectToAction("PayPaymentRequest", new { payReqId });
}
+
var prFormId = prBlob.FormId;
var formData = await FormDataService.GetForm(prFormId);
if (formData is null)
@@ -282,21 +296,26 @@ namespace BTCPayServer.Controllers
emailField.Value = prBlob.Email;
}
}
+
if (Request.Method == "POST" && Request.HasFormContentType)
{
form.ApplyValuesFromForm(Request.Form);
if (FormDataService.Validate(form, ModelState))
{
prBlob.FormResponse = FormDataService.GetValues(form);
- if(string.IsNullOrEmpty(prBlob.Email) && form.GetFieldByFullName("buyerEmail") is { } emailField)
+ if (string.IsNullOrEmpty(prBlob.Email) && form.GetFieldByFullName("buyerEmail") is { } emailField)
{
prBlob.Email = emailField.Value;
}
+ if (prBlob.RequestBaseUrl is null)
+ prBlob.RequestBaseUrl = Request.GetRequestBaseUrl().ToString();
+
result.SetBlob(prBlob);
await _PaymentRequestRepository.CreateOrUpdatePaymentRequest(result);
return RedirectToAction("PayPaymentRequest", new { payReqId });
}
}
+
viewModel.FormName = formData.Name;
viewModel.Form = form;
@@ -331,6 +350,7 @@ namespace BTCPayServer.Controllers
return BadRequest(StringLocalizer["Payment Request cannot be paid as it has been archived"]);
}
+
if (!result.FormSubmitted && !string.IsNullOrEmpty(result.FormId))
{
var formData = await FormDataService.GetForm(result.FormId);
@@ -454,7 +474,7 @@ namespace BTCPayServer.Controllers
var store = GetCurrentStore();
var result = await _PaymentRequestRepository.ArchivePaymentRequest(payReqId, true);
- if(result is not null)
+ if (result is not null)
{
TempData[WellKnownTempData.SuccessMessage] = result.Value
? StringLocalizer["The payment request has been archived and will no longer appear in the payment request list by default again."].Value
@@ -501,7 +521,8 @@ namespace BTCPayServer.Controllers
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Error,
- Html = $"To create a payment request, you need to <a href='{Url.Action(nameof(UIStoresController.SetupWallet), "UIStores", new { cryptoCode = _networkProvider.DefaultNetwork.CryptoCode, storeId })}' class='alert-link'>set up a wallet</a> first",
+ Html =
+ $"To create a payment request, you need to <a href='{Url.Action(nameof(UIStoresController.SetupWallet), "UIStores", new { cryptoCode = _networkProvider.DefaultNetwork.CryptoCode, storeId })}' class='alert-link'>set up a wallet</a> first",
AllowDismiss = false
});
return RedirectToAction(nameof(GetPaymentRequests), new { storeId });
diff --git a/BTCPayServer/Controllers/UIServerController.cs b/BTCPayServer/Controllers/UIServerController.cs
index b869487..a0562b9 100644
--- a/BTCPayServer/Controllers/UIServerController.cs
+++ b/BTCPayServer/Controllers/UIServerController.cs
@@ -1281,21 +1281,39 @@ namespace BTCPayServer.Controllers
settings.Password = null;
await _SettingsRepository.UpdateSetting(settings);
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email server password reset"].Value;
- return RedirectToAction(nameof(Emails));
}
-
- // save if user provided valid email; this will also clear settings if no model.Settings.From
- if (model.Settings.From is not null && !MailboxAddressValidator.IsMailboxAddress(model.Settings.From))
+ else if (command == "mailpit")
{
- ModelState.AddModelError("Settings.From", StringLocalizer["Invalid email"]);
- return View(model);
+ model.Settings.Server = "localhost";
+ model.Settings.Port = 34219;
+ model.Settings.EnabledCertificateCheck = false;
+ model.Settings.Login ??= "store@example.com";
+ model.Settings.From ??= "store@example.com";
+ model.Settings.Password ??= "password";
+ await _SettingsRepository.UpdateSetting<EmailSettings>(model.Settings);
+ TempData.SetStatusMessageModel(new StatusMessageModel()
+ {
+ Severity = StatusMessageModel.StatusSeverity.Info,
+ AllowDismiss = true,
+ Html = "Mailpit is now running on <a href=\"http://localhost:34218\" target=\"_blank\" class=\"alert-link\">localhost</a>. You can use it to test your SMTP settings."
+ });
}
- var oldSettings = await _emailSenderFactory.GetSettings() ?? new EmailSettings();
- if (!string.IsNullOrEmpty(oldSettings.Password))
- model.Settings.Password = oldSettings.Password;
+ else
+ {
+ // save if user provided valid email; this will also clear settings if no model.Settings.From
+ if (model.Settings.From is not null && !MailboxAddressValidator.IsMailboxAddress(model.Settings.From))
+ {
+ ModelState.AddModelError("Settings.From", StringLocalizer["Invalid email"]);
+ return View(model);
+ }
- await _SettingsRepository.UpdateSetting(model.Settings);
- TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email settings saved"].Value;
+ var oldSettings = await _emailSenderFactory.GetSettings() ?? new EmailSettings();
+ if (!string.IsNullOrEmpty(oldSettings.Password))
+ model.Settings.Password = oldSettings.Password;
+
+ await _SettingsRepository.UpdateSetting(model.Settings);
+ TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email settings saved"].Value;
+ }
return RedirectToAction(nameof(Emails));
}
diff --git a/BTCPayServer/Controllers/UIStoresController.Email.cs b/BTCPayServer/Controllers/UIStoresController.Email.cs
deleted file mode 100644
index 1f9d126..0000000
--- a/BTCPayServer/Controllers/UIStoresController.Email.cs
+++ /dev/null
@@ -1,122 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.ComponentModel.DataAnnotations;
-using System.Globalization;
-using System.Linq;
-using System.Threading.Tasks;
-using BTCPayServer.Abstractions.Constants;
-using BTCPayServer.Abstractions.Extensions;
-using BTCPayServer.Abstractions.Models;
-using BTCPayServer.Client;
-using BTCPayServer.Data;
-using BTCPayServer.Models;
-using BTCPayServer.Services.Mails;
-using Microsoft.AspNetCore.Authorization;
-using Microsoft.AspNetCore.Mvc;
-using MimeKit;
-
-namespace BTCPayServer.Controllers;
-
-public partial class UIStoresController
-{
- [HttpGet("{storeId}/email-settings")]
- public async Task<IActionResult> StoreEmailSettings(string storeId)
- {
- var store = HttpContext.GetStoreData();
- if (store == null)
- return NotFound();
-
- var settings = await GetCustomSettings(store.Id);
-
- return View(new EmailsViewModel(settings.Custom ?? new())
- {
- IsFallbackSetup = settings.Fallback is not null,
- IsCustomSMTP = settings.Custom is not null || settings.Fallback is null
- });
- }
-
- record AllEmailSettings(EmailSettings Custom, EmailSettings Fallback);
- private async Task<AllEmailSettings> GetCustomSettings(string storeId)
- {
- var sender = await _emailSenderFactory.GetEmailSender(storeId) as StoreEmailSender;
- if (sender is null)
- return new(null, null);
- var fallback = sender.FallbackSender is { } fb ? await fb.GetEmailSettings() : null;
- if (fallback?.IsComplete() is not true)
- fallback = null;
- return new(await sender.GetCustomSettings(), fallback);
- }
-
- [HttpPost("{storeId}/email-settings")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- public async Task<IActionResult> StoreEmailSettings(string storeId, EmailsViewModel model, string command)
- {
- var store = HttpContext.GetStoreData();
- if (store == null)
- return NotFound();
- var settings = await GetCustomSettings(store.Id);
- model.IsFallbackSetup = settings.Fallback is not null;
- if (!model.IsFallbackSetup)
- model.IsCustomSMTP = true;
- if (model.IsCustomSMTP)
- {
- model.Settings.Validate("Settings.", ModelState);
- if (model.Settings.From is not null && !MailboxAddressValidator.IsMailboxAddress(model.Settings.From))
- {
- ModelState.AddModelError("Settings.From", StringLocalizer["Invalid email"]);
- }
- if (!ModelState.IsValid)
- return View(model);
- }
-
- var storeBlob = store.GetStoreBlob();
- var currentSettings = store.GetStoreBlob().EmailSettings;
- if (model is { IsCustomSMTP: true, Settings: { Password: null } })
- model.Settings.Password = currentSettings?.Password;
-
- if (command == "Test")
- {
- try
- {
- if (string.IsNullOrEmpty(model.TestEmail))
- ModelState.AddModelError(nameof(model.TestEmail), new RequiredAttribute().FormatErrorMessage(nameof(model.TestEmail)));
- if (!ModelState.IsValid)
- return View(model);
- var clientSettings = (model.IsCustomSMTP ? model.Settings : settings.Fallback) ?? new();
- using var client = await clientSettings.CreateSmtpClient();
- var message = clientSettings.CreateMailMessage(MailboxAddress.Parse(model.TestEmail), $"{store.StoreName}: Email test", StringLocalizer["You received it, the BTCPay Server SMTP settings work."], false);
- await client.SendAsync(message);
- await client.DisconnectAsync(true);
- TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email sent to {0}. Please verify you received it.", model.TestEmail].Value;
- }
- catch (Exception ex)
- {
- TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["Error: {0}", ex.Message].Value;
- }
- return View(model);
- }
- else if (command == "ResetPassword")
- {
- if (storeBlob.EmailSettings is not null)
- storeBlob.EmailSettings.Password = null;
- store.SetStoreBlob(storeBlob);
- await _storeRepo.UpdateStore(store);
- TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email server password reset"].Value;
- }
- else if (!model.IsCustomSMTP && currentSettings is not null)
- {
- storeBlob.EmailSettings = null;
- store.SetStoreBlob(storeBlob);
- await _storeRepo.UpdateStore(store);
- TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["You are now using server's email settings"].Value;
- }
- else if (model.IsCustomSMTP)
- {
- storeBlob.EmailSettings = model.Settings;
- store.SetStoreBlob(storeBlob);
- await _storeRepo.UpdateStore(store);
- TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email settings saved"].Value;
- }
- return RedirectToAction(nameof(StoreEmailSettings), new { storeId });
- }
-}
diff --git a/BTCPayServer/Controllers/UIStoresController.EmailRules.cs b/BTCPayServer/Controllers/UIStoresController.EmailRules.cs
deleted file mode 100644
index 9f960cb..0000000
--- a/BTCPayServer/Controllers/UIStoresController.EmailRules.cs
+++ /dev/null
@@ -1,154 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.ComponentModel.DataAnnotations;
-using System.Globalization;
-using System.Linq;
-using System.Threading.Tasks;
-using BTCPayServer.Abstractions.Constants;
-using BTCPayServer.Abstractions.Extensions;
-using BTCPayServer.Abstractions.Models;
-using BTCPayServer.Client;
-using BTCPayServer.Data;
-using BTCPayServer.Models;
-using BTCPayServer.Services.Mails;
-using BTCPayServer.Services.Stores;
-using Microsoft.AspNetCore.Authorization;
-using Microsoft.AspNetCore.Mvc;
-
-namespace BTCPayServer.Controllers
-{
- public partial class UIStoresController
- {
- [HttpGet("{storeId}/emails/rules")]
- public async Task<IActionResult> StoreEmailRulesList(string storeId)
- {
- var store = HttpContext.GetStoreData();
- if (store == null) return NotFound();
-
- var configured = await _emailSenderFactory.IsComplete(store.Id);
- if (!configured && !TempData.HasStatusMessage())
- {
- 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 store email settings</a>."
- });
- }
-
- var rules = store.GetStoreBlob().EmailRules ?? new List<StoreEmailRule>();
- return View("StoreEmailRulesList", rules);
- }
-
- [HttpGet("{storeId}/emails/rules/create")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- public IActionResult StoreEmailRulesCreate(string storeId)
- {
- return View("StoreEmailRulesManage", new StoreEmailRule());
- }
-
- [HttpPost("{storeId}/emails/rules/create")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- public async Task<IActionResult> StoreEmailRulesCreate(string storeId, StoreEmailRule model)
- {
- if (!ModelState.IsValid)
- return View("StoreEmailRulesManage", model);
-
- var store = await _storeRepo.FindStore(storeId);
- if (store == null) return NotFound();
-
- var blob = store.GetStoreBlob();
- var rulesList = blob.EmailRules ?? new List<StoreEmailRule>();
- rulesList.Add(new StoreEmailRule
- {
- Trigger = model.Trigger,
- CustomerEmail = model.CustomerEmail,
- To = model.To,
- Subject = model.Subject,
- Body = model.Body
- });
-
- blob.EmailRules = rulesList;
- store.SetStoreBlob(blob);
- await _storeRepo.UpdateStore(store);
-
- this.TempData.SetStatusSuccess(StringLocalizer["Email rule successfully created"]);
- return RedirectToAction(nameof(StoreEmailRulesList), new { storeId });
- }
-
- [HttpGet("{storeId}/emails/rules/{ruleIndex}/edit")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- public IActionResult StoreEmailRulesEdit(string storeId, int ruleIndex)
- {
- var store = HttpContext.GetStoreData();
- if (store == null) return NotFound();
-
- var rules = store.GetStoreBlob().EmailRules;
- if (rules == null || ruleIndex >= rules.Count) return NotFound();
-
- var rule = rules[ruleIndex];
- return View("StoreEmailRulesManage", rule);
- }
-
- [HttpPost("{storeId}/emails/rules/{ruleIndex}/edit")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- public async Task<IActionResult> StoreEmailRulesEdit(string storeId, int ruleIndex, StoreEmailRule model)
- {
- if (!ModelState.IsValid)
- return View("StoreEmailRulesManage", model);
-
- var store = await _storeRepo.FindStore(storeId);
- if (store == null) return NotFound();
-
- var blob = store.GetStoreBlob();
- if (blob.EmailRules == null || ruleIndex >= blob.EmailRules.Count) return NotFound();
-
- var rule = blob.EmailRules[ruleIndex];
- rule.Trigger = model.Trigger;
- rule.CustomerEmail = model.CustomerEmail;
- rule.To = model.To;
- rule.Subject = model.Subject;
- rule.Body = model.Body;
- store.SetStoreBlob(blob);
- await _storeRepo.UpdateStore(store);
-
- this.TempData.SetStatusSuccess(StringLocalizer["Email rule successfully updated"]);
- return RedirectToAction(nameof(StoreEmailRulesList), new { storeId });
- }
-
- [HttpPost("{storeId}/emails/rules/{ruleIndex}/delete")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- public async Task<IActionResult> StoreEmailRulesDelete(string storeId, int ruleIndex)
- {
- var store = await _storeRepo.FindStore(storeId);
- if (store == null) return NotFound();
-
- var blob = store.GetStoreBlob();
- if (blob.EmailRules == null || ruleIndex >= blob.EmailRules.Count) return NotFound();
-
- blob.EmailRules.RemoveAt(ruleIndex);
- store.SetStoreBlob(blob);
- await _storeRepo.UpdateStore(store);
-
- this.TempData.SetStatusSuccess(StringLocalizer["Email rule successfully deleted"]);
- return RedirectToAction(nameof(StoreEmailRulesList), new { storeId });
- }
-
- public class StoreEmailRule
- {
- [Required]
- public string Trigger { get; set; }
-
- public bool CustomerEmail { get; set; }
-
- public string To { get; set; }
-
- [Required]
- public string Subject { get; set; }
-
- [Required]
- public string Body { get; set; }
- }
- }
-
-}
diff --git a/BTCPayServer/Controllers/UIStoresController.Integrations.cs b/BTCPayServer/Controllers/UIStoresController.Integrations.cs
deleted file mode 100644
index 9a737b4..0000000
--- a/BTCPayServer/Controllers/UIStoresController.Integrations.cs
+++ /dev/null
@@ -1,190 +0,0 @@
-#nullable enable
-using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
-using BTCPayServer.Abstractions.Constants;
-using BTCPayServer.Abstractions.Models;
-using BTCPayServer.Client;
-using BTCPayServer.Client.Models;
-using BTCPayServer.Data;
-using BTCPayServer.Models.StoreViewModels;
-using Microsoft.AspNetCore.Authorization;
-using Microsoft.AspNetCore.Mvc;
-using NBitcoin;
-using NBitcoin.DataEncoders;
-
-namespace BTCPayServer.Controllers;
-
-public partial class UIStoresController
-{
- private async Task<Data.WebhookDeliveryData?> LastDeliveryForWebhook(string webhookId)
- {
- return (await _storeRepo.GetWebhookDeliveries(CurrentStore.Id, webhookId, 1)).ToList().FirstOrDefault();
- }
-
- [HttpGet("{storeId}/webhooks")]
- public async Task<IActionResult> Webhooks()
- {
- var webhooks = await _storeRepo.GetWebhooks(CurrentStore.Id);
- return View(nameof(Webhooks), new WebhooksViewModel
- {
- Webhooks = webhooks.Select(async w =>
- {
- var lastDelivery = await LastDeliveryForWebhook(w.Id);
- var lastDeliveryBlob = lastDelivery?.GetBlob();
-
- return new WebhooksViewModel.WebhookViewModel()
- {
- Id = w.Id,
- Url = w.GetBlob().Url,
- LastDeliveryErrorMessage = lastDeliveryBlob?.ErrorMessage,
- LastDeliveryTimeStamp = lastDelivery?.Timestamp,
- LastDeliverySuccessful = lastDeliveryBlob == null ? true : lastDeliveryBlob.Status == WebhookDeliveryStatus.HttpSuccess,
- };
- }
- ).Select(t => t.Result).ToArray()
- });
- }
-
- [HttpGet("{storeId}/webhooks/new")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- public IActionResult NewWebhook()
- {
- return View(nameof(ModifyWebhook), new EditWebhookViewModel
- {
- Active = true,
- Everything = true,
- IsNew = true,
- Secret = Encoders.Base58.EncodeData(RandomUtils.GetBytes(20))
- });
- }
-
- [HttpGet("{storeId}/webhooks/{webhookId}/remove")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- public async Task<IActionResult> DeleteWebhook(string webhookId)
- {
- var webhook = await _storeRepo.GetWebhook(CurrentStore.Id, webhookId);
- if (webhook is null)
- return NotFound();
-
- return View("Confirm", new ConfirmModel(StringLocalizer["Delete webhook"], StringLocalizer["This webhook will be removed from this store. Are you sure?"], StringLocalizer["Delete"]));
- }
-
- [HttpPost("{storeId}/webhooks/{webhookId}/remove")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- public async Task<IActionResult> DeleteWebhookPost(string webhookId)
- {
- var webhook = await _storeRepo.GetWebhook(CurrentStore.Id, webhookId);
- if (webhook is null)
- return NotFound();
-
- await _storeRepo.DeleteWebhook(CurrentStore.Id, webhookId);
- TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Webhook successfully deleted"].Value;
- return RedirectToAction(nameof(Webhooks), new { storeId = CurrentStore.Id });
- }
-
- [HttpPost("{storeId}/webhooks/new")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- public async Task<IActionResult> NewWebhook(string storeId, EditWebhookViewModel viewModel)
- {
- if (!ModelState.IsValid)
- return View(nameof(ModifyWebhook), viewModel);
-
- await _storeRepo.CreateWebhook(CurrentStore.Id, viewModel.CreateBlob());
- TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["The webhook has been created"].Value;
- return RedirectToAction(nameof(Webhooks), new { storeId });
- }
-
- [HttpGet("{storeId}/webhooks/{webhookId}")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- public async Task<IActionResult> ModifyWebhook(string webhookId)
- {
- var webhook = await _storeRepo.GetWebhook(CurrentStore.Id, webhookId);
- if (webhook is null)
- return NotFound();
-
- var blob = webhook.GetBlob();
- var deliveries = await _storeRepo.GetWebhookDeliveries(CurrentStore.Id, webhookId, 20);
- return View(nameof(ModifyWebhook), new EditWebhookViewModel(blob)
- {
- Deliveries = deliveries
- .Select(s => new DeliveryViewModel(s)).ToList()
- });
- }
-
- [HttpPost("{storeId}/webhooks/{webhookId}")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- public async Task<IActionResult> ModifyWebhook(string webhookId, EditWebhookViewModel viewModel)
- {
- var webhook = await _storeRepo.GetWebhook(CurrentStore.Id, webhookId);
- if (webhook is null)
- return NotFound();
- if (!ModelState.IsValid)
- return View(nameof(ModifyWebhook), viewModel);
-
- await _storeRepo.UpdateWebhook(CurrentStore.Id, webhookId, viewModel.CreateBlob());
- TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["The webhook has been updated"].Value;
- return RedirectToAction(nameof(Webhooks), new { storeId = CurrentStore.Id });
- }
-
- [HttpGet("{storeId}/webhooks/{webhookId}/test")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- public async Task<IActionResult> TestWebhook(string webhookId)
- {
- var webhook = await _storeRepo.GetWebhook(CurrentStore.Id, webhookId);
- if (webhook is null)
- return NotFound();
-
- return View(nameof(TestWebhook));
- }
-
- [HttpPost("{storeId}/webhooks/{webhookId}/test")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- public async Task<IActionResult> TestWebhook(string webhookId, TestWebhookViewModel viewModel, CancellationToken cancellationToken)
- {
- var result = await _webhookNotificationManager.TestWebhook(CurrentStore.Id, webhookId, viewModel.Type, cancellationToken);
-
- if (result.Success)
- {
- TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["{0} event delivered successfully! Delivery ID is {1}", viewModel.Type, result.DeliveryId!].Value;
- }
- else
- {
- TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["{0} event could not be delivered. Error message received: {1}", viewModel.Type, result.ErrorMessage ?? StringLocalizer["unknown"].Value].Value;
- }
-
- return View(nameof(TestWebhook));
- }
-
- [HttpPost("{storeId}/webhooks/{webhookId}/deliveries/{deliveryId}/redeliver")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- public async Task<IActionResult> RedeliverWebhook(string webhookId, string deliveryId)
- {
- var delivery = await _storeRepo.GetWebhookDelivery(CurrentStore.Id, webhookId, deliveryId);
- if (delivery is null)
- return NotFound();
-
- var newDeliveryId = await _webhookNotificationManager.Redeliver(deliveryId);
- if (newDeliveryId is null)
- return NotFound();
-
- TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Successfully planned a redelivery"].Value;
- return RedirectToAction(nameof(ModifyWebhook),
- new
- {
- storeId = CurrentStore.Id,
- webhookId
- });
- }
-
- [HttpGet("{storeId}/webhooks/{webhookId}/deliveries/{deliveryId}/request")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- public async Task<IActionResult> WebhookDelivery(string webhookId, string deliveryId)
- {
- var delivery = await _storeRepo.GetWebhookDelivery(CurrentStore.Id, webhookId, deliveryId);
- if (delivery is null)
- return NotFound();
-
- return File(delivery.GetBlob().Request, "application/json");
- }
-}
diff --git a/BTCPayServer/Controllers/UIStoresController.cs b/BTCPayServer/Controllers/UIStoresController.cs
index e872727..2615b81 100644
--- a/BTCPayServer/Controllers/UIStoresController.cs
+++ b/BTCPayServer/Controllers/UIStoresController.cs
@@ -6,7 +6,6 @@ using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Client;
using BTCPayServer.Configuration;
using BTCPayServer.Data;
-using BTCPayServer.HostedServices.Webhooks;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Security.Bitpay;
using BTCPayServer.Services;
@@ -50,7 +49,6 @@ public partial class UIStoresController : Controller
IAuthorizationService authorizationService,
AppService appService,
IFileService fileService,
- WebhookSender webhookNotificationManager,
IDataProtectionProvider dataProtector,
IOptions<LightningNetworkOptions> lightningNetworkOptions,
IOptions<ExternalServicesOptions> externalServiceOptions,
@@ -94,7 +92,6 @@ public partial class UIStoresController : Controller
_html = html;
_defaultRules = defaultRules;
_dataProtector = dataProtector.CreateProtector("ConfigProtector");
- _webhookNotificationManager = webhookNotificationManager;
_lightningNetworkOptions = lightningNetworkOptions.Value;
_lnHistogramService = lnHistogramService;
_lightningClientFactory = lightningClientFactory;
@@ -127,7 +124,6 @@ public partial class UIStoresController : Controller
private readonly UriResolver _uriResolver;
private readonly EventAggregator _eventAggregator;
private readonly IHtmlHelper _html;
- private readonly WebhookSender _webhookNotificationManager;
private readonly LightningNetworkOptions _lightningNetworkOptions;
private readonly IDataProtector _dataProtector;
private readonly LightningHistogramService _lnHistogramService;
@@ -138,7 +134,7 @@ public partial class UIStoresController : Controller
[TempData]
private bool StoreNotConfigured { get; set; }
-
+
[AllowAnonymous]
[HttpGet("{storeId}/index")]
public async Task<IActionResult> Index(string storeId)
@@ -146,7 +142,7 @@ public partial class UIStoresController : Controller
var userId = _userManager.GetUserId(User);
if (string.IsNullOrEmpty(userId))
return Forbid();
-
+
var store = await _storeRepo.FindStore(storeId);
if (store is null)
return NotFound();
@@ -161,7 +157,7 @@ public partial class UIStoresController : Controller
}
return Forbid();
}
-
+
public StoreData CurrentStore => HttpContext.GetStoreData();
public PaymentMethodOptionViewModel.Format[] GetEnabledPaymentMethodChoices(StoreData storeData)
diff --git a/BTCPayServer/Controllers/UIWalletsController.PSBT.cs b/BTCPayServer/Controllers/UIWalletsController.PSBT.cs
index d5f3fb7..463d79d 100644
--- a/BTCPayServer/Controllers/UIWalletsController.PSBT.cs
+++ b/BTCPayServer/Controllers/UIWalletsController.PSBT.cs
@@ -175,7 +175,7 @@ namespace BTCPayServer.Controllers
switch (command)
{
case "createpending":
- await _pendingTransactionService.CreatePendingTransaction(walletId.StoreId, walletId.CryptoCode, psbt);
+ await _pendingTransactionService.CreatePendingTransaction(walletId.StoreId, walletId.CryptoCode, psbt, Request.GetRequestBaseUrl());
return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() });
case "sign":
return await WalletSign(walletId, vm);
diff --git a/BTCPayServer/Controllers/UIWalletsController.cs b/BTCPayServer/Controllers/UIWalletsController.cs
index 68c61bd..d376811 100644
--- a/BTCPayServer/Controllers/UIWalletsController.cs
+++ b/BTCPayServer/Controllers/UIWalletsController.cs
@@ -444,7 +444,7 @@ namespace BTCPayServer.Controllers
switch (model.Command)
{
case "createpending":
- await _pendingTransactionService.CreatePendingTransaction(walletId.StoreId, walletId.CryptoCode, psbt);
+ await _pendingTransactionService.CreatePendingTransaction(walletId.StoreId, walletId.CryptoCode, psbt, Request.GetRequestBaseUrl());
return RedirectToWalletList(walletId);
default:
// case "sign":
@@ -1296,7 +1296,7 @@ namespace BTCPayServer.Controllers
switch (command)
{
case "createpending":
- await _pendingTransactionService.CreatePendingTransaction(walletId.StoreId, walletId.CryptoCode, psbt);
+ await _pendingTransactionService.CreatePendingTransaction(walletId.StoreId, walletId.CryptoCode, psbt, Request.GetRequestBaseUrl());
return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() });
case "sign":
return await WalletSign(walletId, new WalletPSBTViewModel
diff --git a/BTCPayServer/Data/PaymentRequestBlob.cs b/BTCPayServer/Data/PaymentRequestBlob.cs
index d8d09d8..b08416c 100644
--- a/BTCPayServer/Data/PaymentRequestBlob.cs
+++ b/BTCPayServer/Data/PaymentRequestBlob.cs
@@ -15,5 +15,6 @@ namespace BTCPayServer.Data
public string FormId { get; set; }
public JObject FormResponse { get; set; }
+ public string RequestBaseUrl { get; set; }
}
}
diff --git a/BTCPayServer/Data/StoreBlob.cs b/BTCPayServer/Data/StoreBlob.cs
index 1279e6f..b85bdcd 100644
--- a/BTCPayServer/Data/StoreBlob.cs
+++ b/BTCPayServer/Data/StoreBlob.cs
@@ -1,17 +1,13 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
-using System.Globalization;
using System.Linq;
-using System.Text;
using System.Text.RegularExpressions;
using BTCPayServer.Client.JsonConverters;
using BTCPayServer.Client.Models;
-using BTCPayServer.Controllers;
using BTCPayServer.JsonConverters;
using BTCPayServer.Payments;
using BTCPayServer.Rating;
-using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Mails;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
@@ -221,7 +217,6 @@ namespace BTCPayServer.Data
[JsonConverter(typeof(TimeSpanJsonConverter.Days))]
public TimeSpan RefundBOLT11Expiration { get; set; }
- public List<UIStoresController.StoreEmailRule> EmailRules { get; set; }
public string BrandColor { get; set; }
public bool ApplyBrandColorToBackend { get; set; }
diff --git a/BTCPayServer/Data/WebhookDataExtensions.cs b/BTCPayServer/Data/WebhookDataExtensions.cs
deleted file mode 100644
index 31fe898..0000000
--- a/BTCPayServer/Data/WebhookDataExtensions.cs
+++ /dev/null
@@ -1,90 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.ComponentModel;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-using BTCPayServer.Client.Models;
-using BTCPayServer.HostedServices.Webhooks;
-using Microsoft.CodeAnalysis.CSharp.Syntax;
-using Newtonsoft.Json;
-using Newtonsoft.Json.Linq;
-
-namespace BTCPayServer.Data
-{
- public class AuthorizedWebhookEvents
- {
- public bool Everything { get; set; }
-
- public string[] SpecificEvents { get; set; } = Array.Empty<string>();
- public bool Match(string evt)
- {
- return Everything || SpecificEvents.Contains(evt);
- }
- }
-
-
- public class WebhookDeliveryBlob
- {
- [JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))]
- public WebhookDeliveryStatus Status { get; set; }
- public int? HttpCode { get; set; }
- [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
- public string ErrorMessage { get; set; }
- [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
- public byte[] Request { get; set; }
- public void Prune()
- {
- var request = JObject.Parse(UTF8Encoding.UTF8.GetString(Request));
- foreach (var prop in request.Properties().ToList())
- {
- if (prop.Name == "type")
- continue;
- prop.Remove();
- }
- Request = UTF8Encoding.UTF8.GetBytes(request.ToString(Formatting.None));
- }
- public T ReadRequestAs<T>()
- {
- return JsonConvert.DeserializeObject<T>(UTF8Encoding.UTF8.GetString(Request), WebhookSender.DefaultSerializerSettings);
- }
-
- public bool IsPruned()
- {
- return ReadRequestAs<WebhookEvent>().IsPruned();
- }
- }
- public class WebhookBlob
- {
- public string Url { get; set; }
- public bool Active { get; set; } = true;
- public string Secret { get; set; }
- public bool AutomaticRedelivery { get; set; }
- public AuthorizedWebhookEvents AuthorizedEvents { get; set; }
- }
- public static class WebhookDataExtensions
- {
- public static WebhookBlob GetBlob(this WebhookData webhook)
- {
- return webhook.HasTypedBlob<WebhookBlob>().GetBlob();
- }
- public static void SetBlob(this WebhookData webhook, WebhookBlob blob)
- {
- webhook.HasTypedBlob<WebhookBlob>().SetBlob(blob);
- }
- public static WebhookDeliveryBlob GetBlob(this WebhookDeliveryData webhook)
- {
- if (webhook.Blob is null)
- return null;
- else
- return JsonConvert.DeserializeObject<WebhookDeliveryBlob>(webhook.Blob, WebhookSender.DefaultSerializerSettings);
- }
- public static void SetBlob(this WebhookDeliveryData webhook, WebhookDeliveryBlob blob)
- {
- if (blob is null)
- webhook.Blob = null;
- else
- webhook.Blob = JsonConvert.SerializeObject(blob, WebhookSender.DefaultSerializerSettings);
- }
- }
-}
diff --git a/BTCPayServer/Events/EmailSentEvent.cs b/BTCPayServer/Events/EmailSentEvent.cs
new file mode 100644
index 0000000..8344472
--- /dev/null
+++ b/BTCPayServer/Events/EmailSentEvent.cs
@@ -0,0 +1,10 @@
+using MimeKit;
+
+namespace BTCPayServer.Events;
+
+public class EmailSentEvent(string serverResponse, MimeMessage message)
+{
+ public string ServerResponse { get; } = serverResponse;
+ public MimeMessage Message { get; } = message;
+ public override string ToString() => $"Email sent ({Message.Subject})";
+}
diff --git a/BTCPayServer/Extensions.cs b/BTCPayServer/Extensions.cs
index 5c1fd8c..d099678 100644
--- a/BTCPayServer/Extensions.cs
+++ b/BTCPayServer/Extensions.cs
@@ -1,6 +1,5 @@
using System;
-using System.Collections.Concurrent;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Globalization;
@@ -29,7 +28,6 @@ using BTCPayServer.NTag424;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Payments.Lightning;
-using BTCPayServer.Payouts;
using BTCPayServer.Security;
using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
@@ -37,7 +35,6 @@ using BTCPayServer.Services.Reporting;
using BTCPayServer.Services.Wallets;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Http;
-using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
diff --git a/BTCPayServer/Extensions/UrlHelperExtensions.cs b/BTCPayServer/Extensions/UrlHelperExtensions.cs
index 7327e0b..bb80a2d 100644
--- a/BTCPayServer/Extensions/UrlHelperExtensions.cs
+++ b/BTCPayServer/Extensions/UrlHelperExtensions.cs
@@ -39,6 +39,8 @@ namespace Microsoft.AspNetCore.Mvc
return urlHelper.GetUriByAction(nameof(UIAccountController.LoginUsingCode), "UIAccount", new { loginCode, returnUrl }, scheme, host, pathbase);
}
+ public static string PaymentRequestLink(this LinkGenerator urlHelper, string paymentRequestId, RequestBaseUrl baseUrl)
+ => PaymentRequestLink(urlHelper, paymentRequestId, baseUrl.Scheme, baseUrl.Host, baseUrl.PathBase);
public static string PaymentRequestLink(this LinkGenerator urlHelper, string paymentRequestId, string scheme, HostString host, string pathbase)
{
return urlHelper.GetUriByAction(
@@ -48,6 +50,16 @@ namespace Microsoft.AspNetCore.Mvc
scheme, host, pathbase);
}
+ public static string WalletTransactionsLink(this LinkGenerator urlHelper, WalletId walletId, RequestBaseUrl baseUrl)
+ {
+ return urlHelper.GetUriByAction(
+ action: nameof(UIWalletsController.WalletTransactions),
+ controller: "UIWallets",
+ values: new { walletId = walletId.ToString() },
+ baseUrl
+ );
+ }
+
public static string AppLink(this LinkGenerator urlHelper, string appId, string scheme, HostString host, string pathbase)
{
return urlHelper.GetUriByAction(
@@ -57,6 +69,8 @@ namespace Microsoft.AspNetCore.Mvc
scheme, host, pathbase);
}
+ public static string InvoiceLink(this LinkGenerator urlHelper, string invoiceId, RequestBaseUrl baseUrl)
+ => InvoiceLink(urlHelper, invoiceId, baseUrl.Scheme, baseUrl.Host, baseUrl.PathBase);
public static string InvoiceLink(this LinkGenerator urlHelper, string invoiceId, string scheme, HostString host, string pathbase)
{
return urlHelper.GetUriByAction(
diff --git a/BTCPayServer/HostedServices/CleanupWebhookDeliveriesTask.cs b/BTCPayServer/HostedServices/CleanupWebhookDeliveriesTask.cs
deleted file mode 100644
index fb81ff1..0000000
--- a/BTCPayServer/HostedServices/CleanupWebhookDeliveriesTask.cs
+++ /dev/null
@@ -1,59 +0,0 @@
-using System;
-using Dapper;
-using System.Data.Common;
-using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
-using BTCPayServer.Data;
-using Microsoft.EntityFrameworkCore;
-using System.Collections.Generic;
-
-namespace BTCPayServer.HostedServices
-{
- public class CleanupWebhookDeliveriesTask : IPeriodicTask
- {
- public CleanupWebhookDeliveriesTask(ApplicationDbContextFactory dbContextFactory)
- {
- DbContextFactory = dbContextFactory;
- }
-
- public ApplicationDbContextFactory DbContextFactory { get; }
- public int BatchSize { get; set; } = 500;
- public TimeSpan PruneAfter { get; set; } = TimeSpan.FromDays(60);
-
- public async Task Do(CancellationToken cancellationToken)
- {
- await using var ctx = DbContextFactory.CreateContext();
- var conn = ctx.Database.GetDbConnection();
- bool pruned = false;
- int offset = 0;
-retry:
- var rows = await conn.QueryAsync<WebhookDeliveryData>(@"
-SELECT ""Id"", ""Blob""
-FROM ""WebhookDeliveries""
-WHERE ((now() - ""Timestamp"") > @PruneAfter) AND ""Pruned"" IS FALSE
-ORDER BY ""Timestamp""
-LIMIT @BatchSize OFFSET @offset
-", new { PruneAfter, BatchSize, offset });
-
- foreach (var d in rows)
- {
- var blob = d.GetBlob();
- blob.Prune();
- d.SetBlob(blob);
- d.Pruned = true;
- pruned = true;
- }
- if (pruned)
- {
- pruned = false;
- await conn.ExecuteAsync("UPDATE \"WebhookDeliveries\" SET \"Blob\"=@Blob::JSONB, \"Pruned\"=@Pruned WHERE \"Id\"=@Id", rows);
- if (rows.Count() == BatchSize)
- {
- offset += BatchSize;
- goto retry;
- }
- }
- }
- }
-}
diff --git a/BTCPayServer/HostedServices/PendingTransactionService.cs b/BTCPayServer/HostedServices/PendingTransactionService.cs
index faa8b46..722eafe 100644
--- a/BTCPayServer/HostedServices/PendingTransactionService.cs
+++ b/BTCPayServer/HostedServices/PendingTransactionService.cs
@@ -1,22 +1,14 @@
#nullable enable
using System;
-using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
-using BTCPayServer.Client.Models;
-using BTCPayServer.Controllers;
+using BTCPayServer.Abstractions;
using BTCPayServer.Data;
using BTCPayServer.Events;
-using BTCPayServer.HostedServices.Webhooks;
-using BTCPayServer.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NBitcoin;
-using NBitcoin.DataEncoders;
-using Newtonsoft.Json;
-using Newtonsoft.Json.Linq;
-using WebhookDeliveryData = BTCPayServer.Data.WebhookDeliveryData;
namespace BTCPayServer.HostedServices;
@@ -32,14 +24,14 @@ public class PendingTransactionService(
Subscribe<NewOnChainTransactionEvent>();
base.SubscribeToEvents();
}
-
+
public Task Do(CancellationToken cancellationToken)
{
PushEvent(new CheckForExpiryEvent());
return Task.CompletedTask;
}
- public class CheckForExpiryEvent { }
+ public class CheckForExpiryEvent { }
protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
{
@@ -92,6 +84,7 @@ public class PendingTransactionService(
}
public async Task<PendingTransaction> CreatePendingTransaction(string storeId, string cryptoCode, PSBT psbt,
+ RequestBaseUrl requestBaseUrl,
DateTimeOffset? expiry = null, CancellationToken cancellationToken = default)
{
var network = networkProvider.GetNetwork<BTCPayNetwork>(cryptoCode);
@@ -135,7 +128,8 @@ public class PendingTransactionService(
PSBT = psbt.ToBase64(),
SignaturesCollected = 0,
SignaturesNeeded = signaturesNeeded,
- SignaturesTotal = signaturesTotal
+ SignaturesTotal = signaturesTotal,
+ RequestBaseUrl = requestBaseUrl.ToString()
});
ctx.PendingTransactions.Add(pendingTransaction);
@@ -155,7 +149,7 @@ public class PendingTransactionService(
await using var ctx = dbContextFactory.CreateContext();
var pendingTransaction = await ctx.PendingTransactions.FirstOrDefaultAsync(p =>
p.CryptoCode == id.CryptoCode && p.StoreId == id.StoreId && p.Id == id.Id, cancellationToken);
-
+
if (pendingTransaction?.State is not PendingTransactionState.Pending)
{
return null;
@@ -201,7 +195,7 @@ public class PendingTransactionService(
{
// TODO: For now we're going with estimation of how many signatures were collected until we find better way
// so for example if we have 4 new signatures and only 2 inputs - number of collected signatures will be 2
- blob.SignaturesCollected += newSignatures / newWorkingCopyPsbt.Inputs.Count();
+ blob.SignaturesCollected += newSignatures / newWorkingCopyPsbt.Inputs.Count;
blob.CollectedSignatures.Add(new CollectedSignature
{
ReceivedPSBT = newPsbtBase64,
@@ -215,7 +209,7 @@ public class PendingTransactionService(
// TODO: Better logic here
if (blob.SignaturesCollected < blob.SignaturesNeeded)
blob.SignaturesCollected = blob.SignaturesNeeded;
-
+
pendingTransaction.State = PendingTransactionState.Signed;
}
@@ -284,7 +278,7 @@ public class PendingTransactionService(
public const string SignatureCollected = nameof(SignatureCollected);
public const string Broadcast = nameof(Broadcast);
public const string Cancelled = nameof(Cancelled);
-
+
public PendingTransaction Data { get; set; } = null!;
public string Type { get; set; } = null!;
}
diff --git a/BTCPayServer/HostedServices/PluginUpdateFetcher.cs b/BTCPayServer/HostedServices/PluginUpdateFetcher.cs
index e20965c..3b8ac8e 100644
--- a/BTCPayServer/HostedServices/PluginUpdateFetcher.cs
+++ b/BTCPayServer/HostedServices/PluginUpdateFetcher.cs
@@ -84,7 +84,7 @@ namespace BTCPayServer.HostedServices
var disabledPlugins = pluginService.GetDisabledPlugins();
var installedPlugins = pluginService.Installed;
- var remotePlugins = await pluginService.GetRemotePlugins(null);
+ var remotePlugins = await pluginService.GetRemotePlugins(null, cancellationToken);
//take the latest version of each plugin
var remotePluginsList = remotePlugins
.GroupBy(plugin => plugin.Identifier)
diff --git a/BTCPayServer/HostedServices/StoreEmailRuleProcessorSender.cs b/BTCPayServer/HostedServices/StoreEmailRuleProcessorSender.cs
deleted file mode 100644
index 3bf72cd..0000000
--- a/BTCPayServer/HostedServices/StoreEmailRuleProcessorSender.cs
+++ /dev/null
@@ -1,87 +0,0 @@
-using System;
-using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
-using BTCPayServer.Client.Models;
-using BTCPayServer.Controllers;
-using BTCPayServer.Data;
-using BTCPayServer.HostedServices.Webhooks;
-using BTCPayServer.Services.Mails;
-using BTCPayServer.Services.Stores;
-using Microsoft.Extensions.Logging;
-
-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<WebhookSender.WebhookDeliveryRequest>();
- }
-
- protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
- {
- if (evt is WebhookSender.WebhookDeliveryRequest webhookDeliveryRequest)
- {
- var type = webhookDeliveryRequest.WebhookEvent.Type;
- if (type is null)
- {
- return;
- }
-
- if (webhookDeliveryRequest.WebhookEvent is not StoreWebhookEvent storeWebhookEvent || storeWebhookEvent.StoreId is null)
- {
- return;
- }
- var store = await _storeRepository.FindStore(storeWebhookEvent.StoreId);
- if (store is null)
- {
- return;
- }
-
- var blob = store.GetStoreBlob();
- if (blob.EmailRules?.Any() is true)
- {
- var actionableRules = blob.EmailRules.Where(rule => rule.Trigger == type).ToList();
- if (actionableRules.Any())
- {
- var sender = await _emailSenderFactory.GetEmailSender(storeWebhookEvent.StoreId);
- foreach (UIStoresController.StoreEmailRule actionableRule in actionableRules)
- {
- var request = new SendEmailRequest
- {
- Subject = actionableRule.Subject, Body = actionableRule.Body, Email = actionableRule.To
- };
- request = await webhookDeliveryRequest.Interpolate(request, actionableRule);
-
- var recipients = (request?.Email?.Split(",", StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>())
- .Select(o =>
- {
- MailboxAddressValidator.TryParse(o, out var mb);
- return mb;
- })
- .Where(o => o != null)
- .ToArray();
-
- if (recipients.Length == 0)
- continue;
-
- sender.SendEmail(recipients.ToArray(), null, null, request.Subject, request.Body);
- }
- }
- }
- }
- }
-
-}
diff --git a/BTCPayServer/HostedServices/Webhooks/IWebhookProvider.cs b/BTCPayServer/HostedServices/Webhooks/IWebhookProvider.cs
deleted file mode 100644
index b0592d5..0000000
--- a/BTCPayServer/HostedServices/Webhooks/IWebhookProvider.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-#nullable enable
-using System.Collections.Generic;
-using BTCPayServer.Client.Models;
-
-namespace BTCPayServer.HostedServices.Webhooks;
-
-public interface IWebhookProvider
-{
- public bool SupportsCustomerEmail { get; }
-
- public Dictionary<string, string> GetSupportedWebhookTypes();
-
- public WebhookEvent CreateTestEvent(string type, params object[] args);
-}
diff --git a/BTCPayServer/HostedServices/Webhooks/InvoiceWebhookDeliveryRequest.cs b/BTCPayServer/HostedServices/Webhooks/InvoiceWebhookDeliveryRequest.cs
deleted file mode 100644
index 28dc78d..0000000
--- a/BTCPayServer/HostedServices/Webhooks/InvoiceWebhookDeliveryRequest.cs
+++ /dev/null
@@ -1,55 +0,0 @@
-using System.Globalization;
-using System.Threading.Tasks;
-using BTCPayServer.Client.Models;
-using BTCPayServer.Controllers;
-using BTCPayServer.Data;
-using BTCPayServer.Services.Invoices;
-using WebhookDeliveryData = BTCPayServer.Data.WebhookDeliveryData;
-
-namespace BTCPayServer.HostedServices.Webhooks;
-
-public class InvoiceWebhookDeliveryRequest(
- InvoiceEntity invoice,
- string webhookId,
- WebhookEvent webhookEvent,
- WebhookDeliveryData delivery,
- WebhookBlob webhookBlob)
- : WebhookSender.WebhookDeliveryRequest(webhookId, webhookEvent, delivery, webhookBlob)
-{
- public InvoiceEntity Invoice { get; } = invoice;
-
- public override Task<SendEmailRequest> Interpolate(SendEmailRequest req,
- UIStoresController.StoreEmailRule storeEmailRule)
- {
- if (storeEmailRule.CustomerEmail &&
- MailboxAddressValidator.TryParse(Invoice.Metadata.BuyerEmail, out var bmb))
- {
- req.Email ??= string.Empty;
- req.Email += $",{bmb}";
- }
-
- req.Subject = Interpolate(req.Subject);
- req.Body = Interpolate(req.Body);
- return Task.FromResult(req);
- }
-
- private string Interpolate(string str)
- {
- var res = str.Replace("{Invoice.Id}", Invoice.Id)
- .Replace("{Invoice.StoreId}", Invoice.StoreId)
- .Replace("{Invoice.Price}", Invoice.Price.ToString(CultureInfo.InvariantCulture))
- .Replace("{Invoice.Currency}", Invoice.Currency)
- .Replace("{Invoice.Status}", Invoice.Status.ToString())
- .Replace("{Invoice.AdditionalStatus}", Invoice.ExceptionStatus.ToString())
- .Replace("{Invoice.OrderId}", Invoice.Metadata.OrderId);
-
-
- res = InterpolateJsonField(res, "Invoice.Metadata", Invoice.Metadata.ToJObject());
- return res;
- }
-
- public override string ToString()
- {
- return $"Webhook delivery request ({WebhookEvent.Type})";
- }
-}
diff --git a/BTCPayServer/HostedServices/Webhooks/InvoiceWebhookProvider.cs b/BTCPayServer/HostedServices/Webhooks/InvoiceWebhookProvider.cs
deleted file mode 100644
index 5a920fc..0000000
--- a/BTCPayServer/HostedServices/Webhooks/InvoiceWebhookProvider.cs
+++ /dev/null
@@ -1,113 +0,0 @@
-using System;
-using System.Collections.Generic;
-using BTCPayServer.Client.Models;
-using BTCPayServer.Controllers.Greenfield;
-using BTCPayServer.Data;
-using BTCPayServer.Events;
-using Microsoft.Extensions.Logging;
-
-namespace BTCPayServer.HostedServices.Webhooks;
-
-public class InvoiceWebhookProvider(
- WebhookSender webhookSender,
- EventAggregator eventAggregator,
- ILogger<InvoiceWebhookProvider> logger)
- : WebhookProvider<InvoiceEvent>(eventAggregator, logger, webhookSender)
-{
- public override bool SupportsCustomerEmail { get; } = true;
-
- public override Dictionary<string, string> GetSupportedWebhookTypes()
- {
- return new Dictionary<string, string>
- {
- { WebhookEventType.InvoiceCreated, "Invoice - Created" },
- { WebhookEventType.InvoiceReceivedPayment, "Invoice - Received Payment" },
- { WebhookEventType.InvoicePaymentSettled, "Invoice - Payment Settled" },
- { WebhookEventType.InvoiceProcessing, "Invoice - Is Processing" },
- { WebhookEventType.InvoiceExpired, "Invoice - Expired" },
- { WebhookEventType.InvoiceSettled, "Invoice - Is Settled" },
- { WebhookEventType.InvoiceInvalid, "Invoice - Became Invalid" },
- { WebhookEventType.InvoiceExpiredPaidPartial, "Invoice - Expired Paid Partial" },
- { WebhookEventType.InvoicePaidAfterExpiration, "Invoice - Expired Paid Late" }
- };
- }
-
- protected override WebhookSender.WebhookDeliveryRequest CreateDeliveryRequest(InvoiceEvent invoiceEvent,
- WebhookData webhook)
- {
- var webhookEvent = GetWebhookEvent(invoiceEvent)!;
- var webhookBlob = webhook?.GetBlob();
- webhookEvent.InvoiceId = invoiceEvent.InvoiceId;
- webhookEvent.StoreId = invoiceEvent.Invoice.StoreId;
- webhookEvent.Metadata = invoiceEvent.Invoice.Metadata.ToJObject();
- webhookEvent.WebhookId = webhook?.Id;
- webhookEvent.IsRedelivery = false;
- var delivery = webhook is null ? null : WebhookExtensions.NewWebhookDelivery(webhook.Id);
- if (delivery is not null)
- {
- webhookEvent.DeliveryId = delivery.Id;
- webhookEvent.OriginalDeliveryId = delivery.Id;
- webhookEvent.Timestamp = delivery.Timestamp;
- }
-
- return new InvoiceWebhookDeliveryRequest(invoiceEvent.Invoice, webhook?.Id, webhookEvent,
- delivery, webhookBlob);
- }
-
- public override WebhookEvent CreateTestEvent(string type, params object[] args)
- {
- var storeId = args[0].ToString();
- return new WebhookInvoiceEvent(type, storeId) { InvoiceId = "__test__" + Guid.NewGuid() + "__test__" };
- }
-
- protected override WebhookInvoiceEvent GetWebhookEvent(InvoiceEvent invoiceEvent)
- {
- var eventCode = invoiceEvent.EventCode;
- var storeId = invoiceEvent.Invoice.StoreId;
- switch (eventCode)
- {
- case InvoiceEventCode.Confirmed:
- case InvoiceEventCode.MarkedCompleted:
- return new WebhookInvoiceSettledEvent(storeId)
- {
- ManuallyMarked = eventCode == InvoiceEventCode.MarkedCompleted,
- OverPaid = invoiceEvent.Invoice.ExceptionStatus == InvoiceExceptionStatus.PaidOver
- };
- case InvoiceEventCode.Created:
- return new WebhookInvoiceEvent(WebhookEventType.InvoiceCreated, storeId);
- case InvoiceEventCode.Expired:
- return new WebhookInvoiceExpiredEvent(storeId) { PartiallyPaid = invoiceEvent.PaidPartial };
- case InvoiceEventCode.FailedToConfirm:
- case InvoiceEventCode.MarkedInvalid:
- return new WebhookInvoiceInvalidEvent(storeId) { ManuallyMarked = eventCode == InvoiceEventCode.MarkedInvalid };
- case InvoiceEventCode.PaidInFull:
- return new WebhookInvoiceProcessingEvent(storeId) { OverPaid = invoiceEvent.Invoice.ExceptionStatus == InvoiceExceptionStatus.PaidOver };
- case InvoiceEventCode.ReceivedPayment:
- return new WebhookInvoiceReceivedPaymentEvent(WebhookEventType.InvoiceReceivedPayment, storeId)
- {
- AfterExpiration =
- invoiceEvent.Invoice.Status == InvoiceStatus.Expired ||
- invoiceEvent.Invoice.Status == InvoiceStatus.Invalid,
- PaymentMethodId = invoiceEvent.Payment.PaymentMethodId.ToString(),
- Payment = GreenfieldInvoiceController.ToPaymentModel(invoiceEvent.Invoice, invoiceEvent.Payment),
- StoreId = invoiceEvent.Invoice.StoreId
- };
- case InvoiceEventCode.PaymentSettled:
- return new WebhookInvoiceReceivedPaymentEvent(WebhookEventType.InvoicePaymentSettled, storeId)
- {
- AfterExpiration =
- invoiceEvent.Invoice.Status == InvoiceStatus.Expired ||
- invoiceEvent.Invoice.Status == InvoiceStatus.Invalid,
- PaymentMethodId = invoiceEvent.Payment.PaymentMethodId.ToString(),
- Payment = GreenfieldInvoiceController.ToPaymentModel(invoiceEvent.Invoice, invoiceEvent.Payment),
- StoreId = invoiceEvent.Invoice.StoreId
- };
- case InvoiceEventCode.ExpiredPaidPartial:
- return new WebhookInvoiceEvent(WebhookEventType.InvoiceExpiredPaidPartial, storeId);
- case InvoiceEventCode.PaidAfterExpiration:
- return new WebhookInvoiceEvent(WebhookEventType.InvoicePaidAfterExpiration, storeId);
- default:
- return null;
- }
- }
-}
diff --git a/BTCPayServer/HostedServices/Webhooks/PaymentRequestWebhookDeliveryRequest.cs b/BTCPayServer/HostedServices/Webhooks/PaymentRequestWebhookDeliveryRequest.cs
deleted file mode 100644
index 8a1c885..0000000
--- a/BTCPayServer/HostedServices/Webhooks/PaymentRequestWebhookDeliveryRequest.cs
+++ /dev/null
@@ -1,54 +0,0 @@
-#nullable enable
-using System.Globalization;
-using System.Threading.Tasks;
-using BTCPayServer.Client.Models;
-using BTCPayServer.Controllers;
-using BTCPayServer.Data;
-using BTCPayServer.Services.PaymentRequests;
-using WebhookDeliveryData = BTCPayServer.Data.WebhookDeliveryData;
-
-namespace BTCPayServer.HostedServices.Webhooks;
-
-public class PaymentRequestWebhookDeliveryRequest(
- PaymentRequestEvent evt,
- string webhookId,
- WebhookEvent webhookEvent,
- WebhookDeliveryData delivery,
- WebhookBlob webhookBlob)
- : WebhookSender.WebhookDeliveryRequest(webhookId, webhookEvent, delivery, webhookBlob)
-{
- public override Task<SendEmailRequest?> Interpolate(SendEmailRequest req,
- UIStoresController.StoreEmailRule storeEmailRule)
- {
- var blob = evt.Data.GetBlob();
- if (storeEmailRule.CustomerEmail &&
- MailboxAddressValidator.TryParse(blob.Email, out var bmb))
- {
- req.Email ??= string.Empty;
- req.Email += $",{bmb}";
- }
-
- req.Subject = Interpolate(req.Subject, evt.Data);
- req.Body = Interpolate(req.Body, evt.Data);
- return Task.FromResult(req)!;
- }
-
- private string Interpolate(string str, PaymentRequestData data)
- {
- var id = data.Id;
- var trimmedId = $"{id.Substring(0, 7)}...{id.Substring(id.Length - 7)}";
-
- var blob = data.GetBlob();
- var res = str.Replace("{PaymentRequest.Id}", id)
- .Replace("{PaymentRequest.TrimmedId}", trimmedId)
- .Replace("{PaymentRequest.Amount}", data.Amount.ToString(CultureInfo.InvariantCulture))
- .Replace("{PaymentRequest.Currency}", data.Currency)
- .Replace("{PaymentRequest.Title}", blob.Title)
- .Replace("{PaymentRequest.Description}", blob.Description)
- .Replace("{PaymentRequest.ReferenceId}", data.ReferenceId)
- .Replace("{PaymentRequest.Status}", evt.Data.Status.ToString());
-
- res = InterpolateJsonField(res, "PaymentRequest.FormResponse", blob.FormResponse);
- return res;
- }
-}
diff --git a/BTCPayServer/HostedServices/Webhooks/PaymentRequestWebhookProvider.cs b/BTCPayServer/HostedServices/Webhooks/PaymentRequestWebhookProvider.cs
deleted file mode 100644
index d80c9f2..0000000
--- a/BTCPayServer/HostedServices/Webhooks/PaymentRequestWebhookProvider.cs
+++ /dev/null
@@ -1,65 +0,0 @@
-using System;
-using System.Collections.Generic;
-using BTCPayServer.Client.Models;
-using BTCPayServer.Data;
-using BTCPayServer.Services.PaymentRequests;
-using Microsoft.Extensions.Logging;
-
-namespace BTCPayServer.HostedServices.Webhooks;
-
-public class PaymentRequestWebhookProvider(EventAggregator eventAggregator, ILogger<PaymentRequestWebhookProvider> logger, WebhookSender webhookSender)
- : WebhookProvider<PaymentRequestEvent>(eventAggregator, logger, webhookSender)
-{
- public override bool SupportsCustomerEmail { get; } = true;
-
- public override Dictionary<string, string> GetSupportedWebhookTypes()
- {
- return new Dictionary<string, string>
- {
- { WebhookEventType.PaymentRequestCreated, "Payment Request - Created" },
- { WebhookEventType.PaymentRequestUpdated, "Payment Request - Updated" },
- { WebhookEventType.PaymentRequestArchived, "Payment Request - Archived" },
- { WebhookEventType.PaymentRequestStatusChanged, "Payment Request - Status Changed" },
- { WebhookEventType.PaymentRequestCompleted, "Payment Request - Completed" }
- };
- }
-
- public override WebhookEvent CreateTestEvent(string type, object[] args)
- {
- var storeId = args[0].ToString();
- return new WebhookPaymentRequestEvent(type, storeId) { PaymentRequestId = "__test__" + Guid.NewGuid() + "__test__" };
- }
-
- protected override WebhookPaymentRequestEvent GetWebhookEvent(PaymentRequestEvent evt)
- {
- return evt.Type switch
- {
- PaymentRequestEvent.Created => new WebhookPaymentRequestEvent(WebhookEventType.PaymentRequestCreated, evt.Data.StoreDataId),
- PaymentRequestEvent.Updated => new WebhookPaymentRequestEvent(WebhookEventType.PaymentRequestUpdated, evt.Data.StoreDataId),
- PaymentRequestEvent.Archived => new WebhookPaymentRequestEvent(WebhookEventType.PaymentRequestArchived, evt.Data.StoreDataId),
- PaymentRequestEvent.StatusChanged => new WebhookPaymentRequestEvent(WebhookEventType.PaymentRequestStatusChanged, evt.Data.StoreDataId),
- PaymentRequestEvent.Completed => new WebhookPaymentRequestEvent(WebhookEventType.PaymentRequestCompleted, evt.Data.StoreDataId),
- _ => null
- };
- }
-
- protected override WebhookSender.WebhookDeliveryRequest CreateDeliveryRequest(PaymentRequestEvent paymentRequestEvent, WebhookData webhook)
- {
- var webhookBlob = webhook?.GetBlob();
- var webhookEvent = GetWebhookEvent(paymentRequestEvent)!;
- webhookEvent.StoreId = paymentRequestEvent.Data.StoreDataId;
- webhookEvent.PaymentRequestId = paymentRequestEvent.Data.Id;
- webhookEvent.Status = paymentRequestEvent.Data.Status;
- webhookEvent.WebhookId = webhook?.Id;
- webhookEvent.IsRedelivery = false;
- var delivery = webhook is null ? null : WebhookExtensions.NewWebhookDelivery(webhook.Id);
- if (delivery is not null)
- {
- webhookEvent.DeliveryId = delivery.Id;
- webhookEvent.OriginalDeliveryId = delivery.Id;
- webhookEvent.Timestamp = delivery.Timestamp;
- }
-
- return new PaymentRequestWebhookDeliveryRequest(paymentRequestEvent, webhook?.Id, webhookEvent, delivery, webhookBlob);
- }
-}
diff --git a/BTCPayServer/HostedServices/Webhooks/PayoutWebhookDeliveryRequest.cs b/BTCPayServer/HostedServices/Webhooks/PayoutWebhookDeliveryRequest.cs
deleted file mode 100644
index f4389cb..0000000
--- a/BTCPayServer/HostedServices/Webhooks/PayoutWebhookDeliveryRequest.cs
+++ /dev/null
@@ -1,39 +0,0 @@
-#nullable enable
-using System.Threading.Tasks;
-using BTCPayServer.Client.Models;
-using BTCPayServer.Controllers;
-using BTCPayServer.Data;
-using BTCPayServer.Services;
-using WebhookDeliveryData = BTCPayServer.Data.WebhookDeliveryData;
-
-namespace BTCPayServer.HostedServices.Webhooks;
-
-public class PayoutWebhookDeliveryRequest(
- PayoutEvent evt,
- string? webhookId,
- WebhookEvent webhookEvent,
- WebhookDeliveryData? delivery,
- WebhookBlob? webhookBlob,
- BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings)
- : WebhookSender.WebhookDeliveryRequest(webhookId!, webhookEvent, delivery!, webhookBlob!)
-{
- public override Task<SendEmailRequest?> Interpolate(SendEmailRequest req,
- UIStoresController.StoreEmailRule storeEmailRule)
- {
- req.Subject = Interpolate(req.Subject);
- req.Body = Interpolate(req.Body);
- return Task.FromResult(req)!;
- }
-
- private string Interpolate(string str)
- {
- var blob = evt.Payout.GetBlob(btcPayNetworkJsonSerializerSettings);
- var res = str.Replace("{Payout.Id}", evt.Payout.Id)
- .Replace("{Payout.PullPaymentId}", evt.Payout.PullPaymentDataId)
- .Replace("{Payout.Destination}", evt.Payout.DedupId ?? blob.Destination)
- .Replace("{Payout.State}", evt.Payout.State.ToString());
-
- res = InterpolateJsonField(res, "Payout.Metadata", blob.Metadata);
- return res;
- }
-}
diff --git a/BTCPayServer/HostedServices/Webhooks/PayoutWebhookProvider.cs b/BTCPayServer/HostedServices/Webhooks/PayoutWebhookProvider.cs
deleted file mode 100644
index cea1fd1..0000000
--- a/BTCPayServer/HostedServices/Webhooks/PayoutWebhookProvider.cs
+++ /dev/null
@@ -1,67 +0,0 @@
-using System;
-using System.Collections.Generic;
-using BTCPayServer.Client.Models;
-using BTCPayServer.Data;
-using BTCPayServer.Services;
-using Microsoft.Extensions.Logging;
-
-namespace BTCPayServer.HostedServices.Webhooks;
-
-public class PayoutWebhookProvider(
- EventAggregator eventAggregator,
- ILogger<PayoutWebhookProvider> logger,
- WebhookSender webhookSender,
- BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings)
- : WebhookProvider<PayoutEvent>(eventAggregator, logger, webhookSender)
-{
- public override bool SupportsCustomerEmail { get; } = false;
-
- protected override WebhookSender.WebhookDeliveryRequest CreateDeliveryRequest(PayoutEvent payoutEvent, WebhookData webhook)
- {
- var webhookBlob = webhook?.GetBlob();
-
- var webhookEvent = GetWebhookEvent(payoutEvent)!;
- webhookEvent.StoreId = payoutEvent.Payout.StoreDataId;
- webhookEvent.PayoutId = payoutEvent.Payout.Id;
- webhookEvent.PayoutState = payoutEvent.Payout.State;
- webhookEvent.PullPaymentId = payoutEvent.Payout.PullPaymentDataId;
- webhookEvent.WebhookId = webhook?.Id;
- webhookEvent.IsRedelivery = false;
- var delivery = webhook is null ? null : WebhookExtensions.NewWebhookDelivery(webhook.Id);
- if (delivery is not null)
- {
- webhookEvent.DeliveryId = delivery.Id;
- webhookEvent.OriginalDeliveryId = delivery.Id;
- webhookEvent.Timestamp = delivery.Timestamp;
- }
-
- return new PayoutWebhookDeliveryRequest(payoutEvent, webhook?.Id, webhookEvent, delivery, webhookBlob, btcPayNetworkJsonSerializerSettings);
- }
-
- public override Dictionary<string, string> GetSupportedWebhookTypes()
- {
- return new Dictionary<string, string>
- {
- { WebhookEventType.PayoutCreated, "Payout - Created" },
- { WebhookEventType.PayoutApproved, "Payout - Approved" },
- { WebhookEventType.PayoutUpdated, "Payout - Updated" }
- };
- }
-
- public override WebhookEvent CreateTestEvent(string type, object[] args)
- {
- var storeId = args[0].ToString();
- return new WebhookPayoutEvent(type, storeId) { PayoutId = "__test__" + Guid.NewGuid() + "__test__" };
- }
-
- protected override WebhookPayoutEvent GetWebhookEvent(PayoutEvent payoutEvent)
- {
- return payoutEvent.Type switch
- {
- PayoutEvent.PayoutEventType.Created => new WebhookPayoutEvent(WebhookEventType.PayoutCreated, payoutEvent.Payout.StoreDataId),
- PayoutEvent.PayoutEventType.Approved => new WebhookPayoutEvent(WebhookEventType.PayoutApproved, payoutEvent.Payout.StoreDataId),
- PayoutEvent.PayoutEventType.Updated => new WebhookPayoutEvent(WebhookEventType.PayoutUpdated, payoutEvent.Payout.StoreDataId),
- _ => null
- };
- }
-}
diff --git a/BTCPayServer/HostedServices/Webhooks/PendingTransactionDeliveryRequest.cs b/BTCPayServer/HostedServices/Webhooks/PendingTransactionDeliveryRequest.cs
deleted file mode 100644
index c929061..0000000
--- a/BTCPayServer/HostedServices/Webhooks/PendingTransactionDeliveryRequest.cs
+++ /dev/null
@@ -1,48 +0,0 @@
-using System.Threading.Tasks;
-using BTCPayServer.Client.Models;
-using BTCPayServer.Controllers;
-using BTCPayServer.Data;
-using WebhookDeliveryData = BTCPayServer.Data.WebhookDeliveryData;
-
-namespace BTCPayServer.HostedServices.Webhooks;
-
-public class PendingTransactionDeliveryRequest(
- PendingTransactionService.PendingTransactionEvent evt,
- string webhookId,
- WebhookEvent webhookEvent,
- WebhookDeliveryData delivery,
- WebhookBlob webhookBlob)
- : WebhookSender.WebhookDeliveryRequest(webhookId, webhookEvent, delivery, webhookBlob)
-{
- public override Task<SendEmailRequest> Interpolate(SendEmailRequest req,
- UIStoresController.StoreEmailRule storeEmailRule)
- {
- var blob = evt.Data.GetBlob();
- // if (storeEmailRule.CustomerEmail &&
- // MailboxAddressValidator.TryParse(Invoice.Metadata.BuyerEmail, out var bmb))
- // {
- // req.Email ??= string.Empty;
- // req.Email += $",{bmb}";
- // }
-
- req.Subject = Interpolate(req.Subject, blob);
- req.Body = Interpolate(req.Body, blob);
- return Task.FromResult(req);
- }
-
- private string Interpolate(string str, PendingTransactionBlob blob)
- {
- var id = evt.Data.TransactionId;
- var trimmedId = $"{id.Substring(0, 7)}...{id.Substring(id.Length - 7)}";
-
- var res = str.Replace("{PendingTransaction.Id}", id)
- .Replace("{PendingTransaction.TrimmedId}", trimmedId)
- .Replace("{PendingTransaction.StoreId}", evt.Data.StoreId)
- .Replace("{PendingTransaction.SignaturesCollected}", blob.SignaturesCollected?.ToString())
- .Replace("{PendingTransaction.SignaturesNeeded}", blob.SignaturesNeeded?.ToString())
- .Replace("{PendingTransaction.SignaturesTotal}", blob.SignaturesTotal?.ToString());
-
- // res = InterpolateJsonField(res, "Invoice.Metadata", Invoice.Metadata.ToJObject());
- return res;
- }
-}
diff --git a/BTCPayServer/HostedServices/Webhooks/PendingTransactionWebhookProvider.cs b/BTCPayServer/HostedServices/Webhooks/PendingTransactionWebhookProvider.cs
deleted file mode 100644
index 4a4a251..0000000
--- a/BTCPayServer/HostedServices/Webhooks/PendingTransactionWebhookProvider.cs
+++ /dev/null
@@ -1,89 +0,0 @@
-using System;
-using System.Collections.Generic;
-using BTCPayServer.Client.Models;
-using BTCPayServer.Data;
-using Microsoft.Extensions.Logging;
-using Newtonsoft.Json;
-
-namespace BTCPayServer.HostedServices.Webhooks;
-
-public class PendingTransactionWebhookProvider(
- WebhookSender webhookSender,
- EventAggregator eventAggregator,
- ILogger<InvoiceWebhookProvider> logger)
- : WebhookProvider<PendingTransactionService.PendingTransactionEvent>(eventAggregator, logger, webhookSender)
-{
- public const string PendingTransactionCreated = nameof(PendingTransactionCreated);
- public const string PendingTransactionSignatureCollected = nameof(PendingTransactionSignatureCollected);
- public const string PendingTransactionBroadcast = nameof(PendingTransactionBroadcast);
- public const string PendingTransactionCancelled = nameof(PendingTransactionCancelled);
-
- public override bool SupportsCustomerEmail { get; } = false;
-
- public override Dictionary<string, string> GetSupportedWebhookTypes()
- {
- return new Dictionary<string, string>
- {
- { PendingTransactionCreated, "Pending Transaction - Created" },
- { PendingTransactionSignatureCollected, "Pending Transaction - Signature Collected" },
- { PendingTransactionBroadcast, "Pending Transaction - Broadcast" },
- { PendingTransactionCancelled, "Pending Transaction - Cancelled" }
- };
- }
-
- protected override WebhookSender.WebhookDeliveryRequest CreateDeliveryRequest(PendingTransactionService.PendingTransactionEvent evt,
- WebhookData webhook)
- {
- var webhookBlob = webhook?.GetBlob();
-
- var webhookEvent = GetWebhookEvent(evt)!;
- webhookEvent.StoreId = evt.Data.StoreId;
- webhookEvent.WebhookId = webhook?.Id;
- webhookEvent.IsRedelivery = false;
- var delivery = webhook is null ? null : WebhookExtensions.NewWebhookDelivery(webhook.Id);
- if (delivery is not null)
- {
- webhookEvent.DeliveryId = delivery.Id;
- webhookEvent.OriginalDeliveryId = delivery.Id;
- webhookEvent.Timestamp = delivery.Timestamp;
- }
-
- return new PendingTransactionDeliveryRequest(evt, webhook?.Id, webhookEvent, delivery, webhookBlob);
- }
-
- protected override WebhookPendingTransactionEvent GetWebhookEvent(PendingTransactionService.PendingTransactionEvent evt)
- {
- return evt.Type switch
- {
- PendingTransactionService.PendingTransactionEvent.Created => new WebhookPendingTransactionEvent(
- PendingTransactionCreated, evt.Data.StoreId),
- PendingTransactionService.PendingTransactionEvent.SignatureCollected => new WebhookPendingTransactionEvent(
- PendingTransactionSignatureCollected, evt.Data.StoreId),
- PendingTransactionService.PendingTransactionEvent.Broadcast => new WebhookPendingTransactionEvent(
- PendingTransactionBroadcast, evt.Data.StoreId),
- PendingTransactionService.PendingTransactionEvent.Cancelled => new WebhookPendingTransactionEvent(
- PendingTransactionCancelled, evt.Data.StoreId),
- _ => null
- };
- }
-
- public override WebhookEvent CreateTestEvent(string type, params object[] args)
- {
- var storeId = args[0].ToString();
- return new WebhookPendingTransactionEvent(type, storeId) { PendingTransactionId = "__test__" + Guid.NewGuid() + "__test__" };
- }
-
-
- public class WebhookPendingTransactionEvent : StoreWebhookEvent
- {
- public WebhookPendingTransactionEvent(string type, string storeId)
- {
- if (!type.StartsWith(PendingTransactionCreated.Replace("Created", "").ToLower(), StringComparison.InvariantCultureIgnoreCase))
- throw new ArgumentException("Invalid event type", nameof(type));
- Type = type;
- StoreId = storeId;
- }
-
- [JsonProperty(Order = 2)] public string PendingTransactionId { get; set; }
- }
-}
diff --git a/BTCPayServer/HostedServices/Webhooks/WebhookExtensions.cs b/BTCPayServer/HostedServices/Webhooks/WebhookExtensions.cs
deleted file mode 100644
index daaf3f4..0000000
--- a/BTCPayServer/HostedServices/Webhooks/WebhookExtensions.cs
+++ /dev/null
@@ -1,55 +0,0 @@
-using System;
-using System.Net.Http;
-using BTCPayServer.Data;
-using BTCPayServer.Services;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Hosting;
-using NBitcoin;
-using NBitcoin.DataEncoders;
-
-namespace BTCPayServer.HostedServices.Webhooks;
-
-public static class WebhookExtensions
-{
- public static WebhookDeliveryData NewWebhookDelivery(string webhookId)
- {
- return new WebhookDeliveryData { Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(16)), Timestamp = DateTimeOffset.UtcNow, WebhookId = webhookId };
- }
-
- public static bool ShouldDeliver(this WebhookBlob wh, string type)
- {
- return wh.Active && wh.AuthorizedEvents.Match(type);
- }
-
- public static IServiceCollection AddWebhooks(this IServiceCollection services)
- {
- services.AddSingleton<InvoiceWebhookProvider>();
- services.AddSingleton<IWebhookProvider>(o => o.GetRequiredService<InvoiceWebhookProvider>());
- services.AddHostedService(o => o.GetRequiredService<InvoiceWebhookProvider>());
-
- services.AddSingleton<PayoutWebhookProvider>();
- services.AddSingleton<IWebhookProvider>(o => o.GetRequiredService<PayoutWebhookProvider>());
- services.AddHostedService(o => o.GetRequiredService<PayoutWebhookProvider>());
-
- services.AddSingleton<PaymentRequestWebhookProvider>();
- services.AddSingleton<IWebhookProvider>(o => o.GetRequiredService<PaymentRequestWebhookProvider>());
- services.AddHostedService(o => o.GetRequiredService<PaymentRequestWebhookProvider>());
-
- services.AddSingleton<PendingTransactionWebhookProvider>();
- services.AddSingleton<IWebhookProvider>(o => o.GetRequiredService<PendingTransactionWebhookProvider>());
- services.AddHostedService(o => o.GetRequiredService<PendingTransactionWebhookProvider>());
-
- services.AddSingleton<WebhookSender>();
- services.AddSingleton<IHostedService, WebhookSender>(o => o.GetRequiredService<WebhookSender>());
- services.AddScheduledTask<CleanupWebhookDeliveriesTask>(TimeSpan.FromHours(6.0));
- services.AddHttpClient(WebhookSender.OnionNamedClient)
- .ConfigurePrimaryHttpMessageHandler<Socks5HttpClientHandler>();
- services.AddHttpClient(WebhookSender.LoopbackNamedClient)
- .ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler
- {
- ServerCertificateCustomValidationCallback =
- HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
- });
- return services;
- }
-}
diff --git a/BTCPayServer/HostedServices/Webhooks/WebhookProvider.cs b/BTCPayServer/HostedServices/Webhooks/WebhookProvider.cs
deleted file mode 100644
index 79d89b2..0000000
--- a/BTCPayServer/HostedServices/Webhooks/WebhookProvider.cs
+++ /dev/null
@@ -1,45 +0,0 @@
-using System.Collections.Generic;
-using System.Threading;
-using System.Threading.Tasks;
-using BTCPayServer.Client.Models;
-using BTCPayServer.Data;
-using Microsoft.Extensions.Logging;
-
-namespace BTCPayServer.HostedServices.Webhooks;
-
-public abstract class WebhookProvider<T>(EventAggregator eventAggregator, ILogger logger, WebhookSender webhookSender)
- : EventHostedServiceBase(eventAggregator, logger), IWebhookProvider
-{
- public abstract bool SupportsCustomerEmail { get; }
-
- public abstract Dictionary<string, string> GetSupportedWebhookTypes();
-
- public abstract WebhookEvent CreateTestEvent(string type, params object[] args);
-
- protected abstract WebhookSender.WebhookDeliveryRequest CreateDeliveryRequest(T evt, WebhookData webhook);
-
- protected abstract StoreWebhookEvent GetWebhookEvent(T evt);
-
- protected override void SubscribeToEvents()
- {
- Subscribe<T>();
- base.SubscribeToEvents();
- }
-
- protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
- {
- if (evt is T tEvt)
- {
- if (GetWebhookEvent(tEvt) is not { } webhookEvent)
- return;
-
- var webhooks = await webhookSender.GetWebhooks(webhookEvent.StoreId, webhookEvent.Type);
- foreach (var webhook in webhooks)
- webhookSender.EnqueueDelivery(CreateDeliveryRequest(tEvt, webhook));
-
- EventAggregator.Publish(CreateDeliveryRequest(tEvt, null));
- }
-
- await base.ProcessEvent(evt, cancellationToken);
- }
-}
diff --git a/BTCPayServer/HostedServices/Webhooks/WebhookSender.cs b/BTCPayServer/HostedServices/Webhooks/WebhookSender.cs
deleted file mode 100644
index e7b19a7..0000000
--- a/BTCPayServer/HostedServices/Webhooks/WebhookSender.cs
+++ /dev/null
@@ -1,348 +0,0 @@
-#nullable enable
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Net.Http;
-using System.Net.Http.Headers;
-using System.Security.Cryptography;
-using System.Text;
-using System.Text.RegularExpressions;
-using System.Threading;
-using System.Threading.Tasks;
-using BTCPayServer.Client.Models;
-using BTCPayServer.Controllers;
-using BTCPayServer.Data;
-using BTCPayServer.Services.Stores;
-using Microsoft.EntityFrameworkCore;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Hosting;
-using Microsoft.Extensions.Logging;
-using NBitcoin.DataEncoders;
-using Newtonsoft.Json;
-using Newtonsoft.Json.Linq;
-using WebhookDeliveryData = BTCPayServer.Data.WebhookDeliveryData;
-
-namespace BTCPayServer.HostedServices.Webhooks;
-
-/// <summary>
-/// This class sends webhook notifications
-/// It also makes sure the events sent to a webhook are sent in order to the webhook
-/// </summary>
-public class WebhookSender(
- StoreRepository storeRepository,
- IHttpClientFactory httpClientFactory,
- ApplicationDbContextFactory dbContextFactory,
- ILogger<WebhookSender> logger,
- IServiceProvider serviceProvider)
- : IHostedService
-{
- public const string OnionNamedClient = "greenfield-webhook.onion";
- public const string ClearnetNamedClient = "greenfield-webhook.clearnet";
- public const string LoopbackNamedClient = "greenfield-webhook.loopback";
- public static string[] AllClients = new[] { OnionNamedClient, ClearnetNamedClient, LoopbackNamedClient };
- public static readonly JsonSerializerSettings DefaultSerializerSettings;
-
-
- private readonly MultiProcessingQueue _processingQueue = new();
-
- private readonly Encoding _utf8 = new UTF8Encoding(false);
-
-
- static WebhookSender()
- {
- DefaultSerializerSettings = WebhookEvent.DefaultSerializerSettings;
- }
-
- private StoreRepository StoreRepository { get; } = storeRepository;
- private IHttpClientFactory HttpClientFactory { get; } = httpClientFactory;
-
- public Task StartAsync(CancellationToken cancellationToken)
- {
- return Task.CompletedTask;
- }
-
- public async Task StopAsync(CancellationToken cancellationToken)
- {
- var stopping = _processingQueue.Abort(cancellationToken);
- await stopping;
- }
-
-
- private HttpClient GetClient(Uri uri)
- {
- return HttpClientFactory.CreateClient(uri.IsOnion() ? OnionNamedClient :
- uri.IsLoopback ? LoopbackNamedClient : ClearnetNamedClient);
- }
-
- public async Task<string?> Redeliver(string deliveryId)
- {
- var deliveryRequest = await CreateRedeliveryRequest(deliveryId);
- if (deliveryRequest is null)
- return null;
- EnqueueDelivery(deliveryRequest);
- return deliveryRequest.Delivery.Id;
- }
-
- private async Task<WebhookDeliveryRequest?> CreateRedeliveryRequest(string deliveryId)
- {
- await using var ctx = dbContextFactory.CreateContext();
- var webhookDelivery = await ctx.WebhookDeliveries.AsNoTracking()
- .Where(o => o.Id == deliveryId)
- .Select(o => new { o.Webhook, Delivery = o })
- .FirstOrDefaultAsync();
- if (webhookDelivery is null)
- return null;
- var oldDeliveryBlob = webhookDelivery.Delivery.GetBlob();
- var newDelivery = WebhookExtensions.NewWebhookDelivery(webhookDelivery.Webhook.Id);
- WebhookDeliveryBlob newDeliveryBlob = new();
- newDeliveryBlob.Request = oldDeliveryBlob.Request;
- var webhookEvent = newDeliveryBlob.ReadRequestAs<WebhookEvent>();
- if (webhookEvent.IsPruned())
- return null;
- webhookEvent.DeliveryId = newDelivery.Id;
- webhookEvent.WebhookId = webhookDelivery.Webhook.Id;
- // if we redelivered a redelivery, we still want the initial delivery here
- webhookEvent.OriginalDeliveryId ??= deliveryId;
- webhookEvent.IsRedelivery = true;
- newDeliveryBlob.Request = ToBytes(webhookEvent);
- newDelivery.SetBlob(newDeliveryBlob);
- return new WebhookDeliveryRequest(webhookDelivery.Webhook.Id, webhookEvent, newDelivery,
- webhookDelivery.Webhook.GetBlob());
- }
-
- private WebhookEvent GetTestWebHook(string storeId, string webhookId, string webhookEventType,
- WebhookDeliveryData delivery)
- {
- var webhookProvider = serviceProvider.GetServices<IWebhookProvider>()
- .FirstOrDefault(provider => provider.GetSupportedWebhookTypes().ContainsKey(webhookEventType));
-
- if (webhookProvider is null)
- throw new ArgumentException($"Unknown webhook event type {webhookEventType}", webhookEventType);
-
- var webhookEvent = webhookProvider.CreateTestEvent(webhookEventType, storeId);
- if (webhookEvent is null)
- throw new ArgumentException("Webhook provider does not support tests");
-
- webhookEvent.DeliveryId = delivery.Id;
- webhookEvent.WebhookId = webhookId;
- webhookEvent.OriginalDeliveryId = "__test__" + Guid.NewGuid() + "__test__";
- webhookEvent.IsRedelivery = false;
- webhookEvent.Timestamp = delivery.Timestamp;
-
- return webhookEvent;
- }
-
- public async Task<DeliveryResult> TestWebhook(string storeId, string webhookId, string webhookEventType,
- CancellationToken cancellationToken)
- {
- var delivery = WebhookExtensions.NewWebhookDelivery(webhookId);
- var webhook = (await StoreRepository.GetWebhooks(storeId)).FirstOrDefault(w => w.Id == webhookId);
- WebhookDeliveryRequest deliveryRequest = new(
- webhookId,
- GetTestWebHook(storeId, webhookId, webhookEventType, delivery),
- delivery,
- webhook.GetBlob()
- );
- return await SendDelivery(deliveryRequest, cancellationToken);
- }
-
- public void EnqueueDelivery(WebhookDeliveryRequest context)
- {
- _processingQueue.Enqueue(context.WebhookId, cancellationToken => Process(context, cancellationToken));
- }
-
- private async Task Process(WebhookDeliveryRequest ctx, CancellationToken cancellationToken)
- {
- try
- {
- var wh = (await StoreRepository.GetWebhook(ctx.WebhookId))?.GetBlob();
- if (wh is null || !wh.ShouldDeliver(ctx.WebhookEvent.Type))
- return;
- var result = await SendAndSaveDelivery(ctx, cancellationToken);
- if (ctx.WebhookBlob.AutomaticRedelivery &&
- !result.Success &&
- result.DeliveryId is not null)
- {
- var originalDeliveryId = result.DeliveryId;
- foreach (var wait in new[]
- {
- TimeSpan.FromSeconds(10), TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(10), TimeSpan.FromMinutes(10),
- TimeSpan.FromMinutes(10), TimeSpan.FromMinutes(10), TimeSpan.FromMinutes(10), TimeSpan.FromMinutes(10)
- })
- {
- await Task.Delay(wait, cancellationToken);
- ctx = (await CreateRedeliveryRequest(originalDeliveryId))!;
- // This may have changed
- if (ctx is null || !ctx.WebhookBlob.AutomaticRedelivery ||
- !ctx.WebhookBlob.ShouldDeliver(ctx.WebhookEvent.Type))
- return;
- result = await SendAndSaveDelivery(ctx, cancellationToken);
- if (result.Success)
- return;
- }
- }
- }
- catch when (cancellationToken.IsCancellationRequested)
- {
- }
- catch (Exception ex)
- {
- logger.LogError(ex, "Unexpected error when processing a webhook");
- }
- }
-
- private async Task<DeliveryResult> SendDelivery(WebhookDeliveryRequest ctx, CancellationToken cancellationToken)
- {
- Uri uri = new(ctx.WebhookBlob.Url, UriKind.Absolute);
- var httpClient = GetClient(uri);
- using HttpRequestMessage request = new();
- request.RequestUri = uri;
- request.Method = HttpMethod.Post;
- var bytes = ToBytes(ctx.WebhookEvent);
- ByteArrayContent content = new(bytes);
- content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
- using HMACSHA256 hmac = new(_utf8.GetBytes(ctx.WebhookBlob.Secret ?? string.Empty));
- var sig = Encoders.Hex.EncodeData(hmac.ComputeHash(bytes));
- content.Headers.Add("BTCPay-Sig", $"sha256={sig}");
- request.Content = content;
- var deliveryBlob = ctx.Delivery.GetBlob() ?? new WebhookDeliveryBlob();
- deliveryBlob.Request = bytes;
- try
- {
- using var response = await httpClient.SendAsync(request, cancellationToken);
- if (!response.IsSuccessStatusCode)
- {
- deliveryBlob.Status = WebhookDeliveryStatus.HttpError;
- deliveryBlob.ErrorMessage = $"HTTP Error Code {(int)response.StatusCode}";
- }
- else
- {
- deliveryBlob.Status = WebhookDeliveryStatus.HttpSuccess;
- }
-
- deliveryBlob.HttpCode = (int)response.StatusCode;
- }
- catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
- {
- deliveryBlob.Status = WebhookDeliveryStatus.Failed;
- deliveryBlob.ErrorMessage = ex.Message;
- }
-
- ctx.Delivery.SetBlob(deliveryBlob);
-
- return new DeliveryResult { Success = deliveryBlob.ErrorMessage is null, DeliveryId = ctx.Delivery.Id, ErrorMessage = deliveryBlob.ErrorMessage };
- }
-
- private async Task<DeliveryResult> SendAndSaveDelivery(WebhookDeliveryRequest ctx,
- CancellationToken cancellationToken)
- {
- var result = await SendDelivery(ctx, cancellationToken);
- await StoreRepository.AddWebhookDelivery(ctx.Delivery);
-
- return result;
- }
-
- private byte[] ToBytes(WebhookEvent webhookEvent)
- {
- var str = JsonConvert.SerializeObject(webhookEvent, Formatting.Indented, DefaultSerializerSettings);
- var bytes = _utf8.GetBytes(str);
- return bytes;
- }
-
- public async Task<WebhookData[]> GetWebhooks(string invoiceStoreId, string? webhookEventType)
- {
- return (await StoreRepository.GetWebhooks(invoiceStoreId)).Where(data => webhookEventType is null || data.GetBlob().ShouldDeliver(webhookEventType))
- .ToArray();
- }
-
- public async Task<UIStoresController.StoreEmailRule[]> GetEmailRules(string storeId,
- string type)
- {
- return (await StoreRepository.FindStore(storeId))?.GetStoreBlob().EmailRules?.Where(rule => rule.Trigger == type).ToArray() ??
- Array.Empty<UIStoresController.StoreEmailRule>();
- }
-
- public Dictionary<string, string> GetSupportedWebhookTypes()
- {
- return serviceProvider.GetServices<IWebhookProvider>()
- .SelectMany(provider => provider.GetSupportedWebhookTypes()).ToDictionary(pair => pair.Key, pair => pair.Value);
- }
-
- public Dictionary<string, bool> GetWebhookTypesSupportedByCustomerEmail()
- {
- return serviceProvider.GetServices<IWebhookProvider>()
- .SelectMany(provider => provider.GetSupportedWebhookTypes()
- .Select(pair => new { pair.Key, Value = provider.SupportsCustomerEmail }))
- .ToDictionary(x => x.Key, x => x.Value);
- }
-
- public class WebhookDeliveryRequest(
- string webhookId,
- WebhookEvent webhookEvent,
- WebhookDeliveryData delivery,
- WebhookBlob webhookBlob)
- {
- // Regex pattern to validate JSONPath: alphanumeric, underscore, dot, hyphen, square brackets, asterisk, single/double quotes
- private static readonly Regex _jsonPathRegex = new(@"^[a-zA-Z0-9_\.\-\[\]\*'""]*$", RegexOptions.Compiled);
- public WebhookEvent WebhookEvent { get; } = webhookEvent;
- public WebhookDeliveryData Delivery { get; } = delivery;
- public WebhookBlob WebhookBlob { get; } = webhookBlob;
- public string WebhookId { get; } = webhookId;
-
- public virtual Task<SendEmailRequest?> Interpolate(SendEmailRequest req,
- UIStoresController.StoreEmailRule storeEmailRule)
- {
- return Task.FromResult(req)!;
- }
-
- protected static string InterpolateJsonField(string str, string fieldName, JObject obj)
- {
- if (string.IsNullOrEmpty(str) || string.IsNullOrEmpty(fieldName) || obj == null)
- return str;
-
- fieldName += ".";
-
- //find all instance of {fieldName*} in str, then run obj.SelectToken(*) on it
- while (true)
- {
- var start = str.IndexOf($"{{{fieldName}", StringComparison.InvariantCultureIgnoreCase);
- if (start == -1)
- break;
-
- start += fieldName.Length + 1; // Move past the {
- var end = str.IndexOf("}", start, StringComparison.InvariantCultureIgnoreCase);
- if (end == -1)
- break;
-
- var jsonpath = str.Substring(start, end - start);
- var result = string.Empty;
-
- try
- {
- if (string.IsNullOrEmpty(jsonpath))
- result = obj.ToString();
- else if (_jsonPathRegex.IsMatch(jsonpath))
- // Only process if JSONPath is valid
- result = obj.SelectToken(jsonpath)?.ToString() ?? string.Empty;
- // If jsonpath doesn't match the pattern, result remains empty string
- }
- catch (JsonException)
- {
- // Handle JSON parsing errors (e.g., invalid JSONPath syntax)
- result = string.Empty;
- }
-
- str = str.Replace($"{{{fieldName}{jsonpath}}}", result);
- }
-
- return str;
- }
- }
-
- public class DeliveryResult
- {
- public string? DeliveryId { get; set; }
- public bool Success { get; set; }
- public string? ErrorMessage { get; set; }
- }
-}
diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs
index 9e42640..2c22a35 100644
--- a/BTCPayServer/Hosting/BTCPayServerServices.cs
+++ b/BTCPayServer/Hosting/BTCPayServerServices.cs
@@ -15,7 +15,6 @@ using BTCPayServer.Data;
using BTCPayServer.Data.Payouts.LightningLike;
using BTCPayServer.Forms;
using BTCPayServer.HostedServices;
-using BTCPayServer.HostedServices.Webhooks;
using BTCPayServer.Lightning;
using BTCPayServer.Lightning.Charge;
using BTCPayServer.Lightning.CLightning;
@@ -384,7 +383,6 @@ namespace BTCPayServer.Hosting
services.AddSingleton<IHostedService, Services.NBXplorerConnectionFactory>(o => o.GetRequiredService<Services.NBXplorerConnectionFactory>());
services.AddSingleton<HostedServices.CheckConfigurationHostedService>();
services.AddSingleton<IHostedService, HostedServices.CheckConfigurationHostedService>(o => o.GetRequiredService<CheckConfigurationHostedService>());
- services.AddSingleton<IHostedService, StoreEmailRuleProcessorSender>();
services.AddSingleton<IHostedService, PeriodicTaskLauncherHostedService>();
services.AddScheduledTask<GithubVersionFetcher>(TimeSpan.FromDays(1));
services.AddScheduledTask<PluginUpdateFetcher>(TimeSpan.FromDays(1));
@@ -395,7 +393,6 @@ namespace BTCPayServer.Hosting
services.AddReportProvider<PayoutsReportProvider>();
services.AddReportProvider<InvoicesReportProvider>();
services.AddReportProvider<RefundsReportProvider>();
- services.AddWebhooks();
services.AddSingleton<Dictionary<PaymentMethodId, IPaymentMethodBitpayAPIExtension>>(o =>
o.GetRequiredService<IEnumerable<IPaymentMethodBitpayAPIExtension>>().ToDictionary(o => o.PaymentMethodId, o => o));
@@ -516,16 +513,6 @@ o.GetRequiredService<IEnumerable<IPaymentLinkExtension>>().ToDictionary(o => o.P
services.AddSingleton<IHostedService, Cheater>(o => o.GetRequiredService<Cheater>());
}
- var userAgent = new System.Net.Http.Headers.ProductInfoHeaderValue("BTCPayServer", BTCPayServerEnvironment.GetInformationalVersion());
- foreach (var clientName in WebhookSender.AllClients.Concat(new[] { BitpayIPNSender.NamedClient }))
- {
- services.AddHttpClient(clientName)
- .ConfigureHttpClient(client =>
- {
- client.DefaultRequestHeaders.UserAgent.Add(userAgent);
- });
- }
-
return services;
}
diff --git a/BTCPayServer/Hosting/Startup.cs b/BTCPayServer/Hosting/Startup.cs
index 988dc31..fad9353 100644
--- a/BTCPayServer/Hosting/Startup.cs
+++ b/BTCPayServer/Hosting/Startup.cs
@@ -173,6 +173,8 @@ namespace BTCPayServer.Hosting
o.AreaViewLocationFormats.Add("/Plugins/{2}/Views/{1}/{0}.cshtml");
o.AreaViewLocationFormats.Add("/Plugins/{2}/Views/{0}.cshtml");
o.AreaViewLocationFormats.Add("/Plugins/{2}/Views/Shared/{0}.cshtml");
+
+
o.AreaViewLocationFormats.Add("/{0}.cshtml");
})
.AddNewtonsoftJson()
diff --git a/BTCPayServer/Models/InvoicingModels/InvoiceDetailsModel.cs b/BTCPayServer/Models/InvoicingModels/InvoiceDetailsModel.cs
index d62d6f6..1f6ad65 100644
--- a/BTCPayServer/Models/InvoicingModels/InvoiceDetailsModel.cs
+++ b/BTCPayServer/Models/InvoicingModels/InvoiceDetailsModel.cs
@@ -4,6 +4,7 @@ using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Bitcoin;
+using BTCPayServer.Plugins.Webhooks.Views;
using BTCPayServer.Services.Invoices;
using NBitcoin;
using Newtonsoft.Json;
@@ -85,7 +86,7 @@ namespace BTCPayServer.Models.InvoicingModels
get; set;
}
- public List<StoreViewModels.DeliveryViewModel> Deliveries { get; set; } = new List<StoreViewModels.DeliveryViewModel>();
+ public List<DeliveryViewModel> Deliveries { get; set; } = new ();
public string TaxIncluded { get; set; }
public string TransactionSpeed { get; set; }
diff --git a/BTCPayServer/Models/StoreViewModels/EditWebhookViewModel.cs b/BTCPayServer/Models/StoreViewModels/EditWebhookViewModel.cs
deleted file mode 100644
index 4678486..0000000
--- a/BTCPayServer/Models/StoreViewModels/EditWebhookViewModel.cs
+++ /dev/null
@@ -1,85 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.ComponentModel.DataAnnotations;
-using System.Linq;
-using System.Threading.Tasks;
-using BTCPayServer.Client.Models;
-using BTCPayServer.Data;
-using BTCPayServer.Validation;
-
-namespace BTCPayServer.Models.StoreViewModels
-{
- public class DeliveryViewModel
- {
- public DeliveryViewModel()
- {
-
- }
- public DeliveryViewModel(Data.WebhookDeliveryData s)
- {
- var blob = s.GetBlob();
- Id = s.Id;
- Success = blob.Status == WebhookDeliveryStatus.HttpSuccess;
- ErrorMessage = blob.ErrorMessage ?? "Success";
- Time = s.Timestamp;
- var evt = blob.ReadRequestAs<WebhookEvent>();
- Type = evt.Type;
- Pruned = evt.IsPruned();
- WebhookId = s.Id;
- PayloadUrl = s.Webhook?.GetBlob().Url;
- }
- public string Id { get; set; }
- public DateTimeOffset Time { get; set; }
- public string Type { get; private set; }
- public bool Pruned { get; set; }
- public string WebhookId { get; set; }
- public bool Success { get; set; }
- public string ErrorMessage { get; set; }
- public string PayloadUrl { get; set; }
- }
- public class EditWebhookViewModel
- {
- public EditWebhookViewModel()
- {
-
- }
- public EditWebhookViewModel(WebhookBlob blob)
- {
- Active = blob.Active;
- AutomaticRedelivery = blob.AutomaticRedelivery;
- Everything = blob.AuthorizedEvents.Everything;
- Events = blob.AuthorizedEvents.SpecificEvents;
- PayloadUrl = blob.Url;
- Secret = blob.Secret;
- IsNew = false;
- }
- public bool IsNew { get; set; }
- public bool Active { get; set; }
- public bool AutomaticRedelivery { get; set; }
- public bool Everything { get; set; }
- public string[] Events { get; set; } = Array.Empty<string>();
- [Uri]
- [Required]
- public string PayloadUrl { get; set; }
- [MaxLength(64)]
- public string Secret { get; set; }
-
- public List<DeliveryViewModel> Deliveries { get; set; } = new List<DeliveryViewModel>();
-
- public WebhookBlob CreateBlob()
- {
- return new WebhookBlob()
- {
- Active = Active,
- Secret = Secret,
- AutomaticRedelivery = AutomaticRedelivery,
- Url = new Uri(PayloadUrl, UriKind.Absolute).AbsoluteUri,
- AuthorizedEvents = new AuthorizedWebhookEvents()
- {
- Everything = Everything,
- SpecificEvents = Events
- }
- };
- }
- }
-}
diff --git a/BTCPayServer/Models/StoreViewModels/TestWebhookViewModel.cs b/BTCPayServer/Models/StoreViewModels/TestWebhookViewModel.cs
deleted file mode 100644
index d1d0514..0000000
--- a/BTCPayServer/Models/StoreViewModels/TestWebhookViewModel.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-using BTCPayServer.Client.Models;
-
-namespace BTCPayServer.Models.StoreViewModels
-{
- public class TestWebhookViewModel
- {
- public string Type { get; set; }
- }
-}
diff --git a/BTCPayServer/Models/StoreViewModels/WebhooksViewModel.cs b/BTCPayServer/Models/StoreViewModels/WebhooksViewModel.cs
deleted file mode 100644
index e4c09da..0000000
--- a/BTCPayServer/Models/StoreViewModels/WebhooksViewModel.cs
+++ /dev/null
@@ -1,17 +0,0 @@
-using System;
-
-namespace BTCPayServer.Models.StoreViewModels
-{
- public class WebhooksViewModel
- {
- public class WebhookViewModel
- {
- public string Id { get; set; }
- public string Url { get; set; }
- public bool LastDeliverySuccessful { get; set; } = true;
- public DateTimeOffset? LastDeliveryTimeStamp { get; set; } = null;
- public string LastDeliveryErrorMessage { get; set; } = null;
- }
- public WebhookViewModel[] Webhooks { get; set; }
- }
-}
diff --git a/BTCPayServer/Plugins/Emails/Controllers/UIStoreEmailRulesController.cs b/BTCPayServer/Plugins/Emails/Controllers/UIStoreEmailRulesController.cs
new file mode 100644
index 0000000..de73873
--- /dev/null
+++ b/BTCPayServer/Plugins/Emails/Controllers/UIStoreEmailRulesController.cs
@@ -0,0 +1,157 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using BTCPayServer.Abstractions.Constants;
+using BTCPayServer.Abstractions.Extensions;
+using BTCPayServer.Abstractions.Models;
+using BTCPayServer.Client;
+using BTCPayServer.Data;
+using BTCPayServer.Plugins.Emails.Views;
+using BTCPayServer.Services.Mails;
+using Dapper;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Localization;
+using Npgsql;
+
+namespace BTCPayServer.Plugins.Emails.Controllers;
+
+[Area(EmailsPlugin.Area)]
+[Route("stores/{storeId}/emails/rules")]
+[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)]
+[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
+[AutoValidateAntiforgeryToken]
+public class UIStoreEmailRulesController(
+ EmailSenderFactory emailSenderFactory,
+ LinkGenerator linkGenerator,
+ ApplicationDbContextFactory dbContextFactory,
+ IEnumerable<EmailTriggerViewModel> triggers,
+ IStringLocalizer stringLocalizer) : Controller
+{
+ public IStringLocalizer StringLocalizer { get; set; } = stringLocalizer;
+ [HttpGet("")]
+ public async Task<IActionResult> StoreEmailRulesList(string storeId)
+ {
+ await using var ctx = dbContextFactory.CreateContext();
+ var store = HttpContext.GetStoreData();
+ var configured = await emailSenderFactory.IsComplete(store.Id);
+ if (!configured && !TempData.HasStatusMessage())
+ {
+ TempData.SetStatusMessageModel(new StatusMessageModel
+ {
+ Severity = StatusMessageModel.StatusSeverity.Warning,
+ Html = "You need to configure email settings before this feature works." +
+ $" <a class='alert-link configure-email' href='{linkGenerator.GetStoreEmailSettingsLink(storeId, Request.GetRequestBaseUrl())}'>Configure store email settings</a>."
+ });
+ }
+
+ var rules = await ctx.EmailRules.GetRules(storeId).ToListAsync();
+ return View("StoreEmailRulesList", rules.Select(r => new StoreEmailRuleViewModel(r, triggers)).ToList());
+ }
+
+ [HttpGet("create")]
+ [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
+ public IActionResult StoreEmailRulesCreate(string storeId)
+ {
+ return View("StoreEmailRulesManage", new StoreEmailRuleViewModel(null, triggers));
+ }
+
+ [HttpPost("create")]
+ [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
+ public async Task<IActionResult> StoreEmailRulesCreate(string storeId, StoreEmailRuleViewModel model)
+ {
+ await ValidateCondition(model);
+ if (!ModelState.IsValid)
+ return StoreEmailRulesCreate(storeId);
+
+ await using var ctx = dbContextFactory.CreateContext();
+ var c = new EmailRuleData()
+ {
+ StoreId = storeId,
+ Trigger = model.Trigger,
+ Body = model.Body,
+ Subject = model.Subject,
+ Condition = string.IsNullOrWhiteSpace(model.Condition) ? null : model.Condition,
+ To = model.ToAsArray()
+ };
+ c.SetBTCPayAdditionalData(model.AdditionalData);
+ ctx.EmailRules.Add(c);
+ await ctx.SaveChangesAsync();
+
+ this.TempData.SetStatusSuccess(StringLocalizer["Email rule successfully created"]);
+ return RedirectToAction(nameof(StoreEmailRulesList), new { storeId });
+ }
+
+ [HttpGet("{ruleId}/edit")]
+ [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
+ public async Task<IActionResult> StoreEmailRulesEdit(string storeId, long ruleId)
+ {
+ await using var ctx = dbContextFactory.CreateContext();
+ var r = await ctx.EmailRules.GetRule(storeId, ruleId);
+ if (r is null)
+ return NotFound();
+ return View("StoreEmailRulesManage", new StoreEmailRuleViewModel(r, triggers));
+ }
+
+ [HttpPost("{ruleId}/edit")]
+ [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
+ public async Task<IActionResult> StoreEmailRulesEdit(string storeId, long ruleId, StoreEmailRuleViewModel model)
+ {
+ await ValidateCondition(model);
+ if (!ModelState.IsValid)
+ return await StoreEmailRulesEdit(storeId, ruleId);
+
+ await using var ctx = dbContextFactory.CreateContext();
+ var rule = await ctx.EmailRules.GetRule(storeId, ruleId);
+ if (rule is null) return NotFound();
+
+ rule.Trigger = model.Trigger;
+ rule.SetBTCPayAdditionalData(model.AdditionalData);
+ rule.To = model.ToAsArray();
+ rule.Subject = model.Subject;
+ rule.Condition = model.Condition;
+ rule.Body = model.Body;
+ await ctx.SaveChangesAsync();
+
+ this.TempData.SetStatusSuccess(StringLocalizer["Email rule successfully updated"]);
+ return RedirectToAction(nameof(StoreEmailRulesList), new { storeId });
+ }
+
+ private async Task ValidateCondition(StoreEmailRuleViewModel model)
+ {
+ model.Condition = model.Condition?.Trim() ?? "";
+ if (model.Condition.Length == 0)
+ model.Condition = null;
+ else
+ {
+ await using var ctx = dbContextFactory.CreateContext();
+ try
+ {
+ ctx.Database
+ .GetDbConnection()
+ .ExecuteScalar<bool>("SELECT jsonb_path_exists('{}'::JSONB, @path::jsonpath)", new { path = model.Condition });
+ }
+ catch(PostgresException ex)
+ {
+ ModelState.AddModelError(nameof(model.Condition), $"Invalid condition ({ex.MessageText})");
+ }
+ }
+ }
+
+ [HttpPost("{ruleId}/delete")]
+ [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
+ public async Task<IActionResult> StoreEmailRulesDelete(string storeId, long ruleId)
+ {
+ await using var ctx = dbContextFactory.CreateContext();
+ var r = await ctx.EmailRules.GetRule(storeId, ruleId);
+ if (r is not null)
+ {
+ ctx.EmailRules.Remove(r);
+ await ctx.SaveChangesAsync();
+ this.TempData.SetStatusSuccess(StringLocalizer["Email rule successfully deleted"]);
+ }
+ return RedirectToAction(nameof(StoreEmailRulesList), new { storeId });
+ }
+}
diff --git a/BTCPayServer/Plugins/Emails/Controllers/UIStoresEmailController.cs b/BTCPayServer/Plugins/Emails/Controllers/UIStoresEmailController.cs
new file mode 100644
index 0000000..5436937
--- /dev/null
+++ b/BTCPayServer/Plugins/Emails/Controllers/UIStoresEmailController.cs
@@ -0,0 +1,155 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.Globalization;
+using System.Linq;
+using System.Threading.Tasks;
+using BTCPayServer.Abstractions.Constants;
+using BTCPayServer.Abstractions.Extensions;
+using BTCPayServer.Abstractions.Models;
+using BTCPayServer.Client;
+using BTCPayServer.Data;
+using BTCPayServer.Models;
+using BTCPayServer.Plugins.Emails;
+using BTCPayServer.Services.Mails;
+using BTCPayServer.Services.Stores;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.Localization;
+using MimeKit;
+
+namespace BTCPayServer.Plugins.Emails.Controllers;
+
+[Area(EmailsPlugin.Area)]
+[Route("stores/{storeId}")]
+[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)]
+[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
+[AutoValidateAntiforgeryToken]
+public class UIStoresEmailController(
+ EmailSenderFactory emailSenderFactory,
+ StoreRepository storeRepository,
+ IStringLocalizer stringLocalizer) : Controller
+{
+ public IStringLocalizer StringLocalizer { get; set; } = stringLocalizer;
+ [HttpGet("email-settings")]
+ public async Task<IActionResult> StoreEmailSettings(string storeId)
+ {
+ var store = HttpContext.GetStoreData();
+ if (store == null)
+ return NotFound();
+
+ var settings = await GetCustomSettings(store.Id);
+
+ return View(new EmailsViewModel(settings.Custom ?? new())
+ {
+ IsFallbackSetup = settings.Fallback is not null,
+ IsCustomSMTP = settings.Custom is not null || settings.Fallback is null
+ });
+ }
+
+ record AllEmailSettings(EmailSettings Custom, EmailSettings Fallback);
+ private async Task<AllEmailSettings> GetCustomSettings(string storeId)
+ {
+ var sender = await emailSenderFactory.GetEmailSender(storeId) as StoreEmailSender;
+ if (sender is null)
+ return new(null, null);
+ var fallback = sender.FallbackSender is { } fb ? await fb.GetEmailSettings() : null;
+ if (fallback?.IsComplete() is not true)
+ fallback = null;
+ return new(await sender.GetCustomSettings(), fallback);
+ }
+
+ [HttpPost("email-settings")]
+ [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
+ public async Task<IActionResult> StoreEmailSettings(string storeId, EmailsViewModel model, string command)
+ {
+ var store = HttpContext.GetStoreData();
+ if (store == null)
+ return NotFound();
+ var settings = await GetCustomSettings(store.Id);
+ model.IsFallbackSetup = settings.Fallback is not null;
+ if (!model.IsFallbackSetup)
+ model.IsCustomSMTP = true;
+ if (model.IsCustomSMTP)
+ {
+ model.Settings.Validate("Settings.", ModelState);
+ if (model.Settings.From is not null && !MailboxAddressValidator.IsMailboxAddress(model.Settings.From))
+ {
+ ModelState.AddModelError("Settings.From", StringLocalizer["Invalid email"]);
+ }
+ if (!ModelState.IsValid)
+ return View(model);
+ }
+
+ var storeBlob = store.GetStoreBlob();
+ var currentSettings = store.GetStoreBlob().EmailSettings;
+ if (model is { IsCustomSMTP: true, Settings: { Password: null } })
+ model.Settings.Password = currentSettings?.Password;
+
+ if (command == "Test")
+ {
+ try
+ {
+ if (string.IsNullOrEmpty(model.TestEmail))
+ ModelState.AddModelError(nameof(model.TestEmail), new RequiredAttribute().FormatErrorMessage(nameof(model.TestEmail)));
+ if (!ModelState.IsValid)
+ return View(model);
+ var clientSettings = (model.IsCustomSMTP ? model.Settings : settings.Fallback) ?? new();
+ using var client = await clientSettings.CreateSmtpClient();
+ var message = clientSettings.CreateMailMessage(MailboxAddress.Parse(model.TestEmail), $"{store.StoreName}: Email test", StringLocalizer["You received it, the BTCPay Server SMTP settings work."], false);
+ await client.SendAsync(message);
+ await client.DisconnectAsync(true);
+ TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email sent to {0}. Please verify you received it.", model.TestEmail].Value;
+ }
+ catch (Exception ex)
+ {
+ TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["Error: {0}", ex.Message].Value;
+ }
+ return View(model);
+ }
+ else if (command == "mailpit")
+ {
+
+ storeBlob.EmailSettings = model.Settings;
+ storeBlob.EmailSettings.Server = "localhost";
+ storeBlob.EmailSettings.Port = 34219;
+ storeBlob.EmailSettings.EnabledCertificateCheck = false;
+ storeBlob.EmailSettings.Login ??= "store@example.com";
+ storeBlob.EmailSettings.From ??= "store@example.com";
+ storeBlob.EmailSettings.Password ??= "password";
+ store.SetStoreBlob(storeBlob);
+ await storeRepository.UpdateStore(store);
+ TempData.SetStatusMessageModel(new StatusMessageModel()
+ {
+ Severity = StatusMessageModel.StatusSeverity.Info,
+ AllowDismiss = true,
+ Html = "Mailpit is now running on <a href=\"http://localhost:34218\" target=\"_blank\" class=\"alert-link\">localhost</a>. You can use it to test your SMTP settings."
+ });
+ }
+ else if (command == "ResetPassword")
+ {
+ if (storeBlob.EmailSettings is not null)
+ storeBlob.EmailSettings.Password = null;
+ store.SetStoreBlob(storeBlob);
+ await storeRepository.UpdateStore(store);
+ TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email server password reset"].Value;
+ }
+ else if (!model.IsCustomSMTP && currentSettings is not null)
+ {
+ storeBlob.EmailSettings = null;
+ store.SetStoreBlob(storeBlob);
+ await storeRepository.UpdateStore(store);
+ TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["You are now using server's email settings"].Value;
+ }
+ else if (model.IsCustomSMTP)
+ {
+ storeBlob.EmailSettings = model.Settings;
+ store.SetStoreBlob(storeBlob);
+ await storeRepository.UpdateStore(store);
+ TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email settings saved"].Value;
+ }
+ return RedirectToAction(nameof(StoreEmailSettings), new { storeId });
+ }
+}
diff --git a/BTCPayServer/Plugins/Emails/EmailsExtensions.cs b/BTCPayServer/Plugins/Emails/EmailsExtensions.cs
new file mode 100644
index 0000000..76e51b1
--- /dev/null
+++ b/BTCPayServer/Plugins/Emails/EmailsExtensions.cs
@@ -0,0 +1,14 @@
+using System.Collections.Generic;
+using BTCPayServer.Plugins.Emails.Views;
+
+namespace BTCPayServer;
+
+public static class EmailsExtensions
+{
+ public static List<EmailTriggerViewModel.PlaceHolder> AddStoresPlaceHolders(this List<EmailTriggerViewModel.PlaceHolder> placeholders)
+ {
+ placeholders.Insert(0, new("{Store.Name}", "The name of the store"));
+ placeholders.Insert(0, new("{Store.Id}", "The id of the store"));
+ return placeholders;
+ }
+}
diff --git a/BTCPayServer/Plugins/Emails/EmailsPlugin.cs b/BTCPayServer/Plugins/Emails/EmailsPlugin.cs
new file mode 100644
index 0000000..0b86775
--- /dev/null
+++ b/BTCPayServer/Plugins/Emails/EmailsPlugin.cs
@@ -0,0 +1,21 @@
+using BTCPayServer.Abstractions.Models;
+using BTCPayServer.Plugins.Webhooks;
+using BTCPayServer.Services;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+
+namespace BTCPayServer.Plugins.Emails;
+
+public class EmailsPlugin : BaseBTCPayServerPlugin
+{
+ public const string Area = "Emails";
+ public override string Identifier => "BTCPayServer.Plugins.Emails";
+ public override string Name => "Emails";
+ public override string Description => "Allows you to send emails to your customers!";
+
+ public override void Execute(IServiceCollection services)
+ {
+ services.AddSingleton<IDefaultTranslationProvider, EmailsTranslationProvider>();
+ services.AddSingleton<IHostedService, StoreEmailRuleProcessorSender>();
+ }
+}
diff --git a/BTCPayServer/Plugins/Emails/EmailsTranslationProvider.cs b/BTCPayServer/Plugins/Emails/EmailsTranslationProvider.cs
new file mode 100644
index 0000000..95c4ca6
--- /dev/null
+++ b/BTCPayServer/Plugins/Emails/EmailsTranslationProvider.cs
@@ -0,0 +1,18 @@
+#nullable enable
+
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using BTCPayServer.Plugins.Emails.Views;
+using BTCPayServer.Services;
+
+namespace BTCPayServer.Plugins.Emails;
+
+public class EmailsTranslationProvider(IEnumerable<EmailTriggerViewModel> viewModels) : IDefaultTranslationProvider
+{
+ public Task<KeyValuePair<string, string?>[]> GetDefaultTranslations()
+ => Task.FromResult(
+ viewModels.Select(vm => KeyValuePair.Create(vm.Description, vm.Description))
+ .Concat(viewModels.SelectMany(vm => vm.PlaceHolders).Select(p => KeyValuePair.Create(p.Description, p.Description)))
+ .ToArray())!;
+}
diff --git a/BTCPayServer/Plugins/Emails/LinkGeneratorExtensions.cs b/BTCPayServer/Plugins/Emails/LinkGeneratorExtensions.cs
new file mode 100644
index 0000000..3bcb4e8
--- /dev/null
+++ b/BTCPayServer/Plugins/Emails/LinkGeneratorExtensions.cs
@@ -0,0 +1,23 @@
+#nullable enable
+using BTCPayServer.Abstractions;
+using BTCPayServer.Plugins.Emails;
+using BTCPayServer.Plugins.Emails.Controllers;
+using Microsoft.AspNetCore.Routing;
+
+namespace Microsoft.AspNetCore.Mvc;
+
+public static class EmailsUrlHelperExtensions
+{
+ public static string GetStoreEmailRulesLink(this LinkGenerator linkGenerator, string storeId, RequestBaseUrl baseUrl)
+ => linkGenerator.GetUriByAction(
+ action: nameof(UIStoreEmailRulesController.StoreEmailRulesList),
+ controller: "UIStoreEmailRules",
+ values: new { area = EmailsPlugin.Area, storeId },
+ baseUrl);
+ public static string GetStoreEmailSettingsLink(this LinkGenerator linkGenerator, string storeId, RequestBaseUrl baseUrl)
+ => linkGenerator.GetUriByAction(
+ action: nameof(UIStoresEmailController.StoreEmailSettings),
+ controller: "UIStoresEmail",
+ values: new { area = EmailsPlugin.Area, storeId },
+ baseUrl);
+}
diff --git a/BTCPayServer/Plugins/Emails/StoreEmailRuleProcessorSender.cs b/BTCPayServer/Plugins/Emails/StoreEmailRuleProcessorSender.cs
new file mode 100644
index 0000000..2e8a9bc
--- /dev/null
+++ b/BTCPayServer/Plugins/Emails/StoreEmailRuleProcessorSender.cs
@@ -0,0 +1,84 @@
+#nullable enable
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using BTCPayServer.Data;
+using BTCPayServer.HostedServices;
+using BTCPayServer.Services.Mails;
+using Microsoft.Extensions.Logging;
+using MimeKit;
+using Newtonsoft.Json.Linq;
+
+namespace BTCPayServer.Plugins.Emails;
+
+public interface ITriggerOwner
+{
+ Task BeforeSending(EmailRuleMatchContext context);
+}
+
+public record TriggerEvent(string? StoreId, string Trigger, JObject Model, ITriggerOwner? Owner);
+
+public class EmailRuleMatchContext(
+ TriggerEvent triggerEvent,
+ EmailRuleData matchedRule)
+{
+ 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 class StoreEmailRuleProcessorSender(
+ ApplicationDbContextFactory dbContextFactory,
+ EventAggregator eventAggregator,
+ ILogger<StoreEmailRuleProcessorSender> logger,
+ EmailSenderFactory emailSenderFactory)
+ : EventHostedServiceBase(eventAggregator, logger)
+{
+ protected override void SubscribeToEvents()
+ {
+ Subscribe<TriggerEvent>();
+ }
+
+ protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
+ {
+ if (evt is TriggerEvent triggEvent)
+ {
+ await using var ctx = dbContextFactory.CreateContext();
+ var actionableRules = await ctx.EmailRules
+ .GetMatches(triggEvent.StoreId, triggEvent.Trigger, triggEvent.Model);
+
+ if (actionableRules.Length > 0)
+ {
+ var sender = await emailSenderFactory.GetEmailSender(triggEvent.StoreId);
+ foreach (var actionableRule in actionableRules)
+ {
+ var matchedContext = new EmailRuleMatchContext(triggEvent, actionableRule);
+
+ var body = new TextTemplate(actionableRule.Body ?? "");
+ var subject = new TextTemplate(actionableRule.Subject ?? "");
+ matchedContext.Recipients.AddRange(
+ actionableRule.To
+ .Select(o =>
+ {
+ if (!MailboxAddressValidator.TryParse(o, out var oo))
+ {
+ MailboxAddressValidator.TryParse(new TextTemplate(o).Render(triggEvent.Model), out oo);
+ }
+ return oo;
+ })
+ .Where(o => o != null)!);
+
+ if (triggEvent.Owner is not null)
+ await triggEvent.Owner.BeforeSending(matchedContext);
+ if (matchedContext.Recipients.Count == 0)
+ continue;
+ sender.SendEmail(matchedContext.Recipients.ToArray(), matchedContext.Cc.ToArray(), matchedContext.Bcc.ToArray(), subject.Render(triggEvent.Model), body.Render(triggEvent.Model));
+ }
+ }
+ }
+ }
+}
diff --git a/BTCPayServer/Plugins/Emails/Views/EmailTriggerViewModel.cs b/BTCPayServer/Plugins/Emails/Views/EmailTriggerViewModel.cs
new file mode 100644
index 0000000..6d9a612
--- /dev/null
+++ b/BTCPayServer/Plugins/Emails/Views/EmailTriggerViewModel.cs
@@ -0,0 +1,23 @@
+using System.Collections.Generic;
+
+namespace BTCPayServer.Plugins.Emails.Views;
+
+/// <summary>
+/// This view model is used in StoreEmailRulesManage.cshtml, to display the different triggers that can be used to send emails
+/// </summary>
+public class EmailTriggerViewModel
+{
+ public string Type { get; set; }
+ public string Description { get; set; }
+ public string SubjectExample { get; set; }
+ public string BodyExample { get; set; }
+ public bool CanIncludeCustomerEmail { get; set; }
+
+ public class PlaceHolder(string name, string description)
+ {
+ public string Name { get; set; } = name;
+ public string Description { get; set; } = description;
+ }
+
+ public List<PlaceHolder> PlaceHolders { get; set; } = new();
+}
diff --git a/BTCPayServer/Plugins/Emails/Views/StoreEmailRuleViewModel.cs b/BTCPayServer/Plugins/Emails/Views/StoreEmailRuleViewModel.cs
new file mode 100644
index 0000000..295ca06
--- /dev/null
+++ b/BTCPayServer/Plugins/Emails/Views/StoreEmailRuleViewModel.cs
@@ -0,0 +1,53 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.Linq;
+using BTCPayServer.Data;
+
+namespace BTCPayServer.Plugins.Emails.Views;
+
+public class StoreEmailRuleViewModel
+{
+ public StoreEmailRuleViewModel()
+ {
+
+ }
+ public StoreEmailRuleViewModel(EmailRuleData data, IEnumerable<EmailTriggerViewModel> triggers)
+ {
+ if (data is not null)
+ {
+ Data = data;
+ AdditionalData = data.GetBTCPayAdditionalData() ?? new();
+ Trigger = data.Trigger;
+ Subject = data.Subject;
+ Condition = data.Condition ?? "";
+ Body = data.Body;
+ To = string.Join(",", data.To);
+ }
+ else
+ {
+ AdditionalData = new();
+ }
+
+ Triggers = triggers.ToList();
+ }
+ [Required]
+ public string Trigger { get; set; }
+
+ public string Condition { get; set; }
+
+ [Required]
+ public string Subject { get; set; }
+
+ [Required]
+ public string Body { get; set; }
+ public EmailRuleData Data { get; set; }
+ public EmailRuleData.BTCPayAdditionalData AdditionalData { get; set; }
+ public string To { get; set; }
+
+ public List<EmailTriggerViewModel> Triggers { get; set; }
+ public string[] ToAsArray()
+ => (To ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries)
+ .Select(t => t.Trim())
+ .ToArray();
+}
diff --git a/BTCPayServer/Plugins/Emails/Views/UIStoreEmailRules/StoreEmailRulesList.cshtml b/BTCPayServer/Plugins/Emails/Views/UIStoreEmailRules/StoreEmailRulesList.cshtml
new file mode 100644
index 0000000..f24147e
--- /dev/null
+++ b/BTCPayServer/Plugins/Emails/Views/UIStoreEmailRules/StoreEmailRulesList.cshtml
@@ -0,0 +1,68 @@
+@model List<StoreEmailRuleViewModel>
+
+@{
+ var storeId = Context.GetStoreData().Id;
+ ViewData.SetActivePage(StoreNavPages.Emails, StringLocalizer["Email Rules"], storeId);
+}
+<div class="sticky-header">
+ <nav aria-label="breadcrumb">
+ <ol class="breadcrumb">
+ <li class="breadcrumb-item">
+ <a asp-controller="UIStoresEmail" asp-action="StoreEmailSettings" asp-route-storeId="@storeId" text-translate="true">Emails</a>
+ </li>
+ <li class="breadcrumb-item active" aria-current="page">@ViewData["Title"]</li>
+ </ol>
+ <h2>@ViewData["Title"]</h2>
+ </nav>
+ <a id="CreateEmailRule" permission="@Policies.CanModifyStoreSettings" asp-action="StoreEmailRulesCreate" asp-route-storeId="@storeId"
+ class="btn btn-primary" role="button" text-translate="true">
+ Create Email Rule
+ </a>
+</div>
+
+<partial name="_StatusMessage" />
+<p text-translate="true">
+ Email rules allow BTCPay Server to send customized emails from your store based on events.
+</p>
+
+@if (Model.Any())
+{
+ <div class="table-responsive">
+ <table class="table table-hover">
+ <thead>
+ <tr>
+ <th text-translate="true">Trigger</th>
+ <th text-translate="true">Customer Email</th>
+ <th text-translate="true">To</th>
+ <th text-translate="true">Subject</th>
+ <th class="actions-col" permission="@Policies.CanModifyStoreSettings"></th>
+ </tr>
+ </thead>
+ <tbody>
+ @foreach (var rule in Model)
+ {
+ <tr>
+ <td>@rule.Trigger</td>
+ <td>@(rule.AdditionalData.CustomerEmail is true ? StringLocalizer["Yes"] : StringLocalizer["No"])</td>
+ <td>@string.Join(", ", rule.To)</td>
+ <td>@rule.Subject</td>
+ <td class="actions-col" permission="@Policies.CanModifyStoreSettings">
+ <div class="d-inline-flex align-items-center gap-3">
+ <a asp-action="StoreEmailRulesEdit" asp-route-storeId="@storeId" asp-route-ruleId="@rule.Data.Id">Edit</a>
+ <a asp-action="StoreEmailRulesDelete" asp-route-storeId="@storeId" asp-route-ruleId="@rule.Data.Id" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-description="@ViewLocalizer["This action will remove the rule with the trigger <b>{0}</b>.", Html.Encode(rule.Trigger)]" data-confirm-input="@StringLocalizer["REMOVE"]" text-translate="true">Remove</a>
+ </div>
+ </td>
+ </tr>
+ }
+ </tbody>
+ </table>
+ </div>
+}
+else
+{
+ <p class="text-secondary" text-translate="true">
+ There are no rules yet.
+ </p>
+}
+
+<partial name="_Confirm" model="@(new ConfirmModel(StringLocalizer["Remove email rule"], StringLocalizer["This action will remove this rule. Are you sure?"], StringLocalizer["Delete"]))" permission="@Policies.CanModifyStoreSettings" />
diff --git a/BTCPayServer/Plugins/Emails/Views/UIStoreEmailRules/StoreEmailRulesManage.cshtml b/BTCPayServer/Plugins/Emails/Views/UIStoreEmailRules/StoreEmailRulesManage.cshtml
new file mode 100644
index 0000000..42ee397
--- /dev/null
+++ b/BTCPayServer/Plugins/Emails/Views/UIStoreEmailRules/StoreEmailRulesManage.cshtml
@@ -0,0 +1,161 @@
+@model StoreEmailRuleViewModel
+
+@{
+ var storeId = Context.GetStoreData().Id;
+ bool isEdit = Model.Trigger != null;
+ ViewData.SetActivePage(StoreNavPages.Emails, StringLocalizer[isEdit ? "Edit Email Rule" : "Create Email Rule"], storeId);
+}
+
+@section PageHeadContent {
+ <link href="~/vendor/summernote/summernote-bs5.css" rel="stylesheet" asp-append-version="true" />
+}
+
+<form method="post">
+ <div class="sticky-header">
+ <nav aria-label="breadcrumb">
+ <ol class="breadcrumb">
+ <li class="breadcrumb-item">
+ <a asp-controller="UIStoresEmail" asp-action="StoreEmailSettings" asp-route-storeId="@storeId" text-translate="true">Emails</a>
+ </li>
+ <li class="breadcrumb-item">
+ <a asp-action="StoreEmailRulesList" asp-route-storeId="@storeId" text-translate="true">Email Rules</a>
+ </li>
+ <li class="breadcrumb-item active" aria-current="page">@ViewData["Title"]</li>
+ </ol>
+ <h2>@ViewData["Title"]</h2>
+ </nav>
+ <div>
+ <button id="page-primary" type="submit" class="btn btn-primary" text-translate="true">Save</button>
+ <a asp-action="StoreEmailRulesList" asp-route-storeId="@storeId" class="btn btn-secondary" text-translate="true">Cancel</a>
+ </div>
+ </div>
+ <partial name="_StatusMessage" />
+
+ <div class="form-group">
+ <label asp-for="Trigger" class="form-label" data-required></label>
+ <select asp-for="Trigger" asp-items="@Model.Triggers.Select(t => new SelectListItem(StringLocalizer[t.Description], t.Type))"
+ class="form-select email-rule-trigger" required></select>
+ <span asp-validation-for="Trigger" class="text-danger"></span>
+ <div class="form-text" text-translate="true">Choose what event sends the email.</div>
+ </div>
+
+ <div class="form-group">
+ <label asp-for="Condition" class="form-label"></label>
+ <input asp-for="Condition" class="form-control" placeholder="@StringLocalizer["A Postgres compatible JSON Path (eg. $?(@.Invoice.Metadata.buyerName == \"john\"))"]" />
+ <span asp-validation-for="Condition" class="text-danger"></span>
+ <div class="form-text" text-translate="true">Only send email when the specified JSON Path exists</div>
+ </div>
+
+ <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" />
+ <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-check mb-4 customer-email-container">
+ <input asp-for="AdditionalData.CustomerEmail" type="checkbox" class="form-check-input email-rule-customer-email customer-email-checkbox" />
+ <label asp-for="AdditionalData.CustomerEmail" class="form-check-label" text-translate="true">Send the email to the buyer, if email was provided to the invoice</label>
+ <span asp-validation-for="AdditionalData.CustomerEmail" class="text-danger"></span>
+ </div>
+
+ <div class="form-group">
+ <label asp-for="Subject" class="form-label" data-required></label>
+ <input type="text" asp-for="Subject" class="form-control email-rule-subject" />
+ <span asp-validation-for="Subject" class="text-danger"></span>
+ </div>
+
+ <div class="form-group">
+ <label asp-for="Body" class="form-label" data-required></label>
+ <textarea asp-for="Body" class="form-control richtext email-rule-body" rows="4"></textarea>
+ <span asp-validation-for="Body" class="text-danger"></span>
+ <div class="form-text rounded bg-light p-2">
+ <h6 class="text-muted p-0" text-translate="true">Placeholders</h6>
+ <table id="placeholders" class="table">
+ </table>
+ <span>* These fields are JSON objects. You can access properties within them using <a href="https://www.newtonsoft.com/json/help/html/SelectToken.htm#SelectTokenJSONPath" rel="noreferrer noopener" target="_blank">this syntax</a>. One example is <code>{Invoice.Metadata.itemCode}</code></span>
+ </div>
+ </div>
+
+</form>
+
+@section PageFootContent {
+ <partial name="_ValidationScriptsPartial" />
+ <script src="~/vendor/summernote/summernote-bs5.js" asp-append-version="true"></script>
+ <script>
+ var triggers = @Safe.Json(Model.Triggers);
+ var triggersByType = {};
+ for (var i = 0; i < triggers.length; i++) {
+ triggersByType[triggers[i].type] = triggers[i];
+ }
+ document.addEventListener('DOMContentLoaded', () => {
+
+ const triggerSelect = document.querySelector('.email-rule-trigger');
+ const subjectInput = document.querySelector('.email-rule-subject');
+ 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;
+ if (triggersByType[selectedTrigger]) {
+ if (isEmptyOrDefault(subjectInput.value, 'subjectExample')) {
+ subjectInput.value = triggersByType[selectedTrigger].subjectExample;
+ }
+ var body = triggersByType[selectedTrigger].bodyExample;
+
+ if (isEmptyOrDefault(bodyTextarea.value, 'bodyExample')) {
+ if ($(bodyTextarea).summernote) {
+ $(bodyTextarea).summernote('reset');
+ $(bodyTextarea).summernote('code', body.replace(/\n/g, '<br/>'));
+ } else {
+ bodyTextarea.value = body;
+ }
+ }
+
+ placeholdersTd.innerHTML = '';
+ triggersByType[selectedTrigger].placeHolders.forEach(p => {
+ const tr = document.createElement('tr');
+ const td1 = document.createElement('td');
+ const td2 = document.createElement('td');
+ const code = document.createElement('code');
+ td1.appendChild(code);
+ code.innerText = p.name;
+ td2.innerText = p.description;
+ tr.appendChild(td1);
+ tr.appendChild(td2);
+ placeholdersTd.appendChild(tr);
+ });
+
+ }
+ }
+
+ function toggleCustomerEmailVisibility() {
+ const customerEmailContainer = document.querySelector('.customer-email-container');
+ const customerEmailCheckbox = document.querySelector('.customer-email-checkbox');
+ const selectedTrigger = triggerSelect.value;
+ if (triggersByType[selectedTrigger].canIncludeCustomerEmail) {
+ customerEmailContainer.style.display = 'block';
+ } else {
+ customerEmailContainer.style.display = 'none';
+ customerEmailCheckbox.checked = false;
+ }
+ }
+
+ triggerSelect.addEventListener('change', applyTemplate);
+ triggerSelect.addEventListener('change', toggleCustomerEmailVisibility);
+
+ // Apply template on page load if a trigger is selected
+ if (triggerSelect.value) {
+ applyTemplate();
+ toggleCustomerEmailVisibility();
+ }
+ });
+ </script>
+
+}
diff --git a/BTCPayServer/Plugins/Emails/Views/UIStoresEmail/StoreEmailSettings.cshtml b/BTCPayServer/Plugins/Emails/Views/UIStoresEmail/StoreEmailSettings.cshtml
new file mode 100644
index 0000000..d798da4
--- /dev/null
+++ b/BTCPayServer/Plugins/Emails/Views/UIStoresEmail/StoreEmailSettings.cshtml
@@ -0,0 +1,55 @@
+@using BTCPayServer.Client
+@using BTCPayServer.Plugins.Emails
+@using Microsoft.AspNetCore.Mvc.TagHelpers
+@model BTCPayServer.Models.EmailsViewModel
+@{
+ var storeId = Context.GetStoreData().Id;
+ ViewData.SetActivePage(StoreNavPages.Emails, StringLocalizer["Email Rules"], storeId);
+}
+
+<form method="post" autocomplete="off" permissioned="@Policies.CanModifyStoreSettings">
+ <div class="sticky-header">
+ <h2 text-translate="true">Email Server</h2>
+ <div class="d-flex justify-content-end">
+ <button cheat-mode="true" id="mailpit" type="submit" class="btn btn-info" name="command" value="mailpit">Use mailpit</button>
+ <button id="page-primary" type="submit" class="btn btn-primary" name="command" value="Save">Save</button>
+ </div>
+ </div>
+ <partial name="_StatusMessage" />
+ @if (Model.IsFallbackSetup)
+ {
+ <label class="d-flex align-items-center mb-4">
+ <input type="checkbox" asp-for="IsCustomSMTP" class="btcpay-toggle me-3" data-bs-toggle="collapse" data-bs-target="#SmtpSettings"
+ aria-expanded="@Model.IsCustomSMTP" aria-controls="SmtpSettings" />
+ <div>
+ <span text-translate="true">Use custom SMTP settings for this store</span>
+ <div class="form-text" text-translate="true">Otherwise, the server's SMTP settings will be used to send emails.</div>
+ </div>
+ </label>
+
+ <div class="collapse @(Model.IsCustomSMTP ? "show" : "")" id="SmtpSettings">
+ <partial name="EmailsBody" model="Model" />
+ </div>
+ }
+ else
+ {
+ <input type="hidden" id="IsCustomSMTPHidden" asp-for="IsCustomSMTP" />
+ <partial name="EmailsBody" model="Model" />
+ }
+
+ <partial name="EmailsTest" model="Model" permission="@Policies.CanModifyStoreSettings" />
+</form>
+
+<div class="mt-5" permission="@Policies.CanModifyStoreSettings">
+ <h3 text-translate="true">Email Rules</h3>
+ <p text-translate="true">Email rules allow BTCPay Server to send customized emails from your store based on events.</p>
+ <a id="ConfigureEmailRules" class="btn btn-secondary" asp-area="@EmailsPlugin.Area" asp-controller="UIStoreEmailRules" asp-action="StoreEmailRulesList"
+ asp-route-storeId="@storeId"
+ permission="@Policies.CanViewStoreSettings" text-translate="true">
+ Configure
+ </a>
+</div>
+
+@section PageFootContent {
+ <partial name="_ValidationScriptsPartial" />
+}
diff --git a/BTCPayServer/Plugins/Emails/Views/_ViewImports.cshtml b/BTCPayServer/Plugins/Emails/Views/_ViewImports.cshtml
new file mode 100644
index 0000000..9b02192
--- /dev/null
+++ b/BTCPayServer/Plugins/Emails/Views/_ViewImports.cshtml
@@ -0,0 +1,9 @@
+@using BTCPayServer.Plugins.Emails
+@using BTCPayServer.Abstractions.Models
+@using BTCPayServer.Views.Stores
+@using BTCPayServer.Client
+@using BTCPayServer.TagHelpers
+@using Microsoft.AspNetCore.Mvc.TagHelpers
+
+@namespace BTCPayServer.Plugins.Emails.Views
+
diff --git a/BTCPayServer/Plugins/Emails/Views/_ViewStart.cshtml b/BTCPayServer/Plugins/Emails/Views/_ViewStart.cshtml
new file mode 100644
index 0000000..a5f1004
--- /dev/null
+++ b/BTCPayServer/Plugins/Emails/Views/_ViewStart.cshtml
@@ -0,0 +1,3 @@
+@{
+ Layout = "_Layout";
+}
diff --git a/BTCPayServer/Plugins/PluginBuilderClient.cs b/BTCPayServer/Plugins/PluginBuilderClient.cs
index 05e7fb2..bdda7da 100644
--- a/BTCPayServer/Plugins/PluginBuilderClient.cs
+++ b/BTCPayServer/Plugins/PluginBuilderClient.cs
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Text.RegularExpressions;
+using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
@@ -59,7 +60,7 @@ namespace BTCPayServer.Plugins
_httpClient = httpClient;
}
static JsonSerializerSettings serializerSettings = new() { ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver() };
- public async Task<PublishedVersion[]> GetPublishedVersions(string btcpayVersion, bool includePreRelease, string searchPluginName = null, bool? includeAllVersions = null)
+ public async Task<PublishedVersion[]> GetPublishedVersions(string btcpayVersion, bool includePreRelease, string searchPluginName = null, bool? includeAllVersions = null, CancellationToken cancellationToken = default)
{
var queryString = $"?includePreRelease={includePreRelease}";
if (btcpayVersion is not null)
@@ -68,7 +69,7 @@ namespace BTCPayServer.Plugins
queryString += $"&searchPluginName={Uri.EscapeDataString(searchPluginName)}";
if (includeAllVersions is not null)
queryString += $"&includeAllVersions={includeAllVersions}";
- var result = await _httpClient.GetStringAsync($"api/v1/plugins{queryString}");
+ var result = await _httpClient.GetStringAsync($"api/v1/plugins{queryString}", cancellationToken);
return JsonConvert.DeserializeObject<PublishedVersion[]>(result, serializerSettings) ?? throw new InvalidOperationException();
}
public async Task<PublishedVersion> GetPlugin(string pluginSlug, string version)
@@ -97,7 +98,8 @@ namespace BTCPayServer.Plugins
public async Task<PublishedVersion[]> GetInstalledPluginsUpdates(
string btcpayVersion,
bool includePreRelease,
- IEnumerable<InstalledPluginRequest> plugins)
+ IEnumerable<InstalledPluginRequest> plugins,
+ CancellationToken cancellationToken = default)
{
var queryString = $"?includePreRelease={includePreRelease}";
if (!string.IsNullOrWhiteSpace(btcpayVersion))
@@ -106,10 +108,10 @@ namespace BTCPayServer.Plugins
var json = JsonConvert.SerializeObject(plugins, serializerSettings);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
- using var resp = await _httpClient.PostAsync($"api/v1/plugins/updates{queryString}", content);
+ using var resp = await _httpClient.PostAsync($"api/v1/plugins/updates{queryString}", content, cancellationToken);
resp.EnsureSuccessStatusCode();
- var body = await resp.Content.ReadAsStringAsync();
+ var body = await resp.Content.ReadAsStringAsync(cancellationToken);
var result = JsonConvert.DeserializeObject<PublishedVersion[]>(body, serializerSettings);
if (result is null)
diff --git a/BTCPayServer/Plugins/PluginService.cs b/BTCPayServer/Plugins/PluginService.cs
index a1802a9..43a067e 100644
--- a/BTCPayServer/Plugins/PluginService.cs
+++ b/BTCPayServer/Plugins/PluginService.cs
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
+using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Configuration;
@@ -53,11 +54,11 @@ namespace BTCPayServer.Plugins
private string GetShortBtcpayVersion() => Env.Version.TrimStart('v').Split('+')[0];
- public async Task<AvailablePlugin[]> GetRemotePlugins(string searchPluginName)
+ public async Task<AvailablePlugin[]> GetRemotePlugins(string searchPluginName, CancellationToken cancellationToken = default)
{
string btcpayVersion = GetShortBtcpayVersion();
var versions = await _pluginBuilderClient.GetPublishedVersions(
- btcpayVersion, _policiesSettings.PluginPreReleases, searchPluginName);
+ btcpayVersion, _policiesSettings.PluginPreReleases, searchPluginName, cancellationToken: cancellationToken);
var plugins = versions
.Select(MapToAvailablePlugin)
@@ -79,7 +80,7 @@ namespace BTCPayServer.Plugins
var updates = await _pluginBuilderClient.GetInstalledPluginsUpdates(
btcpayVersion,
_policiesSettings.PluginPreReleases,
- loadedToCheck);
+ loadedToCheck, cancellationToken: cancellationToken);
if (updates is { Length: > 0 })
{
diff --git a/BTCPayServer/Plugins/Webhooks/Controllers/GreenfieldStoreWebhooksController.cs b/BTCPayServer/Plugins/Webhooks/Controllers/GreenfieldStoreWebhooksController.cs
new file mode 100644
index 0000000..1d0d651
--- /dev/null
+++ b/BTCPayServer/Plugins/Webhooks/Controllers/GreenfieldStoreWebhooksController.cs
@@ -0,0 +1,201 @@
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using BTCPayServer.Abstractions.Constants;
+using BTCPayServer.Abstractions.Extensions;
+using BTCPayServer.Client;
+using BTCPayServer.Client.Models;
+using BTCPayServer.Data;
+using BTCPayServer.Services.Stores;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Cors;
+using Microsoft.AspNetCore.Mvc;
+using Newtonsoft.Json.Linq;
+
+namespace BTCPayServer.Plugins.Webhooks.Controllers
+{
+ [ApiController]
+ [Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield,
+ Policy = Policies.CanModifyWebhooks)]
+ [EnableCors(CorsPolicies.All)]
+ public class GreenfieldStoreWebhooksController : ControllerBase
+ {
+ public GreenfieldStoreWebhooksController(StoreRepository storeRepository, WebhookSender webhookSender)
+ {
+ StoreRepository = storeRepository;
+ WebhookSender = webhookSender;
+ }
+
+ public StoreRepository StoreRepository { get; }
+ public WebhookSender WebhookSender { get; }
+
+ [HttpGet("~/api/v1/stores/{storeId}/webhooks/{webhookId?}")]
+ public async Task<IActionResult> ListWebhooks(string storeId, string webhookId)
+ {
+ if (webhookId is null)
+ {
+ return Ok((await StoreRepository.GetWebhooks(CurrentStoreId))
+ .Select(o => FromModel(o, false))
+ .ToArray());
+ }
+ else
+ {
+ var w = await StoreRepository.GetWebhook(CurrentStoreId, webhookId);
+ if (w is null)
+ return WebhookNotFound();
+ return Ok(FromModel(w, false));
+ }
+ }
+
+ string CurrentStoreId
+ {
+ get
+ {
+ return this.HttpContext.GetStoreData()?.Id;
+ }
+ }
+
+ [HttpPost("~/api/v1/stores/{storeId}/webhooks")]
+ public async Task<IActionResult> CreateWebhook(string storeId, Client.Models.CreateStoreWebhookRequest create)
+ {
+ ValidateWebhookRequest(create);
+ if (!ModelState.IsValid)
+ return this.CreateValidationError(ModelState);
+ var webhookId = await StoreRepository.CreateWebhook(CurrentStoreId, ToModel(create));
+ var w = await StoreRepository.GetWebhook(CurrentStoreId, webhookId);
+ if (w is null)
+ return WebhookNotFound();
+ return Ok(FromModel(w, true));
+ }
+
+ private void ValidateWebhookRequest(StoreWebhookBaseData create)
+ {
+ if (!Uri.TryCreate(create?.Url, UriKind.Absolute, out _))
+ ModelState.AddModelError(nameof(Url), "Invalid Url");
+ }
+
+ [HttpPut("~/api/v1/stores/{storeId}/webhooks/{webhookId}")]
+ public async Task<IActionResult> UpdateWebhook(string storeId, string webhookId, Client.Models.UpdateStoreWebhookRequest update)
+ {
+ ValidateWebhookRequest(update);
+ if (!ModelState.IsValid)
+ return this.CreateValidationError(ModelState);
+ var w = await StoreRepository.GetWebhook(CurrentStoreId, webhookId);
+ if (w is null)
+ return WebhookNotFound();
+ await StoreRepository.UpdateWebhook(storeId, webhookId, ToModel(update));
+ return await ListWebhooks(storeId, webhookId);
+ }
+ [HttpDelete("~/api/v1/stores/{storeId}/webhooks/{webhookId}")]
+ public async Task<IActionResult> DeleteWebhook(string storeId, string webhookId)
+ {
+ var w = await StoreRepository.GetWebhook(CurrentStoreId, webhookId);
+ if (w is null)
+ return WebhookNotFound();
+ await StoreRepository.DeleteWebhook(CurrentStoreId, webhookId);
+ return Ok();
+ }
+
+ IActionResult WebhookNotFound()
+ {
+ return this.CreateAPIError(404, "webhook-not-found", "The webhook was not found");
+ }
+ IActionResult WebhookDeliveryNotFound()
+ {
+ return this.CreateAPIError(404, "webhookdelivery-not-found", "The webhook delivery was not found");
+ }
+ private WebhookBlob ToModel(StoreWebhookBaseData create)
+ {
+ return new WebhookBlob()
+ {
+ Active = create.Enabled,
+ Url = create.Url,
+ Secret = create.Secret,
+ AuthorizedEvents = create.AuthorizedEvents is Client.Models.StoreWebhookBaseData.AuthorizedEventsData aed ?
+ new AuthorizedWebhookEvents()
+ {
+ Everything = aed.Everything,
+ SpecificEvents = aed.SpecificEvents
+ } :
+ new AuthorizedWebhookEvents() { Everything = true },
+ AutomaticRedelivery = create.AutomaticRedelivery,
+ };
+ }
+
+
+ [HttpGet("~/api/v1/stores/{storeId}/webhooks/{webhookId}/deliveries/{deliveryId?}")]
+ public async Task<IActionResult> ListDeliveries(string storeId, string webhookId, string deliveryId, int? count = null)
+ {
+ if (deliveryId is null)
+ {
+ return Ok((await StoreRepository.GetWebhookDeliveries(CurrentStoreId, webhookId, count))
+ .Select(o => FromModel(o))
+ .ToList());
+ }
+ else
+ {
+ var delivery = await StoreRepository.GetWebhookDelivery(CurrentStoreId, webhookId, deliveryId);
+ if (delivery is null)
+ return WebhookDeliveryNotFound();
+ return Ok(FromModel(delivery));
+ }
+ }
+ [HttpPost("~/api/v1/stores/{storeId}/webhooks/{webhookId}/deliveries/{deliveryId}/redeliver")]
+ public async Task<IActionResult> RedeliverWebhook(string storeId, string webhookId, string deliveryId)
+ {
+ var delivery = await StoreRepository.GetWebhookDelivery(CurrentStoreId, webhookId, deliveryId);
+ if (delivery is null)
+ return WebhookDeliveryNotFound();
+ if (delivery.GetBlob().IsPruned())
+ return WebhookDeliveryPruned();
+ return this.Ok(new JValue(await WebhookSender.Redeliver(deliveryId)));
+ }
+
+ private IActionResult WebhookDeliveryPruned()
+ {
+ return this.CreateAPIError(409, "webhookdelivery-pruned", "This webhook delivery has been pruned, so it can't be redelivered");
+ }
+
+ [HttpGet("~/api/v1/stores/{storeId}/webhooks/{webhookId}/deliveries/{deliveryId}/request")]
+ public async Task<IActionResult> GetDeliveryRequest(string storeId, string webhookId, string deliveryId)
+ {
+ var delivery = await StoreRepository.GetWebhookDelivery(CurrentStoreId, webhookId, deliveryId);
+ if (delivery is null)
+ return WebhookDeliveryNotFound();
+ if (delivery.GetBlob().IsPruned())
+ return WebhookDeliveryPruned();
+ return File(delivery.GetBlob().Request, "application/json");
+ }
+
+ private Client.Models.WebhookDeliveryData FromModel(Data.WebhookDeliveryData data)
+ {
+ var b = data.GetBlob();
+ return new Client.Models.WebhookDeliveryData()
+ {
+ Id = data.Id,
+ Timestamp = data.Timestamp,
+ Status = b.Status,
+ ErrorMessage = b.ErrorMessage,
+ HttpCode = b.HttpCode
+ };
+ }
+
+ Client.Models.StoreWebhookData FromModel(Data.WebhookData data, bool includeSecret)
+ {
+ var b = data.GetBlob();
+ return new Client.Models.StoreWebhookData()
+ {
+ Id = data.Id,
+ Url = b.Url,
+ Enabled = b.Active,
+ Secret = includeSecret ? b.Secret : null,
+ AutomaticRedelivery = b.AutomaticRedelivery,
+ AuthorizedEvents = new Client.Models.StoreWebhookData.AuthorizedEventsData()
+ {
+ Everything = b.AuthorizedEvents.Everything,
+ SpecificEvents = b.AuthorizedEvents.SpecificEvents
+ }
+ };
+ }
+ }
+}
diff --git a/BTCPayServer/Plugins/Webhooks/Controllers/UIStoreWebhooksController.cs b/BTCPayServer/Plugins/Webhooks/Controllers/UIStoreWebhooksController.cs
new file mode 100644
index 0000000..e0525e4
--- /dev/null
+++ b/BTCPayServer/Plugins/Webhooks/Controllers/UIStoreWebhooksController.cs
@@ -0,0 +1,161 @@
+#nullable enable
+using System.Linq;
+using System.Threading.Tasks;
+using BTCPayServer.Abstractions.Constants;
+using BTCPayServer.Abstractions.Models;
+using BTCPayServer.Client;
+using BTCPayServer.Client.Models;
+using BTCPayServer.Data;
+using BTCPayServer.Plugins.Webhooks.Views;
+using BTCPayServer.Services.Stores;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Localization;
+using NBitcoin;
+using NBitcoin.DataEncoders;
+
+namespace BTCPayServer.Plugins.Webhooks.Controllers;
+
+[Route("stores")]
+[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)]
+[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
+[AutoValidateAntiforgeryToken]
+[Area(WebhooksPlugin.Area)]
+public class UIStoreWebhooksController(
+ StoreRepository storeRepo,
+ IStringLocalizer stringLocalizer,
+ WebhookSender webhookSender) : Controller
+{
+ public Data.StoreData CurrentStore => HttpContext.GetStoreData();
+ public IStringLocalizer StringLocalizer { get; set; } = stringLocalizer;
+ private async Task<Data.WebhookDeliveryData?> LastDeliveryForWebhook(string webhookId)
+ {
+ return (await storeRepo.GetWebhookDeliveries(CurrentStore.Id, webhookId, 1)).FirstOrDefault();
+ }
+
+ [HttpGet("{storeId}/webhooks")]
+ public async Task<IActionResult> Webhooks()
+ {
+ var webhooks = await storeRepo.GetWebhooks(CurrentStore.Id);
+ return View(nameof(Webhooks), new WebhooksViewModel
+ {
+ Webhooks = webhooks.Select(async w =>
+ {
+ var lastDelivery = await LastDeliveryForWebhook(w.Id);
+ var lastDeliveryBlob = lastDelivery?.GetBlob();
+
+ return new WebhooksViewModel.WebhookViewModel()
+ {
+ Id = w.Id,
+ Url = w.GetBlob().Url,
+ LastDeliveryErrorMessage = lastDeliveryBlob?.ErrorMessage,
+ LastDeliveryTimeStamp = lastDelivery?.Timestamp,
+ LastDeliverySuccessful = lastDeliveryBlob == null ? true : lastDeliveryBlob.Status == WebhookDeliveryStatus.HttpSuccess,
+ };
+ }
+ ).Select(t => t.Result).ToArray()
+ });
+ }
+
+ [HttpGet("{storeId}/webhooks/new")]
+ [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
+ public IActionResult NewWebhook()
+ {
+ return View(nameof(ModifyWebhook), new EditWebhookViewModel
+ {
+ Active = true,
+ Everything = true,
+ IsNew = true,
+ Secret = Encoders.Base58.EncodeData(RandomUtils.GetBytes(20))
+ });
+ }
+
+ [HttpPost("{storeId}/webhooks/{webhookId}/remove")]
+ [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
+ public async Task<IActionResult> DeleteWebhook(string webhookId)
+ {
+ var webhook = await storeRepo.GetWebhook(CurrentStore.Id, webhookId);
+ if (webhook is null)
+ return NotFound();
+
+ await storeRepo.DeleteWebhook(CurrentStore.Id, webhookId);
+ TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Webhook successfully deleted"].Value;
+ return RedirectToAction(nameof(Webhooks), new { storeId = CurrentStore.Id });
+ }
+
+ [HttpPost("{storeId}/webhooks/new")]
+ [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
+ public async Task<IActionResult> NewWebhook(string storeId, EditWebhookViewModel viewModel)
+ {
+ if (!ModelState.IsValid)
+ return View(nameof(ModifyWebhook), viewModel);
+
+ await storeRepo.CreateWebhook(CurrentStore.Id, viewModel.CreateBlob());
+ TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["The webhook has been created"].Value;
+ return RedirectToAction(nameof(Webhooks), new { storeId });
+ }
+
+ [HttpGet("{storeId}/webhooks/{webhookId}")]
+ [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
+ public async Task<IActionResult> ModifyWebhook(string webhookId)
+ {
+ var webhook = await storeRepo.GetWebhook(CurrentStore.Id, webhookId);
+ if (webhook is null)
+ return NotFound();
+
+ var blob = webhook.GetBlob();
+ var deliveries = await storeRepo.GetWebhookDeliveries(CurrentStore.Id, webhookId, 20);
+ return View(nameof(ModifyWebhook), new EditWebhookViewModel(blob)
+ {
+ Deliveries = deliveries
+ .Select(s => new DeliveryViewModel(s)).ToList()
+ });
+ }
+
+ [HttpPost("{storeId}/webhooks/{webhookId}")]
+ [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
+ public async Task<IActionResult> ModifyWebhook(string webhookId, EditWebhookViewModel viewModel)
+ {
+ var webhook = await storeRepo.GetWebhook(CurrentStore.Id, webhookId);
+ if (webhook is null)
+ return NotFound();
+ if (!ModelState.IsValid)
+ return View(nameof(ModifyWebhook), viewModel);
+
+ await storeRepo.UpdateWebhook(CurrentStore.Id, webhookId, viewModel.CreateBlob());
+ TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["The webhook has been updated"].Value;
+ return RedirectToAction(nameof(Webhooks), new { storeId = CurrentStore.Id });
+ }
+
+ [HttpPost("{storeId}/webhooks/{webhookId}/deliveries/{deliveryId}/redeliver")]
+ [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
+ public async Task<IActionResult> RedeliverWebhook(string webhookId, string deliveryId)
+ {
+ var delivery = await storeRepo.GetWebhookDelivery(CurrentStore.Id, webhookId, deliveryId);
+ if (delivery is null)
+ return NotFound();
+
+ var newDeliveryId = await webhookSender.Redeliver(deliveryId);
+ if (newDeliveryId is null)
+ return NotFound();
+
+ TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Successfully planned a redelivery"].Value;
+ return RedirectToAction(nameof(ModifyWebhook),
+ new
+ {
+ storeId = CurrentStore.Id,
+ webhookId
+ });
+ }
+
+ [HttpGet("{storeId}/webhooks/{webhookId}/deliveries/{deliveryId}/request")]
+ [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
+ public async Task<IActionResult> WebhookDelivery(string webhookId, string deliveryId)
+ {
+ var delivery = await storeRepo.GetWebhookDelivery(CurrentStore.Id, webhookId, deliveryId);
+ if (delivery is null)
+ return NotFound();
+
+ return File(delivery.GetBlob().Request, "application/json");
+ }
+}
diff --git a/BTCPayServer/Plugins/Webhooks/HostedServices/CleanupWebhookDeliveriesTask.cs b/BTCPayServer/Plugins/Webhooks/HostedServices/CleanupWebhookDeliveriesTask.cs
new file mode 100644
index 0000000..0f83a1a
--- /dev/null
+++ b/BTCPayServer/Plugins/Webhooks/HostedServices/CleanupWebhookDeliveriesTask.cs
@@ -0,0 +1,58 @@
+using System;
+using Dapper;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using BTCPayServer.Data;
+using Microsoft.EntityFrameworkCore;
+using BTCPayServer.HostedServices;
+
+namespace BTCPayServer.Plugins.Webhooks.HostedServices
+{
+ public class CleanupWebhookDeliveriesTask : IPeriodicTask
+ {
+ public CleanupWebhookDeliveriesTask(ApplicationDbContextFactory dbContextFactory)
+ {
+ DbContextFactory = dbContextFactory;
+ }
+
+ public ApplicationDbContextFactory DbContextFactory { get; }
+ public int BatchSize { get; set; } = 500;
+ public TimeSpan PruneAfter { get; set; } = TimeSpan.FromDays(60);
+
+ public async Task Do(CancellationToken cancellationToken)
+ {
+ await using var ctx = DbContextFactory.CreateContext();
+ var conn = ctx.Database.GetDbConnection();
+ bool pruned = false;
+ int offset = 0;
+retry:
+ var rows = await conn.QueryAsync<WebhookDeliveryData>(@"
+SELECT ""Id"", ""Blob""
+FROM ""WebhookDeliveries""
+WHERE ((now() - ""Timestamp"") > @PruneAfter) AND ""Pruned"" IS FALSE
+ORDER BY ""Timestamp""
+LIMIT @BatchSize OFFSET @offset
+", new { PruneAfter, BatchSize, offset });
+
+ foreach (var d in rows)
+ {
+ var blob = d.GetBlob();
+ blob.Prune();
+ d.SetBlob(blob);
+ d.Pruned = true;
+ pruned = true;
+ }
+ if (pruned)
+ {
+ pruned = false;
+ await conn.ExecuteAsync("UPDATE \"WebhookDeliveries\" SET \"Blob\"=@Blob::JSONB, \"Pruned\"=@Pruned WHERE \"Id\"=@Id", rows);
+ if (rows.Count() == BatchSize)
+ {
+ offset += BatchSize;
+ goto retry;
+ }
+ }
+ }
+ }
+}
diff --git a/BTCPayServer/Plugins/Webhooks/HostedServices/WebhookProviderHostedService.cs b/BTCPayServer/Plugins/Webhooks/HostedServices/WebhookProviderHostedService.cs
new file mode 100644
index 0000000..af6f15d
--- /dev/null
+++ b/BTCPayServer/Plugins/Webhooks/HostedServices/WebhookProviderHostedService.cs
@@ -0,0 +1,76 @@
+#nullable enable
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using BTCPayServer.Client.Models;
+using BTCPayServer.Data;
+using BTCPayServer.HostedServices;
+using BTCPayServer.Plugins.Emails;
+using Microsoft.Extensions.Logging;
+using Newtonsoft.Json;
+using StoreData = BTCPayServer.Data.StoreData;
+
+namespace BTCPayServer.Plugins.Webhooks.HostedServices;
+
+/// <summary>
+/// This class listen to <typeparamref name="T"/> events and create webhook notifications and trigger event from it.
+/// </summary>
+public class WebhookProviderHostedService(
+ EventAggregator eventAggregator,
+ ApplicationDbContextFactory dbContextFactory,
+ IEnumerable<WebhookTriggerProvider> webhookTriggerProviders,
+ WebhookSender webhookSender,
+ ILogger<WebhookProviderHostedService> logger)
+ : EventHostedServiceBase(eventAggregator, logger)
+{
+ class WebhookTriggerOwner(WebhookTriggerProvider provider, WebhookTriggerContext ctx) : ITriggerOwner
+ {
+ public Task BeforeSending(EmailRuleMatchContext context)
+ => provider.BeforeSending(context, ctx);
+ }
+
+ protected override void SubscribeToEvents()
+ {
+ SubscribeAny<object>();
+ }
+
+ protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
+ {
+ var (provider, webhookEvent) = webhookTriggerProviders
+ .Select(o => (o, o.GetWebhookEvent(evt)))
+ .FirstOrDefault(o => o.Item2 is not null);
+ if (webhookEvent is null || provider is null)
+ return;
+
+ var webhooks = await webhookSender.GetWebhooks(webhookEvent.StoreId, webhookEvent.Type);
+ foreach (var webhook in webhooks)
+ {
+ var ev = Clone(webhookEvent);
+ ev.WebhookId = webhook.Id;
+ var delivery = Data.WebhookDeliveryData.Create(webhook.Id);
+ ev.DeliveryId = delivery.Id;
+ ev.OriginalDeliveryId = delivery.Id;
+ ev.Timestamp = delivery.Timestamp;
+ ev.IsRedelivery = false;
+ webhookSender.EnqueueDelivery(new(webhook.Id, ev, delivery, webhook.GetBlob()));
+ }
+
+ if (await GetStore(webhookEvent) is {} store)
+ {
+ var ctx = provider.CreateWebhookTriggerContext(store, evt, webhookEvent);
+ var triggerEvent = new TriggerEvent(webhookEvent.StoreId, EmailRuleData.GetWebhookTriggerName(webhookEvent.Type),
+ await provider.GetEmailModel(ctx), new WebhookTriggerOwner(provider, ctx));
+ EventAggregator.Publish(triggerEvent);
+ }
+ }
+
+ private StoreWebhookEvent Clone(StoreWebhookEvent webhookEvent)
+ => (StoreWebhookEvent)JsonConvert.DeserializeObject(JsonConvert.SerializeObject(webhookEvent), webhookEvent.GetType(), WebhookSender.DefaultSerializerSettings)!;
+
+ private async Task<StoreData?> GetStore(StoreWebhookEvent webhookEvent)
+ {
+ await using var ctx = dbContextFactory.CreateContext();
+ return await ctx.Stores.FindAsync(webhookEvent.StoreId);
+ }
+}
diff --git a/BTCPayServer/Plugins/Webhooks/TriggerProviders/InvoiceTriggerProvider.cs b/BTCPayServer/Plugins/Webhooks/TriggerProviders/InvoiceTriggerProvider.cs
new file mode 100644
index 0000000..1bd661d
--- /dev/null
+++ b/BTCPayServer/Plugins/Webhooks/TriggerProviders/InvoiceTriggerProvider.cs
@@ -0,0 +1,91 @@
+#nullable enable
+using System.Threading.Tasks;
+using BTCPayServer.Client.Models;
+using BTCPayServer.Controllers.Greenfield;
+using BTCPayServer.Events;
+using BTCPayServer.PWhy this scored 27/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.