mirror of
https://github.com/aljazceru/btcpayserver.git
synced 2025-12-17 05:54:26 +01:00
Refactoring of Webhooks and Email Rules (#6954)
This commit is contained in:
127
BTCPayServer.Common/TextTemplate.cs
Normal file
127
BTCPayServer.Common/TextTemplate.cs
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
82
BTCPayServer.Data/Data/BaseEntityData.cs
Normal file
82
BTCPayServer.Data/Data/BaseEntityData.cs
Normal file
@@ -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");
|
||||
}
|
||||
}
|
||||
77
BTCPayServer.Data/Data/EmailRuleData.cs
Normal file
77
BTCPayServer.Data/Data/EmailRuleData.cs
Normal file
@@ -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();
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
82
BTCPayServer.Data/Migrations/20251015142818_emailrules.cs
Normal file
82
BTCPayServer.Data/Migrations/20251015142818_emailrules.cs
Normal file
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
|
||||
193
BTCPayServer.Tests/MailPitClient.cs
Normal file
193
BTCPayServer.Tests/MailPitClient.cs
Normal file
@@ -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}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
48
BTCPayServer.Tests/PMO/ConfigureEmailPMO.cs
Normal file
48
BTCPayServer.Tests/PMO/ConfigureEmailPMO.cs
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
34
BTCPayServer.Tests/PMO/EmailRulePMO.cs
Normal file
34
BTCPayServer.Tests/PMO/EmailRulePMO.cs
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,5 +15,6 @@ namespace BTCPayServer.Data
|
||||
public string FormId { get; set; }
|
||||
|
||||
public JObject FormResponse { get; set; }
|
||||
public string RequestBaseUrl { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
10
BTCPayServer/Events/EmailSentEvent.cs
Normal file
10
BTCPayServer/Events/EmailSentEvent.cs
Normal file
@@ -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})";
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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!;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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})";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
using BTCPayServer.Client.Models;
|
||||
|
||||
namespace BTCPayServer.Models.StoreViewModels
|
||||
{
|
||||
public class TestWebhookViewModel
|
||||
{
|
||||
public string Type { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -10,16 +10,30 @@ 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.Controllers;
|
||||
namespace BTCPayServer.Plugins.Emails.Controllers;
|
||||
|
||||
public partial class UIStoresController
|
||||
[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
|
||||
{
|
||||
[HttpGet("{storeId}/email-settings")]
|
||||
public IStringLocalizer StringLocalizer { get; set; } = stringLocalizer;
|
||||
[HttpGet("email-settings")]
|
||||
public async Task<IActionResult> StoreEmailSettings(string storeId)
|
||||
{
|
||||
var store = HttpContext.GetStoreData();
|
||||
@@ -38,7 +52,7 @@ public partial class UIStoresController
|
||||
record AllEmailSettings(EmailSettings Custom, EmailSettings Fallback);
|
||||
private async Task<AllEmailSettings> GetCustomSettings(string storeId)
|
||||
{
|
||||
var sender = await _emailSenderFactory.GetEmailSender(storeId) as StoreEmailSender;
|
||||
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;
|
||||
@@ -47,7 +61,7 @@ public partial class UIStoresController
|
||||
return new(await sender.GetCustomSettings(), fallback);
|
||||
}
|
||||
|
||||
[HttpPost("{storeId}/email-settings")]
|
||||
[HttpPost("email-settings")]
|
||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
public async Task<IActionResult> StoreEmailSettings(string storeId, EmailsViewModel model, string command)
|
||||
{
|
||||
@@ -95,26 +109,45 @@ public partial class UIStoresController
|
||||
}
|
||||
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 _storeRepo.UpdateStore(store);
|
||||
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 _storeRepo.UpdateStore(store);
|
||||
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 _storeRepo.UpdateStore(store);
|
||||
await storeRepository.UpdateStore(store);
|
||||
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email settings saved"].Value;
|
||||
}
|
||||
return RedirectToAction(nameof(StoreEmailSettings), new { storeId });
|
||||
14
BTCPayServer/Plugins/Emails/EmailsExtensions.cs
Normal file
14
BTCPayServer/Plugins/Emails/EmailsExtensions.cs
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
21
BTCPayServer/Plugins/Emails/EmailsPlugin.cs
Normal file
21
BTCPayServer/Plugins/Emails/EmailsPlugin.cs
Normal file
@@ -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>();
|
||||
}
|
||||
}
|
||||
18
BTCPayServer/Plugins/Emails/EmailsTranslationProvider.cs
Normal file
18
BTCPayServer/Plugins/Emails/EmailsTranslationProvider.cs
Normal file
@@ -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())!;
|
||||
}
|
||||
23
BTCPayServer/Plugins/Emails/LinkGeneratorExtensions.cs
Normal file
23
BTCPayServer/Plugins/Emails/LinkGeneratorExtensions.cs
Normal file
@@ -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);
|
||||
}
|
||||
84
BTCPayServer/Plugins/Emails/StoreEmailRuleProcessorSender.cs
Normal file
84
BTCPayServer/Plugins/Emails/StoreEmailRuleProcessorSender.cs
Normal file
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
23
BTCPayServer/Plugins/Emails/Views/EmailTriggerViewModel.cs
Normal file
23
BTCPayServer/Plugins/Emails/Views/EmailTriggerViewModel.cs
Normal file
@@ -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();
|
||||
}
|
||||
53
BTCPayServer/Plugins/Emails/Views/StoreEmailRuleViewModel.cs
Normal file
53
BTCPayServer/Plugins/Emails/Views/StoreEmailRuleViewModel.cs
Normal file
@@ -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();
|
||||
}
|
||||
@@ -1,8 +1,4 @@
|
||||
@using BTCPayServer.Abstractions.Models
|
||||
@using BTCPayServer.Client
|
||||
@using BTCPayServer.TagHelpers
|
||||
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@model List<BTCPayServer.Controllers.UIStoresController.StoreEmailRule>
|
||||
@model List<StoreEmailRuleViewModel>
|
||||
|
||||
@{
|
||||
var storeId = Context.GetStoreData().Id;
|
||||
@@ -12,14 +8,14 @@
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item">
|
||||
<a asp-action="StoreEmailSettings" asp-route-storeId="@storeId" text-translate="true">Emails</a>
|
||||
<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">
|
||||
<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>
|
||||
@@ -35,25 +31,25 @@
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Trigger</th>
|
||||
<th>Customer Email</th>
|
||||
<th>To</th>
|
||||
<th>Subject</th>
|
||||
<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.Select((value, index) => new { value, index }))
|
||||
@foreach (var rule in Model)
|
||||
{
|
||||
<tr>
|
||||
<td>@rule.value.Trigger</td>
|
||||
<td>@(rule.value.CustomerEmail ? "Yes" : "No")</td>
|
||||
<td>@rule.value.To</td>
|
||||
<td>@rule.value.Subject</td>
|
||||
<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-ruleIndex="@rule.index">Edit</a>
|
||||
<a asp-action="StoreEmailRulesDelete" asp-route-storeId="@storeId" asp-route-ruleIndex="@rule.index" 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.value.Trigger)]" data-confirm-input="@StringLocalizer["REMOVE"]" text-translate="true">Remove</a>
|
||||
<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>
|
||||
@@ -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>
|
||||
|
||||
}
|
||||
@@ -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" />
|
||||
}
|
||||
9
BTCPayServer/Plugins/Emails/Views/_ViewImports.cshtml
Normal file
9
BTCPayServer/Plugins/Emails/Views/_ViewImports.cshtml
Normal file
@@ -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
|
||||
|
||||
3
BTCPayServer/Plugins/Emails/Views/_ViewStart.cshtml
Normal file
3
BTCPayServer/Plugins/Emails/Views/_ViewStart.cshtml
Normal file
@@ -0,0 +1,3 @@
|
||||
@{
|
||||
Layout = "_Layout";
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
{
|
||||
|
||||
@@ -6,14 +6,13 @@ 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
|
||||
namespace BTCPayServer.Plugins.Webhooks.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield,
|
||||
@@ -1,31 +1,42 @@
|
||||
#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 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.Controllers;
|
||||
namespace BTCPayServer.Plugins.Webhooks.Controllers;
|
||||
|
||||
public partial class UIStoresController
|
||||
[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)).ToList().FirstOrDefault();
|
||||
return (await storeRepo.GetWebhookDeliveries(CurrentStore.Id, webhookId, 1)).FirstOrDefault();
|
||||
}
|
||||
|
||||
[HttpGet("{storeId}/webhooks")]
|
||||
public async Task<IActionResult> Webhooks()
|
||||
{
|
||||
var webhooks = await _storeRepo.GetWebhooks(CurrentStore.Id);
|
||||
var webhooks = await storeRepo.GetWebhooks(CurrentStore.Id);
|
||||
return View(nameof(Webhooks), new WebhooksViewModel
|
||||
{
|
||||
Webhooks = webhooks.Select(async w =>
|
||||
@@ -59,26 +70,15 @@ public partial class UIStoresController
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("{storeId}/webhooks/{webhookId}/remove")]
|
||||
[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);
|
||||
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);
|
||||
await storeRepo.DeleteWebhook(CurrentStore.Id, webhookId);
|
||||
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Webhook successfully deleted"].Value;
|
||||
return RedirectToAction(nameof(Webhooks), new { storeId = CurrentStore.Id });
|
||||
}
|
||||
@@ -90,7 +90,7 @@ public partial class UIStoresController
|
||||
if (!ModelState.IsValid)
|
||||
return View(nameof(ModifyWebhook), viewModel);
|
||||
|
||||
await _storeRepo.CreateWebhook(CurrentStore.Id, viewModel.CreateBlob());
|
||||
await storeRepo.CreateWebhook(CurrentStore.Id, viewModel.CreateBlob());
|
||||
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["The webhook has been created"].Value;
|
||||
return RedirectToAction(nameof(Webhooks), new { storeId });
|
||||
}
|
||||
@@ -99,12 +99,12 @@ public partial class UIStoresController
|
||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
public async Task<IActionResult> ModifyWebhook(string webhookId)
|
||||
{
|
||||
var webhook = await _storeRepo.GetWebhook(CurrentStore.Id, 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);
|
||||
var deliveries = await storeRepo.GetWebhookDeliveries(CurrentStore.Id, webhookId, 20);
|
||||
return View(nameof(ModifyWebhook), new EditWebhookViewModel(blob)
|
||||
{
|
||||
Deliveries = deliveries
|
||||
@@ -116,55 +116,26 @@ public partial class UIStoresController
|
||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
public async Task<IActionResult> ModifyWebhook(string webhookId, EditWebhookViewModel viewModel)
|
||||
{
|
||||
var webhook = await _storeRepo.GetWebhook(CurrentStore.Id, webhookId);
|
||||
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());
|
||||
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);
|
||||
var delivery = await storeRepo.GetWebhookDelivery(CurrentStore.Id, webhookId, deliveryId);
|
||||
if (delivery is null)
|
||||
return NotFound();
|
||||
|
||||
var newDeliveryId = await _webhookNotificationManager.Redeliver(deliveryId);
|
||||
var newDeliveryId = await webhookSender.Redeliver(deliveryId);
|
||||
if (newDeliveryId is null)
|
||||
return NotFound();
|
||||
|
||||
@@ -181,7 +152,7 @@ public partial class UIStoresController
|
||||
[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);
|
||||
var delivery = await storeRepo.GetWebhookDelivery(CurrentStore.Id, webhookId, deliveryId);
|
||||
if (delivery is null)
|
||||
return NotFound();
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
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;
|
||||
using BTCPayServer.HostedServices;
|
||||
|
||||
namespace BTCPayServer.HostedServices
|
||||
namespace BTCPayServer.Plugins.Webhooks.HostedServices
|
||||
{
|
||||
public class CleanupWebhookDeliveriesTask : IPeriodicTask
|
||||
{
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
#nullable enable
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Controllers.Greenfield;
|
||||
using BTCPayServer.Events;
|
||||
using BTCPayServer.Plugins.Emails;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace BTCPayServer.Plugins.Webhooks.TriggerProviders;
|
||||
|
||||
public class InvoiceTriggerProvider(LinkGenerator linkGenerator)
|
||||
: WebhookTriggerProvider<InvoiceEvent>
|
||||
{
|
||||
protected override async Task<JObject> GetEmailModel(WebhookTriggerContext<InvoiceEvent> webhookTriggerContext)
|
||||
{
|
||||
var evt = webhookTriggerContext.Event;
|
||||
var model = await base.GetEmailModel(webhookTriggerContext);
|
||||
// Keep for backward compatibility
|
||||
model["Invoice"] = new JObject()
|
||||
{
|
||||
["Id"] = evt.Invoice.Id,
|
||||
["StoreId"] = evt.Invoice.StoreId,
|
||||
["Price"] = evt.Invoice.Price,
|
||||
["Currency"] = evt.Invoice.Currency,
|
||||
["Status"] = evt.Invoice.Status.ToString(),
|
||||
["AdditionalStatus"] = evt.Invoice.ExceptionStatus.ToString(),
|
||||
["OrderId"] = evt.Invoice.Metadata.OrderId,
|
||||
["Metadata"] = evt.Invoice.Metadata.ToJObject(),
|
||||
["Link"] = linkGenerator.InvoiceLink(evt.InvoiceId, evt.Invoice.GetRequestBaseUrl())
|
||||
};
|
||||
return model;
|
||||
}
|
||||
|
||||
protected override Task BeforeSending(EmailRuleMatchContext context, WebhookTriggerContext<InvoiceEvent> webhookTriggerContext)
|
||||
{
|
||||
var evt = webhookTriggerContext.Event;
|
||||
var email = evt.Invoice.Metadata?.BuyerEmail;
|
||||
if (email != null &&
|
||||
context.MatchedRule.GetBTCPayAdditionalData()?.CustomerEmail is true &&
|
||||
MailboxAddressValidator.TryParse(email, out var mb))
|
||||
{
|
||||
context.Recipients.Insert(0, mb);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override WebhookInvoiceEvent? GetWebhookEvent(InvoiceEvent invoiceEvent)
|
||||
{
|
||||
var eventCode = invoiceEvent.EventCode;
|
||||
var storeId = invoiceEvent.Invoice.StoreId;
|
||||
var evt = eventCode switch
|
||||
{
|
||||
InvoiceEventCode.Confirmed or InvoiceEventCode.MarkedCompleted => new WebhookInvoiceSettledEvent(storeId)
|
||||
{
|
||||
ManuallyMarked = eventCode == InvoiceEventCode.MarkedCompleted,
|
||||
OverPaid = invoiceEvent.Invoice.ExceptionStatus == InvoiceExceptionStatus.PaidOver
|
||||
},
|
||||
InvoiceEventCode.Created => new WebhookInvoiceEvent(WebhookEventType.InvoiceCreated, storeId),
|
||||
InvoiceEventCode.Expired => new WebhookInvoiceExpiredEvent(storeId) { PartiallyPaid = invoiceEvent.PaidPartial },
|
||||
InvoiceEventCode.FailedToConfirm or InvoiceEventCode.MarkedInvalid => new WebhookInvoiceInvalidEvent(storeId) { ManuallyMarked = eventCode == InvoiceEventCode.MarkedInvalid },
|
||||
InvoiceEventCode.PaidInFull => new WebhookInvoiceProcessingEvent(storeId) { OverPaid = invoiceEvent.Invoice.ExceptionStatus == InvoiceExceptionStatus.PaidOver },
|
||||
InvoiceEventCode.ReceivedPayment => 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)
|
||||
},
|
||||
InvoiceEventCode.PaymentSettled => 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)
|
||||
},
|
||||
InvoiceEventCode.ExpiredPaidPartial => new WebhookInvoiceEvent(WebhookEventType.InvoiceExpiredPaidPartial, storeId),
|
||||
InvoiceEventCode.PaidAfterExpiration => new WebhookInvoiceEvent(WebhookEventType.InvoicePaidAfterExpiration, storeId),
|
||||
_ => null
|
||||
};
|
||||
if (evt is null)
|
||||
return null;
|
||||
evt.StoreId = storeId;
|
||||
evt.InvoiceId = invoiceEvent.InvoiceId;
|
||||
evt.Metadata = invoiceEvent.Invoice.Metadata.ToJObject();
|
||||
return evt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
#nullable enable
|
||||
using System.Globalization;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Abstractions;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.HostedServices;
|
||||
using BTCPayServer.Plugins.Emails;
|
||||
using BTCPayServer.Services.PaymentRequests;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace BTCPayServer.Plugins.Webhooks.TriggerProviders;
|
||||
|
||||
public class PaymentRequestTriggerProvider(LinkGenerator linkGenerator)
|
||||
: WebhookTriggerProvider<PaymentRequestEvent>
|
||||
{
|
||||
protected override WebhookPaymentRequestEvent? GetWebhookEvent(PaymentRequestEvent evt)
|
||||
{
|
||||
var webhookEvt = 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
|
||||
};
|
||||
if (webhookEvt is null)
|
||||
return null;
|
||||
webhookEvt.StoreId = evt.Data.StoreDataId;
|
||||
webhookEvt.PaymentRequestId = evt.Data.Id;
|
||||
webhookEvt.Status = evt.Data.Status;
|
||||
return webhookEvt;
|
||||
}
|
||||
|
||||
protected override Task BeforeSending(EmailRuleMatchContext context, WebhookTriggerContext<PaymentRequestEvent> webhookTriggerContext)
|
||||
{
|
||||
var email = webhookTriggerContext.Event.Data.GetBlob()?.Email;
|
||||
if (email != null &&
|
||||
context.MatchedRule.GetBTCPayAdditionalData()?.CustomerEmail is true &&
|
||||
MailboxAddressValidator.TryParse(email, out var mb))
|
||||
{
|
||||
context.Recipients.Insert(0, mb);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override async Task<JObject> GetEmailModel(WebhookTriggerContext<PaymentRequestEvent> webhookTriggerContext)
|
||||
{
|
||||
var model = await base.GetEmailModel(webhookTriggerContext);
|
||||
var evt = webhookTriggerContext.Event;
|
||||
var data = evt.Data;
|
||||
var id = data.Id;
|
||||
var trimmedId = !string.IsNullOrEmpty(id) && id.Length > 15 ? $"{id.Substring(0, 7)}...{id.Substring(id.Length - 7)}" : id;
|
||||
var blob = data.GetBlob();
|
||||
var o = new JObject()
|
||||
{
|
||||
["Id"] = id,
|
||||
["TrimmedId"] = trimmedId,
|
||||
["Amount"] = data.Amount.ToString(CultureInfo.InvariantCulture),
|
||||
["Currency"] = data.Currency,
|
||||
["Title"] = blob.Title,
|
||||
["Description"] = blob.Description,
|
||||
["ReferenceId"] = data.ReferenceId,
|
||||
["Status"] = evt.Data.Status.ToString(),
|
||||
["FormResponse"] = blob.FormResponse,
|
||||
};
|
||||
model["PaymentRequest"] = o;
|
||||
|
||||
if (blob.RequestBaseUrl is not null && RequestBaseUrl.TryFromUrl(blob.RequestBaseUrl, out var v))
|
||||
{
|
||||
o["Link"] = linkGenerator.PaymentRequestLink(id, v);
|
||||
}
|
||||
|
||||
return model;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
#nullable enable
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.HostedServices;
|
||||
using BTCPayServer.Services;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace BTCPayServer.Plugins.Webhooks.TriggerProviders;
|
||||
|
||||
public class PayoutTriggerProvider(BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings) : WebhookTriggerProvider<PayoutEvent>
|
||||
{
|
||||
protected override async Task<JObject> GetEmailModel(WebhookTriggerContext<PayoutEvent> webhookTriggerContext)
|
||||
{
|
||||
var evt = webhookTriggerContext.Event;
|
||||
var blob = evt.Payout.GetBlob(btcPayNetworkJsonSerializerSettings);
|
||||
var model = await base.GetEmailModel(webhookTriggerContext);
|
||||
model["Payout"] = new JObject()
|
||||
{
|
||||
["Id"] = evt.Payout.Id,
|
||||
["PullPaymentId"] = evt.Payout.PullPaymentDataId,
|
||||
["Destination"] = evt.Payout.DedupId ?? blob.Destination,
|
||||
["State"] = evt.Payout.State.ToString(),
|
||||
["Metadata"] = blob.Metadata
|
||||
};
|
||||
return model;
|
||||
}
|
||||
protected override StoreWebhookEvent? GetWebhookEvent(PayoutEvent payoutEvent)
|
||||
{
|
||||
var webhookEvt = 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
|
||||
};
|
||||
if (webhookEvt is null)
|
||||
return null;
|
||||
webhookEvt.PayoutId = payoutEvent.Payout.Id;
|
||||
webhookEvt.PayoutState = payoutEvent.Payout.State;
|
||||
webhookEvt.PullPaymentId = payoutEvent.Payout.PullPaymentDataId;
|
||||
return webhookEvt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
#nullable enable
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Abstractions;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.HostedServices;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace BTCPayServer.Plugins.Webhooks.TriggerProviders;
|
||||
|
||||
public class PendingTransactionTriggerProvider
|
||||
(LinkGenerator linkGenerator) : WebhookTriggerProvider<PendingTransactionService.PendingTransactionEvent>
|
||||
{
|
||||
public const string PendingTransactionCreated = nameof(PendingTransactionCreated);
|
||||
public const string PendingTransactionSignatureCollected = nameof(PendingTransactionSignatureCollected);
|
||||
public const string PendingTransactionBroadcast = nameof(PendingTransactionBroadcast);
|
||||
public const string PendingTransactionCancelled = nameof(PendingTransactionCancelled);
|
||||
|
||||
protected override async Task<JObject> GetEmailModel(WebhookTriggerContext<PendingTransactionService.PendingTransactionEvent> webhookTriggerContext)
|
||||
{
|
||||
var evt = webhookTriggerContext.Event;
|
||||
var id = evt.Data.TransactionId;
|
||||
var trimmedId = !string.IsNullOrEmpty(id) && id.Length > 15 ? $"{id.Substring(0, 7)}...{id.Substring(id.Length - 7)}" : id;
|
||||
|
||||
var blob = evt.Data.GetBlob() ?? new();
|
||||
var model = await base.GetEmailModel(webhookTriggerContext);
|
||||
var o = new JObject()
|
||||
{
|
||||
["Id"] = id,
|
||||
["TrimmedId"] = trimmedId,
|
||||
["StoreId"] = evt.Data.StoreId,
|
||||
["SignaturesCollected"] = blob.SignaturesCollected,
|
||||
["SignaturesNeeded"] = blob.SignaturesNeeded,
|
||||
["SignaturesTotal"] = blob.SignaturesTotal
|
||||
};
|
||||
model["PendingTransaction"] = o;
|
||||
if (blob.RequestBaseUrl is not null && RequestBaseUrl.TryFromUrl(blob.RequestBaseUrl, out var v))
|
||||
{
|
||||
o["Link"] = linkGenerator.WalletTransactionsLink(new(webhookTriggerContext.Store.Id, evt.Data.CryptoCode), v);
|
||||
}
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
protected override WebhookPendingTransactionEvent? GetWebhookEvent(PendingTransactionService.PendingTransactionEvent evt)
|
||||
{
|
||||
var webhook = evt.Type switch
|
||||
{
|
||||
PendingTransactionService.PendingTransactionEvent.Created => new WebhookPendingTransactionEvent(
|
||||
PendingTransactionCreated, evt.Data.StoreId, evt.Data.CryptoCode),
|
||||
PendingTransactionService.PendingTransactionEvent.SignatureCollected => new WebhookPendingTransactionEvent(
|
||||
PendingTransactionSignatureCollected, evt.Data.StoreId, evt.Data.CryptoCode),
|
||||
PendingTransactionService.PendingTransactionEvent.Broadcast => new WebhookPendingTransactionEvent(
|
||||
PendingTransactionBroadcast, evt.Data.StoreId, evt.Data.CryptoCode),
|
||||
PendingTransactionService.PendingTransactionEvent.Cancelled => new WebhookPendingTransactionEvent(
|
||||
PendingTransactionCancelled, evt.Data.StoreId, evt.Data.CryptoCode),
|
||||
_ => null
|
||||
};
|
||||
if (webhook is not null)
|
||||
webhook.PendingTransactionId = evt.Data.TransactionId;
|
||||
return webhook;
|
||||
}
|
||||
|
||||
public class WebhookPendingTransactionEvent : StoreWebhookEvent
|
||||
{
|
||||
public WebhookPendingTransactionEvent(string type, string storeId, string cryptoCode)
|
||||
{
|
||||
Type = type;
|
||||
StoreId = storeId;
|
||||
CryptoCode = cryptoCode;
|
||||
}
|
||||
|
||||
public string CryptoCode { get; set; }
|
||||
|
||||
[JsonProperty(Order = 2)] public string PendingTransactionId { get; set; } = null!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace BTCPayServer.Plugins.Webhooks.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Used to represent available webhooks with their descriptions in the ModifyWebhook view
|
||||
/// </summary>
|
||||
/// <param name="type">The webhook type</param>
|
||||
/// <param name="description">User friendly description</param>
|
||||
public class AvailableWebhookViewModel(string type, string description)
|
||||
{
|
||||
public string Type { get; } = type;
|
||||
public string Description { get; } = description;
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
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
|
||||
namespace BTCPayServer.Plugins.Webhooks.Views
|
||||
{
|
||||
public class DeliveryViewModel
|
||||
{
|
||||
@@ -25,7 +23,7 @@ namespace BTCPayServer.Models.StoreViewModels
|
||||
var evt = blob.ReadRequestAs<WebhookEvent>();
|
||||
Type = evt.Type;
|
||||
Pruned = evt.IsPruned();
|
||||
WebhookId = s.Id;
|
||||
WebhookId = s.WebhookId;
|
||||
PayloadUrl = s.Webhook?.GetBlob().Url;
|
||||
}
|
||||
public string Id { get; set; }
|
||||
@@ -1,6 +1,5 @@
|
||||
@model EditWebhookViewModel
|
||||
@using BTCPayServer.HostedServices.Webhooks
|
||||
@inject WebhookSender WebhookSender
|
||||
@inject IEnumerable<AvailableWebhookViewModel> Webhooks
|
||||
@{
|
||||
var storeId = Context.GetStoreData().Id;
|
||||
ViewData.SetActivePage(StoreNavPages.Webhooks, StringLocalizer["Webhook"], storeId);
|
||||
@@ -75,11 +74,11 @@
|
||||
</select>
|
||||
<div id="event-selector" class="collapse">
|
||||
<div class="pb-3">
|
||||
@foreach (var evt in WebhookSender.GetSupportedWebhookTypes())
|
||||
@foreach (var evt in Webhooks)
|
||||
{
|
||||
<div class="form-check my-1">
|
||||
<input name="Events" id="@evt.Key" value="@evt.Key" @(Model.Events.Contains(evt.Key) ? "checked" : "") type="checkbox" class="form-check-input" />
|
||||
<label for="@evt.Key" class="form-check-label">@evt.Value</label>
|
||||
<input name="Events" id="@evt.Type" value="@evt.Type" @(Model.Events.Contains(evt.Type) ? "checked" : "") type="checkbox" class="form-check-input" />
|
||||
<label for="@evt.Type" class="form-check-label">@StringLocalizer[evt.Description]</label>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -1,6 +1,5 @@
|
||||
@using BTCPayServer.Abstractions.Models
|
||||
@using BTCPayServer.Client
|
||||
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@model WebhooksViewModel
|
||||
@{
|
||||
ViewData.SetActivePage(StoreNavPages.Webhooks, StringLocalizer["Webhooks"], Context.GetStoreData().Id);
|
||||
@@ -53,7 +52,6 @@
|
||||
</td>
|
||||
<td class="d-block text-break">@wh.Url</td>
|
||||
<td class="actions-col text-md-nowrap" permission="@Policies.CanModifyStoreSettings">
|
||||
<a asp-action="TestWebhook" asp-route-storeId="@Context.GetRouteValue("storeId")" asp-route-webhookId="@wh.Id" text-translate="true">Test</a> -
|
||||
<a asp-action="ModifyWebhook" asp-route-storeId="@Context.GetRouteValue("storeId")" asp-route-webhookId="@wh.Id" text-translate="true">Modify</a> -
|
||||
<a asp-action="DeleteWebhook" asp-route-storeId="@Context.GetRouteValue("storeId")" asp-route-webhookId="@wh.Id" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-confirm-input="DELETE" text-translate="true">Delete</a>
|
||||
</td>
|
||||
@@ -1,6 +1,6 @@
|
||||
using System;
|
||||
|
||||
namespace BTCPayServer.Models.StoreViewModels
|
||||
namespace BTCPayServer.Plugins.Webhooks.Views
|
||||
{
|
||||
public class WebhooksViewModel
|
||||
{
|
||||
6
BTCPayServer/Plugins/Webhooks/Views/_ViewImports.cshtml
Normal file
6
BTCPayServer/Plugins/Webhooks/Views/_ViewImports.cshtml
Normal file
@@ -0,0 +1,6 @@
|
||||
@using BTCPayServer.Plugins.Webhooks
|
||||
@using BTCPayServer.TagHelpers
|
||||
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@using BTCPayServer.Views.Stores
|
||||
@namespace BTCPayServer.Plugins.Webhooks.Views
|
||||
|
||||
3
BTCPayServer/Plugins/Webhooks/Views/_ViewStart.cshtml
Normal file
3
BTCPayServer/Plugins/Webhooks/Views/_ViewStart.cshtml
Normal file
@@ -0,0 +1,3 @@
|
||||
@{
|
||||
Layout = "_Layout";
|
||||
}
|
||||
@@ -1,12 +1,8 @@
|
||||
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 BTCPayServer.Plugins.Webhooks;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
@@ -61,6 +57,8 @@ namespace BTCPayServer.Data
|
||||
public string Secret { get; set; }
|
||||
public bool AutomaticRedelivery { get; set; }
|
||||
public AuthorizedWebhookEvents AuthorizedEvents { get; set; }
|
||||
public bool ShouldDeliver(string type)
|
||||
=> Active && AuthorizedEvents.Match(type);
|
||||
}
|
||||
public static class WebhookDataExtensions
|
||||
{
|
||||
32
BTCPayServer/Plugins/Webhooks/WebhookExtensions.cs
Normal file
32
BTCPayServer/Plugins/Webhooks/WebhookExtensions.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Plugins.Emails.Views;
|
||||
using BTCPayServer.Plugins.Webhooks;
|
||||
using BTCPayServer.Plugins.Webhooks.Views;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace BTCPayServer;
|
||||
|
||||
public static class WebhookExtensions
|
||||
{
|
||||
public static IServiceCollection AddWebhookTriggerProvider<T>(this IServiceCollection services) where T : WebhookTriggerProvider
|
||||
{
|
||||
services.AddSingleton<T>();
|
||||
services.AddSingleton<WebhookTriggerProvider>(o => o.GetRequiredService<T>());
|
||||
return services;
|
||||
}
|
||||
public static IServiceCollection AddWebhookTriggerViewModels(this IServiceCollection services, IEnumerable<EmailTriggerViewModel> viewModels)
|
||||
{
|
||||
foreach(var trigger in viewModels)
|
||||
{
|
||||
var webhookType = trigger.Type;
|
||||
if (trigger.Type.StartsWith("WH-"))
|
||||
throw new ArgumentException("Webhook type cannot start with WH-");
|
||||
trigger.Type = EmailRuleData.GetWebhookTriggerName(trigger.Type);
|
||||
services.AddSingleton(new AvailableWebhookViewModel(webhookType, trigger.Description));
|
||||
services.AddSingleton(trigger);
|
||||
}
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -6,23 +6,19 @@ 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;
|
||||
namespace BTCPayServer.Plugins.Webhooks;
|
||||
|
||||
/// <summary>
|
||||
/// This class sends webhook notifications
|
||||
@@ -32,8 +28,7 @@ public class WebhookSender(
|
||||
StoreRepository storeRepository,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ApplicationDbContextFactory dbContextFactory,
|
||||
ILogger<WebhookSender> logger,
|
||||
IServiceProvider serviceProvider)
|
||||
ILogger<WebhookSender> logger)
|
||||
: IHostedService
|
||||
{
|
||||
public const string OnionNamedClient = "greenfield-webhook.onion";
|
||||
@@ -93,7 +88,7 @@ public class WebhookSender(
|
||||
if (webhookDelivery is null)
|
||||
return null;
|
||||
var oldDeliveryBlob = webhookDelivery.Delivery.GetBlob();
|
||||
var newDelivery = WebhookExtensions.NewWebhookDelivery(webhookDelivery.Webhook.Id);
|
||||
var newDelivery = WebhookDeliveryData.Create(webhookDelivery.Webhook.Id);
|
||||
WebhookDeliveryBlob newDeliveryBlob = new();
|
||||
newDeliveryBlob.Request = oldDeliveryBlob.Request;
|
||||
var webhookEvent = newDeliveryBlob.ReadRequestAs<WebhookEvent>();
|
||||
@@ -110,42 +105,6 @@ public class WebhookSender(
|
||||
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));
|
||||
@@ -160,8 +119,7 @@ public class WebhookSender(
|
||||
return;
|
||||
var result = await SendAndSaveDelivery(ctx, cancellationToken);
|
||||
if (ctx.WebhookBlob.AutomaticRedelivery &&
|
||||
!result.Success &&
|
||||
result.DeliveryId is not null)
|
||||
result is { Success: false, DeliveryId: not null })
|
||||
{
|
||||
var originalDeliveryId = result.DeliveryId;
|
||||
foreach (var wait in new[]
|
||||
@@ -254,89 +212,16 @@ public class WebhookSender(
|
||||
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
|
||||
19
BTCPayServer/Plugins/Webhooks/WebhookTriggerContext.cs
Normal file
19
BTCPayServer/Plugins/Webhooks/WebhookTriggerContext.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
#nullable enable
|
||||
using BTCPayServer.Client.Models;
|
||||
using StoreData = BTCPayServer.Data.StoreData;
|
||||
|
||||
namespace BTCPayServer.Plugins.Webhooks;
|
||||
|
||||
public record WebhookTriggerContext(StoreData Store, object Event, StoreWebhookEvent Webhook)
|
||||
{
|
||||
public object? AdditionalData { get; set; }
|
||||
}
|
||||
|
||||
public record WebhookTriggerContext<T> : WebhookTriggerContext where T : class
|
||||
{
|
||||
public WebhookTriggerContext(StoreData store, T evt, StoreWebhookEvent webhook) : base(store, evt, webhook)
|
||||
{
|
||||
}
|
||||
|
||||
public new T Event => (T)base.Event;
|
||||
}
|
||||
54
BTCPayServer/Plugins/Webhooks/WebhookTriggerProvider.cs
Normal file
54
BTCPayServer/Plugins/Webhooks/WebhookTriggerProvider.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
#nullable enable
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.HostedServices;
|
||||
using BTCPayServer.Plugins.Emails;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using StoreData = BTCPayServer.Data.StoreData;
|
||||
|
||||
namespace BTCPayServer.Plugins.Webhooks;
|
||||
|
||||
public abstract class WebhookTriggerProvider
|
||||
{
|
||||
public abstract StoreWebhookEvent? GetWebhookEvent(object evt);
|
||||
|
||||
public virtual Task<JObject> GetEmailModel(WebhookTriggerContext webhookTriggerContext)
|
||||
{
|
||||
var webhookData = JObject.FromObject(webhookTriggerContext.Webhook, JsonSerializer.Create(WebhookSender.DefaultSerializerSettings));
|
||||
var model = new JObject();
|
||||
model["Webhook"] = webhookData;
|
||||
model["Store"] = new JObject()
|
||||
{
|
||||
["Id"] = webhookTriggerContext.Store.Id,
|
||||
["Name"] = webhookTriggerContext.Store.StoreName
|
||||
};
|
||||
return Task.FromResult(model);
|
||||
}
|
||||
|
||||
public virtual Task BeforeSending(EmailRuleMatchContext context, WebhookTriggerContext webhookTriggerContext)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public virtual WebhookTriggerContext CreateWebhookTriggerContext(StoreData store, object evt, StoreWebhookEvent webhookEvent)
|
||||
=> new (store, evt, webhookEvent);
|
||||
}
|
||||
|
||||
public abstract class WebhookTriggerProvider<T> : WebhookTriggerProvider where T : class
|
||||
{
|
||||
public sealed override StoreWebhookEvent? GetWebhookEvent(object evt)
|
||||
=> evt is T t ? GetWebhookEvent(t) : null;
|
||||
protected abstract StoreWebhookEvent? GetWebhookEvent(T evt);
|
||||
public sealed override Task<JObject> GetEmailModel(WebhookTriggerContext webhookTriggerContext)
|
||||
=> GetEmailModel((WebhookTriggerContext<T>)webhookTriggerContext);
|
||||
protected virtual Task<JObject> GetEmailModel(WebhookTriggerContext<T> webhookTriggerContext)
|
||||
=> base.GetEmailModel(webhookTriggerContext);
|
||||
|
||||
public sealed override Task BeforeSending(EmailRuleMatchContext context, WebhookTriggerContext webhookTriggerContext)
|
||||
=> BeforeSending(context,(WebhookTriggerContext<T>)webhookTriggerContext);
|
||||
|
||||
protected virtual Task BeforeSending(EmailRuleMatchContext context, WebhookTriggerContext<T> webhookTriggerContext)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public override WebhookTriggerContext CreateWebhookTriggerContext(StoreData store, object evt, StoreWebhookEvent webhookEvent)
|
||||
=> new WebhookTriggerContext<T>(store, (T)evt, webhookEvent);
|
||||
}
|
||||
321
BTCPayServer/Plugins/Webhooks/WebhooksPlugin.cs
Normal file
321
BTCPayServer/Plugins/Webhooks/WebhooksPlugin.cs
Normal file
@@ -0,0 +1,321 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using BTCPayServer.Abstractions.Models;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.HostedServices;
|
||||
using BTCPayServer.Plugins.Emails.Views;
|
||||
using BTCPayServer.Plugins.Webhooks.HostedServices;
|
||||
using BTCPayServer.Plugins.Webhooks.TriggerProviders;
|
||||
using BTCPayServer.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace BTCPayServer.Plugins.Webhooks;
|
||||
|
||||
public class WebhooksPlugin : BaseBTCPayServerPlugin
|
||||
{
|
||||
public const string Area = "Webhooks";
|
||||
public override string Identifier => "BTCPayServer.Plugins.Webhooks";
|
||||
public override string Name => "Webhooks";
|
||||
public override string Description => "Allows you to send webhooks";
|
||||
|
||||
public override void Execute(IServiceCollection services)
|
||||
{
|
||||
services.AddHostedService<WebhookProviderHostedService>();
|
||||
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
|
||||
});
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
// Add built in webhooks
|
||||
AddInvoiceWebhooks(services);
|
||||
AddPayoutWebhooks(services);
|
||||
AddPaymentRequestWebhooks(services);
|
||||
AddPendingTransactionWebhooks(services);
|
||||
}
|
||||
|
||||
private static void AddPendingTransactionWebhooks(IServiceCollection services)
|
||||
{
|
||||
services.AddWebhookTriggerProvider<PendingTransactionTriggerProvider>();
|
||||
var pendingTransactionsPlaceholders = new List<EmailTriggerViewModel.PlaceHolder>()
|
||||
{
|
||||
new("{PendingTransaction.Id}", "The id of the pending transaction"),
|
||||
new("{PendingTransaction.TrimmedId}", "The trimmed id of the pending transaction"),
|
||||
new("{PendingTransaction.StoreId}", "The store id of the pending transaction"),
|
||||
new("{PendingTransaction.SignaturesCollected}", "The number of signatures collected"),
|
||||
new("{PendingTransaction.SignaturesNeeded}", "The number of signatures needed"),
|
||||
new("{PendingTransaction.SignaturesTotal}", "The total number of signatures"),
|
||||
new("{PendingTransaction.Link}", "The link to the wallet transaction list")
|
||||
}.AddStoresPlaceHolders();
|
||||
|
||||
var pendingTransactionTriggers = new List<EmailTriggerViewModel>()
|
||||
{
|
||||
new()
|
||||
{
|
||||
Type = PendingTransactionTriggerProvider.PendingTransactionCreated,
|
||||
Description = "Pending Transaction - Created",
|
||||
SubjectExample = "Pending Transaction {PendingTransaction.TrimmedId} Created",
|
||||
BodyExample = "Review the transaction {PendingTransaction.Id} and sign it on: {PendingTransaction.Link}",
|
||||
PlaceHolders = pendingTransactionsPlaceholders
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = PendingTransactionTriggerProvider.PendingTransactionSignatureCollected,
|
||||
Description = "Pending Transaction - Signature Collected",
|
||||
SubjectExample = "Signature Collected for Pending Transaction {PendingTransaction.TrimmedId}",
|
||||
BodyExample = "So far {PendingTransaction.SignaturesCollected} signatures collected out of {PendingTransaction.SignaturesNeeded} signatures needed. ",
|
||||
PlaceHolders = pendingTransactionsPlaceholders
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = PendingTransactionTriggerProvider.PendingTransactionBroadcast,
|
||||
Description = "Pending Transaction - Broadcast",
|
||||
SubjectExample = "Transaction {PendingTransaction.TrimmedId} has been Broadcast",
|
||||
BodyExample = "Transaction is visible in mempool on: https://mempool.space/tx/{PendingTransaction.Id}. ",
|
||||
PlaceHolders = pendingTransactionsPlaceholders
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = PendingTransactionTriggerProvider.PendingTransactionCancelled,
|
||||
Description = "Pending Transaction - Cancelled",
|
||||
SubjectExample = "Pending Transaction {PendingTransaction.TrimmedId} Cancelled",
|
||||
BodyExample = "Transaction {PendingTransaction.Id} is cancelled and signatures are no longer being collected. ",
|
||||
PlaceHolders = pendingTransactionsPlaceholders
|
||||
}
|
||||
};
|
||||
|
||||
services.AddSingleton<IDefaultTranslationProvider, WebhooksTranslationProvider>();
|
||||
services.AddWebhookTriggerViewModels(pendingTransactionTriggers);
|
||||
}
|
||||
|
||||
private static void AddPaymentRequestWebhooks(IServiceCollection services)
|
||||
{
|
||||
services.AddWebhookTriggerProvider<PaymentRequestTriggerProvider>();
|
||||
var paymentRequestPlaceholders = new List<EmailTriggerViewModel.PlaceHolder>()
|
||||
{
|
||||
new("{PaymentRequest.Id}", "The id of the payment request"),
|
||||
new("{PaymentRequest.TrimmedId}", "The trimmed id of the payment request"),
|
||||
new("{PaymentRequest.Amount}", "The amount of the payment request"),
|
||||
new("{PaymentRequest.Currency}", "The currency of the payment request"),
|
||||
new("{PaymentRequest.Title}", "The title of the payment request"),
|
||||
new("{PaymentRequest.Description}", "The description of the payment request"),
|
||||
new("{PaymentRequest.Link}", "The link to the payment request"),
|
||||
new("{PaymentRequest.ReferenceId}", "The reference id of the payment request"),
|
||||
new("{PaymentRequest.Status}", "The status of the payment request"),
|
||||
new("{PaymentRequest.FormResponse}*", "The form response associated with the payment request")
|
||||
}.AddStoresPlaceHolders();
|
||||
|
||||
var paymentRequestTriggers = new List<EmailTriggerViewModel>()
|
||||
{
|
||||
new()
|
||||
{
|
||||
Type = WebhookEventType.PaymentRequestCreated,
|
||||
Description = "Payment Request - Created",
|
||||
SubjectExample = "Payment Request {PaymentRequest.Id} created",
|
||||
BodyExample = "Payment Request {PaymentRequest.Id} ({PaymentRequest.Title}) created.",
|
||||
PlaceHolders = paymentRequestPlaceholders,
|
||||
CanIncludeCustomerEmail = true
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = WebhookEventType.PaymentRequestUpdated,
|
||||
Description = "Payment Request - Updated",
|
||||
SubjectExample = "Payment Request {PaymentRequest.Id} updated",
|
||||
BodyExample = "Payment Request {PaymentRequest.Id} ({PaymentRequest.Title}) updated.",
|
||||
PlaceHolders = paymentRequestPlaceholders,
|
||||
CanIncludeCustomerEmail = true
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = WebhookEventType.PaymentRequestArchived,
|
||||
Description = "Payment Request - Archived",
|
||||
SubjectExample = "Payment Request {PaymentRequest.Id} archived",
|
||||
BodyExample = "Payment Request {PaymentRequest.Id} ({PaymentRequest.Title}) archived.",
|
||||
PlaceHolders = paymentRequestPlaceholders,
|
||||
CanIncludeCustomerEmail = true
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = WebhookEventType.PaymentRequestStatusChanged,
|
||||
Description = "Payment Request - Status Changed",
|
||||
SubjectExample = "Payment Request {PaymentRequest.Id} status changed",
|
||||
BodyExample = "Payment Request {PaymentRequest.Id} ({PaymentRequest.Title}) status changed to {PaymentRequest.Status}.",
|
||||
PlaceHolders = paymentRequestPlaceholders,
|
||||
CanIncludeCustomerEmail = true
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = WebhookEventType.PaymentRequestCompleted,
|
||||
Description = "Payment Request - Completed",
|
||||
SubjectExample = "Payment Request {PaymentRequest.Title} {PaymentRequest.ReferenceId} Completed",
|
||||
BodyExample = "The total of {PaymentRequest.Amount} {PaymentRequest.Currency} has been received and Payment Request {PaymentRequest.Id} is completed.\nReview the payment request: {PaymentRequest.Link}",
|
||||
PlaceHolders = paymentRequestPlaceholders,
|
||||
CanIncludeCustomerEmail = true
|
||||
}
|
||||
};
|
||||
services.AddWebhookTriggerViewModels(paymentRequestTriggers);
|
||||
}
|
||||
|
||||
private static void AddPayoutWebhooks(IServiceCollection services)
|
||||
{
|
||||
services.AddWebhookTriggerProvider<PayoutTriggerProvider>();
|
||||
var payoutPlaceholders = new List<EmailTriggerViewModel.PlaceHolder>()
|
||||
{
|
||||
new("{Payout.Id}", "The id of the payout"),
|
||||
new("{Payout.PullPaymentId}", "The id of the pull payment"),
|
||||
new("{Payout.Destination}", "The destination of the payout"),
|
||||
new("{Payout.State}", "The current state of the payout"),
|
||||
new("{Payout.Metadata}*", "The metadata associated with the payout")
|
||||
}.AddStoresPlaceHolders();
|
||||
var payoutTriggers = new List<EmailTriggerViewModel>()
|
||||
{
|
||||
new()
|
||||
{
|
||||
Type = WebhookEventType.PayoutCreated,
|
||||
Description = "Payout - Created",
|
||||
SubjectExample = "Payout {Payout.Id} created",
|
||||
BodyExample = "Payout {Payout.Id} (Pull Payment Id: {Payout.PullPaymentId}) created.",
|
||||
PlaceHolders = payoutPlaceholders
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = WebhookEventType.PayoutApproved,
|
||||
Description = "Payout - Approved",
|
||||
SubjectExample = "Payout {Payout.Id} approved",
|
||||
BodyExample = "Payout {Payout.Id} (Pull Payment Id: {Payout.PullPaymentId}) approved.",
|
||||
PlaceHolders = payoutPlaceholders
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = WebhookEventType.PayoutUpdated,
|
||||
Description = "Payout - Updated",
|
||||
SubjectExample = "Payout {Payout.Id} updated",
|
||||
BodyExample = "Payout {Payout.Id} (Pull Payment Id: {Payout.PullPaymentId}) updated.",
|
||||
PlaceHolders = payoutPlaceholders
|
||||
}
|
||||
};
|
||||
|
||||
services.AddWebhookTriggerViewModels(payoutTriggers);
|
||||
}
|
||||
|
||||
private static void AddInvoiceWebhooks(IServiceCollection services)
|
||||
{
|
||||
services.AddWebhookTriggerProvider<InvoiceTriggerProvider>();
|
||||
var invoicePlaceholders = new List<EmailTriggerViewModel.PlaceHolder>()
|
||||
{
|
||||
new("{Invoice.Id}", "The id of the invoice"),
|
||||
new("{Invoice.StoreId}", "The id of the store"),
|
||||
new("{Invoice.Price}", "The price of the invoice"),
|
||||
new("{Invoice.Currency}", "The currency of the invoice"),
|
||||
new("{Invoice.Status}", "The current status of the invoice"),
|
||||
new("{Invoice.Link}", "The backend link to the invoice"),
|
||||
new("{Invoice.AdditionalStatus}", "Additional status information of the invoice"),
|
||||
new("{Invoice.OrderId}", "The order id associated with the invoice"),
|
||||
new("{Invoice.Metadata}*", "The metadata associated with the invoice")
|
||||
}.AddStoresPlaceHolders();
|
||||
var emailTriggers = new List<EmailTriggerViewModel>()
|
||||
{
|
||||
new()
|
||||
{
|
||||
Type = WebhookEventType.InvoiceCreated,
|
||||
Description = "Invoice - Created",
|
||||
SubjectExample = "Invoice {Invoice.Id} created",
|
||||
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) created.",
|
||||
PlaceHolders = invoicePlaceholders,
|
||||
CanIncludeCustomerEmail = true
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = WebhookEventType.InvoiceReceivedPayment,
|
||||
Description = "Invoice - Received Payment",
|
||||
SubjectExample = "Invoice {Invoice.Id} received payment",
|
||||
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) received payment.",
|
||||
PlaceHolders = invoicePlaceholders,
|
||||
CanIncludeCustomerEmail = true
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = WebhookEventType.InvoiceProcessing,
|
||||
Description = "Invoice - Is Processing",
|
||||
SubjectExample = "Invoice {Invoice.Id} processing",
|
||||
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) is processing.",
|
||||
PlaceHolders = invoicePlaceholders,
|
||||
CanIncludeCustomerEmail = true
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = WebhookEventType.InvoiceExpired,
|
||||
Description = "Invoice - Expired",
|
||||
SubjectExample = "Invoice {Invoice.Id} expired",
|
||||
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) expired.",
|
||||
PlaceHolders = invoicePlaceholders,
|
||||
CanIncludeCustomerEmail = true
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = WebhookEventType.InvoiceSettled,
|
||||
Description = "Invoice - Is Settled",
|
||||
SubjectExample = "Invoice {Invoice.Id} settled",
|
||||
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) is settled.",
|
||||
PlaceHolders = invoicePlaceholders,
|
||||
CanIncludeCustomerEmail = true
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = WebhookEventType.InvoiceInvalid,
|
||||
Description = "Invoice - Became Invalid",
|
||||
SubjectExample = "Invoice {Invoice.Id} invalid",
|
||||
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) invalid.",
|
||||
PlaceHolders = invoicePlaceholders,
|
||||
CanIncludeCustomerEmail = true
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = WebhookEventType.InvoicePaymentSettled,
|
||||
Description = "Invoice - Payment Settled",
|
||||
SubjectExample = "Invoice {Invoice.Id} payment settled",
|
||||
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) payment settled.",
|
||||
PlaceHolders = invoicePlaceholders,
|
||||
CanIncludeCustomerEmail = true
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = WebhookEventType.InvoiceExpiredPaidPartial,
|
||||
Description = "Invoice - Expired Paid Partial",
|
||||
SubjectExample = "Invoice {Invoice.Id} expired with partial payment",
|
||||
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) expired with partial payment. \nPlease review and take appropriate action: {Invoice.Link}",
|
||||
PlaceHolders = invoicePlaceholders,
|
||||
CanIncludeCustomerEmail = true
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = WebhookEventType.InvoicePaidAfterExpiration,
|
||||
Description = "Invoice - Expired Paid Late",
|
||||
SubjectExample = "Invoice {Invoice.Id} paid after expiration",
|
||||
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) paid after expiration.",
|
||||
PlaceHolders = invoicePlaceholders,
|
||||
CanIncludeCustomerEmail = true
|
||||
}
|
||||
};
|
||||
services.AddWebhookTriggerViewModels(emailTriggers);
|
||||
}
|
||||
}
|
||||
15
BTCPayServer/Plugins/Webhooks/WebhooksTranslationProvider.cs
Normal file
15
BTCPayServer/Plugins/Webhooks/WebhooksTranslationProvider.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
#nullable enable
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Plugins.Webhooks.Views;
|
||||
using BTCPayServer.Services;
|
||||
|
||||
namespace BTCPayServer.Plugins.Webhooks;
|
||||
|
||||
public class WebhooksTranslationProvider(IEnumerable<AvailableWebhookViewModel> viewModels) : IDefaultTranslationProvider
|
||||
{
|
||||
public Task<KeyValuePair<string, string?>[]> GetDefaultTranslations()
|
||||
=> Task.FromResult(viewModels.Select(vm => KeyValuePair.Create(vm.Description, vm.Description)).ToArray())!;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using BTCPayServer.Abstractions;
|
||||
using BTCPayServer.Abstractions.Extensions;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Data;
|
||||
@@ -756,6 +757,8 @@ namespace BTCPayServer.Services.Invoices
|
||||
public decimal NetSettled { get; private set; }
|
||||
[JsonIgnore]
|
||||
public bool DisableAccounting { get; set; }
|
||||
|
||||
public RequestBaseUrl GetRequestBaseUrl() => RequestBaseUrl.FromUrl(ServerUrl);
|
||||
}
|
||||
|
||||
public enum InvoiceStatusLegacy
|
||||
|
||||
@@ -9,12 +9,14 @@ namespace BTCPayServer.Services.Mails
|
||||
{
|
||||
public abstract class EmailSender : IEmailSender
|
||||
{
|
||||
public EventAggregator EventAggregator { get; }
|
||||
public Logs Logs { get; }
|
||||
|
||||
readonly IBackgroundJobClient _JobClient;
|
||||
|
||||
public EmailSender(IBackgroundJobClient jobClient, Logs logs)
|
||||
public EmailSender(IBackgroundJobClient jobClient, EventAggregator eventAggregator, Logs logs)
|
||||
{
|
||||
EventAggregator = eventAggregator;
|
||||
Logs = logs;
|
||||
_JobClient = jobClient ?? throw new ArgumentNullException(nameof(jobClient));
|
||||
}
|
||||
@@ -36,14 +38,13 @@ namespace BTCPayServer.Services.Mails
|
||||
}
|
||||
|
||||
using var smtp = await emailSettings.CreateSmtpClient();
|
||||
var prefixedSubject = await GetPrefixedSubject(subject);
|
||||
var mail = emailSettings.CreateMailMessage(email, cc, bcc, prefixedSubject, message, true);
|
||||
await smtp.SendAsync(mail, cancellationToken);
|
||||
var mail = emailSettings.CreateMailMessage(email, cc, bcc, subject, message, true);
|
||||
var response = await smtp.SendAsync(mail, cancellationToken);
|
||||
await smtp.DisconnectAsync(true, cancellationToken);
|
||||
EventAggregator.Publish(new Events.EmailSentEvent(response, mail));
|
||||
}, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
public abstract Task<EmailSettings?> GetEmailSettings();
|
||||
public abstract Task<string> GetPrefixedSubject(string subject);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,10 +13,12 @@ namespace BTCPayServer.Services.Mails
|
||||
|
||||
private readonly IBackgroundJobClient _jobClient;
|
||||
private readonly SettingsRepository _settingsRepository;
|
||||
private readonly EventAggregator _eventAggregator;
|
||||
private readonly StoreRepository _storeRepository;
|
||||
|
||||
public EmailSenderFactory(IBackgroundJobClient jobClient,
|
||||
SettingsRepository settingsSettingsRepository,
|
||||
EventAggregator eventAggregator,
|
||||
ISettingsAccessor<PoliciesSettings> policiesSettings,
|
||||
StoreRepository storeRepository,
|
||||
Logs logs)
|
||||
@@ -24,18 +26,19 @@ namespace BTCPayServer.Services.Mails
|
||||
Logs = logs;
|
||||
_jobClient = jobClient;
|
||||
_settingsRepository = settingsSettingsRepository;
|
||||
_eventAggregator = eventAggregator;
|
||||
PoliciesSettings = policiesSettings;
|
||||
_storeRepository = storeRepository;
|
||||
}
|
||||
|
||||
public Task<IEmailSender> GetEmailSender(string? storeId = null)
|
||||
{
|
||||
var serverSender = new ServerEmailSender(_settingsRepository, _jobClient, Logs);
|
||||
var serverSender = new ServerEmailSender(_settingsRepository, _jobClient, _eventAggregator, Logs);
|
||||
if (string.IsNullOrEmpty(storeId))
|
||||
return Task.FromResult<IEmailSender>(serverSender);
|
||||
return Task.FromResult<IEmailSender>(new StoreEmailSender(_storeRepository,
|
||||
!PoliciesSettings.Settings.DisableStoresToUseServerEmailSettings ? serverSender : null, _jobClient,
|
||||
storeId, Logs));
|
||||
_eventAggregator, storeId, Logs));
|
||||
}
|
||||
|
||||
public async Task<bool> IsComplete(string? storeId = null)
|
||||
|
||||
@@ -8,7 +8,8 @@ namespace BTCPayServer.Services.Mails
|
||||
{
|
||||
public ServerEmailSender(SettingsRepository settingsRepository,
|
||||
IBackgroundJobClient backgroundJobClient,
|
||||
Logs logs) : base(backgroundJobClient, logs)
|
||||
EventAggregator eventAggregator,
|
||||
Logs logs) : base(backgroundJobClient, eventAggregator, logs)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(settingsRepository);
|
||||
SettingsRepository = settingsRepository;
|
||||
@@ -20,12 +21,5 @@ namespace BTCPayServer.Services.Mails
|
||||
{
|
||||
return SettingsRepository.GetSettingAsync<EmailSettings>();
|
||||
}
|
||||
|
||||
public override async Task<string> GetPrefixedSubject(string subject)
|
||||
{
|
||||
var settings = await SettingsRepository.GetSettingAsync<ServerSettings>();
|
||||
var prefix = string.IsNullOrEmpty(settings?.ServerName) ? "BTCPay Server" : settings.ServerName;
|
||||
return $"{prefix}: {subject}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,9 @@ namespace BTCPayServer.Services.Mails
|
||||
public StoreEmailSender(StoreRepository storeRepository,
|
||||
EmailSender? fallback,
|
||||
IBackgroundJobClient backgroundJobClient,
|
||||
EventAggregator eventAggregator,
|
||||
string storeId,
|
||||
Logs logs) : base(backgroundJobClient, logs)
|
||||
Logs logs) : base(backgroundJobClient, eventAggregator, logs)
|
||||
{
|
||||
StoreId = storeId ?? throw new ArgumentNullException(nameof(storeId));
|
||||
StoreRepository = storeRepository;
|
||||
@@ -44,7 +45,7 @@ namespace BTCPayServer.Services.Mails
|
||||
return GetCustomSettings(store);
|
||||
}
|
||||
EmailSettings? GetCustomSettings(StoreData store)
|
||||
{
|
||||
{
|
||||
var emailSettings = store.GetStoreBlob().EmailSettings;
|
||||
if (emailSettings?.IsComplete() is true)
|
||||
{
|
||||
@@ -52,11 +53,5 @@ namespace BTCPayServer.Services.Mails
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public override async Task<string> GetPrefixedSubject(string subject)
|
||||
{
|
||||
var store = await StoreRepository.FindStore(StoreId);
|
||||
return string.IsNullOrEmpty(store?.StoreName) ? subject : $"{store.StoreName}: {subject}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Abstractions;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Services.Invoices;
|
||||
@@ -21,14 +22,14 @@ namespace BTCPayServer.Services.PaymentRequests
|
||||
public PaymentRequestData Data { get; set; }
|
||||
public string Type { get; set; }
|
||||
}
|
||||
|
||||
|
||||
public class PaymentRequestRepository
|
||||
{
|
||||
private readonly ApplicationDbContextFactory _ContextFactory;
|
||||
private readonly InvoiceRepository _InvoiceRepository;
|
||||
private readonly EventAggregator _eventAggregator;
|
||||
|
||||
public PaymentRequestRepository(ApplicationDbContextFactory contextFactory,
|
||||
public PaymentRequestRepository(ApplicationDbContextFactory contextFactory,
|
||||
InvoiceRepository invoiceRepository, EventAggregator eventAggregator)
|
||||
{
|
||||
_ContextFactory = contextFactory;
|
||||
@@ -68,7 +69,7 @@ namespace BTCPayServer.Services.PaymentRequests
|
||||
return null;
|
||||
if(pr.Archived && !toggle)
|
||||
return pr.Archived;
|
||||
pr.Archived = !pr.Archived;
|
||||
pr.Archived = !pr.Archived;
|
||||
await context.SaveChangesAsync();
|
||||
if (pr.Archived)
|
||||
{
|
||||
@@ -78,7 +79,7 @@ namespace BTCPayServer.Services.PaymentRequests
|
||||
Type = PaymentRequestEvent.Archived
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
return pr.Archived;
|
||||
}
|
||||
|
||||
@@ -105,9 +106,9 @@ namespace BTCPayServer.Services.PaymentRequests
|
||||
if (paymentRequestData == null || paymentRequestData.Status == status)
|
||||
return;
|
||||
paymentRequestData.Status = status;
|
||||
|
||||
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
|
||||
_eventAggregator.Publish(new PaymentRequestEvent()
|
||||
{
|
||||
Data = paymentRequestData,
|
||||
@@ -127,9 +128,9 @@ namespace BTCPayServer.Services.PaymentRequests
|
||||
{
|
||||
using var context = _ContextFactory.CreateContext();
|
||||
var queryable = context.PaymentRequests.Include(data => data.StoreData).AsQueryable();
|
||||
queryable =
|
||||
queryable =
|
||||
queryable
|
||||
.Where(data =>
|
||||
.Where(data =>
|
||||
(data.Status == Client.Models.PaymentRequestStatus.Pending || data.Status == Client.Models.PaymentRequestStatus.Processing) &&
|
||||
data.Expiry != null);
|
||||
return await queryable.ToArrayAsync(cancellationToken);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
@using BTCPayServer.Plugins.Emails
|
||||
<div class="alert alert-warning alert-dismissible">
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="@StringLocalizer["Close"]">
|
||||
<vc:icon symbol="close" />
|
||||
</button>
|
||||
<span text-translate="true">The Email settings have not been configured on this server or store yet. Setting this field will not send emails until then.</span>
|
||||
<a asp-action="StoreEmailSettings" asp-controller="UIStores" asp-route-storeId="@Model" class="alert-link" text-translate="true">Configure store email settings</a>
|
||||
<a asp-area="@EmailsPlugin.Area" asp-action="StoreEmailSettings" asp-controller="UIStoresEmail" asp-route-storeId="@Model" class="alert-link" text-translate="true">Configure store email settings</a>
|
||||
</div>
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
@using BTCPayServer.Client
|
||||
@using BTCPayServer.Controllers
|
||||
@using BTCPayServer.Forms
|
||||
@using BTCPayServer.Plugins.Emails
|
||||
@using BTCPayServer.TagHelpers
|
||||
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@inject FormDataService FormDataService
|
||||
@inject LinkGenerator LinkGenerator
|
||||
@model BTCPayServer.Models.PaymentRequestViewModels.UpdatePaymentRequestViewModel
|
||||
@{
|
||||
var checkoutFormOptions = await FormDataService.GetSelect(Model.StoreId, Model.FormId);
|
||||
@@ -45,7 +47,7 @@
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<partial name="_StatusMessage" />
|
||||
|
||||
<div class="row">
|
||||
@@ -105,8 +107,8 @@
|
||||
<input type="email" asp-for="Email" placeholder="@StringLocalizer["Firstname Lastname <email@example.com>"]" class="form-control" />
|
||||
<span asp-validation-for="Email" class="text-danger"></span>
|
||||
<div id="PaymentRequestEmailHelpBlock" class="form-text">
|
||||
@ViewLocalizer["This will send notification mails to the recipient, as configured by the {0}.",
|
||||
Html.ActionLink(StringLocalizer["email rules"], nameof(UIStoresController.StoreEmailRulesList), "UIStores", new { storeId = Model.StoreId })]
|
||||
@ViewLocalizer["This will send notification mails to the recipient, as configured by the <a href=\"{0}\">email rules</a>.",
|
||||
LinkGenerator.GetStoreEmailRulesLink(Model.StoreId, Context.Request.GetRequestBaseUrl())]
|
||||
@if (Model.HasEmailRules is not true)
|
||||
{
|
||||
<div class="info-note mt-1 text-warning" role="alert">
|
||||
@@ -121,7 +123,7 @@
|
||||
<select asp-for="FormId" class="form-select w-auto" asp-items="@checkoutFormOptions"></select>
|
||||
<span asp-validation-for="FormId" class="text-danger"></span>
|
||||
</div>
|
||||
|
||||
|
||||
@if (Model.FormResponse is not null)
|
||||
{
|
||||
<div class="bg-tile rounded py-2 px-3 mb-5">
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
<form method="post" autocomplete="off">
|
||||
<div class="sticky-header">
|
||||
<h2>@ViewData["Title"]</h2>
|
||||
<button id="page-primary" type="submit" class="btn btn-primary" name="command" value="Save">Save</button>
|
||||
<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" />
|
||||
<div class="form-group mb-4">
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
@using BTCPayServer.Abstractions.TagHelpers
|
||||
@using BTCPayServer.Client.Models
|
||||
@using BTCPayServer.HostedServices.Webhooks
|
||||
@using BTCPayServer.Services
|
||||
@using BTCPayServer.TagHelpers
|
||||
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@model BTCPayServer.Controllers.UIStoresController.StoreEmailRule
|
||||
@inject WebhookSender WebhookSender
|
||||
@inject CallbackGenerator CallbackGenerator
|
||||
|
||||
@{
|
||||
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 asp-action="@(isEdit ? "StoreEmailRulesEdit" : "StoreEmailRulesCreate")" asp-route-storeId="@storeId" method="post">
|
||||
<div class="sticky-header">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item">
|
||||
<a 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="SaveEmailRules" type="submit" class="btn btn-primary">Save</button>
|
||||
<a asp-action="StoreEmailRulesList" asp-route-storeId="@storeId" class="btn btn-secondary">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="@WebhookSender.GetSupportedWebhookTypes()
|
||||
.OrderBy(a=>a.Value).Select(s => new SelectListItem(s.Value, s.Key))"
|
||||
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="To" class="form-label">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="CustomerEmail" type="checkbox" class="form-check-input email-rule-customer-email customer-email-checkbox" />
|
||||
<label asp-for="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="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">
|
||||
<table class="table table-sm caption-top m-0">
|
||||
<caption class="text-muted p-0" text-translate="true">Placeholders</caption>
|
||||
<tr>
|
||||
<th text-translate="true">Invoice</th>
|
||||
<td>
|
||||
<code>{Invoice.Id}</code>,
|
||||
<code>{Invoice.StoreId}</code>,
|
||||
<code>{Invoice.Price}</code>,
|
||||
<code>{Invoice.Currency}</code>,
|
||||
<code>{Invoice.Status}</code>,
|
||||
<code>{Invoice.AdditionalStatus}</code>,
|
||||
<code>{Invoice.OrderId}</code>
|
||||
<code>{Invoice.Metadata}*</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th text-translate="true">Request</th>
|
||||
<td>
|
||||
<code>{PaymentRequest.Id}</code>,
|
||||
<code>{PaymentRequest.TrimmedId}</code>,
|
||||
<code>{PaymentRequest.Amount}</code>,
|
||||
<code>{PaymentRequest.Currency}</code>,
|
||||
<code>{PaymentRequest.Title}</code>,
|
||||
<code>{PaymentRequest.Description}</code>,
|
||||
<code>{PaymentRequest.ReferenceId}</code>,
|
||||
<code>{PaymentRequest.Status}</code>
|
||||
<code>{PaymentRequest.FormResponse}*</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th text-translate="true">Payout</th>
|
||||
<td>
|
||||
<code>{Payout.Id}</code>,
|
||||
<code>{Payout.PullPaymentId}</code>,
|
||||
<code>{Payout.Destination}</code>,
|
||||
<code>{Payout.State}</code>
|
||||
<code>{Payout.Metadata}*</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th text-translate="true">Pending Transaction</th>
|
||||
<td>
|
||||
<code>{PendingTransaction.Id}</code>,
|
||||
<code>{PendingTransaction.TrimmedId}</code>,
|
||||
<code>{PendingTransaction.StoreId}</code>,
|
||||
<code>{PendingTransaction.SignaturesCollected}</code>,
|
||||
<code>{PendingTransaction.SignaturesNeeded}</code>,
|
||||
<code>{PendingTransaction.SignaturesTotal}</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr><td colspan="2">* 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></td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
@section PageFootContent {
|
||||
<partial name="_ValidationScriptsPartial" />
|
||||
<script src="~/vendor/summernote/summernote-bs5.js" asp-append-version="true"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const templates = {
|
||||
@WebhookEventType.InvoiceCreated: {
|
||||
subject: 'Invoice {Invoice.Id} created',
|
||||
body: 'Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) created.'
|
||||
},
|
||||
@WebhookEventType.InvoiceReceivedPayment: {
|
||||
subject: 'Invoice {Invoice.Id} received payment',
|
||||
body: 'Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) received payment.'
|
||||
},
|
||||
@WebhookEventType.InvoiceProcessing: {
|
||||
subject: 'Invoice {Invoice.Id} processing',
|
||||
body: 'Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) is processing.'
|
||||
},
|
||||
@WebhookEventType.InvoiceExpired: {
|
||||
subject: 'Invoice {Invoice.Id} expired',
|
||||
body: 'Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) expired.'
|
||||
},
|
||||
@WebhookEventType.InvoiceSettled: {
|
||||
subject: 'Invoice {Invoice.Id} settled',
|
||||
body: 'Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) is settled.'
|
||||
},
|
||||
@WebhookEventType.InvoiceInvalid: {
|
||||
subject: 'Invoice {Invoice.Id} invalid',
|
||||
body: 'Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) invalid.'
|
||||
},
|
||||
@WebhookEventType.InvoicePaymentSettled: {
|
||||
subject: 'Invoice {Invoice.Id} payment settled',
|
||||
body: 'Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) payment settled.'
|
||||
},
|
||||
@{ var invoiceLink = CallbackGenerator.InvoiceLink("INVOICE_ID", this.Context.Request).Replace("INVOICE_ID", ""); }
|
||||
@WebhookEventType.InvoiceExpiredPaidPartial: {
|
||||
subject: 'Invoice {Invoice.Id} Expired Paid Partial',
|
||||
body: 'Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) has expired partially paid. '+
|
||||
'\nPlease review and take appropriate action: ' + @Safe.Json(invoiceLink) + '{Invoice.Id}'
|
||||
},
|
||||
@WebhookEventType.InvoicePaidAfterExpiration: {
|
||||
subject: 'Invoice {Invoice.Id} Expired Paid Late',
|
||||
body: 'Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) has been paid after expiration. '+
|
||||
'\nPlease review and take appropriate action: ' + @Safe.Json(invoiceLink) + '{Invoice.Id}'
|
||||
},
|
||||
@{ var paymentRequestsLink = CallbackGenerator.PaymentRequestListLink(storeId, this.Context.Request); }
|
||||
@WebhookEventType.PaymentRequestCompleted: {
|
||||
subject: 'Payment Request {PaymentRequest.Title} {PaymentRequest.ReferenceId} Completed',
|
||||
body: 'The total of {PaymentRequest.Amount} {PaymentRequest.Currency} has been received and Payment Request {PaymentRequest.Id} is completed. ' +
|
||||
'\nReview the payment requests: ' + @Safe.Json(paymentRequestsLink)
|
||||
},
|
||||
@{ var bitcoinWalletTransactions = CallbackGenerator.WalletTransactionsLink(new (storeId, "BTC"), this.Context.Request); }
|
||||
@PendingTransactionWebhookProvider.PendingTransactionCreated : {
|
||||
subject: 'Pending Transaction {PendingTransaction.TrimmedId} Created',
|
||||
body: 'Review the transaction {PendingTransaction.Id} and sign it on: ' + @Safe.Json(bitcoinWalletTransactions)
|
||||
},
|
||||
@PendingTransactionWebhookProvider.PendingTransactionSignatureCollected : {
|
||||
subject: 'Signature Collected for Pending Transaction {PendingTransaction.TrimmedId}',
|
||||
body: 'So far {PendingTransaction.SignaturesCollected} signatures collected out of {PendingTransaction.SignaturesNeeded} signatures needed. ' +
|
||||
'Review the transaction and sign it on: ' + @Safe.Json(bitcoinWalletTransactions)
|
||||
},
|
||||
@PendingTransactionWebhookProvider.PendingTransactionBroadcast : {
|
||||
subject: 'Transaction {PendingTransaction.TrimmedId} has been Broadcast',
|
||||
body: 'Transaction is visible in mempool on: https://mempool.space/tx/{PendingTransaction.Id}. ' +
|
||||
'Review the transaction: ' + @Safe.Json(bitcoinWalletTransactions)
|
||||
},
|
||||
@PendingTransactionWebhookProvider.PendingTransactionCancelled : {
|
||||
subject: 'Pending Transaction {PendingTransaction.TrimmedId} Cancelled',
|
||||
body: 'Transaction {PendingTransaction.Id} is cancelled and signatures are no longer being collected. ' +
|
||||
'Review the wallet: ' + @Safe.Json(bitcoinWalletTransactions)
|
||||
},
|
||||
};
|
||||
|
||||
const triggerSelect = document.querySelector('.email-rule-trigger');
|
||||
const subjectInput = document.querySelector('.email-rule-subject');
|
||||
const bodyTextarea = document.querySelector('.email-rule-body');
|
||||
|
||||
const isEmptyOrDefault = (value, type) => {
|
||||
const val = value.replace(/<.*?>/gi, '').trim();
|
||||
if (!val) return true;
|
||||
return Object.values(templates).some(t => t[type] === val);
|
||||
};
|
||||
|
||||
function applyTemplate() {
|
||||
const selectedTrigger = triggerSelect.value;
|
||||
if (templates[selectedTrigger]) {
|
||||
if (isEmptyOrDefault(subjectInput.value, 'subject')) {
|
||||
subjectInput.value = templates[selectedTrigger].subject;
|
||||
}
|
||||
if (isEmptyOrDefault(bodyTextarea.value, 'body')) {
|
||||
if ($(bodyTextarea).summernote) {
|
||||
$(bodyTextarea).summernote('reset');
|
||||
$(bodyTextarea).summernote('insertText', templates[selectedTrigger].body);
|
||||
} else {
|
||||
bodyTextarea.value = templates[selectedTrigger].body;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCustomerEmailVisibility() {
|
||||
const customerEmailContainer = document.querySelector('.customer-email-container');
|
||||
const customerEmailCheckbox = document.querySelector('.customer-email-checkbox');
|
||||
const selectedTrigger = triggerSelect.value;
|
||||
const supportsCustomerEmailDict = @Safe.Json(WebhookSender.GetWebhookTypesSupportedByCustomerEmail());
|
||||
|
||||
if (supportsCustomerEmailDict[selectedTrigger]) {
|
||||
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>
|
||||
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
@using BTCPayServer.Client
|
||||
@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>
|
||||
<button id="page-primary" type="submit" class="btn btn-primary" name="command" value="Save">Save</button>
|
||||
</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-controller="UIStores" asp-action="StoreEmailRulesList" asp-route-storeId="@storeId"
|
||||
permission="@Policies.CanViewStoreSettings" text-translate="true">
|
||||
Configure
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@section PageFootContent {
|
||||
<partial name="_ValidationScriptsPartial" />
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user