mirror of
https://github.com/aljazceru/btcpayserver.git
synced 2025-12-18 06:24:24 +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<FormData> Forms { get; set; }
|
||||||
public DbSet<PendingTransaction> PendingTransactions { get; set; }
|
public DbSet<PendingTransaction> PendingTransactions { get; set; }
|
||||||
|
|
||||||
|
public DbSet<EmailRuleData> EmailRules { get; set; }
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder builder)
|
protected override void OnModelCreating(ModelBuilder builder)
|
||||||
{
|
{
|
||||||
base.OnModelCreating(builder);
|
base.OnModelCreating(builder);
|
||||||
|
|
||||||
// some of the data models don't have OnModelCreating for now, commenting them
|
// some of the data models don't have OnModelCreating for now, commenting them
|
||||||
|
EmailRuleData.OnModelCreating(builder, Database);
|
||||||
ApplicationUser.OnModelCreating(builder, Database);
|
ApplicationUser.OnModelCreating(builder, Database);
|
||||||
AddressInvoiceData.OnModelCreating(builder);
|
AddressInvoiceData.OnModelCreating(builder);
|
||||||
APIKeyData.OnModelCreating(builder, Database);
|
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;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using BTCPayServer.Abstractions;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
namespace BTCPayServer.Data;
|
namespace BTCPayServer.Data;
|
||||||
|
|
||||||
@@ -56,6 +59,7 @@ public class PendingTransaction: IHasBlob<PendingTransactionBlob>
|
|||||||
public class PendingTransactionBlob
|
public class PendingTransactionBlob
|
||||||
{
|
{
|
||||||
public string PSBT { get; set; }
|
public string PSBT { get; set; }
|
||||||
|
public string RequestBaseUrl { get; set; }
|
||||||
public List<CollectedSignature> CollectedSignatures { get; set; } = new();
|
public List<CollectedSignature> CollectedSignatures { get; set; } = new();
|
||||||
|
|
||||||
public int? SignaturesCollected { get; set; }
|
public int? SignaturesCollected { get; set; }
|
||||||
|
|||||||
@@ -2,11 +2,15 @@ using System;
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using NBitcoin;
|
||||||
|
using NBitcoin.DataEncoders;
|
||||||
|
|
||||||
namespace BTCPayServer.Data
|
namespace BTCPayServer.Data
|
||||||
{
|
{
|
||||||
public class WebhookDeliveryData
|
public class WebhookDeliveryData
|
||||||
{
|
{
|
||||||
|
public static WebhookDeliveryData Create(string webhookId)
|
||||||
|
=> new WebhookDeliveryData { Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(16)), Timestamp = DateTimeOffset.UtcNow, WebhookId = webhookId };
|
||||||
[Key]
|
[Key]
|
||||||
[MaxLength(25)]
|
[MaxLength(25)]
|
||||||
public string Id { get; set; }
|
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);
|
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 =>
|
modelBuilder.Entity("BTCPayServer.Data.Fido2Credential", b =>
|
||||||
{
|
{
|
||||||
b.Property<string>("Id")
|
b.Property<string>("Id")
|
||||||
@@ -1240,6 +1303,16 @@ namespace BTCPayServer.Migrations
|
|||||||
b.Navigation("StoreData");
|
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 =>
|
modelBuilder.Entity("BTCPayServer.Data.Fido2Credential", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("BTCPayServer.Data.ApplicationUser", "ApplicationUser")
|
b.HasOne("BTCPayServer.Data.ApplicationUser", "ApplicationUser")
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ using BTCPayServer.PayoutProcessors;
|
|||||||
using BTCPayServer.PayoutProcessors.Lightning;
|
using BTCPayServer.PayoutProcessors.Lightning;
|
||||||
using BTCPayServer.Plugins.PointOfSale.Controllers;
|
using BTCPayServer.Plugins.PointOfSale.Controllers;
|
||||||
using BTCPayServer.Plugins.PointOfSale.Models;
|
using BTCPayServer.Plugins.PointOfSale.Models;
|
||||||
|
using BTCPayServer.Plugins.Webhooks.HostedServices;
|
||||||
using BTCPayServer.Rating;
|
using BTCPayServer.Rating;
|
||||||
using BTCPayServer.Services;
|
using BTCPayServer.Services;
|
||||||
using BTCPayServer.Services.Apps;
|
using BTCPayServer.Services.Apps;
|
||||||
@@ -1957,7 +1958,7 @@ namespace BTCPayServer.Tests
|
|||||||
|
|
||||||
|
|
||||||
TestLogs.LogInformation("Can prune deliveries");
|
TestLogs.LogInformation("Can prune deliveries");
|
||||||
var cleanup = tester.PayTester.GetService<HostedServices.CleanupWebhookDeliveriesTask>();
|
var cleanup = tester.PayTester.GetService<CleanupWebhookDeliveriesTask>();
|
||||||
cleanup.BatchSize = 1;
|
cleanup.BatchSize = 1;
|
||||||
cleanup.PruneAfter = TimeSpan.Zero;
|
cleanup.PruneAfter = TimeSpan.Zero;
|
||||||
await cleanup.Do(default);
|
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.Invoices;
|
||||||
using BTCPayServer.Services.Rates;
|
using BTCPayServer.Services.Rates;
|
||||||
using BTCPayServer.Services.Wallets;
|
using BTCPayServer.Services.Wallets;
|
||||||
|
using BTCPayServer.Tests.PMO;
|
||||||
using BTCPayServer.Views.Manage;
|
using BTCPayServer.Views.Manage;
|
||||||
using BTCPayServer.Views.Server;
|
using BTCPayServer.Views.Server;
|
||||||
using BTCPayServer.Views.Stores;
|
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());
|
Assert.DoesNotContain("You need to configure email settings before this feature works", await s.Page.ContentAsync());
|
||||||
|
|
||||||
await s.Page.ClickAsync("#CreateEmailRule");
|
await s.Page.ClickAsync("#CreateEmailRule");
|
||||||
await s.Page.Locator("#Trigger").SelectOptionAsync(new[] { "InvoicePaymentSettled" });
|
var pmo = new EmailRulePMO(s);
|
||||||
await s.Page.FillAsync("#To", "test@gmail.com");
|
await pmo.Fill(new()
|
||||||
await s.Page.ClickAsync("#CustomerEmail");
|
{
|
||||||
await s.Page.FillAsync("#Subject", "Thanks!");
|
Trigger = "WH-InvoicePaymentSettled",
|
||||||
await s.Page.Locator(".note-editable").FillAsync("Your invoice is settled");
|
To = "test@gmail.com",
|
||||||
await s.Page.ClickAsync("#SaveEmailRules");
|
CustomerEmail = true,
|
||||||
|
Subject = "Thanks!",
|
||||||
|
Body = "Your invoice is settled"
|
||||||
|
});
|
||||||
|
|
||||||
await s.FindAlertMessage();
|
await s.FindAlertMessage();
|
||||||
// we now have a rule
|
// we now have a rule
|
||||||
Assert.DoesNotContain("There are no rules yet.", await s.Page.ContentAsync());
|
Assert.DoesNotContain("There are no rules yet.", await s.Page.ContentAsync());
|
||||||
@@ -1404,34 +1409,55 @@ namespace BTCPayServer.Tests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task CanSetupEmailRules()
|
public async Task CanSetupEmailRules()
|
||||||
{
|
{
|
||||||
await using var s = CreatePlaywrightTester();
|
await using var s = CreatePlaywrightTester(newDb: true);
|
||||||
await s.StartAsync();
|
await s.StartAsync();
|
||||||
await s.RegisterNewUser(true);
|
await s.RegisterNewUser(true);
|
||||||
await s.CreateNewStore();
|
var (storeName, _) = await s.CreateNewStore();
|
||||||
|
|
||||||
await s.GoToStore(StoreNavPages.Emails);
|
await s.GoToStore(StoreNavPages.Emails);
|
||||||
await s.Page.ClickAsync("#ConfigureEmailRules");
|
await s.Page.ClickAsync("#ConfigureEmailRules");
|
||||||
Assert.Contains("There are no rules yet.", await s.Page.ContentAsync());
|
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());
|
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.ClickAsync("#CreateEmailRule");
|
||||||
await s.Page.SelectOptionAsync("#Trigger", "InvoiceCreated");
|
|
||||||
await s.Page.FillAsync("#To", "invoicecreated@gmail.com");
|
await pmo.Fill(new() {
|
||||||
await s.Page.ClickAsync("#CustomerEmail");
|
Trigger = "WH-InvoiceCreated",
|
||||||
await s.Page.ClickAsync("#SaveEmailRules");
|
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();
|
await s.FindAlertMessage();
|
||||||
Assert.DoesNotContain("There are no rules yet.", await s.Page.ContentAsync());
|
var page = await s.Page.ContentAsync();
|
||||||
Assert.Contains("invoicecreated@gmail.com", await s.Page.ContentAsync());
|
Assert.DoesNotContain("There are no rules yet.", page);
|
||||||
Assert.Contains("Invoice {Invoice.Id} created", await s.Page.ContentAsync());
|
Assert.Contains("invoicecreated@gmail.com", page);
|
||||||
Assert.Contains("Yes", await s.Page.ContentAsync());
|
Assert.Contains("Invoice Created in {Invoice.Currency}!", page);
|
||||||
|
Assert.Contains("Yes", page);
|
||||||
|
|
||||||
await s.Page.ClickAsync("#CreateEmailRule");
|
await s.Page.ClickAsync("#CreateEmailRule");
|
||||||
await s.Page.SelectOptionAsync("#Trigger", "PaymentRequestStatusChanged");
|
|
||||||
await s.Page.FillAsync("#To", "statuschanged@gmail.com");
|
await pmo.Fill(new() {
|
||||||
await s.Page.FillAsync("#Subject", "Status changed!");
|
Trigger = "WH-PaymentRequestStatusChanged",
|
||||||
await s.Page.Locator(".note-editable").FillAsync("Your Payment Request Status is Changed");
|
To = "statuschanged@gmail.com",
|
||||||
await s.Page.ClickAsync("#SaveEmailRules");
|
Subject = "Status changed!",
|
||||||
|
Body = "Your Payment Request Status is Changed"
|
||||||
|
});
|
||||||
|
|
||||||
await s.FindAlertMessage();
|
await s.FindAlertMessage();
|
||||||
Assert.Contains("statuschanged@gmail.com", await s.Page.ContentAsync());
|
Assert.Contains("statuschanged@gmail.com", await s.Page.ContentAsync());
|
||||||
@@ -1441,14 +1467,23 @@ namespace BTCPayServer.Tests
|
|||||||
Assert.True(await editButtons.CountAsync() >= 2);
|
Assert.True(await editButtons.CountAsync() >= 2);
|
||||||
await editButtons.Nth(1).ClickAsync();
|
await editButtons.Nth(1).ClickAsync();
|
||||||
|
|
||||||
await s.Page.Locator("#To").ClearAsync();
|
await pmo.Fill(new() {
|
||||||
await s.Page.FillAsync("#To", "changedagain@gmail.com");
|
To = "changedagain@gmail.com"
|
||||||
await s.Page.ClickAsync("#SaveEmailRules");
|
});
|
||||||
|
|
||||||
await s.FindAlertMessage();
|
await s.FindAlertMessage();
|
||||||
Assert.Contains("changedagain@gmail.com", await s.Page.ContentAsync());
|
Assert.Contains("changedagain@gmail.com", await s.Page.ContentAsync());
|
||||||
Assert.DoesNotContain("statuschanged@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" });
|
var deleteLinks = s.Page.GetByRole(AriaRole.Link, new() { Name = "Remove" });
|
||||||
Assert.Equal(2, await deleteLinks.CountAsync());
|
Assert.Equal(2, await deleteLinks.CountAsync());
|
||||||
|
|
||||||
@@ -1466,6 +1501,24 @@ namespace BTCPayServer.Tests
|
|||||||
|
|
||||||
await s.FindAlertMessage();
|
await s.FindAlertMessage();
|
||||||
Assert.Contains("There are no rules yet.", await s.Page.ContentAsync());
|
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]
|
[Fact]
|
||||||
|
|||||||
@@ -20,12 +20,14 @@ using NBitpayClient;
|
|||||||
using NBXplorer;
|
using NBXplorer;
|
||||||
using BTCPayServer.Abstractions.Contracts;
|
using BTCPayServer.Abstractions.Contracts;
|
||||||
using System.Diagnostics.Metrics;
|
using System.Diagnostics.Metrics;
|
||||||
|
using BTCPayServer.Events;
|
||||||
|
|
||||||
namespace BTCPayServer.Tests
|
namespace BTCPayServer.Tests
|
||||||
{
|
{
|
||||||
public class ServerTester : IDisposable
|
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 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>();
|
public List<IDisposable> Resources = new List<IDisposable>();
|
||||||
readonly string _Directory;
|
readonly string _Directory;
|
||||||
|
|
||||||
@@ -43,6 +45,11 @@ namespace BTCPayServer.Tests
|
|||||||
if (!Directory.Exists(_Directory))
|
if (!Directory.Exists(_Directory))
|
||||||
Directory.CreateDirectory(_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;
|
_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 = new RPCClient(RPCCredentialString.Parse(GetEnvironment("TESTS_BTCRPCCONNECTION", "server=http://127.0.0.1:43782;ceiwHEbqWI83:DwubwWsoo3")), NetworkProvider.GetNetwork<BTCPayNetwork>("BTC").NBitcoinNetwork);
|
||||||
ExplorerNode.ScanRPCCapabilities();
|
ExplorerNode.ScanRPCCapabilities();
|
||||||
@@ -286,5 +293,19 @@ namespace BTCPayServer.Tests
|
|||||||
cryptoCode == "LTC" ? NetworkProvider.GetNetwork<BTCPayNetwork>("LTC") :
|
cryptoCode == "LTC" ? NetworkProvider.GetNetwork<BTCPayNetwork>("LTC") :
|
||||||
cryptoCode == "LBTC" ? NetworkProvider.GetNetwork<BTCPayNetwork>("LBTC") :
|
cryptoCode == "LBTC" ? NetworkProvider.GetNetwork<BTCPayNetwork>("LBTC") :
|
||||||
throw new NotSupportedException();
|
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 RatesViewModel = BTCPayServer.Models.StoreViewModels.RatesViewModel;
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
using PosViewType = BTCPayServer.Client.Models.PosViewType;
|
using PosViewType = BTCPayServer.Client.Models.PosViewType;
|
||||||
using BTCPayServer.PaymentRequest;
|
using BTCPayServer.Plugins.Emails.Controllers;
|
||||||
using BTCPayServer.Views.Stores;
|
using BTCPayServer.Views.Stores;
|
||||||
|
using MimeKit;
|
||||||
using NBXplorer.DerivationStrategy;
|
using NBXplorer.DerivationStrategy;
|
||||||
|
|
||||||
namespace BTCPayServer.Tests
|
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]
|
[Fact]
|
||||||
[Trait("Integration", "Integration")]
|
[Trait("Integration", "Integration")]
|
||||||
public async Task CanUseDefaultCurrency()
|
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.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.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",
|
From = "store@store.com",
|
||||||
Login = "store@store.com",
|
Login = "store@store.com",
|
||||||
Password = "store@store.com",
|
Password = "store@store.com",
|
||||||
Port = 1234,
|
Port = tester.MailPitSettings.SmtpPort,
|
||||||
Server = "store.com"
|
Server = tester.MailPitSettings.Hostname
|
||||||
}), ""));
|
}), ""));
|
||||||
|
|
||||||
Assert.Equal("store@store.com", (await Assert.IsType<StoreEmailSender>(await emailSenderFactory.GetEmailSender(acc.StoreId)).GetEmailSettings()).Login);
|
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)]
|
[Fact(Timeout = TestUtils.TestTimeout)]
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ services:
|
|||||||
TESTS_LTCNBXPLORERURL: http://nbxplorer:32838/
|
TESTS_LTCNBXPLORERURL: http://nbxplorer:32838/
|
||||||
TESTS_POSTGRES: User ID=postgres;Include Error Detail=true;Host=postgres;Port=5432;Database=btcpayserver
|
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_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_HOSTNAME: tests
|
||||||
TESTS_RUN_EXTERNAL_INTEGRATION: ${TESTS_RUN_EXTERNAL_INTEGRATION:-"false"}
|
TESTS_RUN_EXTERNAL_INTEGRATION: ${TESTS_RUN_EXTERNAL_INTEGRATION:-"false"}
|
||||||
TESTS_AzureBlobStorageConnectionString: ${TESTS_AzureBlobStorageConnectionString:-none}
|
TESTS_AzureBlobStorageConnectionString: ${TESTS_AzureBlobStorageConnectionString:-none}
|
||||||
@@ -60,6 +63,7 @@ services:
|
|||||||
- merchant_lnd
|
- merchant_lnd
|
||||||
- sshd
|
- sshd
|
||||||
- tor
|
- tor
|
||||||
|
- mailpit
|
||||||
|
|
||||||
sshd:
|
sshd:
|
||||||
build:
|
build:
|
||||||
@@ -98,6 +102,17 @@ services:
|
|||||||
default:
|
default:
|
||||||
custom:
|
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:
|
nbxplorer:
|
||||||
image: nicolasdorier/nbxplorer:2.5.29
|
image: nicolasdorier/nbxplorer:2.5.29
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ services:
|
|||||||
TESTS_BTCNBXPLORERURL: http://nbxplorer:32838/
|
TESTS_BTCNBXPLORERURL: http://nbxplorer:32838/
|
||||||
TESTS_POSTGRES: User ID=postgres;Include Error Detail=true;Host=postgres;Port=5432;Database=btcpayserver
|
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_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_HOSTNAME: tests
|
||||||
TESTS_RUN_EXTERNAL_INTEGRATION: ${TESTS_RUN_EXTERNAL_INTEGRATION:-"false"}
|
TESTS_RUN_EXTERNAL_INTEGRATION: ${TESTS_RUN_EXTERNAL_INTEGRATION:-"false"}
|
||||||
TESTS_AzureBlobStorageConnectionString: ${TESTS_AzureBlobStorageConnectionString:-none}
|
TESTS_AzureBlobStorageConnectionString: ${TESTS_AzureBlobStorageConnectionString:-none}
|
||||||
@@ -56,6 +59,7 @@ services:
|
|||||||
- merchant_lnd
|
- merchant_lnd
|
||||||
- sshd
|
- sshd
|
||||||
- tor
|
- tor
|
||||||
|
- mailpit
|
||||||
|
|
||||||
sshd:
|
sshd:
|
||||||
build:
|
build:
|
||||||
@@ -94,6 +98,17 @@ services:
|
|||||||
default:
|
default:
|
||||||
custom:
|
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:
|
nbxplorer:
|
||||||
image: nicolasdorier/nbxplorer:2.5.29
|
image: nicolasdorier/nbxplorer:2.5.29
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
@using BTCPayServer.Services
|
@using BTCPayServer.Services
|
||||||
@using BTCPayServer.Views.Apps
|
@using BTCPayServer.Views.Apps
|
||||||
@using BTCPayServer.Configuration
|
@using BTCPayServer.Configuration
|
||||||
|
@using BTCPayServer.Plugins.Emails
|
||||||
@inject Microsoft.AspNetCore.Http.IHttpContextAccessor HttpContext;
|
@inject Microsoft.AspNetCore.Http.IHttpContextAccessor HttpContext;
|
||||||
@inject BTCPayServerOptions BtcPayServerOptions
|
@inject BTCPayServerOptions BtcPayServerOptions
|
||||||
@inject BTCPayServerEnvironment Env
|
@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>
|
<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>
|
||||||
<li class="nav-item nav-item-sub" permission="@Policies.CanViewStoreSettings">
|
<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>
|
||||||
<li class="nav-item nav-item-sub" permission="@Policies.CanViewStoreSettings">
|
<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>
|
<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>
|
||||||
<li class="nav-item nav-item-sub" permission="@Policies.CanViewStoreSettings">
|
<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>
|
||||||
<li class="nav-item nav-item-sub" permission="@Policies.CanViewStoreSettings">
|
<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>
|
<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>
|
||||||
|
|||||||
@@ -226,7 +226,8 @@ namespace BTCPayServer.Controllers.Greenfield
|
|||||||
Description = request.Description,
|
Description = request.Description,
|
||||||
Email = request.Email,
|
Email = request.Email,
|
||||||
FormId = request.FormId,
|
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);
|
pr = await _paymentRequestRepository.CreateOrUpdatePaymentRequest(pr);
|
||||||
return Ok(FromModel(pr));
|
return Ok(FromModel(pr));
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ using BTCPayServer.Client;
|
|||||||
using BTCPayServer.Client.Models;
|
using BTCPayServer.Client.Models;
|
||||||
using BTCPayServer.Controllers.GreenField;
|
using BTCPayServer.Controllers.GreenField;
|
||||||
using BTCPayServer.Data;
|
using BTCPayServer.Data;
|
||||||
|
using BTCPayServer.Plugins.Webhooks.Controllers;
|
||||||
using BTCPayServer.Security;
|
using BTCPayServer.Security;
|
||||||
using BTCPayServer.Security.Greenfield;
|
using BTCPayServer.Security.Greenfield;
|
||||||
using BTCPayServer.Services.Mails;
|
using BTCPayServer.Services.Mails;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ using BTCPayServer.Models.PaymentRequestViewModels;
|
|||||||
using BTCPayServer.Payments;
|
using BTCPayServer.Payments;
|
||||||
using BTCPayServer.Payments.Lightning;
|
using BTCPayServer.Payments.Lightning;
|
||||||
using BTCPayServer.Payouts;
|
using BTCPayServer.Payouts;
|
||||||
|
using BTCPayServer.Plugins.Webhooks.Views;
|
||||||
using BTCPayServer.Rating;
|
using BTCPayServer.Rating;
|
||||||
using BTCPayServer.Services;
|
using BTCPayServer.Services;
|
||||||
using BTCPayServer.Services.Apps;
|
using BTCPayServer.Services.Apps;
|
||||||
@@ -154,7 +155,7 @@ namespace BTCPayServer.Controllers
|
|||||||
ShowCheckout = invoice.Status == InvoiceStatus.New,
|
ShowCheckout = invoice.Status == InvoiceStatus.New,
|
||||||
ShowReceipt = invoice.Status == InvoiceStatus.Settled && (invoice.ReceiptOptions?.Enabled ?? receipt.Enabled is true),
|
ShowReceipt = invoice.Status == InvoiceStatus.Settled && (invoice.ReceiptOptions?.Enabled ?? receipt.Enabled is true),
|
||||||
Deliveries = (await _InvoiceRepository.GetWebhookDeliveries(invoiceId))
|
Deliveries = (await _InvoiceRepository.GetWebhookDeliveries(invoiceId))
|
||||||
.Select(c => new Models.StoreViewModels.DeliveryViewModel(c))
|
.Select(c => new DeliveryViewModel(c))
|
||||||
.ToList()
|
.ToList()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -12,10 +12,8 @@ using BTCPayServer.Services;
|
|||||||
using BTCPayServer.Data;
|
using BTCPayServer.Data;
|
||||||
using BTCPayServer.Events;
|
using BTCPayServer.Events;
|
||||||
using BTCPayServer.HostedServices;
|
using BTCPayServer.HostedServices;
|
||||||
using BTCPayServer.HostedServices.Webhooks;
|
|
||||||
using BTCPayServer.Logging;
|
using BTCPayServer.Logging;
|
||||||
using BTCPayServer.Payments;
|
using BTCPayServer.Payments;
|
||||||
using BTCPayServer.Payments.Bitcoin;
|
|
||||||
using BTCPayServer.Rating;
|
using BTCPayServer.Rating;
|
||||||
using BTCPayServer.Security;
|
using BTCPayServer.Security;
|
||||||
using BTCPayServer.Security.Greenfield;
|
using BTCPayServer.Security.Greenfield;
|
||||||
@@ -29,11 +27,10 @@ using Microsoft.AspNetCore.Http;
|
|||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Routing;
|
using Microsoft.AspNetCore.Routing;
|
||||||
using NBitcoin;
|
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
using StoreData = BTCPayServer.Data.StoreData;
|
using StoreData = BTCPayServer.Data.StoreData;
|
||||||
using Serilog.Filters;
|
|
||||||
using BTCPayServer.Payouts;
|
using BTCPayServer.Payouts;
|
||||||
|
using BTCPayServer.Plugins.Webhooks;
|
||||||
using Microsoft.Extensions.Localization;
|
using Microsoft.Extensions.Localization;
|
||||||
|
|
||||||
namespace BTCPayServer.Controllers
|
namespace BTCPayServer.Controllers
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ using BTCPayServer.Services.Stores;
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Localization;
|
using Microsoft.Extensions.Localization;
|
||||||
using PaymentRequestData = BTCPayServer.Data.PaymentRequestData;
|
using PaymentRequestData = BTCPayServer.Data.PaymentRequestData;
|
||||||
using StoreData = BTCPayServer.Data.StoreData;
|
using StoreData = BTCPayServer.Data.StoreData;
|
||||||
@@ -38,10 +39,10 @@ namespace BTCPayServer.Controllers
|
|||||||
private readonly UserManager<ApplicationUser> _UserManager;
|
private readonly UserManager<ApplicationUser> _UserManager;
|
||||||
private readonly PaymentRequestRepository _PaymentRequestRepository;
|
private readonly PaymentRequestRepository _PaymentRequestRepository;
|
||||||
private readonly PaymentRequestService _PaymentRequestService;
|
private readonly PaymentRequestService _PaymentRequestService;
|
||||||
private readonly EventAggregator _EventAggregator;
|
|
||||||
private readonly CurrencyNameTable _Currencies;
|
private readonly CurrencyNameTable _Currencies;
|
||||||
private readonly DisplayFormatter _displayFormatter;
|
private readonly DisplayFormatter _displayFormatter;
|
||||||
private readonly InvoiceRepository _InvoiceRepository;
|
private readonly InvoiceRepository _InvoiceRepository;
|
||||||
|
private readonly ApplicationDbContextFactory _dbContextFactory;
|
||||||
private readonly StoreRepository _storeRepository;
|
private readonly StoreRepository _storeRepository;
|
||||||
private readonly UriResolver _uriResolver;
|
private readonly UriResolver _uriResolver;
|
||||||
private readonly BTCPayNetworkProvider _networkProvider;
|
private readonly BTCPayNetworkProvider _networkProvider;
|
||||||
@@ -56,7 +57,6 @@ namespace BTCPayServer.Controllers
|
|||||||
UserManager<ApplicationUser> userManager,
|
UserManager<ApplicationUser> userManager,
|
||||||
PaymentRequestRepository paymentRequestRepository,
|
PaymentRequestRepository paymentRequestRepository,
|
||||||
PaymentRequestService paymentRequestService,
|
PaymentRequestService paymentRequestService,
|
||||||
EventAggregator eventAggregator,
|
|
||||||
CurrencyNameTable currencies,
|
CurrencyNameTable currencies,
|
||||||
DisplayFormatter displayFormatter,
|
DisplayFormatter displayFormatter,
|
||||||
StoreRepository storeRepository,
|
StoreRepository storeRepository,
|
||||||
@@ -65,6 +65,7 @@ namespace BTCPayServer.Controllers
|
|||||||
FormComponentProviders formProviders,
|
FormComponentProviders formProviders,
|
||||||
FormDataService formDataService,
|
FormDataService formDataService,
|
||||||
IStringLocalizer stringLocalizer,
|
IStringLocalizer stringLocalizer,
|
||||||
|
ApplicationDbContextFactory dbContextFactory,
|
||||||
BTCPayNetworkProvider networkProvider)
|
BTCPayNetworkProvider networkProvider)
|
||||||
{
|
{
|
||||||
_InvoiceController = invoiceController;
|
_InvoiceController = invoiceController;
|
||||||
@@ -72,12 +73,12 @@ namespace BTCPayServer.Controllers
|
|||||||
_UserManager = userManager;
|
_UserManager = userManager;
|
||||||
_PaymentRequestRepository = paymentRequestRepository;
|
_PaymentRequestRepository = paymentRequestRepository;
|
||||||
_PaymentRequestService = paymentRequestService;
|
_PaymentRequestService = paymentRequestService;
|
||||||
_EventAggregator = eventAggregator;
|
|
||||||
_Currencies = currencies;
|
_Currencies = currencies;
|
||||||
_displayFormatter = displayFormatter;
|
_displayFormatter = displayFormatter;
|
||||||
_storeRepository = storeRepository;
|
_storeRepository = storeRepository;
|
||||||
_uriResolver = uriResolver;
|
_uriResolver = uriResolver;
|
||||||
_InvoiceRepository = invoiceRepository;
|
_InvoiceRepository = invoiceRepository;
|
||||||
|
_dbContextFactory = dbContextFactory;
|
||||||
FormProviders = formProviders;
|
FormProviders = formProviders;
|
||||||
FormDataService = formDataService;
|
FormDataService = formDataService;
|
||||||
_networkProvider = networkProvider;
|
_networkProvider = networkProvider;
|
||||||
@@ -126,11 +127,13 @@ namespace BTCPayServer.Controllers
|
|||||||
{
|
{
|
||||||
return NotFound();
|
return NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
var paymentRequest = GetCurrentPaymentRequest();
|
var paymentRequest = GetCurrentPaymentRequest();
|
||||||
if (paymentRequest == null && !string.IsNullOrEmpty(payReqId))
|
if (paymentRequest == null && !string.IsNullOrEmpty(payReqId))
|
||||||
{
|
{
|
||||||
return NotFound();
|
return NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!store.AnyPaymentMethodAvailable(_handlers))
|
if (!store.AnyPaymentMethodAvailable(_handlers))
|
||||||
{
|
{
|
||||||
return NoPaymentMethodResult(storeId);
|
return NoPaymentMethodResult(storeId);
|
||||||
@@ -145,12 +148,19 @@ namespace BTCPayServer.Controllers
|
|||||||
};
|
};
|
||||||
|
|
||||||
vm.Currency ??= storeBlob.DefaultCurrency;
|
vm.Currency ??= storeBlob.DefaultCurrency;
|
||||||
vm.HasEmailRules = storeBlob.EmailRules?.Any(rule =>
|
vm.HasEmailRules = await HasEmailRules(store.Id);
|
||||||
rule.Trigger.Contains("PaymentRequest", StringComparison.InvariantCultureIgnoreCase));
|
|
||||||
|
|
||||||
return View(nameof(EditPaymentRequest), vm);
|
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?}")]
|
[HttpPost("/stores/{storeId}/payment-requests/edit/{payReqId?}")]
|
||||||
[Authorize(Policy = Policies.CanModifyPaymentRequests, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
[Authorize(Policy = Policies.CanModifyPaymentRequests, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||||
public async Task<IActionResult> EditPaymentRequest(string payReqId, UpdatePaymentRequestViewModel viewModel)
|
public async Task<IActionResult> EditPaymentRequest(string payReqId, UpdatePaymentRequestViewModel viewModel)
|
||||||
@@ -168,6 +178,7 @@ namespace BTCPayServer.Controllers
|
|||||||
{
|
{
|
||||||
return NotFound();
|
return NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!store.AnyPaymentMethodAvailable(_handlers))
|
if (!store.AnyPaymentMethodAvailable(_handlers))
|
||||||
{
|
{
|
||||||
return NoPaymentMethodResult(store.Id);
|
return NoPaymentMethodResult(store.Id);
|
||||||
@@ -177,6 +188,7 @@ namespace BTCPayServer.Controllers
|
|||||||
{
|
{
|
||||||
ModelState.AddModelError(string.Empty, StringLocalizer["You cannot edit an archived payment request."]);
|
ModelState.AddModelError(string.Empty, StringLocalizer["You cannot edit an archived payment request."]);
|
||||||
}
|
}
|
||||||
|
|
||||||
var data = paymentRequest ?? new PaymentRequestData();
|
var data = paymentRequest ?? new PaymentRequestData();
|
||||||
data.StoreDataId = viewModel.StoreId;
|
data.StoreDataId = viewModel.StoreId;
|
||||||
data.Archived = viewModel.Archived;
|
data.Archived = viewModel.Archived;
|
||||||
@@ -194,9 +206,7 @@ namespace BTCPayServer.Controllers
|
|||||||
|
|
||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
{
|
{
|
||||||
var storeBlob = store.GetStoreBlob();
|
viewModel.HasEmailRules = await HasEmailRules(store.Id);
|
||||||
viewModel.HasEmailRules = storeBlob.EmailRules?.Any(rule =>
|
|
||||||
rule.Trigger.Contains("PaymentRequest", StringComparison.InvariantCultureIgnoreCase));
|
|
||||||
return View(nameof(EditPaymentRequest), viewModel);
|
return View(nameof(EditPaymentRequest), viewModel);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,6 +219,8 @@ namespace BTCPayServer.Controllers
|
|||||||
data.ReferenceId = viewModel.ReferenceId;
|
data.ReferenceId = viewModel.ReferenceId;
|
||||||
blob.AllowCustomPaymentAmounts = viewModel.AllowCustomPaymentAmounts;
|
blob.AllowCustomPaymentAmounts = viewModel.AllowCustomPaymentAmounts;
|
||||||
blob.FormId = viewModel.FormId;
|
blob.FormId = viewModel.FormId;
|
||||||
|
if (payReqId is null || blob.RequestBaseUrl is null)
|
||||||
|
blob.RequestBaseUrl = Request.GetRequestBaseUrl().ToString();
|
||||||
|
|
||||||
data.SetBlob(blob);
|
data.SetBlob(blob);
|
||||||
var isNewPaymentRequest = string.IsNullOrEmpty(payReqId);
|
var isNewPaymentRequest = string.IsNullOrEmpty(payReqId);
|
||||||
@@ -234,6 +246,7 @@ namespace BTCPayServer.Controllers
|
|||||||
{
|
{
|
||||||
return NotFound();
|
return NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
var store = await _storeRepository.FindStore(vm.StoreId);
|
var store = await _storeRepository.FindStore(vm.StoreId);
|
||||||
if (store == null)
|
if (store == null)
|
||||||
{
|
{
|
||||||
@@ -266,6 +279,7 @@ namespace BTCPayServer.Controllers
|
|||||||
{
|
{
|
||||||
return RedirectToAction("PayPaymentRequest", new { payReqId });
|
return RedirectToAction("PayPaymentRequest", new { payReqId });
|
||||||
}
|
}
|
||||||
|
|
||||||
var prFormId = prBlob.FormId;
|
var prFormId = prBlob.FormId;
|
||||||
var formData = await FormDataService.GetForm(prFormId);
|
var formData = await FormDataService.GetForm(prFormId);
|
||||||
if (formData is null)
|
if (formData is null)
|
||||||
@@ -282,21 +296,26 @@ namespace BTCPayServer.Controllers
|
|||||||
emailField.Value = prBlob.Email;
|
emailField.Value = prBlob.Email;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Request.Method == "POST" && Request.HasFormContentType)
|
if (Request.Method == "POST" && Request.HasFormContentType)
|
||||||
{
|
{
|
||||||
form.ApplyValuesFromForm(Request.Form);
|
form.ApplyValuesFromForm(Request.Form);
|
||||||
if (FormDataService.Validate(form, ModelState))
|
if (FormDataService.Validate(form, ModelState))
|
||||||
{
|
{
|
||||||
prBlob.FormResponse = FormDataService.GetValues(form);
|
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;
|
prBlob.Email = emailField.Value;
|
||||||
}
|
}
|
||||||
|
if (prBlob.RequestBaseUrl is null)
|
||||||
|
prBlob.RequestBaseUrl = Request.GetRequestBaseUrl().ToString();
|
||||||
|
|
||||||
result.SetBlob(prBlob);
|
result.SetBlob(prBlob);
|
||||||
await _PaymentRequestRepository.CreateOrUpdatePaymentRequest(result);
|
await _PaymentRequestRepository.CreateOrUpdatePaymentRequest(result);
|
||||||
return RedirectToAction("PayPaymentRequest", new { payReqId });
|
return RedirectToAction("PayPaymentRequest", new { payReqId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.FormName = formData.Name;
|
viewModel.FormName = formData.Name;
|
||||||
viewModel.Form = form;
|
viewModel.Form = form;
|
||||||
|
|
||||||
@@ -331,6 +350,7 @@ namespace BTCPayServer.Controllers
|
|||||||
|
|
||||||
return BadRequest(StringLocalizer["Payment Request cannot be paid as it has been archived"]);
|
return BadRequest(StringLocalizer["Payment Request cannot be paid as it has been archived"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!result.FormSubmitted && !string.IsNullOrEmpty(result.FormId))
|
if (!result.FormSubmitted && !string.IsNullOrEmpty(result.FormId))
|
||||||
{
|
{
|
||||||
var formData = await FormDataService.GetForm(result.FormId);
|
var formData = await FormDataService.GetForm(result.FormId);
|
||||||
@@ -454,7 +474,7 @@ namespace BTCPayServer.Controllers
|
|||||||
var store = GetCurrentStore();
|
var store = GetCurrentStore();
|
||||||
|
|
||||||
var result = await _PaymentRequestRepository.ArchivePaymentRequest(payReqId, true);
|
var result = await _PaymentRequestRepository.ArchivePaymentRequest(payReqId, true);
|
||||||
if(result is not null)
|
if (result is not null)
|
||||||
{
|
{
|
||||||
TempData[WellKnownTempData.SuccessMessage] = result.Value
|
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
|
? 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
|
TempData.SetStatusMessageModel(new StatusMessageModel
|
||||||
{
|
{
|
||||||
Severity = StatusMessageModel.StatusSeverity.Error,
|
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
|
AllowDismiss = false
|
||||||
});
|
});
|
||||||
return RedirectToAction(nameof(GetPaymentRequests), new { storeId });
|
return RedirectToAction(nameof(GetPaymentRequests), new { storeId });
|
||||||
|
|||||||
@@ -1281,21 +1281,39 @@ namespace BTCPayServer.Controllers
|
|||||||
settings.Password = null;
|
settings.Password = null;
|
||||||
await _SettingsRepository.UpdateSetting(settings);
|
await _SettingsRepository.UpdateSetting(settings);
|
||||||
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email server password reset"].Value;
|
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email server password reset"].Value;
|
||||||
return RedirectToAction(nameof(Emails));
|
|
||||||
}
|
}
|
||||||
|
else if (command == "mailpit")
|
||||||
// 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"]);
|
model.Settings.Server = "localhost";
|
||||||
return View(model);
|
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();
|
else
|
||||||
if (!string.IsNullOrEmpty(oldSettings.Password))
|
{
|
||||||
model.Settings.Password = oldSettings.Password;
|
// 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);
|
var oldSettings = await _emailSenderFactory.GetSettings() ?? new EmailSettings();
|
||||||
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email settings saved"].Value;
|
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));
|
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.Client;
|
||||||
using BTCPayServer.Configuration;
|
using BTCPayServer.Configuration;
|
||||||
using BTCPayServer.Data;
|
using BTCPayServer.Data;
|
||||||
using BTCPayServer.HostedServices.Webhooks;
|
|
||||||
using BTCPayServer.Models.StoreViewModels;
|
using BTCPayServer.Models.StoreViewModels;
|
||||||
using BTCPayServer.Security.Bitpay;
|
using BTCPayServer.Security.Bitpay;
|
||||||
using BTCPayServer.Services;
|
using BTCPayServer.Services;
|
||||||
@@ -50,7 +49,6 @@ public partial class UIStoresController : Controller
|
|||||||
IAuthorizationService authorizationService,
|
IAuthorizationService authorizationService,
|
||||||
AppService appService,
|
AppService appService,
|
||||||
IFileService fileService,
|
IFileService fileService,
|
||||||
WebhookSender webhookNotificationManager,
|
|
||||||
IDataProtectionProvider dataProtector,
|
IDataProtectionProvider dataProtector,
|
||||||
IOptions<LightningNetworkOptions> lightningNetworkOptions,
|
IOptions<LightningNetworkOptions> lightningNetworkOptions,
|
||||||
IOptions<ExternalServicesOptions> externalServiceOptions,
|
IOptions<ExternalServicesOptions> externalServiceOptions,
|
||||||
@@ -94,7 +92,6 @@ public partial class UIStoresController : Controller
|
|||||||
_html = html;
|
_html = html;
|
||||||
_defaultRules = defaultRules;
|
_defaultRules = defaultRules;
|
||||||
_dataProtector = dataProtector.CreateProtector("ConfigProtector");
|
_dataProtector = dataProtector.CreateProtector("ConfigProtector");
|
||||||
_webhookNotificationManager = webhookNotificationManager;
|
|
||||||
_lightningNetworkOptions = lightningNetworkOptions.Value;
|
_lightningNetworkOptions = lightningNetworkOptions.Value;
|
||||||
_lnHistogramService = lnHistogramService;
|
_lnHistogramService = lnHistogramService;
|
||||||
_lightningClientFactory = lightningClientFactory;
|
_lightningClientFactory = lightningClientFactory;
|
||||||
@@ -127,7 +124,6 @@ public partial class UIStoresController : Controller
|
|||||||
private readonly UriResolver _uriResolver;
|
private readonly UriResolver _uriResolver;
|
||||||
private readonly EventAggregator _eventAggregator;
|
private readonly EventAggregator _eventAggregator;
|
||||||
private readonly IHtmlHelper _html;
|
private readonly IHtmlHelper _html;
|
||||||
private readonly WebhookSender _webhookNotificationManager;
|
|
||||||
private readonly LightningNetworkOptions _lightningNetworkOptions;
|
private readonly LightningNetworkOptions _lightningNetworkOptions;
|
||||||
private readonly IDataProtector _dataProtector;
|
private readonly IDataProtector _dataProtector;
|
||||||
private readonly LightningHistogramService _lnHistogramService;
|
private readonly LightningHistogramService _lnHistogramService;
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ namespace BTCPayServer.Controllers
|
|||||||
switch (command)
|
switch (command)
|
||||||
{
|
{
|
||||||
case "createpending":
|
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() });
|
return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() });
|
||||||
case "sign":
|
case "sign":
|
||||||
return await WalletSign(walletId, vm);
|
return await WalletSign(walletId, vm);
|
||||||
|
|||||||
@@ -444,7 +444,7 @@ namespace BTCPayServer.Controllers
|
|||||||
switch (model.Command)
|
switch (model.Command)
|
||||||
{
|
{
|
||||||
case "createpending":
|
case "createpending":
|
||||||
await _pendingTransactionService.CreatePendingTransaction(walletId.StoreId, walletId.CryptoCode, psbt);
|
await _pendingTransactionService.CreatePendingTransaction(walletId.StoreId, walletId.CryptoCode, psbt, Request.GetRequestBaseUrl());
|
||||||
return RedirectToWalletList(walletId);
|
return RedirectToWalletList(walletId);
|
||||||
default:
|
default:
|
||||||
// case "sign":
|
// case "sign":
|
||||||
@@ -1296,7 +1296,7 @@ namespace BTCPayServer.Controllers
|
|||||||
switch (command)
|
switch (command)
|
||||||
{
|
{
|
||||||
case "createpending":
|
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() });
|
return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() });
|
||||||
case "sign":
|
case "sign":
|
||||||
return await WalletSign(walletId, new WalletPSBTViewModel
|
return await WalletSign(walletId, new WalletPSBTViewModel
|
||||||
|
|||||||
@@ -15,5 +15,6 @@ namespace BTCPayServer.Data
|
|||||||
public string FormId { get; set; }
|
public string FormId { get; set; }
|
||||||
|
|
||||||
public JObject FormResponse { get; set; }
|
public JObject FormResponse { get; set; }
|
||||||
|
public string RequestBaseUrl { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Globalization;
|
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using BTCPayServer.Client.JsonConverters;
|
using BTCPayServer.Client.JsonConverters;
|
||||||
using BTCPayServer.Client.Models;
|
using BTCPayServer.Client.Models;
|
||||||
using BTCPayServer.Controllers;
|
|
||||||
using BTCPayServer.JsonConverters;
|
using BTCPayServer.JsonConverters;
|
||||||
using BTCPayServer.Payments;
|
using BTCPayServer.Payments;
|
||||||
using BTCPayServer.Rating;
|
using BTCPayServer.Rating;
|
||||||
using BTCPayServer.Services.Invoices;
|
|
||||||
using BTCPayServer.Services.Mails;
|
using BTCPayServer.Services.Mails;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
@@ -221,7 +217,6 @@ namespace BTCPayServer.Data
|
|||||||
[JsonConverter(typeof(TimeSpanJsonConverter.Days))]
|
[JsonConverter(typeof(TimeSpanJsonConverter.Days))]
|
||||||
public TimeSpan RefundBOLT11Expiration { get; set; }
|
public TimeSpan RefundBOLT11Expiration { get; set; }
|
||||||
|
|
||||||
public List<UIStoresController.StoreEmailRule> EmailRules { get; set; }
|
|
||||||
public string BrandColor { get; set; }
|
public string BrandColor { get; set; }
|
||||||
public bool ApplyBrandColorToBackend { 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;
|
||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
@@ -29,7 +28,6 @@ using BTCPayServer.NTag424;
|
|||||||
using BTCPayServer.Payments;
|
using BTCPayServer.Payments;
|
||||||
using BTCPayServer.Payments.Bitcoin;
|
using BTCPayServer.Payments.Bitcoin;
|
||||||
using BTCPayServer.Payments.Lightning;
|
using BTCPayServer.Payments.Lightning;
|
||||||
using BTCPayServer.Payouts;
|
|
||||||
using BTCPayServer.Security;
|
using BTCPayServer.Security;
|
||||||
using BTCPayServer.Services;
|
using BTCPayServer.Services;
|
||||||
using BTCPayServer.Services.Invoices;
|
using BTCPayServer.Services.Invoices;
|
||||||
@@ -37,7 +35,6 @@ using BTCPayServer.Services.Reporting;
|
|||||||
using BTCPayServer.Services.Wallets;
|
using BTCPayServer.Services.Wallets;
|
||||||
using Microsoft.AspNetCore.DataProtection;
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.HttpOverrides;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
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);
|
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)
|
public static string PaymentRequestLink(this LinkGenerator urlHelper, string paymentRequestId, string scheme, HostString host, string pathbase)
|
||||||
{
|
{
|
||||||
return urlHelper.GetUriByAction(
|
return urlHelper.GetUriByAction(
|
||||||
@@ -48,6 +50,16 @@ namespace Microsoft.AspNetCore.Mvc
|
|||||||
scheme, host, pathbase);
|
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)
|
public static string AppLink(this LinkGenerator urlHelper, string appId, string scheme, HostString host, string pathbase)
|
||||||
{
|
{
|
||||||
return urlHelper.GetUriByAction(
|
return urlHelper.GetUriByAction(
|
||||||
@@ -57,6 +69,8 @@ namespace Microsoft.AspNetCore.Mvc
|
|||||||
scheme, host, pathbase);
|
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)
|
public static string InvoiceLink(this LinkGenerator urlHelper, string invoiceId, string scheme, HostString host, string pathbase)
|
||||||
{
|
{
|
||||||
return urlHelper.GetUriByAction(
|
return urlHelper.GetUriByAction(
|
||||||
|
|||||||
@@ -1,22 +1,14 @@
|
|||||||
#nullable enable
|
#nullable enable
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using BTCPayServer.Client.Models;
|
using BTCPayServer.Abstractions;
|
||||||
using BTCPayServer.Controllers;
|
|
||||||
using BTCPayServer.Data;
|
using BTCPayServer.Data;
|
||||||
using BTCPayServer.Events;
|
using BTCPayServer.Events;
|
||||||
using BTCPayServer.HostedServices.Webhooks;
|
|
||||||
using BTCPayServer.Services;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using NBitcoin;
|
using NBitcoin;
|
||||||
using NBitcoin.DataEncoders;
|
|
||||||
using Newtonsoft.Json;
|
|
||||||
using Newtonsoft.Json.Linq;
|
|
||||||
using WebhookDeliveryData = BTCPayServer.Data.WebhookDeliveryData;
|
|
||||||
|
|
||||||
namespace BTCPayServer.HostedServices;
|
namespace BTCPayServer.HostedServices;
|
||||||
|
|
||||||
@@ -92,6 +84,7 @@ public class PendingTransactionService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PendingTransaction> CreatePendingTransaction(string storeId, string cryptoCode, PSBT psbt,
|
public async Task<PendingTransaction> CreatePendingTransaction(string storeId, string cryptoCode, PSBT psbt,
|
||||||
|
RequestBaseUrl requestBaseUrl,
|
||||||
DateTimeOffset? expiry = null, CancellationToken cancellationToken = default)
|
DateTimeOffset? expiry = null, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var network = networkProvider.GetNetwork<BTCPayNetwork>(cryptoCode);
|
var network = networkProvider.GetNetwork<BTCPayNetwork>(cryptoCode);
|
||||||
@@ -135,7 +128,8 @@ public class PendingTransactionService(
|
|||||||
PSBT = psbt.ToBase64(),
|
PSBT = psbt.ToBase64(),
|
||||||
SignaturesCollected = 0,
|
SignaturesCollected = 0,
|
||||||
SignaturesNeeded = signaturesNeeded,
|
SignaturesNeeded = signaturesNeeded,
|
||||||
SignaturesTotal = signaturesTotal
|
SignaturesTotal = signaturesTotal,
|
||||||
|
RequestBaseUrl = requestBaseUrl.ToString()
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.PendingTransactions.Add(pendingTransaction);
|
ctx.PendingTransactions.Add(pendingTransaction);
|
||||||
@@ -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
|
// 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
|
// 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
|
blob.CollectedSignatures.Add(new CollectedSignature
|
||||||
{
|
{
|
||||||
ReceivedPSBT = newPsbtBase64,
|
ReceivedPSBT = newPsbtBase64,
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ namespace BTCPayServer.HostedServices
|
|||||||
var disabledPlugins = pluginService.GetDisabledPlugins();
|
var disabledPlugins = pluginService.GetDisabledPlugins();
|
||||||
|
|
||||||
var installedPlugins = pluginService.Installed;
|
var installedPlugins = pluginService.Installed;
|
||||||
var remotePlugins = await pluginService.GetRemotePlugins(null);
|
var remotePlugins = await pluginService.GetRemotePlugins(null, cancellationToken);
|
||||||
//take the latest version of each plugin
|
//take the latest version of each plugin
|
||||||
var remotePluginsList = remotePlugins
|
var remotePluginsList = remotePlugins
|
||||||
.GroupBy(plugin => plugin.Identifier)
|
.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.Data.Payouts.LightningLike;
|
||||||
using BTCPayServer.Forms;
|
using BTCPayServer.Forms;
|
||||||
using BTCPayServer.HostedServices;
|
using BTCPayServer.HostedServices;
|
||||||
using BTCPayServer.HostedServices.Webhooks;
|
|
||||||
using BTCPayServer.Lightning;
|
using BTCPayServer.Lightning;
|
||||||
using BTCPayServer.Lightning.Charge;
|
using BTCPayServer.Lightning.Charge;
|
||||||
using BTCPayServer.Lightning.CLightning;
|
using BTCPayServer.Lightning.CLightning;
|
||||||
@@ -384,7 +383,6 @@ namespace BTCPayServer.Hosting
|
|||||||
services.AddSingleton<IHostedService, Services.NBXplorerConnectionFactory>(o => o.GetRequiredService<Services.NBXplorerConnectionFactory>());
|
services.AddSingleton<IHostedService, Services.NBXplorerConnectionFactory>(o => o.GetRequiredService<Services.NBXplorerConnectionFactory>());
|
||||||
services.AddSingleton<HostedServices.CheckConfigurationHostedService>();
|
services.AddSingleton<HostedServices.CheckConfigurationHostedService>();
|
||||||
services.AddSingleton<IHostedService, HostedServices.CheckConfigurationHostedService>(o => o.GetRequiredService<CheckConfigurationHostedService>());
|
services.AddSingleton<IHostedService, HostedServices.CheckConfigurationHostedService>(o => o.GetRequiredService<CheckConfigurationHostedService>());
|
||||||
services.AddSingleton<IHostedService, StoreEmailRuleProcessorSender>();
|
|
||||||
services.AddSingleton<IHostedService, PeriodicTaskLauncherHostedService>();
|
services.AddSingleton<IHostedService, PeriodicTaskLauncherHostedService>();
|
||||||
services.AddScheduledTask<GithubVersionFetcher>(TimeSpan.FromDays(1));
|
services.AddScheduledTask<GithubVersionFetcher>(TimeSpan.FromDays(1));
|
||||||
services.AddScheduledTask<PluginUpdateFetcher>(TimeSpan.FromDays(1));
|
services.AddScheduledTask<PluginUpdateFetcher>(TimeSpan.FromDays(1));
|
||||||
@@ -395,7 +393,6 @@ namespace BTCPayServer.Hosting
|
|||||||
services.AddReportProvider<PayoutsReportProvider>();
|
services.AddReportProvider<PayoutsReportProvider>();
|
||||||
services.AddReportProvider<InvoicesReportProvider>();
|
services.AddReportProvider<InvoicesReportProvider>();
|
||||||
services.AddReportProvider<RefundsReportProvider>();
|
services.AddReportProvider<RefundsReportProvider>();
|
||||||
services.AddWebhooks();
|
|
||||||
|
|
||||||
services.AddSingleton<Dictionary<PaymentMethodId, IPaymentMethodBitpayAPIExtension>>(o =>
|
services.AddSingleton<Dictionary<PaymentMethodId, IPaymentMethodBitpayAPIExtension>>(o =>
|
||||||
o.GetRequiredService<IEnumerable<IPaymentMethodBitpayAPIExtension>>().ToDictionary(o => o.PaymentMethodId, o => 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>());
|
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;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -173,6 +173,8 @@ namespace BTCPayServer.Hosting
|
|||||||
o.AreaViewLocationFormats.Add("/Plugins/{2}/Views/{1}/{0}.cshtml");
|
o.AreaViewLocationFormats.Add("/Plugins/{2}/Views/{1}/{0}.cshtml");
|
||||||
o.AreaViewLocationFormats.Add("/Plugins/{2}/Views/{0}.cshtml");
|
o.AreaViewLocationFormats.Add("/Plugins/{2}/Views/{0}.cshtml");
|
||||||
o.AreaViewLocationFormats.Add("/Plugins/{2}/Views/Shared/{0}.cshtml");
|
o.AreaViewLocationFormats.Add("/Plugins/{2}/Views/Shared/{0}.cshtml");
|
||||||
|
|
||||||
|
|
||||||
o.AreaViewLocationFormats.Add("/{0}.cshtml");
|
o.AreaViewLocationFormats.Add("/{0}.cshtml");
|
||||||
})
|
})
|
||||||
.AddNewtonsoftJson()
|
.AddNewtonsoftJson()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using BTCPayServer.Client.Models;
|
|||||||
using BTCPayServer.Data;
|
using BTCPayServer.Data;
|
||||||
using BTCPayServer.Payments;
|
using BTCPayServer.Payments;
|
||||||
using BTCPayServer.Payments.Bitcoin;
|
using BTCPayServer.Payments.Bitcoin;
|
||||||
|
using BTCPayServer.Plugins.Webhooks.Views;
|
||||||
using BTCPayServer.Services.Invoices;
|
using BTCPayServer.Services.Invoices;
|
||||||
using NBitcoin;
|
using NBitcoin;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
@@ -85,7 +86,7 @@ namespace BTCPayServer.Models.InvoicingModels
|
|||||||
get; set;
|
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 TaxIncluded { get; set; }
|
||||||
|
|
||||||
public string TransactionSpeed { 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.Client;
|
||||||
using BTCPayServer.Data;
|
using BTCPayServer.Data;
|
||||||
using BTCPayServer.Models;
|
using BTCPayServer.Models;
|
||||||
|
using BTCPayServer.Plugins.Emails;
|
||||||
using BTCPayServer.Services.Mails;
|
using BTCPayServer.Services.Mails;
|
||||||
|
using BTCPayServer.Services.Stores;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using Microsoft.Extensions.Localization;
|
||||||
using MimeKit;
|
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)
|
public async Task<IActionResult> StoreEmailSettings(string storeId)
|
||||||
{
|
{
|
||||||
var store = HttpContext.GetStoreData();
|
var store = HttpContext.GetStoreData();
|
||||||
@@ -38,7 +52,7 @@ public partial class UIStoresController
|
|||||||
record AllEmailSettings(EmailSettings Custom, EmailSettings Fallback);
|
record AllEmailSettings(EmailSettings Custom, EmailSettings Fallback);
|
||||||
private async Task<AllEmailSettings> GetCustomSettings(string storeId)
|
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)
|
if (sender is null)
|
||||||
return new(null, null);
|
return new(null, null);
|
||||||
var fallback = sender.FallbackSender is { } fb ? await fb.GetEmailSettings() : 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);
|
return new(await sender.GetCustomSettings(), fallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{storeId}/email-settings")]
|
[HttpPost("email-settings")]
|
||||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||||
public async Task<IActionResult> StoreEmailSettings(string storeId, EmailsViewModel model, string command)
|
public async Task<IActionResult> StoreEmailSettings(string storeId, EmailsViewModel model, string command)
|
||||||
{
|
{
|
||||||
@@ -95,26 +109,45 @@ public partial class UIStoresController
|
|||||||
}
|
}
|
||||||
return View(model);
|
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")
|
else if (command == "ResetPassword")
|
||||||
{
|
{
|
||||||
if (storeBlob.EmailSettings is not null)
|
if (storeBlob.EmailSettings is not null)
|
||||||
storeBlob.EmailSettings.Password = null;
|
storeBlob.EmailSettings.Password = null;
|
||||||
store.SetStoreBlob(storeBlob);
|
store.SetStoreBlob(storeBlob);
|
||||||
await _storeRepo.UpdateStore(store);
|
await storeRepository.UpdateStore(store);
|
||||||
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email server password reset"].Value;
|
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email server password reset"].Value;
|
||||||
}
|
}
|
||||||
else if (!model.IsCustomSMTP && currentSettings is not null)
|
else if (!model.IsCustomSMTP && currentSettings is not null)
|
||||||
{
|
{
|
||||||
storeBlob.EmailSettings = null;
|
storeBlob.EmailSettings = null;
|
||||||
store.SetStoreBlob(storeBlob);
|
store.SetStoreBlob(storeBlob);
|
||||||
await _storeRepo.UpdateStore(store);
|
await storeRepository.UpdateStore(store);
|
||||||
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["You are now using server's email settings"].Value;
|
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["You are now using server's email settings"].Value;
|
||||||
}
|
}
|
||||||
else if (model.IsCustomSMTP)
|
else if (model.IsCustomSMTP)
|
||||||
{
|
{
|
||||||
storeBlob.EmailSettings = model.Settings;
|
storeBlob.EmailSettings = model.Settings;
|
||||||
store.SetStoreBlob(storeBlob);
|
store.SetStoreBlob(storeBlob);
|
||||||
await _storeRepo.UpdateStore(store);
|
await storeRepository.UpdateStore(store);
|
||||||
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email settings saved"].Value;
|
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email settings saved"].Value;
|
||||||
}
|
}
|
||||||
return RedirectToAction(nameof(StoreEmailSettings), new { storeId });
|
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
|
@model List<StoreEmailRuleViewModel>
|
||||||
@using BTCPayServer.Client
|
|
||||||
@using BTCPayServer.TagHelpers
|
|
||||||
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
|
||||||
@model List<BTCPayServer.Controllers.UIStoresController.StoreEmailRule>
|
|
||||||
|
|
||||||
@{
|
@{
|
||||||
var storeId = Context.GetStoreData().Id;
|
var storeId = Context.GetStoreData().Id;
|
||||||
@@ -12,14 +8,14 @@
|
|||||||
<nav aria-label="breadcrumb">
|
<nav aria-label="breadcrumb">
|
||||||
<ol class="breadcrumb">
|
<ol class="breadcrumb">
|
||||||
<li class="breadcrumb-item">
|
<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>
|
||||||
<li class="breadcrumb-item active" aria-current="page">@ViewData["Title"]</li>
|
<li class="breadcrumb-item active" aria-current="page">@ViewData["Title"]</li>
|
||||||
</ol>
|
</ol>
|
||||||
<h2>@ViewData["Title"]</h2>
|
<h2>@ViewData["Title"]</h2>
|
||||||
</nav>
|
</nav>
|
||||||
<a id="CreateEmailRule" permission="@Policies.CanModifyStoreSettings" asp-action="StoreEmailRulesCreate" asp-route-storeId="@storeId"
|
<a id="CreateEmailRule" permission="@Policies.CanModifyStoreSettings" asp-action="StoreEmailRulesCreate" asp-route-storeId="@storeId"
|
||||||
class="btn btn-primary" role="button">
|
class="btn btn-primary" role="button" text-translate="true">
|
||||||
Create Email Rule
|
Create Email Rule
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -35,25 +31,25 @@
|
|||||||
<table class="table table-hover">
|
<table class="table table-hover">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Trigger</th>
|
<th text-translate="true">Trigger</th>
|
||||||
<th>Customer Email</th>
|
<th text-translate="true">Customer Email</th>
|
||||||
<th>To</th>
|
<th text-translate="true">To</th>
|
||||||
<th>Subject</th>
|
<th text-translate="true">Subject</th>
|
||||||
<th class="actions-col" permission="@Policies.CanModifyStoreSettings"></th>
|
<th class="actions-col" permission="@Policies.CanModifyStoreSettings"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@foreach (var rule in Model.Select((value, index) => new { value, index }))
|
@foreach (var rule in Model)
|
||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<td>@rule.value.Trigger</td>
|
<td>@rule.Trigger</td>
|
||||||
<td>@(rule.value.CustomerEmail ? "Yes" : "No")</td>
|
<td>@(rule.AdditionalData.CustomerEmail is true ? StringLocalizer["Yes"] : StringLocalizer["No"])</td>
|
||||||
<td>@rule.value.To</td>
|
<td>@string.Join(", ", rule.To)</td>
|
||||||
<td>@rule.value.Subject</td>
|
<td>@rule.Subject</td>
|
||||||
<td class="actions-col" permission="@Policies.CanModifyStoreSettings">
|
<td class="actions-col" permission="@Policies.CanModifyStoreSettings">
|
||||||
<div class="d-inline-flex align-items-center gap-3">
|
<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="StoreEmailRulesEdit" asp-route-storeId="@storeId" asp-route-ruleId="@rule.Data.Id">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="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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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.Net.Http;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
@@ -59,7 +60,7 @@ namespace BTCPayServer.Plugins
|
|||||||
_httpClient = httpClient;
|
_httpClient = httpClient;
|
||||||
}
|
}
|
||||||
static JsonSerializerSettings serializerSettings = new() { ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver() };
|
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}";
|
var queryString = $"?includePreRelease={includePreRelease}";
|
||||||
if (btcpayVersion is not null)
|
if (btcpayVersion is not null)
|
||||||
@@ -68,7 +69,7 @@ namespace BTCPayServer.Plugins
|
|||||||
queryString += $"&searchPluginName={Uri.EscapeDataString(searchPluginName)}";
|
queryString += $"&searchPluginName={Uri.EscapeDataString(searchPluginName)}";
|
||||||
if (includeAllVersions is not null)
|
if (includeAllVersions is not null)
|
||||||
queryString += $"&includeAllVersions={includeAllVersions}";
|
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();
|
return JsonConvert.DeserializeObject<PublishedVersion[]>(result, serializerSettings) ?? throw new InvalidOperationException();
|
||||||
}
|
}
|
||||||
public async Task<PublishedVersion> GetPlugin(string pluginSlug, string version)
|
public async Task<PublishedVersion> GetPlugin(string pluginSlug, string version)
|
||||||
@@ -97,7 +98,8 @@ namespace BTCPayServer.Plugins
|
|||||||
public async Task<PublishedVersion[]> GetInstalledPluginsUpdates(
|
public async Task<PublishedVersion[]> GetInstalledPluginsUpdates(
|
||||||
string btcpayVersion,
|
string btcpayVersion,
|
||||||
bool includePreRelease,
|
bool includePreRelease,
|
||||||
IEnumerable<InstalledPluginRequest> plugins)
|
IEnumerable<InstalledPluginRequest> plugins,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var queryString = $"?includePreRelease={includePreRelease}";
|
var queryString = $"?includePreRelease={includePreRelease}";
|
||||||
if (!string.IsNullOrWhiteSpace(btcpayVersion))
|
if (!string.IsNullOrWhiteSpace(btcpayVersion))
|
||||||
@@ -106,10 +108,10 @@ namespace BTCPayServer.Plugins
|
|||||||
var json = JsonConvert.SerializeObject(plugins, serializerSettings);
|
var json = JsonConvert.SerializeObject(plugins, serializerSettings);
|
||||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
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();
|
resp.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
var body = await resp.Content.ReadAsStringAsync();
|
var body = await resp.Content.ReadAsStringAsync(cancellationToken);
|
||||||
var result = JsonConvert.DeserializeObject<PublishedVersion[]>(body, serializerSettings);
|
var result = JsonConvert.DeserializeObject<PublishedVersion[]>(body, serializerSettings);
|
||||||
|
|
||||||
if (result is null)
|
if (result is null)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using BTCPayServer.Abstractions.Contracts;
|
using BTCPayServer.Abstractions.Contracts;
|
||||||
using BTCPayServer.Configuration;
|
using BTCPayServer.Configuration;
|
||||||
@@ -53,11 +54,11 @@ namespace BTCPayServer.Plugins
|
|||||||
|
|
||||||
private string GetShortBtcpayVersion() => Env.Version.TrimStart('v').Split('+')[0];
|
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();
|
string btcpayVersion = GetShortBtcpayVersion();
|
||||||
var versions = await _pluginBuilderClient.GetPublishedVersions(
|
var versions = await _pluginBuilderClient.GetPublishedVersions(
|
||||||
btcpayVersion, _policiesSettings.PluginPreReleases, searchPluginName);
|
btcpayVersion, _policiesSettings.PluginPreReleases, searchPluginName, cancellationToken: cancellationToken);
|
||||||
|
|
||||||
var plugins = versions
|
var plugins = versions
|
||||||
.Select(MapToAvailablePlugin)
|
.Select(MapToAvailablePlugin)
|
||||||
@@ -79,7 +80,7 @@ namespace BTCPayServer.Plugins
|
|||||||
var updates = await _pluginBuilderClient.GetInstalledPluginsUpdates(
|
var updates = await _pluginBuilderClient.GetInstalledPluginsUpdates(
|
||||||
btcpayVersion,
|
btcpayVersion,
|
||||||
_policiesSettings.PluginPreReleases,
|
_policiesSettings.PluginPreReleases,
|
||||||
loadedToCheck);
|
loadedToCheck, cancellationToken: cancellationToken);
|
||||||
|
|
||||||
if (updates is { Length: > 0 })
|
if (updates is { Length: > 0 })
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,14 +6,13 @@ using BTCPayServer.Abstractions.Extensions;
|
|||||||
using BTCPayServer.Client;
|
using BTCPayServer.Client;
|
||||||
using BTCPayServer.Client.Models;
|
using BTCPayServer.Client.Models;
|
||||||
using BTCPayServer.Data;
|
using BTCPayServer.Data;
|
||||||
using BTCPayServer.HostedServices.Webhooks;
|
|
||||||
using BTCPayServer.Services.Stores;
|
using BTCPayServer.Services.Stores;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Cors;
|
using Microsoft.AspNetCore.Cors;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
namespace BTCPayServer.Controllers.Greenfield
|
namespace BTCPayServer.Plugins.Webhooks.Controllers
|
||||||
{
|
{
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield,
|
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield,
|
||||||
@@ -1,31 +1,42 @@
|
|||||||
#nullable enable
|
#nullable enable
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using BTCPayServer.Abstractions.Constants;
|
using BTCPayServer.Abstractions.Constants;
|
||||||
using BTCPayServer.Abstractions.Models;
|
using BTCPayServer.Abstractions.Models;
|
||||||
using BTCPayServer.Client;
|
using BTCPayServer.Client;
|
||||||
using BTCPayServer.Client.Models;
|
using BTCPayServer.Client.Models;
|
||||||
using BTCPayServer.Data;
|
using BTCPayServer.Data;
|
||||||
using BTCPayServer.Models.StoreViewModels;
|
using BTCPayServer.Plugins.Webhooks.Views;
|
||||||
|
using BTCPayServer.Services.Stores;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Localization;
|
||||||
using NBitcoin;
|
using NBitcoin;
|
||||||
using NBitcoin.DataEncoders;
|
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)
|
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")]
|
[HttpGet("{storeId}/webhooks")]
|
||||||
public async Task<IActionResult> 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
|
return View(nameof(Webhooks), new WebhooksViewModel
|
||||||
{
|
{
|
||||||
Webhooks = webhooks.Select(async w =>
|
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)]
|
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||||
public async Task<IActionResult> DeleteWebhook(string webhookId)
|
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)
|
if (webhook is null)
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
|
||||||
return View("Confirm", new ConfirmModel(StringLocalizer["Delete webhook"], StringLocalizer["This webhook will be removed from this store. Are you sure?"], StringLocalizer["Delete"]));
|
await storeRepo.DeleteWebhook(CurrentStore.Id, webhookId);
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("{storeId}/webhooks/{webhookId}/remove")]
|
|
||||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
|
||||||
public async Task<IActionResult> DeleteWebhookPost(string webhookId)
|
|
||||||
{
|
|
||||||
var webhook = await _storeRepo.GetWebhook(CurrentStore.Id, webhookId);
|
|
||||||
if (webhook is null)
|
|
||||||
return NotFound();
|
|
||||||
|
|
||||||
await _storeRepo.DeleteWebhook(CurrentStore.Id, webhookId);
|
|
||||||
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Webhook successfully deleted"].Value;
|
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Webhook successfully deleted"].Value;
|
||||||
return RedirectToAction(nameof(Webhooks), new { storeId = CurrentStore.Id });
|
return RedirectToAction(nameof(Webhooks), new { storeId = CurrentStore.Id });
|
||||||
}
|
}
|
||||||
@@ -90,7 +90,7 @@ public partial class UIStoresController
|
|||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
return View(nameof(ModifyWebhook), viewModel);
|
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;
|
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["The webhook has been created"].Value;
|
||||||
return RedirectToAction(nameof(Webhooks), new { storeId });
|
return RedirectToAction(nameof(Webhooks), new { storeId });
|
||||||
}
|
}
|
||||||
@@ -99,12 +99,12 @@ public partial class UIStoresController
|
|||||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||||
public async Task<IActionResult> ModifyWebhook(string webhookId)
|
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)
|
if (webhook is null)
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
|
||||||
var blob = webhook.GetBlob();
|
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)
|
return View(nameof(ModifyWebhook), new EditWebhookViewModel(blob)
|
||||||
{
|
{
|
||||||
Deliveries = deliveries
|
Deliveries = deliveries
|
||||||
@@ -116,55 +116,26 @@ public partial class UIStoresController
|
|||||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||||
public async Task<IActionResult> ModifyWebhook(string webhookId, EditWebhookViewModel viewModel)
|
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)
|
if (webhook is null)
|
||||||
return NotFound();
|
return NotFound();
|
||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
return View(nameof(ModifyWebhook), viewModel);
|
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;
|
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["The webhook has been updated"].Value;
|
||||||
return RedirectToAction(nameof(Webhooks), new { storeId = CurrentStore.Id });
|
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")]
|
[HttpPost("{storeId}/webhooks/{webhookId}/deliveries/{deliveryId}/redeliver")]
|
||||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||||
public async Task<IActionResult> RedeliverWebhook(string webhookId, string deliveryId)
|
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)
|
if (delivery is null)
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
|
||||||
var newDeliveryId = await _webhookNotificationManager.Redeliver(deliveryId);
|
var newDeliveryId = await webhookSender.Redeliver(deliveryId);
|
||||||
if (newDeliveryId is null)
|
if (newDeliveryId is null)
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
|
||||||
@@ -181,7 +152,7 @@ public partial class UIStoresController
|
|||||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||||
public async Task<IActionResult> WebhookDelivery(string webhookId, string deliveryId)
|
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)
|
if (delivery is null)
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
using System;
|
using System;
|
||||||
using Dapper;
|
using Dapper;
|
||||||
using System.Data.Common;
|
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using BTCPayServer.Data;
|
using BTCPayServer.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using System.Collections.Generic;
|
using BTCPayServer.HostedServices;
|
||||||
|
|
||||||
namespace BTCPayServer.HostedServices
|
namespace BTCPayServer.Plugins.Webhooks.HostedServices
|
||||||
{
|
{
|
||||||
public class CleanupWebhookDeliveriesTask : IPeriodicTask
|
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;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.Linq;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using BTCPayServer.Client.Models;
|
using BTCPayServer.Client.Models;
|
||||||
using BTCPayServer.Data;
|
using BTCPayServer.Data;
|
||||||
using BTCPayServer.Validation;
|
using BTCPayServer.Validation;
|
||||||
|
|
||||||
namespace BTCPayServer.Models.StoreViewModels
|
namespace BTCPayServer.Plugins.Webhooks.Views
|
||||||
{
|
{
|
||||||
public class DeliveryViewModel
|
public class DeliveryViewModel
|
||||||
{
|
{
|
||||||
@@ -25,7 +23,7 @@ namespace BTCPayServer.Models.StoreViewModels
|
|||||||
var evt = blob.ReadRequestAs<WebhookEvent>();
|
var evt = blob.ReadRequestAs<WebhookEvent>();
|
||||||
Type = evt.Type;
|
Type = evt.Type;
|
||||||
Pruned = evt.IsPruned();
|
Pruned = evt.IsPruned();
|
||||||
WebhookId = s.Id;
|
WebhookId = s.WebhookId;
|
||||||
PayloadUrl = s.Webhook?.GetBlob().Url;
|
PayloadUrl = s.Webhook?.GetBlob().Url;
|
||||||
}
|
}
|
||||||
public string Id { get; set; }
|
public string Id { get; set; }
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
@model EditWebhookViewModel
|
@model EditWebhookViewModel
|
||||||
@using BTCPayServer.HostedServices.Webhooks
|
@inject IEnumerable<AvailableWebhookViewModel> Webhooks
|
||||||
@inject WebhookSender WebhookSender
|
|
||||||
@{
|
@{
|
||||||
var storeId = Context.GetStoreData().Id;
|
var storeId = Context.GetStoreData().Id;
|
||||||
ViewData.SetActivePage(StoreNavPages.Webhooks, StringLocalizer["Webhook"], storeId);
|
ViewData.SetActivePage(StoreNavPages.Webhooks, StringLocalizer["Webhook"], storeId);
|
||||||
@@ -75,11 +74,11 @@
|
|||||||
</select>
|
</select>
|
||||||
<div id="event-selector" class="collapse">
|
<div id="event-selector" class="collapse">
|
||||||
<div class="pb-3">
|
<div class="pb-3">
|
||||||
@foreach (var evt in WebhookSender.GetSupportedWebhookTypes())
|
@foreach (var evt in Webhooks)
|
||||||
{
|
{
|
||||||
<div class="form-check my-1">
|
<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" />
|
<input name="Events" id="@evt.Type" value="@evt.Type" @(Model.Events.Contains(evt.Type) ? "checked" : "") type="checkbox" class="form-check-input" />
|
||||||
<label for="@evt.Key" class="form-check-label">@evt.Value</label>
|
<label for="@evt.Type" class="form-check-label">@StringLocalizer[evt.Description]</label>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
@using BTCPayServer.Abstractions.Models
|
@using BTCPayServer.Abstractions.Models
|
||||||
@using BTCPayServer.Client
|
@using BTCPayServer.Client
|
||||||
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
|
||||||
@model WebhooksViewModel
|
@model WebhooksViewModel
|
||||||
@{
|
@{
|
||||||
ViewData.SetActivePage(StoreNavPages.Webhooks, StringLocalizer["Webhooks"], Context.GetStoreData().Id);
|
ViewData.SetActivePage(StoreNavPages.Webhooks, StringLocalizer["Webhooks"], Context.GetStoreData().Id);
|
||||||
@@ -53,7 +52,6 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="d-block text-break">@wh.Url</td>
|
<td class="d-block text-break">@wh.Url</td>
|
||||||
<td class="actions-col text-md-nowrap" permission="@Policies.CanModifyStoreSettings">
|
<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="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>
|
<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>
|
</td>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
|
|
||||||
namespace BTCPayServer.Models.StoreViewModels
|
namespace BTCPayServer.Plugins.Webhooks.Views
|
||||||
{
|
{
|
||||||
public class WebhooksViewModel
|
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;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.ComponentModel;
|
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Threading.Tasks;
|
|
||||||
using BTCPayServer.Client.Models;
|
using BTCPayServer.Client.Models;
|
||||||
using BTCPayServer.HostedServices.Webhooks;
|
using BTCPayServer.Plugins.Webhooks;
|
||||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
@@ -61,6 +57,8 @@ namespace BTCPayServer.Data
|
|||||||
public string Secret { get; set; }
|
public string Secret { get; set; }
|
||||||
public bool AutomaticRedelivery { get; set; }
|
public bool AutomaticRedelivery { get; set; }
|
||||||
public AuthorizedWebhookEvents AuthorizedEvents { get; set; }
|
public AuthorizedWebhookEvents AuthorizedEvents { get; set; }
|
||||||
|
public bool ShouldDeliver(string type)
|
||||||
|
=> Active && AuthorizedEvents.Match(type);
|
||||||
}
|
}
|
||||||
public static class WebhookDataExtensions
|
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.Net.Http.Headers;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using BTCPayServer.Client.Models;
|
using BTCPayServer.Client.Models;
|
||||||
using BTCPayServer.Controllers;
|
|
||||||
using BTCPayServer.Data;
|
using BTCPayServer.Data;
|
||||||
using BTCPayServer.Services.Stores;
|
using BTCPayServer.Services.Stores;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using NBitcoin.DataEncoders;
|
using NBitcoin.DataEncoders;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using Newtonsoft.Json.Linq;
|
|
||||||
using WebhookDeliveryData = BTCPayServer.Data.WebhookDeliveryData;
|
using WebhookDeliveryData = BTCPayServer.Data.WebhookDeliveryData;
|
||||||
|
|
||||||
namespace BTCPayServer.HostedServices.Webhooks;
|
namespace BTCPayServer.Plugins.Webhooks;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// This class sends webhook notifications
|
/// This class sends webhook notifications
|
||||||
@@ -32,8 +28,7 @@ public class WebhookSender(
|
|||||||
StoreRepository storeRepository,
|
StoreRepository storeRepository,
|
||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
ApplicationDbContextFactory dbContextFactory,
|
ApplicationDbContextFactory dbContextFactory,
|
||||||
ILogger<WebhookSender> logger,
|
ILogger<WebhookSender> logger)
|
||||||
IServiceProvider serviceProvider)
|
|
||||||
: IHostedService
|
: IHostedService
|
||||||
{
|
{
|
||||||
public const string OnionNamedClient = "greenfield-webhook.onion";
|
public const string OnionNamedClient = "greenfield-webhook.onion";
|
||||||
@@ -93,7 +88,7 @@ public class WebhookSender(
|
|||||||
if (webhookDelivery is null)
|
if (webhookDelivery is null)
|
||||||
return null;
|
return null;
|
||||||
var oldDeliveryBlob = webhookDelivery.Delivery.GetBlob();
|
var oldDeliveryBlob = webhookDelivery.Delivery.GetBlob();
|
||||||
var newDelivery = WebhookExtensions.NewWebhookDelivery(webhookDelivery.Webhook.Id);
|
var newDelivery = WebhookDeliveryData.Create(webhookDelivery.Webhook.Id);
|
||||||
WebhookDeliveryBlob newDeliveryBlob = new();
|
WebhookDeliveryBlob newDeliveryBlob = new();
|
||||||
newDeliveryBlob.Request = oldDeliveryBlob.Request;
|
newDeliveryBlob.Request = oldDeliveryBlob.Request;
|
||||||
var webhookEvent = newDeliveryBlob.ReadRequestAs<WebhookEvent>();
|
var webhookEvent = newDeliveryBlob.ReadRequestAs<WebhookEvent>();
|
||||||
@@ -110,42 +105,6 @@ public class WebhookSender(
|
|||||||
webhookDelivery.Webhook.GetBlob());
|
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)
|
public void EnqueueDelivery(WebhookDeliveryRequest context)
|
||||||
{
|
{
|
||||||
_processingQueue.Enqueue(context.WebhookId, cancellationToken => Process(context, cancellationToken));
|
_processingQueue.Enqueue(context.WebhookId, cancellationToken => Process(context, cancellationToken));
|
||||||
@@ -160,8 +119,7 @@ public class WebhookSender(
|
|||||||
return;
|
return;
|
||||||
var result = await SendAndSaveDelivery(ctx, cancellationToken);
|
var result = await SendAndSaveDelivery(ctx, cancellationToken);
|
||||||
if (ctx.WebhookBlob.AutomaticRedelivery &&
|
if (ctx.WebhookBlob.AutomaticRedelivery &&
|
||||||
!result.Success &&
|
result is { Success: false, DeliveryId: not null })
|
||||||
result.DeliveryId is not null)
|
|
||||||
{
|
{
|
||||||
var originalDeliveryId = result.DeliveryId;
|
var originalDeliveryId = result.DeliveryId;
|
||||||
foreach (var wait in new[]
|
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))
|
return (await StoreRepository.GetWebhooks(invoiceStoreId)).Where(data => webhookEventType is null || data.GetBlob().ShouldDeliver(webhookEventType))
|
||||||
.ToArray();
|
.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(
|
public class WebhookDeliveryRequest(
|
||||||
string webhookId,
|
string webhookId,
|
||||||
WebhookEvent webhookEvent,
|
WebhookEvent webhookEvent,
|
||||||
WebhookDeliveryData delivery,
|
WebhookDeliveryData delivery,
|
||||||
WebhookBlob webhookBlob)
|
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 WebhookEvent WebhookEvent { get; } = webhookEvent;
|
||||||
public WebhookDeliveryData Delivery { get; } = delivery;
|
public WebhookDeliveryData Delivery { get; } = delivery;
|
||||||
public WebhookBlob WebhookBlob { get; } = webhookBlob;
|
public WebhookBlob WebhookBlob { get; } = webhookBlob;
|
||||||
public string WebhookId { get; } = webhookId;
|
public string WebhookId { get; } = webhookId;
|
||||||
|
|
||||||
public virtual Task<SendEmailRequest?> Interpolate(SendEmailRequest req,
|
|
||||||
UIStoresController.StoreEmailRule storeEmailRule)
|
|
||||||
{
|
|
||||||
return Task.FromResult(req)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected static string InterpolateJsonField(string str, string fieldName, JObject obj)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(str) || string.IsNullOrEmpty(fieldName) || obj == null)
|
|
||||||
return str;
|
|
||||||
|
|
||||||
fieldName += ".";
|
|
||||||
|
|
||||||
//find all instance of {fieldName*} in str, then run obj.SelectToken(*) on it
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
var start = str.IndexOf($"{{{fieldName}", StringComparison.InvariantCultureIgnoreCase);
|
|
||||||
if (start == -1)
|
|
||||||
break;
|
|
||||||
|
|
||||||
start += fieldName.Length + 1; // Move past the {
|
|
||||||
var end = str.IndexOf("}", start, StringComparison.InvariantCultureIgnoreCase);
|
|
||||||
if (end == -1)
|
|
||||||
break;
|
|
||||||
|
|
||||||
var jsonpath = str.Substring(start, end - start);
|
|
||||||
var result = string.Empty;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(jsonpath))
|
|
||||||
result = obj.ToString();
|
|
||||||
else if (_jsonPathRegex.IsMatch(jsonpath))
|
|
||||||
// Only process if JSONPath is valid
|
|
||||||
result = obj.SelectToken(jsonpath)?.ToString() ?? string.Empty;
|
|
||||||
// If jsonpath doesn't match the pattern, result remains empty string
|
|
||||||
}
|
|
||||||
catch (JsonException)
|
|
||||||
{
|
|
||||||
// Handle JSON parsing errors (e.g., invalid JSONPath syntax)
|
|
||||||
result = string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
str = str.Replace($"{{{fieldName}{jsonpath}}}", result);
|
|
||||||
}
|
|
||||||
|
|
||||||
return str;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public class DeliveryResult
|
public 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.Globalization;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using BTCPayServer.Abstractions;
|
||||||
using BTCPayServer.Abstractions.Extensions;
|
using BTCPayServer.Abstractions.Extensions;
|
||||||
using BTCPayServer.Client.Models;
|
using BTCPayServer.Client.Models;
|
||||||
using BTCPayServer.Data;
|
using BTCPayServer.Data;
|
||||||
@@ -756,6 +757,8 @@ namespace BTCPayServer.Services.Invoices
|
|||||||
public decimal NetSettled { get; private set; }
|
public decimal NetSettled { get; private set; }
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public bool DisableAccounting { get; set; }
|
public bool DisableAccounting { get; set; }
|
||||||
|
|
||||||
|
public RequestBaseUrl GetRequestBaseUrl() => RequestBaseUrl.FromUrl(ServerUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum InvoiceStatusLegacy
|
public enum InvoiceStatusLegacy
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ namespace BTCPayServer.Services.Mails
|
|||||||
{
|
{
|
||||||
public abstract class EmailSender : IEmailSender
|
public abstract class EmailSender : IEmailSender
|
||||||
{
|
{
|
||||||
|
public EventAggregator EventAggregator { get; }
|
||||||
public Logs Logs { get; }
|
public Logs Logs { get; }
|
||||||
|
|
||||||
readonly IBackgroundJobClient _JobClient;
|
readonly IBackgroundJobClient _JobClient;
|
||||||
|
|
||||||
public EmailSender(IBackgroundJobClient jobClient, Logs logs)
|
public EmailSender(IBackgroundJobClient jobClient, EventAggregator eventAggregator, Logs logs)
|
||||||
{
|
{
|
||||||
|
EventAggregator = eventAggregator;
|
||||||
Logs = logs;
|
Logs = logs;
|
||||||
_JobClient = jobClient ?? throw new ArgumentNullException(nameof(jobClient));
|
_JobClient = jobClient ?? throw new ArgumentNullException(nameof(jobClient));
|
||||||
}
|
}
|
||||||
@@ -36,14 +38,13 @@ namespace BTCPayServer.Services.Mails
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var smtp = await emailSettings.CreateSmtpClient();
|
using var smtp = await emailSettings.CreateSmtpClient();
|
||||||
var prefixedSubject = await GetPrefixedSubject(subject);
|
var mail = emailSettings.CreateMailMessage(email, cc, bcc, subject, message, true);
|
||||||
var mail = emailSettings.CreateMailMessage(email, cc, bcc, prefixedSubject, message, true);
|
var response = await smtp.SendAsync(mail, cancellationToken);
|
||||||
await smtp.SendAsync(mail, cancellationToken);
|
|
||||||
await smtp.DisconnectAsync(true, cancellationToken);
|
await smtp.DisconnectAsync(true, cancellationToken);
|
||||||
|
EventAggregator.Publish(new Events.EmailSentEvent(response, mail));
|
||||||
}, TimeSpan.Zero);
|
}, TimeSpan.Zero);
|
||||||
}
|
}
|
||||||
|
|
||||||
public abstract Task<EmailSettings?> GetEmailSettings();
|
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 IBackgroundJobClient _jobClient;
|
||||||
private readonly SettingsRepository _settingsRepository;
|
private readonly SettingsRepository _settingsRepository;
|
||||||
|
private readonly EventAggregator _eventAggregator;
|
||||||
private readonly StoreRepository _storeRepository;
|
private readonly StoreRepository _storeRepository;
|
||||||
|
|
||||||
public EmailSenderFactory(IBackgroundJobClient jobClient,
|
public EmailSenderFactory(IBackgroundJobClient jobClient,
|
||||||
SettingsRepository settingsSettingsRepository,
|
SettingsRepository settingsSettingsRepository,
|
||||||
|
EventAggregator eventAggregator,
|
||||||
ISettingsAccessor<PoliciesSettings> policiesSettings,
|
ISettingsAccessor<PoliciesSettings> policiesSettings,
|
||||||
StoreRepository storeRepository,
|
StoreRepository storeRepository,
|
||||||
Logs logs)
|
Logs logs)
|
||||||
@@ -24,18 +26,19 @@ namespace BTCPayServer.Services.Mails
|
|||||||
Logs = logs;
|
Logs = logs;
|
||||||
_jobClient = jobClient;
|
_jobClient = jobClient;
|
||||||
_settingsRepository = settingsSettingsRepository;
|
_settingsRepository = settingsSettingsRepository;
|
||||||
|
_eventAggregator = eventAggregator;
|
||||||
PoliciesSettings = policiesSettings;
|
PoliciesSettings = policiesSettings;
|
||||||
_storeRepository = storeRepository;
|
_storeRepository = storeRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<IEmailSender> GetEmailSender(string? storeId = null)
|
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))
|
if (string.IsNullOrEmpty(storeId))
|
||||||
return Task.FromResult<IEmailSender>(serverSender);
|
return Task.FromResult<IEmailSender>(serverSender);
|
||||||
return Task.FromResult<IEmailSender>(new StoreEmailSender(_storeRepository,
|
return Task.FromResult<IEmailSender>(new StoreEmailSender(_storeRepository,
|
||||||
!PoliciesSettings.Settings.DisableStoresToUseServerEmailSettings ? serverSender : null, _jobClient,
|
!PoliciesSettings.Settings.DisableStoresToUseServerEmailSettings ? serverSender : null, _jobClient,
|
||||||
storeId, Logs));
|
_eventAggregator, storeId, Logs));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> IsComplete(string? storeId = null)
|
public async Task<bool> IsComplete(string? storeId = null)
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ namespace BTCPayServer.Services.Mails
|
|||||||
{
|
{
|
||||||
public ServerEmailSender(SettingsRepository settingsRepository,
|
public ServerEmailSender(SettingsRepository settingsRepository,
|
||||||
IBackgroundJobClient backgroundJobClient,
|
IBackgroundJobClient backgroundJobClient,
|
||||||
Logs logs) : base(backgroundJobClient, logs)
|
EventAggregator eventAggregator,
|
||||||
|
Logs logs) : base(backgroundJobClient, eventAggregator, logs)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(settingsRepository);
|
ArgumentNullException.ThrowIfNull(settingsRepository);
|
||||||
SettingsRepository = settingsRepository;
|
SettingsRepository = settingsRepository;
|
||||||
@@ -20,12 +21,5 @@ namespace BTCPayServer.Services.Mails
|
|||||||
{
|
{
|
||||||
return SettingsRepository.GetSettingAsync<EmailSettings>();
|
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,
|
public StoreEmailSender(StoreRepository storeRepository,
|
||||||
EmailSender? fallback,
|
EmailSender? fallback,
|
||||||
IBackgroundJobClient backgroundJobClient,
|
IBackgroundJobClient backgroundJobClient,
|
||||||
|
EventAggregator eventAggregator,
|
||||||
string storeId,
|
string storeId,
|
||||||
Logs logs) : base(backgroundJobClient, logs)
|
Logs logs) : base(backgroundJobClient, eventAggregator, logs)
|
||||||
{
|
{
|
||||||
StoreId = storeId ?? throw new ArgumentNullException(nameof(storeId));
|
StoreId = storeId ?? throw new ArgumentNullException(nameof(storeId));
|
||||||
StoreRepository = storeRepository;
|
StoreRepository = storeRepository;
|
||||||
@@ -52,11 +53,5 @@ namespace BTCPayServer.Services.Mails
|
|||||||
}
|
}
|
||||||
return null;
|
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.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using BTCPayServer.Abstractions;
|
||||||
using BTCPayServer.Client.Models;
|
using BTCPayServer.Client.Models;
|
||||||
using BTCPayServer.Data;
|
using BTCPayServer.Data;
|
||||||
using BTCPayServer.Services.Invoices;
|
using BTCPayServer.Services.Invoices;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
@using BTCPayServer.Plugins.Emails
|
||||||
<div class="alert alert-warning alert-dismissible">
|
<div class="alert alert-warning alert-dismissible">
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="@StringLocalizer["Close"]">
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="@StringLocalizer["Close"]">
|
||||||
<vc:icon symbol="close" />
|
<vc:icon symbol="close" />
|
||||||
</button>
|
</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>
|
<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>
|
</div>
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
@using BTCPayServer.Client
|
@using BTCPayServer.Client
|
||||||
@using BTCPayServer.Controllers
|
@using BTCPayServer.Controllers
|
||||||
@using BTCPayServer.Forms
|
@using BTCPayServer.Forms
|
||||||
|
@using BTCPayServer.Plugins.Emails
|
||||||
@using BTCPayServer.TagHelpers
|
@using BTCPayServer.TagHelpers
|
||||||
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||||
@inject FormDataService FormDataService
|
@inject FormDataService FormDataService
|
||||||
|
@inject LinkGenerator LinkGenerator
|
||||||
@model BTCPayServer.Models.PaymentRequestViewModels.UpdatePaymentRequestViewModel
|
@model BTCPayServer.Models.PaymentRequestViewModels.UpdatePaymentRequestViewModel
|
||||||
@{
|
@{
|
||||||
var checkoutFormOptions = await FormDataService.GetSelect(Model.StoreId, Model.FormId);
|
var checkoutFormOptions = await FormDataService.GetSelect(Model.StoreId, Model.FormId);
|
||||||
@@ -105,8 +107,8 @@
|
|||||||
<input type="email" asp-for="Email" placeholder="@StringLocalizer["Firstname Lastname <email@example.com>"]" class="form-control" />
|
<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>
|
<span asp-validation-for="Email" class="text-danger"></span>
|
||||||
<div id="PaymentRequestEmailHelpBlock" class="form-text">
|
<div id="PaymentRequestEmailHelpBlock" class="form-text">
|
||||||
@ViewLocalizer["This will send notification mails to the recipient, as configured by the {0}.",
|
@ViewLocalizer["This will send notification mails to the recipient, as configured by the <a href=\"{0}\">email rules</a>.",
|
||||||
Html.ActionLink(StringLocalizer["email rules"], nameof(UIStoresController.StoreEmailRulesList), "UIStores", new { storeId = Model.StoreId })]
|
LinkGenerator.GetStoreEmailRulesLink(Model.StoreId, Context.Request.GetRequestBaseUrl())]
|
||||||
@if (Model.HasEmailRules is not true)
|
@if (Model.HasEmailRules is not true)
|
||||||
{
|
{
|
||||||
<div class="info-note mt-1 text-warning" role="alert">
|
<div class="info-note mt-1 text-warning" role="alert">
|
||||||
|
|||||||
@@ -6,7 +6,10 @@
|
|||||||
<form method="post" autocomplete="off">
|
<form method="post" autocomplete="off">
|
||||||
<div class="sticky-header">
|
<div class="sticky-header">
|
||||||
<h2>@ViewData["Title"]</h2>
|
<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>
|
</div>
|
||||||
<partial name="_StatusMessage" />
|
<partial name="_StatusMessage" />
|
||||||
<div class="form-group mb-4">
|
<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