From e8282ca849c4829cb2b9ae77dee62e7b3715d8f3 Mon Sep 17 00:00:00 2001 From: Nicolas Dorier Date: Sun, 19 Oct 2025 22:31:24 +0900 Subject: [PATCH] Refactoring of Webhooks and Email Rules (#6954) --- BTCPayServer.Common/TextTemplate.cs | 127 +++++++ BTCPayServer.Data/ApplicationDbContext.cs | 4 +- BTCPayServer.Data/Data/BaseEntityData.cs | 82 +++++ BTCPayServer.Data/Data/EmailRuleData.cs | 77 +++++ BTCPayServer.Data/Data/PendingTransaction.cs | 10 +- BTCPayServer.Data/Data/WebhookDeliveryData.cs | 4 + .../Migrations/20251015142818_emailrules.cs | 82 +++++ .../ApplicationDbContextModelSnapshot.cs | 73 ++++ BTCPayServer.Tests/GreenfieldAPITests.cs | 3 +- BTCPayServer.Tests/MailPitClient.cs | 193 +++++++++++ BTCPayServer.Tests/PMO/ConfigureEmailPMO.cs | 48 +++ BTCPayServer.Tests/PMO/EmailRulePMO.cs | 34 ++ BTCPayServer.Tests/PlaywrightTests.cs | 105 ++++-- BTCPayServer.Tests/ServerTester.cs | 21 ++ BTCPayServer.Tests/UnitTest1.cs | 120 ++++++- .../docker-compose.altcoins.yml | 15 + BTCPayServer.Tests/docker-compose.yml | 15 + .../Components/MainNav/Default.cshtml | 5 +- .../GreenfieldPaymentRequestsController.cs | 5 +- .../GreenField/LocalBTCPayServerClient.cs | 1 + .../Controllers/UIInvoiceController.UI.cs | 3 +- .../Controllers/UIInvoiceController.cs | 5 +- .../Controllers/UIPaymentRequestController.cs | 43 ++- .../Controllers/UIServerController.cs | 40 ++- .../UIStoresController.EmailRules.cs | 154 --------- .../Controllers/UIStoresController.cs | 10 +- .../Controllers/UIWalletsController.PSBT.cs | 2 +- .../Controllers/UIWalletsController.cs | 4 +- BTCPayServer/Data/PaymentRequestBlob.cs | 1 + BTCPayServer/Data/StoreBlob.cs | 5 - BTCPayServer/Events/EmailSentEvent.cs | 10 + BTCPayServer/Extensions.cs | 3 - .../Extensions/UrlHelperExtensions.cs | 14 + .../PendingTransactionService.cs | 26 +- .../HostedServices/PluginUpdateFetcher.cs | 2 +- .../StoreEmailRuleProcessorSender.cs | 87 ----- .../Webhooks/IWebhookProvider.cs | 14 - .../Webhooks/InvoiceWebhookDeliveryRequest.cs | 55 --- .../Webhooks/InvoiceWebhookProvider.cs | 113 ------ .../PaymentRequestWebhookDeliveryRequest.cs | 54 --- .../Webhooks/PaymentRequestWebhookProvider.cs | 65 ---- .../Webhooks/PayoutWebhookDeliveryRequest.cs | 39 --- .../Webhooks/PayoutWebhookProvider.cs | 67 ---- .../PendingTransactionDeliveryRequest.cs | 48 --- .../PendingTransactionWebhookProvider.cs | 89 ----- .../Webhooks/WebhookExtensions.cs | 55 --- .../Webhooks/WebhookProvider.cs | 45 --- BTCPayServer/Hosting/BTCPayServerServices.cs | 13 - BTCPayServer/Hosting/Startup.cs | 2 + .../InvoicingModels/InvoiceDetailsModel.cs | 3 +- .../StoreViewModels/TestWebhookViewModel.cs | 9 - .../UIStoreEmailRulesController.cs | 157 +++++++++ .../Controllers/UIStoresEmailController.cs} | 49 ++- .../Plugins/Emails/EmailsExtensions.cs | 14 + BTCPayServer/Plugins/Emails/EmailsPlugin.cs | 21 ++ .../Emails/EmailsTranslationProvider.cs | 18 + .../Plugins/Emails/LinkGeneratorExtensions.cs | 23 ++ .../Emails/StoreEmailRuleProcessorSender.cs | 84 +++++ .../Emails/Views/EmailTriggerViewModel.cs | 23 ++ .../Emails/Views/StoreEmailRuleViewModel.cs | 53 +++ .../StoreEmailRulesList.cshtml | 34 +- .../StoreEmailRulesManage.cshtml | 161 +++++++++ .../UIStoresEmail/StoreEmailSettings.cshtml | 55 +++ .../Plugins/Emails/Views/_ViewImports.cshtml | 9 + .../Plugins/Emails/Views/_ViewStart.cshtml | 3 + BTCPayServer/Plugins/PluginBuilderClient.cs | 12 +- BTCPayServer/Plugins/PluginService.cs | 7 +- .../GreenfieldStoreWebhooksController.cs | 3 +- .../Controllers/UIStoreWebhooksController.cs} | 85 ++--- .../CleanupWebhookDeliveriesTask.cs | 5 +- .../WebhookProviderHostedService.cs | 76 +++++ .../InvoiceTriggerProvider.cs | 91 +++++ .../PaymentRequestTriggerProvider.cs | 79 +++++ .../TriggerProviders/PayoutTriggerProvider.cs | 44 +++ .../PendingTransactionTriggerProvider.cs | 80 +++++ .../Views/AvailableWebhookViewModel.cs | 12 + .../Webhooks/Views}/EditWebhookViewModel.cs | 6 +- .../Webhooks/Views}/ModifyWebhook.cshtml | 9 +- .../Webhooks/Views}/Webhooks.cshtml | 2 - .../Webhooks/Views}/WebhooksViewModel.cs | 2 +- .../Webhooks/Views/_ViewImports.cshtml | 6 + .../Plugins/Webhooks/Views/_ViewStart.cshtml | 3 + .../Webhooks}/WebhookDataExtensions.cs | 8 +- .../Plugins/Webhooks/WebhookExtensions.cs | 32 ++ .../Webhooks/WebhookSender.cs | 123 +------ .../Plugins/Webhooks/WebhookTriggerContext.cs | 19 ++ .../Webhooks/WebhookTriggerProvider.cs | 54 +++ .../Plugins/Webhooks/WebhooksPlugin.cs | 321 ++++++++++++++++++ .../Webhooks/WebhooksTranslationProvider.cs | 15 + .../Services/Invoices/InvoiceEntity.cs | 3 + BTCPayServer/Services/Mails/EmailSender.cs | 11 +- .../Services/Mails/EmailSenderFactory.cs | 7 +- .../Services/Mails/ServerEmailSender.cs | 10 +- .../Services/Mails/StoreEmailSender.cs | 11 +- .../PaymentRequestRepository.cs | 17 +- .../Shared/NotificationEmailWarning.cshtml | 3 +- .../EditPaymentRequest.cshtml | 10 +- BTCPayServer/Views/UIServer/Emails.cshtml | 5 +- .../UIStores/StoreEmailRulesManage.cshtml | 257 -------------- .../Views/UIStores/StoreEmailSettings.cshtml | 49 --- .../Views/UIStores/TestWebhook.cshtml | 26 -- 101 files changed, 2700 insertions(+), 1611 deletions(-) create mode 100644 BTCPayServer.Common/TextTemplate.cs create mode 100644 BTCPayServer.Data/Data/BaseEntityData.cs create mode 100644 BTCPayServer.Data/Data/EmailRuleData.cs create mode 100644 BTCPayServer.Data/Migrations/20251015142818_emailrules.cs create mode 100644 BTCPayServer.Tests/MailPitClient.cs create mode 100644 BTCPayServer.Tests/PMO/ConfigureEmailPMO.cs create mode 100644 BTCPayServer.Tests/PMO/EmailRulePMO.cs delete mode 100644 BTCPayServer/Controllers/UIStoresController.EmailRules.cs create mode 100644 BTCPayServer/Events/EmailSentEvent.cs delete mode 100644 BTCPayServer/HostedServices/StoreEmailRuleProcessorSender.cs delete mode 100644 BTCPayServer/HostedServices/Webhooks/IWebhookProvider.cs delete mode 100644 BTCPayServer/HostedServices/Webhooks/InvoiceWebhookDeliveryRequest.cs delete mode 100644 BTCPayServer/HostedServices/Webhooks/InvoiceWebhookProvider.cs delete mode 100644 BTCPayServer/HostedServices/Webhooks/PaymentRequestWebhookDeliveryRequest.cs delete mode 100644 BTCPayServer/HostedServices/Webhooks/PaymentRequestWebhookProvider.cs delete mode 100644 BTCPayServer/HostedServices/Webhooks/PayoutWebhookDeliveryRequest.cs delete mode 100644 BTCPayServer/HostedServices/Webhooks/PayoutWebhookProvider.cs delete mode 100644 BTCPayServer/HostedServices/Webhooks/PendingTransactionDeliveryRequest.cs delete mode 100644 BTCPayServer/HostedServices/Webhooks/PendingTransactionWebhookProvider.cs delete mode 100644 BTCPayServer/HostedServices/Webhooks/WebhookExtensions.cs delete mode 100644 BTCPayServer/HostedServices/Webhooks/WebhookProvider.cs delete mode 100644 BTCPayServer/Models/StoreViewModels/TestWebhookViewModel.cs create mode 100644 BTCPayServer/Plugins/Emails/Controllers/UIStoreEmailRulesController.cs rename BTCPayServer/{Controllers/UIStoresController.Email.cs => Plugins/Emails/Controllers/UIStoresEmailController.cs} (70%) create mode 100644 BTCPayServer/Plugins/Emails/EmailsExtensions.cs create mode 100644 BTCPayServer/Plugins/Emails/EmailsPlugin.cs create mode 100644 BTCPayServer/Plugins/Emails/EmailsTranslationProvider.cs create mode 100644 BTCPayServer/Plugins/Emails/LinkGeneratorExtensions.cs create mode 100644 BTCPayServer/Plugins/Emails/StoreEmailRuleProcessorSender.cs create mode 100644 BTCPayServer/Plugins/Emails/Views/EmailTriggerViewModel.cs create mode 100644 BTCPayServer/Plugins/Emails/Views/StoreEmailRuleViewModel.cs rename BTCPayServer/{Views/UIStores => Plugins/Emails/Views/UIStoreEmailRules}/StoreEmailRulesList.cshtml (60%) create mode 100644 BTCPayServer/Plugins/Emails/Views/UIStoreEmailRules/StoreEmailRulesManage.cshtml create mode 100644 BTCPayServer/Plugins/Emails/Views/UIStoresEmail/StoreEmailSettings.cshtml create mode 100644 BTCPayServer/Plugins/Emails/Views/_ViewImports.cshtml create mode 100644 BTCPayServer/Plugins/Emails/Views/_ViewStart.cshtml rename BTCPayServer/{Controllers/GreenField => Plugins/Webhooks/Controllers}/GreenfieldStoreWebhooksController.cs (98%) rename BTCPayServer/{Controllers/UIStoresController.Integrations.cs => Plugins/Webhooks/Controllers/UIStoreWebhooksController.cs} (62%) rename BTCPayServer/{ => Plugins/Webhooks}/HostedServices/CleanupWebhookDeliveriesTask.cs (94%) create mode 100644 BTCPayServer/Plugins/Webhooks/HostedServices/WebhookProviderHostedService.cs create mode 100644 BTCPayServer/Plugins/Webhooks/TriggerProviders/InvoiceTriggerProvider.cs create mode 100644 BTCPayServer/Plugins/Webhooks/TriggerProviders/PaymentRequestTriggerProvider.cs create mode 100644 BTCPayServer/Plugins/Webhooks/TriggerProviders/PayoutTriggerProvider.cs create mode 100644 BTCPayServer/Plugins/Webhooks/TriggerProviders/PendingTransactionTriggerProvider.cs create mode 100644 BTCPayServer/Plugins/Webhooks/Views/AvailableWebhookViewModel.cs rename BTCPayServer/{Models/StoreViewModels => Plugins/Webhooks/Views}/EditWebhookViewModel.cs (95%) rename BTCPayServer/{Views/UIStores => Plugins/Webhooks/Views}/ModifyWebhook.cshtml (94%) rename BTCPayServer/{Views/UIStores => Plugins/Webhooks/Views}/Webhooks.cshtml (94%) rename BTCPayServer/{Models/StoreViewModels => Plugins/Webhooks/Views}/WebhooksViewModel.cs (91%) create mode 100644 BTCPayServer/Plugins/Webhooks/Views/_ViewImports.cshtml create mode 100644 BTCPayServer/Plugins/Webhooks/Views/_ViewStart.cshtml rename BTCPayServer/{Data => Plugins/Webhooks}/WebhookDataExtensions.cs (94%) create mode 100644 BTCPayServer/Plugins/Webhooks/WebhookExtensions.cs rename BTCPayServer/{HostedServices => Plugins}/Webhooks/WebhookSender.cs (63%) create mode 100644 BTCPayServer/Plugins/Webhooks/WebhookTriggerContext.cs create mode 100644 BTCPayServer/Plugins/Webhooks/WebhookTriggerProvider.cs create mode 100644 BTCPayServer/Plugins/Webhooks/WebhooksPlugin.cs create mode 100644 BTCPayServer/Plugins/Webhooks/WebhooksTranslationProvider.cs delete mode 100644 BTCPayServer/Views/UIStores/StoreEmailRulesManage.cshtml delete mode 100644 BTCPayServer/Views/UIStores/StoreEmailSettings.cshtml delete mode 100644 BTCPayServer/Views/UIStores/TestWebhook.cshtml diff --git a/BTCPayServer.Common/TextTemplate.cs b/BTCPayServer.Common/TextTemplate.cs new file mode 100644 index 000000000..faddf8408 --- /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() ?? $""; + } + catch + { + return $""; + } + }); + } + + public List GetPaths(JObject model) + { + var paths = new List>(); + GetAvailablePaths(model, paths, null); + + List result = new List(); + 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> paths, List? currentPath) + { + if (tok is JProperty prop) + { + if (currentPath is null) + { + currentPath = new List(); + } + 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() : new List(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() : new List(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()) + { + 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 bd3d0e32c..8de6923ad 100644 --- a/BTCPayServer.Data/ApplicationDbContext.cs +++ b/BTCPayServer.Data/ApplicationDbContext.cs @@ -65,12 +65,14 @@ namespace BTCPayServer.Data public DbSet Forms { get; set; } public DbSet PendingTransactions { get; set; } + public DbSet 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 000000000..ba77957ba --- /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; + + /// + /// User-defined custom data + /// + [Column("metadata", TypeName = "jsonb")] + public string Metadata { get; set; } = "{}"; + + /// + /// Data that is not user-defined, but can be used and extended internally by BTCPay Server or plugins. + /// + [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(string key) where T: class + => JObject.Parse(AdditionalData)[key]?.ToObject(Serializer); + + public void SetAdditionalData(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(EntityTypeBuilder 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 000000000..c874753d7 --- /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("btcpay"); + public void SetBTCPayAdditionalData(BTCPayAdditionalData? data) => this.SetAdditionalData("btcpay", data); + + internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade) + { + var b = builder.Entity(); + 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 GetRules(this IQueryable query, string storeId) + => query.Where(o => o.StoreId == storeId) + .OrderBy(o => o.Id); + + public static Task GetMatches(this DbSet 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 GetRule(this IQueryable 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 f968087af..ef1e3e80b 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 public byte[] Blob { get; set; } public string Blob2 { get; set; } - - + + internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade) { builder.Entity() @@ -37,7 +40,7 @@ public class PendingTransaction: IHasBlob builder.Entity() .Property(o => o.Blob2) - .HasColumnType("JSONB"); + .HasColumnType("JSONB"); builder.Entity() .Property(o => o.OutpointsUsed) .HasColumnType("text[]"); @@ -56,6 +59,7 @@ public class PendingTransaction: IHasBlob public class PendingTransactionBlob { public string PSBT { get; set; } + public string RequestBaseUrl { get; set; } public List CollectedSignatures { get; set; } = new(); public int? SignaturesCollected { get; set; } diff --git a/BTCPayServer.Data/Data/WebhookDeliveryData.cs b/BTCPayServer.Data/Data/WebhookDeliveryData.cs index 977834f9d..673a1e63a 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 000000000..16a901fb7 --- /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 + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "email_rules", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityAlwaysColumn), + store_id = table.Column(type: "text", nullable: true), + trigger = table.Column(type: "text", nullable: false), + condition = table.Column(type: "text", nullable: true), + to = table.Column(type: "text[]", nullable: false), + subject = table.Column(type: "text", nullable: false, defaultValue: ""), + body = table.Column(type: "text", nullable: false), + metadata = table.Column(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"), + additional_data = table.Column(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"), + created_at = table.Column(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'; + """ + ); + } + + /// + 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 cc6fbd4cd..9c6f6eb63 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id")); + + b.Property("AdditionalData") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("additional_data") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text") + .HasColumnName("body"); + + b.Property("Condition") + .HasColumnType("text") + .HasColumnName("condition"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamptz") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Metadata") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("StoreId") + .HasColumnType("text") + .HasColumnName("store_id"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("text") + .HasColumnName("subject"); + + b.Property("To") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("to"); + + b.Property("Trigger") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger"); + + b.HasKey("Id"); + + b.HasIndex("StoreId"); + + b.ToTable("email_rules"); + }); + modelBuilder.Entity("BTCPayServer.Data.Fido2Credential", b => { b.Property("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 5d0386caf..5a8f96adc 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(); + var cleanup = tester.PayTester.GetService(); 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 000000000..dd313533a --- /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 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(result, settings); + } + + public sealed class Message + { + [JsonProperty("Attachments")] + public List Attachments { get; set; } + + [JsonProperty("Bcc")] + public List Bcc { get; set; } + + [JsonProperty("Cc")] + public List 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 Inline { get; set; } + + [JsonProperty("ListUnsubscribe")] + public ListUnsubscribeInfo ListUnsubscribe { get; set; } + + [JsonProperty("MessageID")] + public string MessageId { get; set; } + + [JsonProperty("ReplyTo")] + public List 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 Tags { get; set; } + + [JsonProperty("Text")] + public string Text { get; set; } + + [JsonProperty("To")] + public List To { get; set; } + + [JsonProperty("Username")] + public string Username { get; set; } + + // Capture any unexpected fields without breaking deserialization. + [JsonExtensionData] + public IDictionary 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 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 Links { get; set; } + + [JsonExtensionData] + public IDictionary Extra { get; set; } + } + + /// + /// Permissive RFC3339/RFC3339Nano converter to DateTimeOffset. + /// Trims fractional seconds to 7 digits (the .NET limit). + /// + public sealed class Rfc3339NanoDateTimeOffsetConverter : JsonConverter + { + // 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 000000000..27ffa811a --- /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 000000000..4aabe8ad9 --- /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 4d04ee6d1..53ce83727 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(() => 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(() => 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 cfff82a43..4bb9db08a 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 Resources = new List(); 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("BTC").NBitcoinNetwork); ExplorerNode.ScanRPCCapabilities(); @@ -286,5 +293,19 @@ namespace BTCPayServer.Tests cryptoCode == "LTC" ? NetworkProvider.GetNetwork("LTC") : cryptoCode == "LBTC" ? NetworkProvider.GetNetwork("LBTC") : throw new NotSupportedException(); + + public async Task AssertHasEmail(EmailSentEvent sent) + { + var mailPitClient = GetMailPitClient(); + return await mailPitClient.GetMessage(sent.ServerResponse.Split(' ').Last()); + } + + public MailPitClient GetMailPitClient() + { + var http = PayTester.GetService().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 63a781030..68076042a 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 !", result); + + // Is Case insensitive + model = new() + { + ["Name"] = new JObject + { + ["firstname"] = "John", + } + }; + result = template.Render(model); + Assert.Equal("Hello mister John !", 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(await emailSenderFactory.GetEmailSender()).GetEmailSettings()).Login); Assert.Null(await Assert.IsType(await emailSenderFactory.GetEmailSender(acc.StoreId)).GetEmailSettings()); - Assert.IsType(await acc.GetController().StoreEmailSettings(acc.StoreId, new EmailsViewModel(new EmailSettings + Assert.IsType(await acc.GetController().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(await emailSenderFactory.GetEmailSender(acc.StoreId)).GetEmailSettings()).Login); + + var sent = await tester.WaitForEvent( + 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 beff000cb..17b1df5af 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 b05b6f1bb..627b11606 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 2233ce141..f29113a6a 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 @@ Roles