Refactoring of Webhooks and Email Rules (#6954)

This commit is contained in:
Nicolas Dorier
2025-10-19 22:31:24 +09:00
committed by GitHub
parent 6b727dd192
commit e8282ca849
101 changed files with 2700 additions and 1611 deletions

View 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();
}
}

View File

@@ -65,12 +65,14 @@ namespace BTCPayServer.Data
public DbSet<FormData> Forms { get; set; }
public DbSet<PendingTransaction> PendingTransactions { get; set; }
public DbSet<EmailRuleData> EmailRules { get; set; }
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
// some of the data models don't have OnModelCreating for now, commenting them
EmailRuleData.OnModelCreating(builder, Database);
ApplicationUser.OnModelCreating(builder, Database);
AddressInvoiceData.OnModelCreating(builder);
APIKeyData.OnModelCreating(builder, Database);

View 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");
}
}

View 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();
}

View File

@@ -1,8 +1,11 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using BTCPayServer.Abstractions;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Newtonsoft.Json;
namespace BTCPayServer.Data;
@@ -21,8 +24,8 @@ public class PendingTransaction: IHasBlob<PendingTransactionBlob>
public byte[] Blob { get; set; }
public string Blob2 { get; set; }
internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
{
builder.Entity<PendingTransaction>()
@@ -37,7 +40,7 @@ public class PendingTransaction: IHasBlob<PendingTransactionBlob>
builder.Entity<PendingTransaction>()
.Property(o => o.Blob2)
.HasColumnType("JSONB");
.HasColumnType("JSONB");
builder.Entity<PendingTransaction>()
.Property(o => o.OutpointsUsed)
.HasColumnType("text[]");
@@ -56,6 +59,7 @@ public class PendingTransaction: IHasBlob<PendingTransactionBlob>
public class PendingTransactionBlob
{
public string PSBT { get; set; }
public string RequestBaseUrl { get; set; }
public List<CollectedSignature> CollectedSignatures { get; set; } = new();
public int? SignaturesCollected { get; set; }

View File

@@ -2,11 +2,15 @@ using System;
using System.ComponentModel.DataAnnotations;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using NBitcoin;
using NBitcoin.DataEncoders;
namespace BTCPayServer.Data
{
public class WebhookDeliveryData
{
public static WebhookDeliveryData Create(string webhookId)
=> new WebhookDeliveryData { Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(16)), Timestamp = DateTimeOffset.UtcNow, WebhookId = webhookId };
[Key]
[MaxLength(25)]
public string Id { get; set; }

View 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");
}
}
}

View File

@@ -194,6 +194,69 @@ namespace BTCPayServer.Migrations
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("BTCPayServer.Data.EmailRuleData", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property<long>("Id"));
b.Property<string>("AdditionalData")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("additional_data")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<string>("Body")
.IsRequired()
.HasColumnType("text")
.HasColumnName("body");
b.Property<string>("Condition")
.HasColumnType("text")
.HasColumnName("condition");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamptz")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("Metadata")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("metadata")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<string>("StoreId")
.HasColumnType("text")
.HasColumnName("store_id");
b.Property<string>("Subject")
.IsRequired()
.HasColumnType("text")
.HasColumnName("subject");
b.Property<string[]>("To")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("to");
b.Property<string>("Trigger")
.IsRequired()
.HasColumnType("text")
.HasColumnName("trigger");
b.HasKey("Id");
b.HasIndex("StoreId");
b.ToTable("email_rules");
});
modelBuilder.Entity("BTCPayServer.Data.Fido2Credential", b =>
{
b.Property<string>("Id")
@@ -1240,6 +1303,16 @@ namespace BTCPayServer.Migrations
b.Navigation("StoreData");
});
modelBuilder.Entity("BTCPayServer.Data.EmailRuleData", b =>
{
b.HasOne("BTCPayServer.Data.StoreData", "Store")
.WithMany()
.HasForeignKey("StoreId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("Store");
});
modelBuilder.Entity("BTCPayServer.Data.Fido2Credential", b =>
{
b.HasOne("BTCPayServer.Data.ApplicationUser", "ApplicationUser")

View File

@@ -20,6 +20,7 @@ using BTCPayServer.PayoutProcessors;
using BTCPayServer.PayoutProcessors.Lightning;
using BTCPayServer.Plugins.PointOfSale.Controllers;
using BTCPayServer.Plugins.PointOfSale.Models;
using BTCPayServer.Plugins.Webhooks.HostedServices;
using BTCPayServer.Rating;
using BTCPayServer.Services;
using BTCPayServer.Services.Apps;
@@ -1957,7 +1958,7 @@ namespace BTCPayServer.Tests
TestLogs.LogInformation("Can prune deliveries");
var cleanup = tester.PayTester.GetService<HostedServices.CleanupWebhookDeliveriesTask>();
var cleanup = tester.PayTester.GetService<CleanupWebhookDeliveriesTask>();
cleanup.BatchSize = 1;
cleanup.PruneAfter = TimeSpan.Zero;
await cleanup.Do(default);

View 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}'.");
}
}
}

View 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();
}
}

View 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();
}
}

View File

@@ -18,6 +18,7 @@ using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Wallets;
using BTCPayServer.Tests.PMO;
using BTCPayServer.Views.Manage;
using BTCPayServer.Views.Server;
using BTCPayServer.Views.Stores;
@@ -390,12 +391,16 @@ namespace BTCPayServer.Tests
Assert.DoesNotContain("You need to configure email settings before this feature works", await s.Page.ContentAsync());
await s.Page.ClickAsync("#CreateEmailRule");
await s.Page.Locator("#Trigger").SelectOptionAsync(new[] { "InvoicePaymentSettled" });
await s.Page.FillAsync("#To", "test@gmail.com");
await s.Page.ClickAsync("#CustomerEmail");
await s.Page.FillAsync("#Subject", "Thanks!");
await s.Page.Locator(".note-editable").FillAsync("Your invoice is settled");
await s.Page.ClickAsync("#SaveEmailRules");
var pmo = new EmailRulePMO(s);
await pmo.Fill(new()
{
Trigger = "WH-InvoicePaymentSettled",
To = "test@gmail.com",
CustomerEmail = true,
Subject = "Thanks!",
Body = "Your invoice is settled"
});
await s.FindAlertMessage();
// we now have a rule
Assert.DoesNotContain("There are no rules yet.", await s.Page.ContentAsync());
@@ -1404,34 +1409,55 @@ namespace BTCPayServer.Tests
[Fact]
public async Task CanSetupEmailRules()
{
await using var s = CreatePlaywrightTester();
await using var s = CreatePlaywrightTester(newDb: true);
await s.StartAsync();
await s.RegisterNewUser(true);
await s.CreateNewStore();
var (storeName, _) = await s.CreateNewStore();
await s.GoToStore(StoreNavPages.Emails);
await s.Page.ClickAsync("#ConfigureEmailRules");
Assert.Contains("There are no rules yet.", await s.Page.ContentAsync());
Assert.Contains("You need to configure email settings before this feature works", await s.Page.ContentAsync());
await s.Page.ClickAsync(".configure-email");
var mailPMO = new ConfigureEmailPMO(s);
await mailPMO.FillMailPit(new()
{
From = "store@store.com",
Login = "store@store.com",
Password = "password"
});
await s.GoToStore(StoreNavPages.Emails);
await s.Page.ClickAsync("#ConfigureEmailRules");
var pmo = new EmailRulePMO(s);
await s.Page.ClickAsync("#CreateEmailRule");
await s.Page.SelectOptionAsync("#Trigger", "InvoiceCreated");
await s.Page.FillAsync("#To", "invoicecreated@gmail.com");
await s.Page.ClickAsync("#CustomerEmail");
await s.Page.ClickAsync("#SaveEmailRules");
await pmo.Fill(new() {
Trigger = "WH-InvoiceCreated",
To = "invoicecreated@gmail.com",
Subject = "Invoice Created in {Invoice.Currency}!",
Body = "Invoice has been created in {Invoice.Currency} for {Invoice.Price}!",
CustomerEmail = true
});
await s.FindAlertMessage();
Assert.DoesNotContain("There are no rules yet.", await s.Page.ContentAsync());
Assert.Contains("invoicecreated@gmail.com", await s.Page.ContentAsync());
Assert.Contains("Invoice {Invoice.Id} created", await s.Page.ContentAsync());
Assert.Contains("Yes", await s.Page.ContentAsync());
var page = await s.Page.ContentAsync();
Assert.DoesNotContain("There are no rules yet.", page);
Assert.Contains("invoicecreated@gmail.com", page);
Assert.Contains("Invoice Created in {Invoice.Currency}!", page);
Assert.Contains("Yes", page);
await s.Page.ClickAsync("#CreateEmailRule");
await s.Page.SelectOptionAsync("#Trigger", "PaymentRequestStatusChanged");
await s.Page.FillAsync("#To", "statuschanged@gmail.com");
await s.Page.FillAsync("#Subject", "Status changed!");
await s.Page.Locator(".note-editable").FillAsync("Your Payment Request Status is Changed");
await s.Page.ClickAsync("#SaveEmailRules");
await pmo.Fill(new() {
Trigger = "WH-PaymentRequestStatusChanged",
To = "statuschanged@gmail.com",
Subject = "Status changed!",
Body = "Your Payment Request Status is Changed"
});
await s.FindAlertMessage();
Assert.Contains("statuschanged@gmail.com", await s.Page.ContentAsync());
@@ -1441,14 +1467,23 @@ namespace BTCPayServer.Tests
Assert.True(await editButtons.CountAsync() >= 2);
await editButtons.Nth(1).ClickAsync();
await s.Page.Locator("#To").ClearAsync();
await s.Page.FillAsync("#To", "changedagain@gmail.com");
await s.Page.ClickAsync("#SaveEmailRules");
await pmo.Fill(new() {
To = "changedagain@gmail.com"
});
await s.FindAlertMessage();
Assert.Contains("changedagain@gmail.com", await s.Page.ContentAsync());
Assert.DoesNotContain("statuschanged@gmail.com", await s.Page.ContentAsync());
var rulesUrl = s.Page.Url;
await s.AddDerivationScheme();
await s.GoToInvoices();
var sent = await s.Server.WaitForEvent<EmailSentEvent>(() => s.CreateInvoice(amount: 10m, currency: "USD"));
var message = await s.Server.AssertHasEmail(sent);
Assert.Equal("Invoice has been created in USD for 10!", message.Text);
await s.GoToUrl(rulesUrl);
var deleteLinks = s.Page.GetByRole(AriaRole.Link, new() { Name = "Remove" });
Assert.Equal(2, await deleteLinks.CountAsync());
@@ -1466,6 +1501,24 @@ namespace BTCPayServer.Tests
await s.FindAlertMessage();
Assert.Contains("There are no rules yet.", await s.Page.ContentAsync());
await s.Page.ClickAsync("#CreateEmailRule");
await pmo.Fill(new() {
Trigger = "WH-InvoiceCreated",
To = "invoicecreated@gmail.com",
Subject = "Invoice Created in {Invoice.Currency} for {Store.Name}!",
Body = "Invoice has been created in {Invoice.Currency} for {Invoice.Price}!",
CustomerEmail = true,
Condition = "$ ?(@.Invoice.Metadata.buyerEmail == \"john@test.com\")"
});
await s.GoToInvoices();
sent = await s.Server.WaitForEvent<EmailSentEvent>(() => s.CreateInvoice(amount: 10m, currency: "USD", refundEmail: "john@test.com"));
message = await s.Server.AssertHasEmail(sent);
Assert.Equal("Invoice Created in USD for " + storeName + "!", message.Subject);
Assert.Equal("Invoice has been created in USD for 10!", message.Text);
Assert.Equal("john@test.com", message.To[0].Address);
}
[Fact]
@@ -2034,7 +2087,7 @@ namespace BTCPayServer.Tests
var address = await s.Server.ExplorerNode.GetNewAddressAsync();
await newPage.FillAsync("#Destination", address.ToString());
await newPage.PressAsync("#Destination", "Enter");
await s.GoToStore(s.StoreId, StoreNavPages.Payouts);
await s.Page.ClickAsync("#InProgress-view");
@@ -2049,7 +2102,7 @@ namespace BTCPayServer.Tests
await s.Page.ClickAsync(".mass-action-select-all[data-payout-state='InProgress']");
await s.Page.ClickAsync("#InProgress-mark-awaiting-payment");
await s.Page.ClickAsync("#AwaitingPayment-view");
var pageContent = await s.Page.ContentAsync();
Assert.Contains("PP1", pageContent);
}

View File

@@ -20,12 +20,14 @@ using NBitpayClient;
using NBXplorer;
using BTCPayServer.Abstractions.Contracts;
using System.Diagnostics.Metrics;
using BTCPayServer.Events;
namespace BTCPayServer.Tests
{
public class ServerTester : IDisposable
{
public const string DefaultConnectionString = "User ID=postgres;Include Error Detail=true;Host=127.0.0.1;Port=39372;Database=btcpayserver";
public (string Hostname, int SmtpPort, int HttpPort) MailPitSettings { get; set; }
public List<IDisposable> Resources = new List<IDisposable>();
readonly string _Directory;
@@ -43,6 +45,11 @@ namespace BTCPayServer.Tests
if (!Directory.Exists(_Directory))
Directory.CreateDirectory(_Directory);
MailPitSettings = (
GetEnvironment("TESTS_MAILPIT_HOST", "127.0.0.1"),
int.Parse(GetEnvironment("TESTS_MAILPIT_SMTP", "34219")),
int.Parse(GetEnvironment("TESTS_MAILPIT_HTTP", "34218")));
_NetworkProvider = networkProvider;
ExplorerNode = new RPCClient(RPCCredentialString.Parse(GetEnvironment("TESTS_BTCRPCCONNECTION", "server=http://127.0.0.1:43782;ceiwHEbqWI83:DwubwWsoo3")), NetworkProvider.GetNetwork<BTCPayNetwork>("BTC").NBitcoinNetwork);
ExplorerNode.ScanRPCCapabilities();
@@ -286,5 +293,19 @@ namespace BTCPayServer.Tests
cryptoCode == "LTC" ? NetworkProvider.GetNetwork<BTCPayNetwork>("LTC") :
cryptoCode == "LBTC" ? NetworkProvider.GetNetwork<BTCPayNetwork>("LBTC") :
throw new NotSupportedException();
public async Task<MailPitClient.Message> AssertHasEmail(EmailSentEvent sent)
{
var mailPitClient = GetMailPitClient();
return await mailPitClient.GetMessage(sent.ServerResponse.Split(' ').Last());
}
public MailPitClient GetMailPitClient()
{
var http = PayTester.GetService<IHttpClientFactory>().CreateClient("MAIL_PIT");
http.BaseAddress = new Uri($"http://{MailPitSettings.Hostname}:{MailPitSettings.HttpPort}");
var mailPitClient = new MailPitClient(http);
return mailPitClient;
}
}
}

View File

@@ -76,8 +76,9 @@ using MarkPayoutRequest = BTCPayServer.Client.Models.MarkPayoutRequest;
using RatesViewModel = BTCPayServer.Models.StoreViewModels.RatesViewModel;
using Microsoft.Extensions.Caching.Memory;
using PosViewType = BTCPayServer.Client.Models.PosViewType;
using BTCPayServer.PaymentRequest;
using BTCPayServer.Plugins.Emails.Controllers;
using BTCPayServer.Views.Stores;
using MimeKit;
using NBXplorer.DerivationStrategy;
namespace BTCPayServer.Tests
@@ -1599,6 +1600,107 @@ namespace BTCPayServer.Tests
});
}
[Fact]
[Trait("UnitTest", "UnitTest")]
public void TestMailTemplate()
{
var template = new TextTemplate("Hello mister {Name.Firstname} {Name.Lastname} !");
// Happy path
JObject model = new()
{
["Name"] = new JObject
{
["Firstname"] = "John",
["Lastname"] = "Doe"
}
};
var result = template.Render(model);
// Null values are not rendered
Assert.Equal("Hello mister John Doe !", result);
model = new()
{
["Name"] = new JObject
{
["Firstname"] = "John",
["Lastname"] = null
}
};
result = template.Render(model);
Assert.Equal("Hello mister John !", result);
// No crash on missing fields
model = new()
{
["Name"] = new JObject
{
["Firstname"] = "John",
}
};
result = template.Render(model);
Assert.Equal("Hello mister John <NotFound(Name.Lastname)> !", result);
// Is Case insensitive
model = new()
{
["Name"] = new JObject
{
["firstname"] = "John",
}
};
result = template.Render(model);
Assert.Equal("Hello mister John <NotFound(Name.Lastname)> !", result);
model = new()
{
["Name"] = new JObject
{
["Firstname"] = "John",
["Lastname"] = "Doe",
["NameInner"] = new JObject
{
["Ogg"] = 2,
["Arr"] = new JArray {
new JObject() { ["ItemName"] = "hello" },
2,
new JObject() { ["ItemName"] = "world" } }
}
}
};
var paths = template.GetPaths(model);
Assert.Equal("{Name.Firstname}", paths[0]);
Assert.Equal("{Name.Lastname}", paths[1]);
Assert.Equal("{Name.NameInner.Ogg}", paths[2]);
Assert.Equal("{Name.NameInner.Arr[0].ItemName}", paths[3]);
Assert.Equal("{Name.NameInner.Arr[1]}", paths[4]);
Assert.Equal("{Name.NameInner.Arr[2].ItemName}", paths[5]);
model = new()
{
["Name"] = new JObject
{
["Firstname"] = "John",
["Lastname"] = "Doe",
["NameInner"] = new JObject
{
["Ogg"] = 2,
["Arr"] = new JArray {
new JObject() { ["ItemName"] = "hello" },
2,
new JObject() { ["ItemName"] = "world" } }
},
["nameInner"] = new JObject()
{
["Ogg2"] = 3
}
}
};
template = new TextTemplate("Hello mister {Name.NameInner.Ogg} {Name.NameInner.Ogg2} !");
result = template.Render(model);
Assert.Equal("Hello mister 2 3 !", result);
}
[Fact]
[Trait("Integration", "Integration")]
public async Task CanUseDefaultCurrency()
@@ -3250,16 +3352,26 @@ namespace BTCPayServer.Tests
Assert.Equal("admin@admin.com", (await Assert.IsType<ServerEmailSender>(await emailSenderFactory.GetEmailSender()).GetEmailSettings()).Login);
Assert.Null(await Assert.IsType<StoreEmailSender>(await emailSenderFactory.GetEmailSender(acc.StoreId)).GetEmailSettings());
Assert.IsType<RedirectToActionResult>(await acc.GetController<UIStoresController>().StoreEmailSettings(acc.StoreId, new EmailsViewModel(new EmailSettings
Assert.IsType<RedirectToActionResult>(await acc.GetController<UIStoresEmailController>().StoreEmailSettings(acc.StoreId, new EmailsViewModel(new EmailSettings
{
From = "store@store.com",
Login = "store@store.com",
Password = "store@store.com",
Port = 1234,
Server = "store.com"
Port = tester.MailPitSettings.SmtpPort,
Server = tester.MailPitSettings.Hostname
}), ""));
Assert.Equal("store@store.com", (await Assert.IsType<StoreEmailSender>(await emailSenderFactory.GetEmailSender(acc.StoreId)).GetEmailSettings()).Login);
var sent = await tester.WaitForEvent<Events.EmailSentEvent>(
async () =>
{
var sender = await emailSenderFactory.GetEmailSender(acc.StoreId);
sender.SendEmail(MailboxAddress.Parse("destination@test.com"), "test", "hello world");
});
var message = await tester.AssertHasEmail(sent);
Assert.Equal("test", message.Subject);
Assert.Equal("hello world", message.Text);
}
[Fact(Timeout = TestUtils.TestTimeout)]

View File

@@ -18,6 +18,9 @@ services:
TESTS_LTCNBXPLORERURL: http://nbxplorer:32838/
TESTS_POSTGRES: User ID=postgres;Include Error Detail=true;Host=postgres;Port=5432;Database=btcpayserver
TESTS_EXPLORER_POSTGRES: User ID=postgres;Include Error Detail=true;Host=postgres;Port=5432;Database=nbxplorer
TESTS_MAILPIT_HOST: mailpit
TESTS_MAILPIT_SMTP: 1025
TESTS_MAILPIT_HTTP: 8025
TESTS_HOSTNAME: tests
TESTS_RUN_EXTERNAL_INTEGRATION: ${TESTS_RUN_EXTERNAL_INTEGRATION:-"false"}
TESTS_AzureBlobStorageConnectionString: ${TESTS_AzureBlobStorageConnectionString:-none}
@@ -60,6 +63,7 @@ services:
- merchant_lnd
- sshd
- tor
- mailpit
sshd:
build:
@@ -98,6 +102,17 @@ services:
default:
custom:
mailpit:
image: axllent/mailpit:v1.27
ports:
# Web UI
- "34218:8025"
# SMTP
- "34219:1025"
environment:
MP_SMTP_AUTH_ACCEPT_ANY: 1
MP_SMTP_AUTH_ALLOW_INSECURE: 1
nbxplorer:
image: nicolasdorier/nbxplorer:2.5.29
restart: unless-stopped

View File

@@ -16,6 +16,9 @@ services:
TESTS_BTCNBXPLORERURL: http://nbxplorer:32838/
TESTS_POSTGRES: User ID=postgres;Include Error Detail=true;Host=postgres;Port=5432;Database=btcpayserver
TESTS_EXPLORER_POSTGRES: User ID=postgres;Include Error Detail=true;Host=postgres;Port=5432;Database=nbxplorer
TESTS_MAILPIT_HOST: mailpit
TESTS_MAILPIT_SMTP: 1025
TESTS_MAILPIT_HTTP: 8025
TESTS_HOSTNAME: tests
TESTS_RUN_EXTERNAL_INTEGRATION: ${TESTS_RUN_EXTERNAL_INTEGRATION:-"false"}
TESTS_AzureBlobStorageConnectionString: ${TESTS_AzureBlobStorageConnectionString:-none}
@@ -56,6 +59,7 @@ services:
- merchant_lnd
- sshd
- tor
- mailpit
sshd:
build:
@@ -94,6 +98,17 @@ services:
default:
custom:
mailpit:
image: axllent/mailpit:v1.27
ports:
# Web UI
- "34218:8025"
# SMTP
- "34219:1025"
environment:
MP_SMTP_AUTH_ACCEPT_ANY: 1
MP_SMTP_AUTH_ALLOW_INSECURE: 1
nbxplorer:
image: nicolasdorier/nbxplorer:2.5.29
restart: unless-stopped

View File

@@ -11,6 +11,7 @@
@using BTCPayServer.Services
@using BTCPayServer.Views.Apps
@using BTCPayServer.Configuration
@using BTCPayServer.Plugins.Emails
@inject Microsoft.AspNetCore.Http.IHttpContextAccessor HttpContext;
@inject BTCPayServerOptions BtcPayServerOptions
@inject BTCPayServerEnvironment Env
@@ -61,13 +62,13 @@
<a id="StoreNav-@(nameof(StoreNavPages.Roles))" class="nav-link @ViewData.ActivePageClass(StoreNavPages.Roles)" asp-controller="UIStores" asp-action="ListRoles" asp-route-storeId="@Model.Store.Id" text-translate="true">Roles</a>
</li>
<li class="nav-item nav-item-sub" permission="@Policies.CanViewStoreSettings">
<a id="StoreNav-@(nameof(StoreNavPages.Webhooks))" class="nav-link @ViewData.ActivePageClass(StoreNavPages.Webhooks)" asp-controller="UIStores" asp-action="Webhooks" asp-route-storeId="@Model.Store.Id" text-translate="true">Webhooks</a>
<a id="StoreNav-@(nameof(StoreNavPages.Webhooks))" class="nav-link @ViewData.ActivePageClass(StoreNavPages.Webhooks)" asp-area="Webhooks" asp-controller="UIStoreWebhooks" asp-action="Webhooks" asp-route-storeId="@Model.Store.Id" text-translate="true">Webhooks</a>
</li>
<li class="nav-item nav-item-sub" permission="@Policies.CanViewStoreSettings">
<a id="StoreNav-@(nameof(StoreNavPages.PayoutProcessors))" class="nav-link @ViewData.ActivePageClass(StoreNavPages.PayoutProcessors)" asp-controller="UIPayoutProcessors" asp-action="ConfigureStorePayoutProcessors" asp-route-storeId="@Model.Store.Id" text-translate="true">Payout Processors</a>
</li>
<li class="nav-item nav-item-sub" permission="@Policies.CanViewStoreSettings">
<a id="StoreNav-@(nameof(StoreNavPages.Emails))" class="nav-link @ViewData.ActivePageClass(StoreNavPages.Emails)" asp-controller="UIStores" asp-action="StoreEmailSettings" asp-route-storeId="@Model.Store.Id" text-translate="true">Emails</a>
<a id="StoreNav-@(nameof(StoreNavPages.Emails))" class="nav-link @ViewData.ActivePageClass(StoreNavPages.Emails)" asp-area="@EmailsPlugin.Area" asp-controller="UIStoresEmail" asp-action="StoreEmailSettings" asp-route-storeId="@Model.Store.Id" text-translate="true">Emails</a>
</li>
<li class="nav-item nav-item-sub" permission="@Policies.CanViewStoreSettings">
<a id="StoreNav-@(nameof(StoreNavPages.Forms))" class="nav-link @ViewData.ActivePageClass(StoreNavPages.Forms)" asp-controller="UIForms" asp-action="FormsList" asp-route-storeId="@Model.Store.Id" text-translate="true">Forms</a>

View File

@@ -182,7 +182,7 @@ namespace BTCPayServer.Controllers.Greenfield
new PaymentRequestQuery() { StoreId = storeId, Ids = new[] { paymentRequestId } })).FirstOrDefault();
if (pr is null)
return PaymentRequestNotFound();
if ((pr.Amount != request.Amount && request.Amount != 0.0m) ||
if ((pr.Amount != request.Amount && request.Amount != 0.0m) ||
(pr.Currency != request.Currency && request.Currency != null))
{
var prWithInvoices = await this.PaymentRequestService.GetPaymentRequest(paymentRequestId, GetUserId());
@@ -226,7 +226,8 @@ namespace BTCPayServer.Controllers.Greenfield
Description = request.Description,
Email = request.Email,
FormId = request.FormId,
FormResponse = blob.FormId != request.FormId ? null : blob.FormResponse
FormResponse = blob.FormId != request.FormId ? null : blob.FormResponse,
RequestBaseUrl = Request.GetRequestBaseUrl().ToString()
});
pr = await _paymentRequestRepository.CreateOrUpdatePaymentRequest(pr);
return Ok(FromModel(pr));

View File

@@ -12,6 +12,7 @@ using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers.GreenField;
using BTCPayServer.Data;
using BTCPayServer.Plugins.Webhooks.Controllers;
using BTCPayServer.Security;
using BTCPayServer.Security.Greenfield;
using BTCPayServer.Services.Mails;

View File

@@ -20,6 +20,7 @@ using BTCPayServer.Models.PaymentRequestViewModels;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.Payouts;
using BTCPayServer.Plugins.Webhooks.Views;
using BTCPayServer.Rating;
using BTCPayServer.Services;
using BTCPayServer.Services.Apps;
@@ -154,7 +155,7 @@ namespace BTCPayServer.Controllers
ShowCheckout = invoice.Status == InvoiceStatus.New,
ShowReceipt = invoice.Status == InvoiceStatus.Settled && (invoice.ReceiptOptions?.Enabled ?? receipt.Enabled is true),
Deliveries = (await _InvoiceRepository.GetWebhookDeliveries(invoiceId))
.Select(c => new Models.StoreViewModels.DeliveryViewModel(c))
.Select(c => new DeliveryViewModel(c))
.ToList()
};

View File

@@ -12,10 +12,8 @@ using BTCPayServer.Services;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.HostedServices;
using BTCPayServer.HostedServices.Webhooks;
using BTCPayServer.Logging;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Rating;
using BTCPayServer.Security;
using BTCPayServer.Security.Greenfield;
@@ -29,11 +27,10 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using NBitcoin;
using Newtonsoft.Json.Linq;
using StoreData = BTCPayServer.Data.StoreData;
using Serilog.Filters;
using BTCPayServer.Payouts;
using BTCPayServer.Plugins.Webhooks;
using Microsoft.Extensions.Localization;
namespace BTCPayServer.Controllers

View File

@@ -23,6 +23,7 @@ using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
using PaymentRequestData = BTCPayServer.Data.PaymentRequestData;
using StoreData = BTCPayServer.Data.StoreData;
@@ -38,10 +39,10 @@ namespace BTCPayServer.Controllers
private readonly UserManager<ApplicationUser> _UserManager;
private readonly PaymentRequestRepository _PaymentRequestRepository;
private readonly PaymentRequestService _PaymentRequestService;
private readonly EventAggregator _EventAggregator;
private readonly CurrencyNameTable _Currencies;
private readonly DisplayFormatter _displayFormatter;
private readonly InvoiceRepository _InvoiceRepository;
private readonly ApplicationDbContextFactory _dbContextFactory;
private readonly StoreRepository _storeRepository;
private readonly UriResolver _uriResolver;
private readonly BTCPayNetworkProvider _networkProvider;
@@ -56,7 +57,6 @@ namespace BTCPayServer.Controllers
UserManager<ApplicationUser> userManager,
PaymentRequestRepository paymentRequestRepository,
PaymentRequestService paymentRequestService,
EventAggregator eventAggregator,
CurrencyNameTable currencies,
DisplayFormatter displayFormatter,
StoreRepository storeRepository,
@@ -65,6 +65,7 @@ namespace BTCPayServer.Controllers
FormComponentProviders formProviders,
FormDataService formDataService,
IStringLocalizer stringLocalizer,
ApplicationDbContextFactory dbContextFactory,
BTCPayNetworkProvider networkProvider)
{
_InvoiceController = invoiceController;
@@ -72,12 +73,12 @@ namespace BTCPayServer.Controllers
_UserManager = userManager;
_PaymentRequestRepository = paymentRequestRepository;
_PaymentRequestService = paymentRequestService;
_EventAggregator = eventAggregator;
_Currencies = currencies;
_displayFormatter = displayFormatter;
_storeRepository = storeRepository;
_uriResolver = uriResolver;
_InvoiceRepository = invoiceRepository;
_dbContextFactory = dbContextFactory;
FormProviders = formProviders;
FormDataService = formDataService;
_networkProvider = networkProvider;
@@ -126,11 +127,13 @@ namespace BTCPayServer.Controllers
{
return NotFound();
}
var paymentRequest = GetCurrentPaymentRequest();
if (paymentRequest == null && !string.IsNullOrEmpty(payReqId))
{
return NotFound();
}
if (!store.AnyPaymentMethodAvailable(_handlers))
{
return NoPaymentMethodResult(storeId);
@@ -145,12 +148,19 @@ namespace BTCPayServer.Controllers
};
vm.Currency ??= storeBlob.DefaultCurrency;
vm.HasEmailRules = storeBlob.EmailRules?.Any(rule =>
rule.Trigger.Contains("PaymentRequest", StringComparison.InvariantCultureIgnoreCase));
vm.HasEmailRules = await HasEmailRules(store.Id);
return View(nameof(EditPaymentRequest), vm);
}
private async Task<bool> HasEmailRules(string storeId)
{
await using var ctx = _dbContextFactory.CreateContext();
return await ctx.Set<EmailRuleData>()
.AsNoTracking()
.AnyAsync(r => r.StoreId == storeId && EF.Functions.Like(r.Trigger, "WH-PaymentRequest%"));
}
[HttpPost("/stores/{storeId}/payment-requests/edit/{payReqId?}")]
[Authorize(Policy = Policies.CanModifyPaymentRequests, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> EditPaymentRequest(string payReqId, UpdatePaymentRequestViewModel viewModel)
@@ -168,6 +178,7 @@ namespace BTCPayServer.Controllers
{
return NotFound();
}
if (!store.AnyPaymentMethodAvailable(_handlers))
{
return NoPaymentMethodResult(store.Id);
@@ -177,6 +188,7 @@ namespace BTCPayServer.Controllers
{
ModelState.AddModelError(string.Empty, StringLocalizer["You cannot edit an archived payment request."]);
}
var data = paymentRequest ?? new PaymentRequestData();
data.StoreDataId = viewModel.StoreId;
data.Archived = viewModel.Archived;
@@ -194,9 +206,7 @@ namespace BTCPayServer.Controllers
if (!ModelState.IsValid)
{
var storeBlob = store.GetStoreBlob();
viewModel.HasEmailRules = storeBlob.EmailRules?.Any(rule =>
rule.Trigger.Contains("PaymentRequest", StringComparison.InvariantCultureIgnoreCase));
viewModel.HasEmailRules = await HasEmailRules(store.Id);
return View(nameof(EditPaymentRequest), viewModel);
}
@@ -209,6 +219,8 @@ namespace BTCPayServer.Controllers
data.ReferenceId = viewModel.ReferenceId;
blob.AllowCustomPaymentAmounts = viewModel.AllowCustomPaymentAmounts;
blob.FormId = viewModel.FormId;
if (payReqId is null || blob.RequestBaseUrl is null)
blob.RequestBaseUrl = Request.GetRequestBaseUrl().ToString();
data.SetBlob(blob);
var isNewPaymentRequest = string.IsNullOrEmpty(payReqId);
@@ -234,6 +246,7 @@ namespace BTCPayServer.Controllers
{
return NotFound();
}
var store = await _storeRepository.FindStore(vm.StoreId);
if (store == null)
{
@@ -266,6 +279,7 @@ namespace BTCPayServer.Controllers
{
return RedirectToAction("PayPaymentRequest", new { payReqId });
}
var prFormId = prBlob.FormId;
var formData = await FormDataService.GetForm(prFormId);
if (formData is null)
@@ -282,21 +296,26 @@ namespace BTCPayServer.Controllers
emailField.Value = prBlob.Email;
}
}
if (Request.Method == "POST" && Request.HasFormContentType)
{
form.ApplyValuesFromForm(Request.Form);
if (FormDataService.Validate(form, ModelState))
{
prBlob.FormResponse = FormDataService.GetValues(form);
if(string.IsNullOrEmpty(prBlob.Email) && form.GetFieldByFullName("buyerEmail") is { } emailField)
if (string.IsNullOrEmpty(prBlob.Email) && form.GetFieldByFullName("buyerEmail") is { } emailField)
{
prBlob.Email = emailField.Value;
}
if (prBlob.RequestBaseUrl is null)
prBlob.RequestBaseUrl = Request.GetRequestBaseUrl().ToString();
result.SetBlob(prBlob);
await _PaymentRequestRepository.CreateOrUpdatePaymentRequest(result);
return RedirectToAction("PayPaymentRequest", new { payReqId });
}
}
viewModel.FormName = formData.Name;
viewModel.Form = form;
@@ -331,6 +350,7 @@ namespace BTCPayServer.Controllers
return BadRequest(StringLocalizer["Payment Request cannot be paid as it has been archived"]);
}
if (!result.FormSubmitted && !string.IsNullOrEmpty(result.FormId))
{
var formData = await FormDataService.GetForm(result.FormId);
@@ -454,7 +474,7 @@ namespace BTCPayServer.Controllers
var store = GetCurrentStore();
var result = await _PaymentRequestRepository.ArchivePaymentRequest(payReqId, true);
if(result is not null)
if (result is not null)
{
TempData[WellKnownTempData.SuccessMessage] = result.Value
? StringLocalizer["The payment request has been archived and will no longer appear in the payment request list by default again."].Value
@@ -501,7 +521,8 @@ namespace BTCPayServer.Controllers
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Error,
Html = $"To create a payment request, you need to <a href='{Url.Action(nameof(UIStoresController.SetupWallet), "UIStores", new { cryptoCode = _networkProvider.DefaultNetwork.CryptoCode, storeId })}' class='alert-link'>set up a wallet</a> first",
Html =
$"To create a payment request, you need to <a href='{Url.Action(nameof(UIStoresController.SetupWallet), "UIStores", new { cryptoCode = _networkProvider.DefaultNetwork.CryptoCode, storeId })}' class='alert-link'>set up a wallet</a> first",
AllowDismiss = false
});
return RedirectToAction(nameof(GetPaymentRequests), new { storeId });

View File

@@ -1281,21 +1281,39 @@ namespace BTCPayServer.Controllers
settings.Password = null;
await _SettingsRepository.UpdateSetting(settings);
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email server password reset"].Value;
return RedirectToAction(nameof(Emails));
}
// save if user provided valid email; this will also clear settings if no model.Settings.From
if (model.Settings.From is not null && !MailboxAddressValidator.IsMailboxAddress(model.Settings.From))
else if (command == "mailpit")
{
ModelState.AddModelError("Settings.From", StringLocalizer["Invalid email"]);
return View(model);
model.Settings.Server = "localhost";
model.Settings.Port = 34219;
model.Settings.EnabledCertificateCheck = false;
model.Settings.Login ??= "store@example.com";
model.Settings.From ??= "store@example.com";
model.Settings.Password ??= "password";
await _SettingsRepository.UpdateSetting<EmailSettings>(model.Settings);
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Info,
AllowDismiss = true,
Html = "Mailpit is now running on <a href=\"http://localhost:34218\" target=\"_blank\" class=\"alert-link\">localhost</a>. You can use it to test your SMTP settings."
});
}
var oldSettings = await _emailSenderFactory.GetSettings() ?? new EmailSettings();
if (!string.IsNullOrEmpty(oldSettings.Password))
model.Settings.Password = oldSettings.Password;
else
{
// save if user provided valid email; this will also clear settings if no model.Settings.From
if (model.Settings.From is not null && !MailboxAddressValidator.IsMailboxAddress(model.Settings.From))
{
ModelState.AddModelError("Settings.From", StringLocalizer["Invalid email"]);
return View(model);
}
await _SettingsRepository.UpdateSetting(model.Settings);
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email settings saved"].Value;
var oldSettings = await _emailSenderFactory.GetSettings() ?? new EmailSettings();
if (!string.IsNullOrEmpty(oldSettings.Password))
model.Settings.Password = oldSettings.Password;
await _SettingsRepository.UpdateSetting(model.Settings);
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email settings saved"].Value;
}
return RedirectToAction(nameof(Emails));
}

View File

@@ -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; }
}
}
}

View File

@@ -6,7 +6,6 @@ using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Client;
using BTCPayServer.Configuration;
using BTCPayServer.Data;
using BTCPayServer.HostedServices.Webhooks;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Security.Bitpay;
using BTCPayServer.Services;
@@ -50,7 +49,6 @@ public partial class UIStoresController : Controller
IAuthorizationService authorizationService,
AppService appService,
IFileService fileService,
WebhookSender webhookNotificationManager,
IDataProtectionProvider dataProtector,
IOptions<LightningNetworkOptions> lightningNetworkOptions,
IOptions<ExternalServicesOptions> externalServiceOptions,
@@ -94,7 +92,6 @@ public partial class UIStoresController : Controller
_html = html;
_defaultRules = defaultRules;
_dataProtector = dataProtector.CreateProtector("ConfigProtector");
_webhookNotificationManager = webhookNotificationManager;
_lightningNetworkOptions = lightningNetworkOptions.Value;
_lnHistogramService = lnHistogramService;
_lightningClientFactory = lightningClientFactory;
@@ -127,7 +124,6 @@ public partial class UIStoresController : Controller
private readonly UriResolver _uriResolver;
private readonly EventAggregator _eventAggregator;
private readonly IHtmlHelper _html;
private readonly WebhookSender _webhookNotificationManager;
private readonly LightningNetworkOptions _lightningNetworkOptions;
private readonly IDataProtector _dataProtector;
private readonly LightningHistogramService _lnHistogramService;
@@ -138,7 +134,7 @@ public partial class UIStoresController : Controller
[TempData]
private bool StoreNotConfigured { get; set; }
[AllowAnonymous]
[HttpGet("{storeId}/index")]
public async Task<IActionResult> Index(string storeId)
@@ -146,7 +142,7 @@ public partial class UIStoresController : Controller
var userId = _userManager.GetUserId(User);
if (string.IsNullOrEmpty(userId))
return Forbid();
var store = await _storeRepo.FindStore(storeId);
if (store is null)
return NotFound();
@@ -161,7 +157,7 @@ public partial class UIStoresController : Controller
}
return Forbid();
}
public StoreData CurrentStore => HttpContext.GetStoreData();
public PaymentMethodOptionViewModel.Format[] GetEnabledPaymentMethodChoices(StoreData storeData)

View File

@@ -175,7 +175,7 @@ namespace BTCPayServer.Controllers
switch (command)
{
case "createpending":
await _pendingTransactionService.CreatePendingTransaction(walletId.StoreId, walletId.CryptoCode, psbt);
await _pendingTransactionService.CreatePendingTransaction(walletId.StoreId, walletId.CryptoCode, psbt, Request.GetRequestBaseUrl());
return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() });
case "sign":
return await WalletSign(walletId, vm);

View File

@@ -444,7 +444,7 @@ namespace BTCPayServer.Controllers
switch (model.Command)
{
case "createpending":
await _pendingTransactionService.CreatePendingTransaction(walletId.StoreId, walletId.CryptoCode, psbt);
await _pendingTransactionService.CreatePendingTransaction(walletId.StoreId, walletId.CryptoCode, psbt, Request.GetRequestBaseUrl());
return RedirectToWalletList(walletId);
default:
// case "sign":
@@ -1296,7 +1296,7 @@ namespace BTCPayServer.Controllers
switch (command)
{
case "createpending":
await _pendingTransactionService.CreatePendingTransaction(walletId.StoreId, walletId.CryptoCode, psbt);
await _pendingTransactionService.CreatePendingTransaction(walletId.StoreId, walletId.CryptoCode, psbt, Request.GetRequestBaseUrl());
return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() });
case "sign":
return await WalletSign(walletId, new WalletPSBTViewModel

View File

@@ -15,5 +15,6 @@ namespace BTCPayServer.Data
public string FormId { get; set; }
public JObject FormResponse { get; set; }
public string RequestBaseUrl { get; set; }
}
}

View File

@@ -1,17 +1,13 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using BTCPayServer.Client.JsonConverters;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers;
using BTCPayServer.JsonConverters;
using BTCPayServer.Payments;
using BTCPayServer.Rating;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Mails;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
@@ -221,7 +217,6 @@ namespace BTCPayServer.Data
[JsonConverter(typeof(TimeSpanJsonConverter.Days))]
public TimeSpan RefundBOLT11Expiration { get; set; }
public List<UIStoresController.StoreEmailRule> EmailRules { get; set; }
public string BrandColor { get; set; }
public bool ApplyBrandColorToBackend { get; set; }

View 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})";
}

View File

@@ -1,6 +1,5 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Globalization;
@@ -29,7 +28,6 @@ using BTCPayServer.NTag424;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.Payouts;
using BTCPayServer.Security;
using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
@@ -37,7 +35,6 @@ using BTCPayServer.Services.Reporting;
using BTCPayServer.Services.Wallets;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

View File

@@ -39,6 +39,8 @@ namespace Microsoft.AspNetCore.Mvc
return urlHelper.GetUriByAction(nameof(UIAccountController.LoginUsingCode), "UIAccount", new { loginCode, returnUrl }, scheme, host, pathbase);
}
public static string PaymentRequestLink(this LinkGenerator urlHelper, string paymentRequestId, RequestBaseUrl baseUrl)
=> PaymentRequestLink(urlHelper, paymentRequestId, baseUrl.Scheme, baseUrl.Host, baseUrl.PathBase);
public static string PaymentRequestLink(this LinkGenerator urlHelper, string paymentRequestId, string scheme, HostString host, string pathbase)
{
return urlHelper.GetUriByAction(
@@ -48,6 +50,16 @@ namespace Microsoft.AspNetCore.Mvc
scheme, host, pathbase);
}
public static string WalletTransactionsLink(this LinkGenerator urlHelper, WalletId walletId, RequestBaseUrl baseUrl)
{
return urlHelper.GetUriByAction(
action: nameof(UIWalletsController.WalletTransactions),
controller: "UIWallets",
values: new { walletId = walletId.ToString() },
baseUrl
);
}
public static string AppLink(this LinkGenerator urlHelper, string appId, string scheme, HostString host, string pathbase)
{
return urlHelper.GetUriByAction(
@@ -57,6 +69,8 @@ namespace Microsoft.AspNetCore.Mvc
scheme, host, pathbase);
}
public static string InvoiceLink(this LinkGenerator urlHelper, string invoiceId, RequestBaseUrl baseUrl)
=> InvoiceLink(urlHelper, invoiceId, baseUrl.Scheme, baseUrl.Host, baseUrl.PathBase);
public static string InvoiceLink(this LinkGenerator urlHelper, string invoiceId, string scheme, HostString host, string pathbase)
{
return urlHelper.GetUriByAction(

View File

@@ -1,22 +1,14 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers;
using BTCPayServer.Abstractions;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.HostedServices.Webhooks;
using BTCPayServer.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NBitcoin;
using NBitcoin.DataEncoders;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using WebhookDeliveryData = BTCPayServer.Data.WebhookDeliveryData;
namespace BTCPayServer.HostedServices;
@@ -32,14 +24,14 @@ public class PendingTransactionService(
Subscribe<NewOnChainTransactionEvent>();
base.SubscribeToEvents();
}
public Task Do(CancellationToken cancellationToken)
{
PushEvent(new CheckForExpiryEvent());
return Task.CompletedTask;
}
public class CheckForExpiryEvent { }
public class CheckForExpiryEvent { }
protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
{
@@ -92,6 +84,7 @@ public class PendingTransactionService(
}
public async Task<PendingTransaction> CreatePendingTransaction(string storeId, string cryptoCode, PSBT psbt,
RequestBaseUrl requestBaseUrl,
DateTimeOffset? expiry = null, CancellationToken cancellationToken = default)
{
var network = networkProvider.GetNetwork<BTCPayNetwork>(cryptoCode);
@@ -135,7 +128,8 @@ public class PendingTransactionService(
PSBT = psbt.ToBase64(),
SignaturesCollected = 0,
SignaturesNeeded = signaturesNeeded,
SignaturesTotal = signaturesTotal
SignaturesTotal = signaturesTotal,
RequestBaseUrl = requestBaseUrl.ToString()
});
ctx.PendingTransactions.Add(pendingTransaction);
@@ -155,7 +149,7 @@ public class PendingTransactionService(
await using var ctx = dbContextFactory.CreateContext();
var pendingTransaction = await ctx.PendingTransactions.FirstOrDefaultAsync(p =>
p.CryptoCode == id.CryptoCode && p.StoreId == id.StoreId && p.Id == id.Id, cancellationToken);
if (pendingTransaction?.State is not PendingTransactionState.Pending)
{
return null;
@@ -201,7 +195,7 @@ public class PendingTransactionService(
{
// TODO: For now we're going with estimation of how many signatures were collected until we find better way
// so for example if we have 4 new signatures and only 2 inputs - number of collected signatures will be 2
blob.SignaturesCollected += newSignatures / newWorkingCopyPsbt.Inputs.Count();
blob.SignaturesCollected += newSignatures / newWorkingCopyPsbt.Inputs.Count;
blob.CollectedSignatures.Add(new CollectedSignature
{
ReceivedPSBT = newPsbtBase64,
@@ -215,7 +209,7 @@ public class PendingTransactionService(
// TODO: Better logic here
if (blob.SignaturesCollected < blob.SignaturesNeeded)
blob.SignaturesCollected = blob.SignaturesNeeded;
pendingTransaction.State = PendingTransactionState.Signed;
}
@@ -284,7 +278,7 @@ public class PendingTransactionService(
public const string SignatureCollected = nameof(SignatureCollected);
public const string Broadcast = nameof(Broadcast);
public const string Cancelled = nameof(Cancelled);
public PendingTransaction Data { get; set; } = null!;
public string Type { get; set; } = null!;
}

View File

@@ -84,7 +84,7 @@ namespace BTCPayServer.HostedServices
var disabledPlugins = pluginService.GetDisabledPlugins();
var installedPlugins = pluginService.Installed;
var remotePlugins = await pluginService.GetRemotePlugins(null);
var remotePlugins = await pluginService.GetRemotePlugins(null, cancellationToken);
//take the latest version of each plugin
var remotePluginsList = remotePlugins
.GroupBy(plugin => plugin.Identifier)

View File

@@ -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);
}
}
}
}
}
}

View File

@@ -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);
}

View File

@@ -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})";
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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
};
}
}

View File

@@ -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;
}
}

View File

@@ -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; }
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -15,7 +15,6 @@ using BTCPayServer.Data;
using BTCPayServer.Data.Payouts.LightningLike;
using BTCPayServer.Forms;
using BTCPayServer.HostedServices;
using BTCPayServer.HostedServices.Webhooks;
using BTCPayServer.Lightning;
using BTCPayServer.Lightning.Charge;
using BTCPayServer.Lightning.CLightning;
@@ -384,7 +383,6 @@ namespace BTCPayServer.Hosting
services.AddSingleton<IHostedService, Services.NBXplorerConnectionFactory>(o => o.GetRequiredService<Services.NBXplorerConnectionFactory>());
services.AddSingleton<HostedServices.CheckConfigurationHostedService>();
services.AddSingleton<IHostedService, HostedServices.CheckConfigurationHostedService>(o => o.GetRequiredService<CheckConfigurationHostedService>());
services.AddSingleton<IHostedService, StoreEmailRuleProcessorSender>();
services.AddSingleton<IHostedService, PeriodicTaskLauncherHostedService>();
services.AddScheduledTask<GithubVersionFetcher>(TimeSpan.FromDays(1));
services.AddScheduledTask<PluginUpdateFetcher>(TimeSpan.FromDays(1));
@@ -395,7 +393,6 @@ namespace BTCPayServer.Hosting
services.AddReportProvider<PayoutsReportProvider>();
services.AddReportProvider<InvoicesReportProvider>();
services.AddReportProvider<RefundsReportProvider>();
services.AddWebhooks();
services.AddSingleton<Dictionary<PaymentMethodId, IPaymentMethodBitpayAPIExtension>>(o =>
o.GetRequiredService<IEnumerable<IPaymentMethodBitpayAPIExtension>>().ToDictionary(o => o.PaymentMethodId, o => o));
@@ -516,16 +513,6 @@ o.GetRequiredService<IEnumerable<IPaymentLinkExtension>>().ToDictionary(o => o.P
services.AddSingleton<IHostedService, Cheater>(o => o.GetRequiredService<Cheater>());
}
var userAgent = new System.Net.Http.Headers.ProductInfoHeaderValue("BTCPayServer", BTCPayServerEnvironment.GetInformationalVersion());
foreach (var clientName in WebhookSender.AllClients.Concat(new[] { BitpayIPNSender.NamedClient }))
{
services.AddHttpClient(clientName)
.ConfigureHttpClient(client =>
{
client.DefaultRequestHeaders.UserAgent.Add(userAgent);
});
}
return services;
}

View File

@@ -173,6 +173,8 @@ namespace BTCPayServer.Hosting
o.AreaViewLocationFormats.Add("/Plugins/{2}/Views/{1}/{0}.cshtml");
o.AreaViewLocationFormats.Add("/Plugins/{2}/Views/{0}.cshtml");
o.AreaViewLocationFormats.Add("/Plugins/{2}/Views/Shared/{0}.cshtml");
o.AreaViewLocationFormats.Add("/{0}.cshtml");
})
.AddNewtonsoftJson()

View File

@@ -4,6 +4,7 @@ using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Plugins.Webhooks.Views;
using BTCPayServer.Services.Invoices;
using NBitcoin;
using Newtonsoft.Json;
@@ -85,7 +86,7 @@ namespace BTCPayServer.Models.InvoicingModels
get; set;
}
public List<StoreViewModels.DeliveryViewModel> Deliveries { get; set; } = new List<StoreViewModels.DeliveryViewModel>();
public List<DeliveryViewModel> Deliveries { get; set; } = new ();
public string TaxIncluded { get; set; }
public string TransactionSpeed { get; set; }

View File

@@ -1,9 +0,0 @@
using BTCPayServer.Client.Models;
namespace BTCPayServer.Models.StoreViewModels
{
public class TestWebhookViewModel
{
public string Type { get; set; }
}
}

View File

@@ -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 });
}
}

View File

@@ -10,16 +10,30 @@ using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client;
using BTCPayServer.Data;
using BTCPayServer.Models;
using BTCPayServer.Plugins.Emails;
using BTCPayServer.Services.Mails;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Localization;
using MimeKit;
namespace BTCPayServer.Controllers;
namespace BTCPayServer.Plugins.Emails.Controllers;
public partial class UIStoresController
[Area(EmailsPlugin.Area)]
[Route("stores/{storeId}")]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[AutoValidateAntiforgeryToken]
public class UIStoresEmailController(
EmailSenderFactory emailSenderFactory,
StoreRepository storeRepository,
IStringLocalizer stringLocalizer) : Controller
{
[HttpGet("{storeId}/email-settings")]
public IStringLocalizer StringLocalizer { get; set; } = stringLocalizer;
[HttpGet("email-settings")]
public async Task<IActionResult> StoreEmailSettings(string storeId)
{
var store = HttpContext.GetStoreData();
@@ -38,7 +52,7 @@ public partial class UIStoresController
record AllEmailSettings(EmailSettings Custom, EmailSettings Fallback);
private async Task<AllEmailSettings> GetCustomSettings(string storeId)
{
var sender = await _emailSenderFactory.GetEmailSender(storeId) as StoreEmailSender;
var sender = await emailSenderFactory.GetEmailSender(storeId) as StoreEmailSender;
if (sender is null)
return new(null, null);
var fallback = sender.FallbackSender is { } fb ? await fb.GetEmailSettings() : null;
@@ -47,7 +61,7 @@ public partial class UIStoresController
return new(await sender.GetCustomSettings(), fallback);
}
[HttpPost("{storeId}/email-settings")]
[HttpPost("email-settings")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> StoreEmailSettings(string storeId, EmailsViewModel model, string command)
{
@@ -95,26 +109,45 @@ public partial class UIStoresController
}
return View(model);
}
else if (command == "mailpit")
{
storeBlob.EmailSettings = model.Settings;
storeBlob.EmailSettings.Server = "localhost";
storeBlob.EmailSettings.Port = 34219;
storeBlob.EmailSettings.EnabledCertificateCheck = false;
storeBlob.EmailSettings.Login ??= "store@example.com";
storeBlob.EmailSettings.From ??= "store@example.com";
storeBlob.EmailSettings.Password ??= "password";
store.SetStoreBlob(storeBlob);
await storeRepository.UpdateStore(store);
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Info,
AllowDismiss = true,
Html = "Mailpit is now running on <a href=\"http://localhost:34218\" target=\"_blank\" class=\"alert-link\">localhost</a>. You can use it to test your SMTP settings."
});
}
else if (command == "ResetPassword")
{
if (storeBlob.EmailSettings is not null)
storeBlob.EmailSettings.Password = null;
store.SetStoreBlob(storeBlob);
await _storeRepo.UpdateStore(store);
await storeRepository.UpdateStore(store);
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email server password reset"].Value;
}
else if (!model.IsCustomSMTP && currentSettings is not null)
{
storeBlob.EmailSettings = null;
store.SetStoreBlob(storeBlob);
await _storeRepo.UpdateStore(store);
await storeRepository.UpdateStore(store);
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["You are now using server's email settings"].Value;
}
else if (model.IsCustomSMTP)
{
storeBlob.EmailSettings = model.Settings;
store.SetStoreBlob(storeBlob);
await _storeRepo.UpdateStore(store);
await storeRepository.UpdateStore(store);
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email settings saved"].Value;
}
return RedirectToAction(nameof(StoreEmailSettings), new { storeId });

View 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;
}
}

View 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>();
}
}

View 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())!;
}

View 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);
}

View 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));
}
}
}
}
}

View 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();
}

View 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();
}

View File

@@ -1,8 +1,4 @@
@using BTCPayServer.Abstractions.Models
@using BTCPayServer.Client
@using BTCPayServer.TagHelpers
@using Microsoft.AspNetCore.Mvc.TagHelpers
@model List<BTCPayServer.Controllers.UIStoresController.StoreEmailRule>
@model List<StoreEmailRuleViewModel>
@{
var storeId = Context.GetStoreData().Id;
@@ -12,14 +8,14 @@
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a asp-action="StoreEmailSettings" asp-route-storeId="@storeId" text-translate="true">Emails</a>
<a asp-controller="UIStoresEmail" asp-action="StoreEmailSettings" asp-route-storeId="@storeId" text-translate="true">Emails</a>
</li>
<li class="breadcrumb-item active" aria-current="page">@ViewData["Title"]</li>
</ol>
<h2>@ViewData["Title"]</h2>
</nav>
<a id="CreateEmailRule" permission="@Policies.CanModifyStoreSettings" asp-action="StoreEmailRulesCreate" asp-route-storeId="@storeId"
class="btn btn-primary" role="button">
<a id="CreateEmailRule" permission="@Policies.CanModifyStoreSettings" asp-action="StoreEmailRulesCreate" asp-route-storeId="@storeId"
class="btn btn-primary" role="button" text-translate="true">
Create Email Rule
</a>
</div>
@@ -35,25 +31,25 @@
<table class="table table-hover">
<thead>
<tr>
<th>Trigger</th>
<th>Customer Email</th>
<th>To</th>
<th>Subject</th>
<th text-translate="true">Trigger</th>
<th text-translate="true">Customer Email</th>
<th text-translate="true">To</th>
<th text-translate="true">Subject</th>
<th class="actions-col" permission="@Policies.CanModifyStoreSettings"></th>
</tr>
</thead>
<tbody>
@foreach (var rule in Model.Select((value, index) => new { value, index }))
@foreach (var rule in Model)
{
<tr>
<td>@rule.value.Trigger</td>
<td>@(rule.value.CustomerEmail ? "Yes" : "No")</td>
<td>@rule.value.To</td>
<td>@rule.value.Subject</td>
<td>@rule.Trigger</td>
<td>@(rule.AdditionalData.CustomerEmail is true ? StringLocalizer["Yes"] : StringLocalizer["No"])</td>
<td>@string.Join(", ", rule.To)</td>
<td>@rule.Subject</td>
<td class="actions-col" permission="@Policies.CanModifyStoreSettings">
<div class="d-inline-flex align-items-center gap-3">
<a asp-action="StoreEmailRulesEdit" asp-route-storeId="@storeId" asp-route-ruleIndex="@rule.index">Edit</a>
<a asp-action="StoreEmailRulesDelete" asp-route-storeId="@storeId" asp-route-ruleIndex="@rule.index" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-description="@ViewLocalizer["This action will remove the rule with the trigger <b>{0}</b>.", Html.Encode(rule.value.Trigger)]" data-confirm-input="@StringLocalizer["REMOVE"]" text-translate="true">Remove</a>
<a asp-action="StoreEmailRulesEdit" asp-route-storeId="@storeId" asp-route-ruleId="@rule.Data.Id">Edit</a>
<a asp-action="StoreEmailRulesDelete" asp-route-storeId="@storeId" asp-route-ruleId="@rule.Data.Id" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-description="@ViewLocalizer["This action will remove the rule with the trigger <b>{0}</b>.", Html.Encode(rule.Trigger)]" data-confirm-input="@StringLocalizer["REMOVE"]" text-translate="true">Remove</a>
</div>
</td>
</tr>

View File

@@ -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>
}

View File

@@ -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" />
}

View 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

View File

@@ -0,0 +1,3 @@
@{
Layout = "_Layout";
}

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
@@ -59,7 +60,7 @@ namespace BTCPayServer.Plugins
_httpClient = httpClient;
}
static JsonSerializerSettings serializerSettings = new() { ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver() };
public async Task<PublishedVersion[]> GetPublishedVersions(string btcpayVersion, bool includePreRelease, string searchPluginName = null, bool? includeAllVersions = null)
public async Task<PublishedVersion[]> GetPublishedVersions(string btcpayVersion, bool includePreRelease, string searchPluginName = null, bool? includeAllVersions = null, CancellationToken cancellationToken = default)
{
var queryString = $"?includePreRelease={includePreRelease}";
if (btcpayVersion is not null)
@@ -68,7 +69,7 @@ namespace BTCPayServer.Plugins
queryString += $"&searchPluginName={Uri.EscapeDataString(searchPluginName)}";
if (includeAllVersions is not null)
queryString += $"&includeAllVersions={includeAllVersions}";
var result = await _httpClient.GetStringAsync($"api/v1/plugins{queryString}");
var result = await _httpClient.GetStringAsync($"api/v1/plugins{queryString}", cancellationToken);
return JsonConvert.DeserializeObject<PublishedVersion[]>(result, serializerSettings) ?? throw new InvalidOperationException();
}
public async Task<PublishedVersion> GetPlugin(string pluginSlug, string version)
@@ -97,7 +98,8 @@ namespace BTCPayServer.Plugins
public async Task<PublishedVersion[]> GetInstalledPluginsUpdates(
string btcpayVersion,
bool includePreRelease,
IEnumerable<InstalledPluginRequest> plugins)
IEnumerable<InstalledPluginRequest> plugins,
CancellationToken cancellationToken = default)
{
var queryString = $"?includePreRelease={includePreRelease}";
if (!string.IsNullOrWhiteSpace(btcpayVersion))
@@ -106,10 +108,10 @@ namespace BTCPayServer.Plugins
var json = JsonConvert.SerializeObject(plugins, serializerSettings);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var resp = await _httpClient.PostAsync($"api/v1/plugins/updates{queryString}", content);
using var resp = await _httpClient.PostAsync($"api/v1/plugins/updates{queryString}", content, cancellationToken);
resp.EnsureSuccessStatusCode();
var body = await resp.Content.ReadAsStringAsync();
var body = await resp.Content.ReadAsStringAsync(cancellationToken);
var result = JsonConvert.DeserializeObject<PublishedVersion[]>(body, serializerSettings);
if (result is null)

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Configuration;
@@ -53,11 +54,11 @@ namespace BTCPayServer.Plugins
private string GetShortBtcpayVersion() => Env.Version.TrimStart('v').Split('+')[0];
public async Task<AvailablePlugin[]> GetRemotePlugins(string searchPluginName)
public async Task<AvailablePlugin[]> GetRemotePlugins(string searchPluginName, CancellationToken cancellationToken = default)
{
string btcpayVersion = GetShortBtcpayVersion();
var versions = await _pluginBuilderClient.GetPublishedVersions(
btcpayVersion, _policiesSettings.PluginPreReleases, searchPluginName);
btcpayVersion, _policiesSettings.PluginPreReleases, searchPluginName, cancellationToken: cancellationToken);
var plugins = versions
.Select(MapToAvailablePlugin)
@@ -79,7 +80,7 @@ namespace BTCPayServer.Plugins
var updates = await _pluginBuilderClient.GetInstalledPluginsUpdates(
btcpayVersion,
_policiesSettings.PluginPreReleases,
loadedToCheck);
loadedToCheck, cancellationToken: cancellationToken);
if (updates is { Length: > 0 })
{

View File

@@ -6,14 +6,13 @@ using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.HostedServices.Webhooks;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Controllers.Greenfield
namespace BTCPayServer.Plugins.Webhooks.Controllers
{
[ApiController]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield,

View File

@@ -1,31 +1,42 @@
#nullable enable
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Plugins.Webhooks.Views;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization;
using NBitcoin;
using NBitcoin.DataEncoders;
namespace BTCPayServer.Controllers;
namespace BTCPayServer.Plugins.Webhooks.Controllers;
public partial class UIStoresController
[Route("stores")]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[AutoValidateAntiforgeryToken]
[Area(WebhooksPlugin.Area)]
public class UIStoreWebhooksController(
StoreRepository storeRepo,
IStringLocalizer stringLocalizer,
WebhookSender webhookSender) : Controller
{
public Data.StoreData CurrentStore => HttpContext.GetStoreData();
public IStringLocalizer StringLocalizer { get; set; } = stringLocalizer;
private async Task<Data.WebhookDeliveryData?> LastDeliveryForWebhook(string webhookId)
{
return (await _storeRepo.GetWebhookDeliveries(CurrentStore.Id, webhookId, 1)).ToList().FirstOrDefault();
return (await storeRepo.GetWebhookDeliveries(CurrentStore.Id, webhookId, 1)).FirstOrDefault();
}
[HttpGet("{storeId}/webhooks")]
public async Task<IActionResult> Webhooks()
{
var webhooks = await _storeRepo.GetWebhooks(CurrentStore.Id);
var webhooks = await storeRepo.GetWebhooks(CurrentStore.Id);
return View(nameof(Webhooks), new WebhooksViewModel
{
Webhooks = webhooks.Select(async w =>
@@ -59,26 +70,15 @@ public partial class UIStoresController
});
}
[HttpGet("{storeId}/webhooks/{webhookId}/remove")]
[HttpPost("{storeId}/webhooks/{webhookId}/remove")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> DeleteWebhook(string webhookId)
{
var webhook = await _storeRepo.GetWebhook(CurrentStore.Id, webhookId);
var webhook = await storeRepo.GetWebhook(CurrentStore.Id, webhookId);
if (webhook is null)
return NotFound();
return View("Confirm", new ConfirmModel(StringLocalizer["Delete webhook"], StringLocalizer["This webhook will be removed from this store. Are you sure?"], StringLocalizer["Delete"]));
}
[HttpPost("{storeId}/webhooks/{webhookId}/remove")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> DeleteWebhookPost(string webhookId)
{
var webhook = await _storeRepo.GetWebhook(CurrentStore.Id, webhookId);
if (webhook is null)
return NotFound();
await _storeRepo.DeleteWebhook(CurrentStore.Id, webhookId);
await storeRepo.DeleteWebhook(CurrentStore.Id, webhookId);
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Webhook successfully deleted"].Value;
return RedirectToAction(nameof(Webhooks), new { storeId = CurrentStore.Id });
}
@@ -90,7 +90,7 @@ public partial class UIStoresController
if (!ModelState.IsValid)
return View(nameof(ModifyWebhook), viewModel);
await _storeRepo.CreateWebhook(CurrentStore.Id, viewModel.CreateBlob());
await storeRepo.CreateWebhook(CurrentStore.Id, viewModel.CreateBlob());
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["The webhook has been created"].Value;
return RedirectToAction(nameof(Webhooks), new { storeId });
}
@@ -99,12 +99,12 @@ public partial class UIStoresController
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> ModifyWebhook(string webhookId)
{
var webhook = await _storeRepo.GetWebhook(CurrentStore.Id, webhookId);
var webhook = await storeRepo.GetWebhook(CurrentStore.Id, webhookId);
if (webhook is null)
return NotFound();
var blob = webhook.GetBlob();
var deliveries = await _storeRepo.GetWebhookDeliveries(CurrentStore.Id, webhookId, 20);
var deliveries = await storeRepo.GetWebhookDeliveries(CurrentStore.Id, webhookId, 20);
return View(nameof(ModifyWebhook), new EditWebhookViewModel(blob)
{
Deliveries = deliveries
@@ -116,55 +116,26 @@ public partial class UIStoresController
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> ModifyWebhook(string webhookId, EditWebhookViewModel viewModel)
{
var webhook = await _storeRepo.GetWebhook(CurrentStore.Id, webhookId);
var webhook = await storeRepo.GetWebhook(CurrentStore.Id, webhookId);
if (webhook is null)
return NotFound();
if (!ModelState.IsValid)
return View(nameof(ModifyWebhook), viewModel);
await _storeRepo.UpdateWebhook(CurrentStore.Id, webhookId, viewModel.CreateBlob());
await storeRepo.UpdateWebhook(CurrentStore.Id, webhookId, viewModel.CreateBlob());
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["The webhook has been updated"].Value;
return RedirectToAction(nameof(Webhooks), new { storeId = CurrentStore.Id });
}
[HttpGet("{storeId}/webhooks/{webhookId}/test")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> TestWebhook(string webhookId)
{
var webhook = await _storeRepo.GetWebhook(CurrentStore.Id, webhookId);
if (webhook is null)
return NotFound();
return View(nameof(TestWebhook));
}
[HttpPost("{storeId}/webhooks/{webhookId}/test")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> TestWebhook(string webhookId, TestWebhookViewModel viewModel, CancellationToken cancellationToken)
{
var result = await _webhookNotificationManager.TestWebhook(CurrentStore.Id, webhookId, viewModel.Type, cancellationToken);
if (result.Success)
{
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["{0} event delivered successfully! Delivery ID is {1}", viewModel.Type, result.DeliveryId!].Value;
}
else
{
TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["{0} event could not be delivered. Error message received: {1}", viewModel.Type, result.ErrorMessage ?? StringLocalizer["unknown"].Value].Value;
}
return View(nameof(TestWebhook));
}
[HttpPost("{storeId}/webhooks/{webhookId}/deliveries/{deliveryId}/redeliver")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> RedeliverWebhook(string webhookId, string deliveryId)
{
var delivery = await _storeRepo.GetWebhookDelivery(CurrentStore.Id, webhookId, deliveryId);
var delivery = await storeRepo.GetWebhookDelivery(CurrentStore.Id, webhookId, deliveryId);
if (delivery is null)
return NotFound();
var newDeliveryId = await _webhookNotificationManager.Redeliver(deliveryId);
var newDeliveryId = await webhookSender.Redeliver(deliveryId);
if (newDeliveryId is null)
return NotFound();
@@ -181,7 +152,7 @@ public partial class UIStoresController
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> WebhookDelivery(string webhookId, string deliveryId)
{
var delivery = await _storeRepo.GetWebhookDelivery(CurrentStore.Id, webhookId, deliveryId);
var delivery = await storeRepo.GetWebhookDelivery(CurrentStore.Id, webhookId, deliveryId);
if (delivery is null)
return NotFound();

View File

@@ -1,14 +1,13 @@
using System;
using Dapper;
using System.Data.Common;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore;
using System.Collections.Generic;
using BTCPayServer.HostedServices;
namespace BTCPayServer.HostedServices
namespace BTCPayServer.Plugins.Webhooks.HostedServices
{
public class CleanupWebhookDeliveriesTask : IPeriodicTask
{

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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!;
}
}

View File

@@ -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;
}

View File

@@ -1,13 +1,11 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Validation;
namespace BTCPayServer.Models.StoreViewModels
namespace BTCPayServer.Plugins.Webhooks.Views
{
public class DeliveryViewModel
{
@@ -25,7 +23,7 @@ namespace BTCPayServer.Models.StoreViewModels
var evt = blob.ReadRequestAs<WebhookEvent>();
Type = evt.Type;
Pruned = evt.IsPruned();
WebhookId = s.Id;
WebhookId = s.WebhookId;
PayloadUrl = s.Webhook?.GetBlob().Url;
}
public string Id { get; set; }

View File

@@ -1,6 +1,5 @@
@model EditWebhookViewModel
@using BTCPayServer.HostedServices.Webhooks
@inject WebhookSender WebhookSender
@inject IEnumerable<AvailableWebhookViewModel> Webhooks
@{
var storeId = Context.GetStoreData().Id;
ViewData.SetActivePage(StoreNavPages.Webhooks, StringLocalizer["Webhook"], storeId);
@@ -75,11 +74,11 @@
</select>
<div id="event-selector" class="collapse">
<div class="pb-3">
@foreach (var evt in WebhookSender.GetSupportedWebhookTypes())
@foreach (var evt in Webhooks)
{
<div class="form-check my-1">
<input name="Events" id="@evt.Key" value="@evt.Key" @(Model.Events.Contains(evt.Key) ? "checked" : "") type="checkbox" class="form-check-input" />
<label for="@evt.Key" class="form-check-label">@evt.Value</label>
<input name="Events" id="@evt.Type" value="@evt.Type" @(Model.Events.Contains(evt.Type) ? "checked" : "") type="checkbox" class="form-check-input" />
<label for="@evt.Type" class="form-check-label">@StringLocalizer[evt.Description]</label>
</div>
}
</div>

View File

@@ -1,6 +1,5 @@
@using BTCPayServer.Abstractions.Models
@using BTCPayServer.Client
@using Microsoft.AspNetCore.Mvc.TagHelpers
@model WebhooksViewModel
@{
ViewData.SetActivePage(StoreNavPages.Webhooks, StringLocalizer["Webhooks"], Context.GetStoreData().Id);
@@ -53,7 +52,6 @@
</td>
<td class="d-block text-break">@wh.Url</td>
<td class="actions-col text-md-nowrap" permission="@Policies.CanModifyStoreSettings">
<a asp-action="TestWebhook" asp-route-storeId="@Context.GetRouteValue("storeId")" asp-route-webhookId="@wh.Id" text-translate="true">Test</a> -
<a asp-action="ModifyWebhook" asp-route-storeId="@Context.GetRouteValue("storeId")" asp-route-webhookId="@wh.Id" text-translate="true">Modify</a> -
<a asp-action="DeleteWebhook" asp-route-storeId="@Context.GetRouteValue("storeId")" asp-route-webhookId="@wh.Id" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-confirm-input="DELETE" text-translate="true">Delete</a>
</td>

View File

@@ -1,6 +1,6 @@
using System;
namespace BTCPayServer.Models.StoreViewModels
namespace BTCPayServer.Plugins.Webhooks.Views
{
public class WebhooksViewModel
{

View 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

View File

@@ -0,0 +1,3 @@
@{
Layout = "_Layout";
}

View File

@@ -1,12 +1,8 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.HostedServices.Webhooks;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using BTCPayServer.Plugins.Webhooks;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
@@ -61,6 +57,8 @@ namespace BTCPayServer.Data
public string Secret { get; set; }
public bool AutomaticRedelivery { get; set; }
public AuthorizedWebhookEvents AuthorizedEvents { get; set; }
public bool ShouldDeliver(string type)
=> Active && AuthorizedEvents.Match(type);
}
public static class WebhookDataExtensions
{

View 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;
}
}

View File

@@ -6,23 +6,19 @@ using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers;
using BTCPayServer.Data;
using BTCPayServer.Services.Stores;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using NBitcoin.DataEncoders;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using WebhookDeliveryData = BTCPayServer.Data.WebhookDeliveryData;
namespace BTCPayServer.HostedServices.Webhooks;
namespace BTCPayServer.Plugins.Webhooks;
/// <summary>
/// This class sends webhook notifications
@@ -32,8 +28,7 @@ public class WebhookSender(
StoreRepository storeRepository,
IHttpClientFactory httpClientFactory,
ApplicationDbContextFactory dbContextFactory,
ILogger<WebhookSender> logger,
IServiceProvider serviceProvider)
ILogger<WebhookSender> logger)
: IHostedService
{
public const string OnionNamedClient = "greenfield-webhook.onion";
@@ -93,7 +88,7 @@ public class WebhookSender(
if (webhookDelivery is null)
return null;
var oldDeliveryBlob = webhookDelivery.Delivery.GetBlob();
var newDelivery = WebhookExtensions.NewWebhookDelivery(webhookDelivery.Webhook.Id);
var newDelivery = WebhookDeliveryData.Create(webhookDelivery.Webhook.Id);
WebhookDeliveryBlob newDeliveryBlob = new();
newDeliveryBlob.Request = oldDeliveryBlob.Request;
var webhookEvent = newDeliveryBlob.ReadRequestAs<WebhookEvent>();
@@ -110,42 +105,6 @@ public class WebhookSender(
webhookDelivery.Webhook.GetBlob());
}
private WebhookEvent GetTestWebHook(string storeId, string webhookId, string webhookEventType,
WebhookDeliveryData delivery)
{
var webhookProvider = serviceProvider.GetServices<IWebhookProvider>()
.FirstOrDefault(provider => provider.GetSupportedWebhookTypes().ContainsKey(webhookEventType));
if (webhookProvider is null)
throw new ArgumentException($"Unknown webhook event type {webhookEventType}", webhookEventType);
var webhookEvent = webhookProvider.CreateTestEvent(webhookEventType, storeId);
if (webhookEvent is null)
throw new ArgumentException("Webhook provider does not support tests");
webhookEvent.DeliveryId = delivery.Id;
webhookEvent.WebhookId = webhookId;
webhookEvent.OriginalDeliveryId = "__test__" + Guid.NewGuid() + "__test__";
webhookEvent.IsRedelivery = false;
webhookEvent.Timestamp = delivery.Timestamp;
return webhookEvent;
}
public async Task<DeliveryResult> TestWebhook(string storeId, string webhookId, string webhookEventType,
CancellationToken cancellationToken)
{
var delivery = WebhookExtensions.NewWebhookDelivery(webhookId);
var webhook = (await StoreRepository.GetWebhooks(storeId)).FirstOrDefault(w => w.Id == webhookId);
WebhookDeliveryRequest deliveryRequest = new(
webhookId,
GetTestWebHook(storeId, webhookId, webhookEventType, delivery),
delivery,
webhook.GetBlob()
);
return await SendDelivery(deliveryRequest, cancellationToken);
}
public void EnqueueDelivery(WebhookDeliveryRequest context)
{
_processingQueue.Enqueue(context.WebhookId, cancellationToken => Process(context, cancellationToken));
@@ -160,8 +119,7 @@ public class WebhookSender(
return;
var result = await SendAndSaveDelivery(ctx, cancellationToken);
if (ctx.WebhookBlob.AutomaticRedelivery &&
!result.Success &&
result.DeliveryId is not null)
result is { Success: false, DeliveryId: not null })
{
var originalDeliveryId = result.DeliveryId;
foreach (var wait in new[]
@@ -254,89 +212,16 @@ public class WebhookSender(
return (await StoreRepository.GetWebhooks(invoiceStoreId)).Where(data => webhookEventType is null || data.GetBlob().ShouldDeliver(webhookEventType))
.ToArray();
}
public async Task<UIStoresController.StoreEmailRule[]> GetEmailRules(string storeId,
string type)
{
return (await StoreRepository.FindStore(storeId))?.GetStoreBlob().EmailRules?.Where(rule => rule.Trigger == type).ToArray() ??
Array.Empty<UIStoresController.StoreEmailRule>();
}
public Dictionary<string, string> GetSupportedWebhookTypes()
{
return serviceProvider.GetServices<IWebhookProvider>()
.SelectMany(provider => provider.GetSupportedWebhookTypes()).ToDictionary(pair => pair.Key, pair => pair.Value);
}
public Dictionary<string, bool> GetWebhookTypesSupportedByCustomerEmail()
{
return serviceProvider.GetServices<IWebhookProvider>()
.SelectMany(provider => provider.GetSupportedWebhookTypes()
.Select(pair => new { pair.Key, Value = provider.SupportsCustomerEmail }))
.ToDictionary(x => x.Key, x => x.Value);
}
public class WebhookDeliveryRequest(
string webhookId,
WebhookEvent webhookEvent,
WebhookDeliveryData delivery,
WebhookBlob webhookBlob)
{
// Regex pattern to validate JSONPath: alphanumeric, underscore, dot, hyphen, square brackets, asterisk, single/double quotes
private static readonly Regex _jsonPathRegex = new(@"^[a-zA-Z0-9_\.\-\[\]\*'""]*$", RegexOptions.Compiled);
public WebhookEvent WebhookEvent { get; } = webhookEvent;
public WebhookDeliveryData Delivery { get; } = delivery;
public WebhookBlob WebhookBlob { get; } = webhookBlob;
public string WebhookId { get; } = webhookId;
public virtual Task<SendEmailRequest?> Interpolate(SendEmailRequest req,
UIStoresController.StoreEmailRule storeEmailRule)
{
return Task.FromResult(req)!;
}
protected static string InterpolateJsonField(string str, string fieldName, JObject obj)
{
if (string.IsNullOrEmpty(str) || string.IsNullOrEmpty(fieldName) || obj == null)
return str;
fieldName += ".";
//find all instance of {fieldName*} in str, then run obj.SelectToken(*) on it
while (true)
{
var start = str.IndexOf($"{{{fieldName}", StringComparison.InvariantCultureIgnoreCase);
if (start == -1)
break;
start += fieldName.Length + 1; // Move past the {
var end = str.IndexOf("}", start, StringComparison.InvariantCultureIgnoreCase);
if (end == -1)
break;
var jsonpath = str.Substring(start, end - start);
var result = string.Empty;
try
{
if (string.IsNullOrEmpty(jsonpath))
result = obj.ToString();
else if (_jsonPathRegex.IsMatch(jsonpath))
// Only process if JSONPath is valid
result = obj.SelectToken(jsonpath)?.ToString() ?? string.Empty;
// If jsonpath doesn't match the pattern, result remains empty string
}
catch (JsonException)
{
// Handle JSON parsing errors (e.g., invalid JSONPath syntax)
result = string.Empty;
}
str = str.Replace($"{{{fieldName}{jsonpath}}}", result);
}
return str;
}
}
public class DeliveryResult

View 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;
}

View 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);
}

View 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);
}
}

View 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())!;
}

View File

@@ -4,6 +4,7 @@ using System.ComponentModel.DataAnnotations.Schema;
using System.Globalization;
using System.Linq;
using System.Text;
using BTCPayServer.Abstractions;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
@@ -756,6 +757,8 @@ namespace BTCPayServer.Services.Invoices
public decimal NetSettled { get; private set; }
[JsonIgnore]
public bool DisableAccounting { get; set; }
public RequestBaseUrl GetRequestBaseUrl() => RequestBaseUrl.FromUrl(ServerUrl);
}
public enum InvoiceStatusLegacy

View File

@@ -9,12 +9,14 @@ namespace BTCPayServer.Services.Mails
{
public abstract class EmailSender : IEmailSender
{
public EventAggregator EventAggregator { get; }
public Logs Logs { get; }
readonly IBackgroundJobClient _JobClient;
public EmailSender(IBackgroundJobClient jobClient, Logs logs)
public EmailSender(IBackgroundJobClient jobClient, EventAggregator eventAggregator, Logs logs)
{
EventAggregator = eventAggregator;
Logs = logs;
_JobClient = jobClient ?? throw new ArgumentNullException(nameof(jobClient));
}
@@ -36,14 +38,13 @@ namespace BTCPayServer.Services.Mails
}
using var smtp = await emailSettings.CreateSmtpClient();
var prefixedSubject = await GetPrefixedSubject(subject);
var mail = emailSettings.CreateMailMessage(email, cc, bcc, prefixedSubject, message, true);
await smtp.SendAsync(mail, cancellationToken);
var mail = emailSettings.CreateMailMessage(email, cc, bcc, subject, message, true);
var response = await smtp.SendAsync(mail, cancellationToken);
await smtp.DisconnectAsync(true, cancellationToken);
EventAggregator.Publish(new Events.EmailSentEvent(response, mail));
}, TimeSpan.Zero);
}
public abstract Task<EmailSettings?> GetEmailSettings();
public abstract Task<string> GetPrefixedSubject(string subject);
}
}

View File

@@ -13,10 +13,12 @@ namespace BTCPayServer.Services.Mails
private readonly IBackgroundJobClient _jobClient;
private readonly SettingsRepository _settingsRepository;
private readonly EventAggregator _eventAggregator;
private readonly StoreRepository _storeRepository;
public EmailSenderFactory(IBackgroundJobClient jobClient,
SettingsRepository settingsSettingsRepository,
EventAggregator eventAggregator,
ISettingsAccessor<PoliciesSettings> policiesSettings,
StoreRepository storeRepository,
Logs logs)
@@ -24,18 +26,19 @@ namespace BTCPayServer.Services.Mails
Logs = logs;
_jobClient = jobClient;
_settingsRepository = settingsSettingsRepository;
_eventAggregator = eventAggregator;
PoliciesSettings = policiesSettings;
_storeRepository = storeRepository;
}
public Task<IEmailSender> GetEmailSender(string? storeId = null)
{
var serverSender = new ServerEmailSender(_settingsRepository, _jobClient, Logs);
var serverSender = new ServerEmailSender(_settingsRepository, _jobClient, _eventAggregator, Logs);
if (string.IsNullOrEmpty(storeId))
return Task.FromResult<IEmailSender>(serverSender);
return Task.FromResult<IEmailSender>(new StoreEmailSender(_storeRepository,
!PoliciesSettings.Settings.DisableStoresToUseServerEmailSettings ? serverSender : null, _jobClient,
storeId, Logs));
_eventAggregator, storeId, Logs));
}
public async Task<bool> IsComplete(string? storeId = null)

View File

@@ -8,7 +8,8 @@ namespace BTCPayServer.Services.Mails
{
public ServerEmailSender(SettingsRepository settingsRepository,
IBackgroundJobClient backgroundJobClient,
Logs logs) : base(backgroundJobClient, logs)
EventAggregator eventAggregator,
Logs logs) : base(backgroundJobClient, eventAggregator, logs)
{
ArgumentNullException.ThrowIfNull(settingsRepository);
SettingsRepository = settingsRepository;
@@ -20,12 +21,5 @@ namespace BTCPayServer.Services.Mails
{
return SettingsRepository.GetSettingAsync<EmailSettings>();
}
public override async Task<string> GetPrefixedSubject(string subject)
{
var settings = await SettingsRepository.GetSettingAsync<ServerSettings>();
var prefix = string.IsNullOrEmpty(settings?.ServerName) ? "BTCPay Server" : settings.ServerName;
return $"{prefix}: {subject}";
}
}
}

View File

@@ -12,8 +12,9 @@ namespace BTCPayServer.Services.Mails
public StoreEmailSender(StoreRepository storeRepository,
EmailSender? fallback,
IBackgroundJobClient backgroundJobClient,
EventAggregator eventAggregator,
string storeId,
Logs logs) : base(backgroundJobClient, logs)
Logs logs) : base(backgroundJobClient, eventAggregator, logs)
{
StoreId = storeId ?? throw new ArgumentNullException(nameof(storeId));
StoreRepository = storeRepository;
@@ -44,7 +45,7 @@ namespace BTCPayServer.Services.Mails
return GetCustomSettings(store);
}
EmailSettings? GetCustomSettings(StoreData store)
{
{
var emailSettings = store.GetStoreBlob().EmailSettings;
if (emailSettings?.IsComplete() is true)
{
@@ -52,11 +53,5 @@ namespace BTCPayServer.Services.Mails
}
return null;
}
public override async Task<string> GetPrefixedSubject(string subject)
{
var store = await StoreRepository.FindStore(StoreId);
return string.IsNullOrEmpty(store?.StoreName) ? subject : $"{store.StoreName}: {subject}";
}
}
}

View File

@@ -2,6 +2,7 @@ using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Services.Invoices;
@@ -21,14 +22,14 @@ namespace BTCPayServer.Services.PaymentRequests
public PaymentRequestData Data { get; set; }
public string Type { get; set; }
}
public class PaymentRequestRepository
{
private readonly ApplicationDbContextFactory _ContextFactory;
private readonly InvoiceRepository _InvoiceRepository;
private readonly EventAggregator _eventAggregator;
public PaymentRequestRepository(ApplicationDbContextFactory contextFactory,
public PaymentRequestRepository(ApplicationDbContextFactory contextFactory,
InvoiceRepository invoiceRepository, EventAggregator eventAggregator)
{
_ContextFactory = contextFactory;
@@ -68,7 +69,7 @@ namespace BTCPayServer.Services.PaymentRequests
return null;
if(pr.Archived && !toggle)
return pr.Archived;
pr.Archived = !pr.Archived;
pr.Archived = !pr.Archived;
await context.SaveChangesAsync();
if (pr.Archived)
{
@@ -78,7 +79,7 @@ namespace BTCPayServer.Services.PaymentRequests
Type = PaymentRequestEvent.Archived
});
}
return pr.Archived;
}
@@ -105,9 +106,9 @@ namespace BTCPayServer.Services.PaymentRequests
if (paymentRequestData == null || paymentRequestData.Status == status)
return;
paymentRequestData.Status = status;
await context.SaveChangesAsync(cancellationToken);
_eventAggregator.Publish(new PaymentRequestEvent()
{
Data = paymentRequestData,
@@ -127,9 +128,9 @@ namespace BTCPayServer.Services.PaymentRequests
{
using var context = _ContextFactory.CreateContext();
var queryable = context.PaymentRequests.Include(data => data.StoreData).AsQueryable();
queryable =
queryable =
queryable
.Where(data =>
.Where(data =>
(data.Status == Client.Models.PaymentRequestStatus.Pending || data.Status == Client.Models.PaymentRequestStatus.Processing) &&
data.Expiry != null);
return await queryable.ToArrayAsync(cancellationToken);

View File

@@ -1,7 +1,8 @@
@using BTCPayServer.Plugins.Emails
<div class="alert alert-warning alert-dismissible">
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="@StringLocalizer["Close"]">
<vc:icon symbol="close" />
</button>
<span text-translate="true">The Email settings have not been configured on this server or store yet. Setting this field will not send emails until then.</span>
<a asp-action="StoreEmailSettings" asp-controller="UIStores" asp-route-storeId="@Model" class="alert-link" text-translate="true">Configure store email settings</a>
<a asp-area="@EmailsPlugin.Area" asp-action="StoreEmailSettings" asp-controller="UIStoresEmail" asp-route-storeId="@Model" class="alert-link" text-translate="true">Configure store email settings</a>
</div>

View File

@@ -3,9 +3,11 @@
@using BTCPayServer.Client
@using BTCPayServer.Controllers
@using BTCPayServer.Forms
@using BTCPayServer.Plugins.Emails
@using BTCPayServer.TagHelpers
@using Microsoft.AspNetCore.Mvc.TagHelpers
@inject FormDataService FormDataService
@inject LinkGenerator LinkGenerator
@model BTCPayServer.Models.PaymentRequestViewModels.UpdatePaymentRequestViewModel
@{
var checkoutFormOptions = await FormDataService.GetSelect(Model.StoreId, Model.FormId);
@@ -45,7 +47,7 @@
}
</div>
</div>
<partial name="_StatusMessage" />
<div class="row">
@@ -105,8 +107,8 @@
<input type="email" asp-for="Email" placeholder="@StringLocalizer["Firstname Lastname <email@example.com>"]" class="form-control" />
<span asp-validation-for="Email" class="text-danger"></span>
<div id="PaymentRequestEmailHelpBlock" class="form-text">
@ViewLocalizer["This will send notification mails to the recipient, as configured by the {0}.",
Html.ActionLink(StringLocalizer["email rules"], nameof(UIStoresController.StoreEmailRulesList), "UIStores", new { storeId = Model.StoreId })]
@ViewLocalizer["This will send notification mails to the recipient, as configured by the <a href=\"{0}\">email rules</a>.",
LinkGenerator.GetStoreEmailRulesLink(Model.StoreId, Context.Request.GetRequestBaseUrl())]
@if (Model.HasEmailRules is not true)
{
<div class="info-note mt-1 text-warning" role="alert">
@@ -121,7 +123,7 @@
<select asp-for="FormId" class="form-select w-auto" asp-items="@checkoutFormOptions"></select>
<span asp-validation-for="FormId" class="text-danger"></span>
</div>
@if (Model.FormResponse is not null)
{
<div class="bg-tile rounded py-2 px-3 mb-5">

View File

@@ -6,7 +6,10 @@
<form method="post" autocomplete="off">
<div class="sticky-header">
<h2>@ViewData["Title"]</h2>
<button id="page-primary" type="submit" class="btn btn-primary" name="command" value="Save">Save</button>
<div class="d-flex justify-content-end">
<button cheat-mode="true" id="mailpit" type="submit" class="btn btn-info" name="command" value="mailpit">Use mailpit</button>
<button id="page-primary" type="submit" class="btn btn-primary" name="command" value="Save">Save</button>
</div>
</div>
<partial name="_StatusMessage" />
<div class="form-group mb-4">

View File

@@ -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>
}

View File

@@ -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