mirror of
https://github.com/aljazceru/btcpayserver.git
synced 2025-12-17 22:14:26 +01:00
Merge pull request #6979 from NicolasDorier/server-email-rules
Add Server Email Rules
This commit is contained in:
@@ -11,7 +11,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Migrations.Operations;
|
|||||||
|
|
||||||
namespace BTCPayServer.Abstractions.Contracts
|
namespace BTCPayServer.Abstractions.Contracts
|
||||||
{
|
{
|
||||||
public abstract class BaseDbContextFactory<T> where T : DbContext
|
public abstract class BaseDbContextFactory<T> : IDbContextFactory<T> where T : DbContext
|
||||||
{
|
{
|
||||||
private readonly IOptions<DatabaseOptions> _options;
|
private readonly IOptions<DatabaseOptions> _options;
|
||||||
private readonly string _migrationTableName;
|
private readonly string _migrationTableName;
|
||||||
@@ -90,5 +90,8 @@ namespace BTCPayServer.Abstractions.Contracts
|
|||||||
var searchPaths = connectionStringBuilder.SearchPath?.Split(',');
|
var searchPaths = connectionStringBuilder.SearchPath?.Split(',');
|
||||||
return searchPaths is not { Length: > 0 } ? null : searchPaths[0];
|
return searchPaths is not { Length: > 0 } ? null : searchPaths[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
T IDbContextFactory<T>.CreateDbContext()
|
||||||
|
=> this.CreateContext();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ namespace BTCPayServer.Abstractions.Extensions
|
|||||||
return categoryAndPageMatch && idMatch;
|
return categoryAndPageMatch && idMatch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Obsolete()]
|
||||||
public static bool IsPageActive<T>(this ViewDataDictionary viewData, IEnumerable<T> pages, object id = null)
|
public static bool IsPageActive<T>(this ViewDataDictionary viewData, IEnumerable<T> pages, object id = null)
|
||||||
where T : IConvertible
|
where T : IConvertible
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ namespace BTCPayServer;
|
|||||||
public class TextTemplate(string template)
|
public class TextTemplate(string template)
|
||||||
{
|
{
|
||||||
static readonly Regex _interpolationRegex = new Regex(@"\{([^}]+)\}", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
static readonly Regex _interpolationRegex = new Regex(@"\{([^}]+)\}", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||||
|
public Func<string, string> NotFoundReplacement { get; set; } = path => $"[NotFound({path})]";
|
||||||
|
public Func<string, string> ParsingErrorReplacement { get; set; } = path => $"[ParsingError({path})]";
|
||||||
|
|
||||||
public string Render(JObject model)
|
public string Render(JObject model)
|
||||||
{
|
{
|
||||||
model = (JObject)ToLowerCase(model);
|
model = (JObject)ToLowerCase(model);
|
||||||
@@ -23,11 +26,11 @@ public class TextTemplate(string template)
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var token = model.SelectToken(path);
|
var token = model.SelectToken(path);
|
||||||
return token?.ToString() ?? $"<NotFound({initial})>";
|
return token?.ToString() ?? NotFoundReplacement(initial);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
return $"<ParsingError({initial})>";
|
return ParsingErrorReplacement(initial);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ public class EmailRuleData : BaseEntityData
|
|||||||
[Required]
|
[Required]
|
||||||
[Column("to")]
|
[Column("to")]
|
||||||
public string[] To { get; set; } = null!;
|
public string[] To { get; set; } = null!;
|
||||||
|
[Required]
|
||||||
|
[Column("cc")]
|
||||||
|
public string[] CC { get; set; } = null!;
|
||||||
|
[Required]
|
||||||
|
[Column("bcc")]
|
||||||
|
public string[] BCC { get; set; } = null!;
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
[Column("subject")]
|
[Column("subject")]
|
||||||
@@ -69,16 +75,29 @@ public static partial class ApplicationDbContextExtensions
|
|||||||
public static IQueryable<EmailRuleData> GetRules(this IQueryable<EmailRuleData> query, string storeId)
|
public static IQueryable<EmailRuleData> GetRules(this IQueryable<EmailRuleData> query, string storeId)
|
||||||
=> query.Where(o => o.StoreId == storeId)
|
=> query.Where(o => o.StoreId == storeId)
|
||||||
.OrderBy(o => o.Id);
|
.OrderBy(o => o.Id);
|
||||||
|
public static IQueryable<EmailRuleData> GetServerRules(this IQueryable<EmailRuleData> query)
|
||||||
|
=> query.Where(o => o.StoreId == null).OrderBy(o => o.Id);
|
||||||
|
|
||||||
public static Task<EmailRuleData[]> GetMatches(this DbSet<EmailRuleData> set, string? storeId, string trigger, JObject model)
|
public static Task<EmailRuleData[]> GetMatches(this DbSet<EmailRuleData> set, string? storeId, string trigger, JObject model)
|
||||||
=> set
|
=>
|
||||||
|
storeId is null
|
||||||
|
? set
|
||||||
.FromSqlInterpolated($"""
|
.FromSqlInterpolated($"""
|
||||||
SELECT * FROM email_rules
|
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))
|
WHERE store_id IS NULL AND trigger = {trigger} AND (condition IS NULL OR jsonb_path_exists({model.ToString()}::JSONB, condition::JSONPATH))
|
||||||
|
""")
|
||||||
|
.ToArrayAsync()
|
||||||
|
: set
|
||||||
|
.FromSqlInterpolated($"""
|
||||||
|
SELECT * FROM email_rules
|
||||||
|
WHERE store_id = {storeId} AND trigger = {trigger} AND (condition IS NULL OR jsonb_path_exists({model.ToString()}::JSONB, condition::JSONPATH))
|
||||||
""")
|
""")
|
||||||
.ToArrayAsync();
|
.ToArrayAsync();
|
||||||
|
|
||||||
public static Task<EmailRuleData?> GetRule(this IQueryable<EmailRuleData> query, string storeId, long id)
|
public static Task<EmailRuleData?> GetRule(this IQueryable<EmailRuleData> query, string storeId, long id)
|
||||||
=> query.Where(o => o.StoreId == storeId && o.Id == id)
|
=> query.Where(o => o.StoreId == storeId && o.Id == id)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
|
public static Task<EmailRuleData?> GetServerRule(this IQueryable<EmailRuleData> query, long id)
|
||||||
|
=> query.Where(o => o.StoreId == null && o.Id == id)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
}
|
}
|
||||||
|
|||||||
43
BTCPayServer.Data/Migrations/20251107131717_emailccbcc.cs
Normal file
43
BTCPayServer.Data/Migrations/20251107131717_emailccbcc.cs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
using BTCPayServer.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace BTCPayServer.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(ApplicationDbContext))]
|
||||||
|
[Migration("20251107131717_emailccbcc")]
|
||||||
|
public partial class emailccbcc : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string[]>(
|
||||||
|
name: "bcc",
|
||||||
|
table: "email_rules",
|
||||||
|
type: "text[]",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: new string[0]);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string[]>(
|
||||||
|
name: "cc",
|
||||||
|
table: "email_rules",
|
||||||
|
type: "text[]",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: new string[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "bcc",
|
||||||
|
table: "email_rules");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "cc",
|
||||||
|
table: "email_rules");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -280,11 +280,21 @@ namespace BTCPayServer.Migrations
|
|||||||
.HasColumnName("additional_data")
|
.HasColumnName("additional_data")
|
||||||
.HasDefaultValueSql("'{}'::jsonb");
|
.HasDefaultValueSql("'{}'::jsonb");
|
||||||
|
|
||||||
|
b.Property<string[]>("BCC")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text[]")
|
||||||
|
.HasColumnName("bcc");
|
||||||
|
|
||||||
b.Property<string>("Body")
|
b.Property<string>("Body")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text")
|
.HasColumnType("text")
|
||||||
.HasColumnName("body");
|
.HasColumnName("body");
|
||||||
|
|
||||||
|
b.Property<string[]>("CC")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text[]")
|
||||||
|
.HasColumnName("cc");
|
||||||
|
|
||||||
b.Property<string>("Condition")
|
b.Property<string>("Condition")
|
||||||
.HasColumnType("text")
|
.HasColumnType("text")
|
||||||
.HasColumnName("condition");
|
.HasColumnName("condition");
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ using BTCPayServer.HostedServices;
|
|||||||
using BTCPayServer.Hosting;
|
using BTCPayServer.Hosting;
|
||||||
using BTCPayServer.JsonConverters;
|
using BTCPayServer.JsonConverters;
|
||||||
using BTCPayServer.Payments;
|
using BTCPayServer.Payments;
|
||||||
|
using BTCPayServer.Plugins.Emails.Views;
|
||||||
using BTCPayServer.Rating;
|
using BTCPayServer.Rating;
|
||||||
using BTCPayServer.Services;
|
using BTCPayServer.Services;
|
||||||
using BTCPayServer.Services.Apps;
|
using BTCPayServer.Services.Apps;
|
||||||
@@ -2305,6 +2306,16 @@ bc1qfzu57kgu5jthl934f9xrdzzx8mmemx7gn07tf0grnvz504j6kzusu2v0ku
|
|||||||
Assert.Equal("1-of-xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL-xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL-[legacy]", parsedDescriptor.AccountDerivation.ToString());
|
Assert.Equal("1-of-xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL-xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL-[legacy]", parsedDescriptor.AccountDerivation.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("FastTest", "FastTest")]
|
||||||
|
public void CanParseEmailDestination()
|
||||||
|
{
|
||||||
|
var vm = new StoreEmailRuleViewModel();
|
||||||
|
var actual = vm.AsArray("\"Nicolas, The, Great\" <emperor@btc.pay>,{SomeTemplate} ,\"Madd,Test\" <madd@example.com>");
|
||||||
|
string[] expected = ["\"Nicolas, The, Great\" <emperor@btc.pay>", "{SomeTemplate}", "\"Madd,Test\" <madd@example.com>"];
|
||||||
|
Assert.Equal(expected, actual);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
[Trait("Altcoins", "Altcoins")]
|
[Trait("Altcoins", "Altcoins")]
|
||||||
public void CanCalculateCryptoDue2()
|
public void CanCalculateCryptoDue2()
|
||||||
|
|||||||
@@ -3245,33 +3245,23 @@ namespace BTCPayServer.Tests
|
|||||||
var unrestricted = await user.CreateClient(Policies.Unrestricted);
|
var unrestricted = await user.CreateClient(Policies.Unrestricted);
|
||||||
var store1 = await unrestricted.CreateStore(new CreateStoreRequest { Name = "Store A" });
|
var store1 = await unrestricted.CreateStore(new CreateStoreRequest { Name = "Store A" });
|
||||||
await tester.PayTester.GetService<NotificationSender>()
|
await tester.PayTester.GetService<NotificationSender>()
|
||||||
.SendNotification(new UserScope(user.UserId), new InviteAcceptedNotification{
|
.SendNotification(new UserScope(user.UserId), new InvoiceEventNotification("aaaaaaaa", InvoiceEvent.Confirmed, store1.Id));
|
||||||
UserId = user.UserId,
|
|
||||||
UserEmail = user.Email,
|
|
||||||
StoreId = store1.Id,
|
|
||||||
StoreName = store1.Name
|
|
||||||
});
|
|
||||||
notifications = (await client.GetNotifications()).ToList();
|
notifications = (await client.GetNotifications()).ToList();
|
||||||
Assert.Single(notifications);
|
Assert.Single(notifications);
|
||||||
|
|
||||||
notification = notifications.First();
|
notification = notifications.First();
|
||||||
Assert.Equal(store1.Id, notification.StoreId);
|
Assert.Equal(store1.Id, notification.StoreId);
|
||||||
Assert.Equal($"User {user.Email} accepted the invite to {store1.Name}.", notification.Body);
|
Assert.Equal($"Invoice aaaaa.. is settled", notification.Body);
|
||||||
|
|
||||||
var store2 = await unrestricted.CreateStore(new CreateStoreRequest { Name = "Store B" });
|
var store2 = await unrestricted.CreateStore(new CreateStoreRequest { Name = "Store B" });
|
||||||
await tester.PayTester.GetService<NotificationSender>()
|
await tester.PayTester.GetService<NotificationSender>()
|
||||||
.SendNotification(new UserScope(user.UserId), new InviteAcceptedNotification{
|
.SendNotification(new UserScope(user.UserId), new InvoiceEventNotification("baaaaaaa", InvoiceEvent.Confirmed, store2.Id));
|
||||||
UserId = user.UserId,
|
|
||||||
UserEmail = user.Email,
|
|
||||||
StoreId = store2.Id,
|
|
||||||
StoreName = store2.Name
|
|
||||||
});
|
|
||||||
notifications = (await client.GetNotifications(storeId: [store2.Id])).ToList();
|
notifications = (await client.GetNotifications(storeId: [store2.Id])).ToList();
|
||||||
Assert.Single(notifications);
|
Assert.Single(notifications);
|
||||||
|
|
||||||
notification = notifications.First();
|
notification = notifications.First();
|
||||||
Assert.Equal(store2.Id, notification.StoreId);
|
Assert.Equal(store2.Id, notification.StoreId);
|
||||||
Assert.Equal($"User {user.Email} accepted the invite to {store2.Name}.", notification.Body);
|
Assert.Equal($"Invoice baaaa.. is settled", notification.Body);
|
||||||
|
|
||||||
Assert.Equal(2, (await client.GetNotifications(storeId: [store1.Id, store2.Id])).Count());
|
Assert.Equal(2, (await client.GetNotifications(storeId: [store1.Id, store2.Id])).Count());
|
||||||
Assert.Equal(2, (await client.GetNotifications()).Count());
|
Assert.Equal(2, (await client.GetNotifications()).Count());
|
||||||
@@ -3280,25 +3270,25 @@ namespace BTCPayServer.Tests
|
|||||||
var settings = await client.GetNotificationSettings();
|
var settings = await client.GetNotificationSettings();
|
||||||
Assert.True(settings.Notifications.Find(n => n.Identifier == "newversion").Enabled);
|
Assert.True(settings.Notifications.Find(n => n.Identifier == "newversion").Enabled);
|
||||||
Assert.True(settings.Notifications.Find(n => n.Identifier == "pluginupdate").Enabled);
|
Assert.True(settings.Notifications.Find(n => n.Identifier == "pluginupdate").Enabled);
|
||||||
Assert.True(settings.Notifications.Find(n => n.Identifier == "inviteaccepted").Enabled);
|
Assert.True(settings.Notifications.Find(n => n.Identifier == "invoicestate").Enabled);
|
||||||
|
|
||||||
var request = new UpdateNotificationSettingsRequest { Disabled = ["newversion", "pluginupdate"] };
|
var request = new UpdateNotificationSettingsRequest { Disabled = ["newversion", "pluginupdate"] };
|
||||||
settings = await client.UpdateNotificationSettings(request);
|
settings = await client.UpdateNotificationSettings(request);
|
||||||
Assert.False(settings.Notifications.Find(n => n.Identifier == "newversion").Enabled);
|
Assert.False(settings.Notifications.Find(n => n.Identifier == "newversion").Enabled);
|
||||||
Assert.False(settings.Notifications.Find(n => n.Identifier == "pluginupdate").Enabled);
|
Assert.False(settings.Notifications.Find(n => n.Identifier == "pluginupdate").Enabled);
|
||||||
Assert.True(settings.Notifications.Find(n => n.Identifier == "inviteaccepted").Enabled);
|
Assert.True(settings.Notifications.Find(n => n.Identifier == "invoicestate").Enabled);
|
||||||
|
|
||||||
request = new UpdateNotificationSettingsRequest { Disabled = ["all"] };
|
request = new UpdateNotificationSettingsRequest { Disabled = ["all"] };
|
||||||
settings = await client.UpdateNotificationSettings(request);
|
settings = await client.UpdateNotificationSettings(request);
|
||||||
Assert.False(settings.Notifications.Find(n => n.Identifier == "newversion").Enabled);
|
Assert.False(settings.Notifications.Find(n => n.Identifier == "newversion").Enabled);
|
||||||
Assert.False(settings.Notifications.Find(n => n.Identifier == "pluginupdate").Enabled);
|
Assert.False(settings.Notifications.Find(n => n.Identifier == "pluginupdate").Enabled);
|
||||||
Assert.False(settings.Notifications.Find(n => n.Identifier == "inviteaccepted").Enabled);
|
Assert.False(settings.Notifications.Find(n => n.Identifier == "invoicestate").Enabled);
|
||||||
|
|
||||||
request = new UpdateNotificationSettingsRequest { Disabled = [] };
|
request = new UpdateNotificationSettingsRequest { Disabled = [] };
|
||||||
settings = await client.UpdateNotificationSettings(request);
|
settings = await client.UpdateNotificationSettings(request);
|
||||||
Assert.True(settings.Notifications.Find(n => n.Identifier == "newversion").Enabled);
|
Assert.True(settings.Notifications.Find(n => n.Identifier == "newversion").Enabled);
|
||||||
Assert.True(settings.Notifications.Find(n => n.Identifier == "pluginupdate").Enabled);
|
Assert.True(settings.Notifications.Find(n => n.Identifier == "pluginupdate").Enabled);
|
||||||
Assert.True(settings.Notifications.Find(n => n.Identifier == "inviteaccepted").Enabled);
|
Assert.True(settings.Notifications.Find(n => n.Identifier == "invoicestate").Enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(Timeout = TestTimeout)]
|
[Fact(Timeout = TestTimeout)]
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#nullable enable
|
#nullable enable
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using BTCPayServer.Abstractions.Models;
|
||||||
|
|
||||||
namespace BTCPayServer.Tests.PMO;
|
namespace BTCPayServer.Tests.PMO;
|
||||||
|
|
||||||
@@ -15,14 +16,20 @@ public class ConfigureEmailPMO(PlaywrightTester s)
|
|||||||
public bool? EnabledCertificateCheck { get; set; }
|
public bool? EnabledCertificateCheck { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task FillMailPit(Form form)
|
public async Task<EmailRulesPMO> ConfigureEmailRules()
|
||||||
|
{
|
||||||
|
await s.Page.ClickAsync("#ConfigureEmailRules");
|
||||||
|
return new EmailRulesPMO(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task FillMailPit(Form? form = null)
|
||||||
=> Fill(new()
|
=> Fill(new()
|
||||||
{
|
{
|
||||||
Server = s.Server.MailPitSettings.Hostname,
|
Server = s.Server.MailPitSettings.Hostname,
|
||||||
Port = s.Server.MailPitSettings.SmtpPort,
|
Port = s.Server.MailPitSettings.SmtpPort,
|
||||||
From = form.From,
|
From = form?.From ?? "from@example.com",
|
||||||
Login = form.Login,
|
Login = form?.Login ?? "login@example.com",
|
||||||
Password = form.Password,
|
Password = form?.Password ?? "password",
|
||||||
EnabledCertificateCheck = false,
|
EnabledCertificateCheck = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,20 @@ using System.Threading.Tasks;
|
|||||||
|
|
||||||
namespace BTCPayServer.Tests.PMO;
|
namespace BTCPayServer.Tests.PMO;
|
||||||
|
|
||||||
|
public class EmailRulesPMO(PlaywrightTester s)
|
||||||
|
{
|
||||||
|
public async Task<EmailRulePMO> CreateEmailRule()
|
||||||
|
{
|
||||||
|
await s.Page.ClickAsync("#CreateEmailRule");
|
||||||
|
return new EmailRulePMO(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task EditRule(string trigger, int nth = 0)
|
||||||
|
{
|
||||||
|
await s.Page.ClickAsync($"tr[data-trigger='{trigger}']:nth-child({nth + 1}) a:has-text('Edit')");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public class EmailRulePMO(PlaywrightTester s)
|
public class EmailRulePMO(PlaywrightTester s)
|
||||||
{
|
{
|
||||||
public class Form
|
public class Form
|
||||||
@@ -13,6 +27,7 @@ public class EmailRulePMO(PlaywrightTester s)
|
|||||||
public string? Body { get; set; }
|
public string? Body { get; set; }
|
||||||
public bool? CustomerEmail { get; set; }
|
public bool? CustomerEmail { get; set; }
|
||||||
public string? Condition { get; set; }
|
public string? Condition { get; set; }
|
||||||
|
public bool HtmlBody { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task Fill(Form form)
|
public async Task Fill(Form form)
|
||||||
@@ -26,7 +41,18 @@ public class EmailRulePMO(PlaywrightTester s)
|
|||||||
if (form.Subject is not null)
|
if (form.Subject is not null)
|
||||||
await s.Page.FillAsync("#Subject", form.Subject);
|
await s.Page.FillAsync("#Subject", form.Subject);
|
||||||
if (form.Body is not null)
|
if (form.Body is not null)
|
||||||
await s.Page.Locator(".note-editable").FillAsync(form.Body);
|
{
|
||||||
|
if (form.HtmlBody)
|
||||||
|
{
|
||||||
|
await s.Page.ClickAsync(".btn-codeview");
|
||||||
|
await s.Page.FillAsync(".note-codable", form.Body);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await s.Page.FillAsync(".note-editable", form.Body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (form.CustomerEmail is {} v)
|
if (form.CustomerEmail is {} v)
|
||||||
await s.Page.SetCheckedAsync("#AdditionalData_CustomerEmail", v);
|
await s.Page.SetCheckedAsync("#AdditionalData_CustomerEmail", v);
|
||||||
await s.ClickPagePrimary();
|
await s.ClickPagePrimary();
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ namespace BTCPayServer.Tests
|
|||||||
public class PlaywrightTester : IAsyncDisposable
|
public class PlaywrightTester : IAsyncDisposable
|
||||||
{
|
{
|
||||||
public Uri ServerUri;
|
public Uri ServerUri;
|
||||||
private string CreatedUser;
|
public string CreatedUser;
|
||||||
internal string InvoiceId;
|
internal string InvoiceId;
|
||||||
public Logging.ILog TestLogs => Server.TestLogs;
|
public Logging.ILog TestLogs => Server.TestLogs;
|
||||||
public IPage Page { get; set; }
|
public IPage Page { get; set; }
|
||||||
@@ -369,7 +369,11 @@ namespace BTCPayServer.Tests
|
|||||||
public async Task GoToServer(ServerNavPages navPages = ServerNavPages.Policies)
|
public async Task GoToServer(ServerNavPages navPages = ServerNavPages.Policies)
|
||||||
{
|
{
|
||||||
await Page.ClickAsync("#menu-item-Policies");
|
await Page.ClickAsync("#menu-item-Policies");
|
||||||
if (navPages != ServerNavPages.Policies)
|
if (navPages == ServerNavPages.Emails)
|
||||||
|
{
|
||||||
|
await Page.ClickAsync($"#menu-item-Server-{navPages}");
|
||||||
|
}
|
||||||
|
else if (navPages != ServerNavPages.Policies)
|
||||||
{
|
{
|
||||||
await Page.ClickAsync($"#menu-item-{navPages}");
|
await Page.ClickAsync($"#menu-item-{navPages}");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1496,8 +1496,7 @@ namespace BTCPayServer.Tests
|
|||||||
|
|
||||||
await s.AddDerivationScheme();
|
await s.AddDerivationScheme();
|
||||||
await s.GoToInvoices();
|
await s.GoToInvoices();
|
||||||
var sent = await s.Server.WaitForEvent<EmailSentEvent>(() => s.CreateInvoice(amount: 10m, currency: "USD"));
|
var message = await s.Server.AssertHasEmail(() => 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);
|
Assert.Equal("Invoice has been created in USD for 10!", message.Text);
|
||||||
|
|
||||||
await s.GoToUrl(rulesUrl);
|
await s.GoToUrl(rulesUrl);
|
||||||
@@ -1529,11 +1528,27 @@ namespace BTCPayServer.Tests
|
|||||||
});
|
});
|
||||||
|
|
||||||
await s.GoToInvoices();
|
await s.GoToInvoices();
|
||||||
sent = await s.Server.WaitForEvent<EmailSentEvent>(() => s.CreateInvoice(amount: 10m, currency: "USD", refundEmail: "john@test.com"));
|
message = await s.Server.AssertHasEmail(() => 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 Created in USD for " + storeName + "!", message.Subject);
|
||||||
Assert.Equal("Invoice has been created in USD for 10!", message.Text);
|
Assert.Equal("Invoice has been created in USD for 10!", message.Text);
|
||||||
Assert.Equal("john@test.com", message.To[0].Address);
|
Assert.Equal("john@test.com", message.To[0].Address);
|
||||||
|
|
||||||
|
await s.GoToServer(ServerNavPages.Emails);
|
||||||
|
|
||||||
|
await mailPMO.FillMailPit();
|
||||||
|
var rules = await mailPMO.ConfigureEmailRules();
|
||||||
|
await rules.EditRule("SRV-PasswordReset");
|
||||||
|
await pmo.Fill(new()
|
||||||
|
{
|
||||||
|
Trigger = "SRV-PasswordReset",
|
||||||
|
HtmlBody = true,
|
||||||
|
Body = "<p>Hello, <a id=\"reset-link\" href=\"{ResetLink}\">click here</a> to reset the password</p>"
|
||||||
|
});
|
||||||
|
await s.Logout();
|
||||||
|
await s.Page.GetByRole(AriaRole.Link, new() { Name = "Forgot password?" }).ClickAsync();
|
||||||
|
await s.Page.FillAsync("#Email", s.CreatedUser);
|
||||||
|
message = await s.Server.AssertHasEmail(() => s.ClickPagePrimary());
|
||||||
|
Assert.Contains("<p>Hello, <a id=\"reset-link\" href=\"http://", message.Html);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -1595,11 +1610,10 @@ namespace BTCPayServer.Tests
|
|||||||
await s.GoToInvoices();
|
await s.GoToInvoices();
|
||||||
|
|
||||||
await s.ClickPagePrimary();
|
await s.ClickPagePrimary();
|
||||||
Assert.Contains("To create an invoice, you need to", await s.Page.ContentAsync());
|
|
||||||
|
|
||||||
await s.AddDerivationScheme();
|
await s.AddDerivationScheme();
|
||||||
await s.GoToInvoices();
|
await s.GoToInvoices();
|
||||||
var invoiceId = await s.CreateInvoice();
|
await s.CreateInvoice();
|
||||||
await s.Page.ClickAsync("[data-invoice-state-badge] .dropdown-toggle");
|
await s.Page.ClickAsync("[data-invoice-state-badge] .dropdown-toggle");
|
||||||
await s.Page.ClickAsync("[data-invoice-state-badge] .dropdown-menu button:first-child");
|
await s.Page.ClickAsync("[data-invoice-state-badge] .dropdown-menu button:first-child");
|
||||||
await TestUtils.EventuallyAsync(async () => Assert.Contains("Invalid (marked)", await s.Page.ContentAsync()));
|
await TestUtils.EventuallyAsync(async () => Assert.Contains("Invalid (marked)", await s.Page.ContentAsync()));
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ using NBitpayClient;
|
|||||||
using NBXplorer;
|
using NBXplorer;
|
||||||
using BTCPayServer.Abstractions.Contracts;
|
using BTCPayServer.Abstractions.Contracts;
|
||||||
using System.Diagnostics.Metrics;
|
using System.Diagnostics.Metrics;
|
||||||
|
using System.Threading;
|
||||||
using BTCPayServer.Events;
|
using BTCPayServer.Events;
|
||||||
|
|
||||||
namespace BTCPayServer.Tests
|
namespace BTCPayServer.Tests
|
||||||
@@ -49,7 +50,7 @@ namespace BTCPayServer.Tests
|
|||||||
GetEnvironment("TESTS_MAILPIT_HOST", "127.0.0.1"),
|
GetEnvironment("TESTS_MAILPIT_HOST", "127.0.0.1"),
|
||||||
int.Parse(GetEnvironment("TESTS_MAILPIT_SMTP", "34219")),
|
int.Parse(GetEnvironment("TESTS_MAILPIT_SMTP", "34219")),
|
||||||
int.Parse(GetEnvironment("TESTS_MAILPIT_HTTP", "34218")));
|
int.Parse(GetEnvironment("TESTS_MAILPIT_HTTP", "34218")));
|
||||||
|
TestLogs.LogInformation($"MailPit settings: http://{MailPitSettings.Hostname}:{MailPitSettings.HttpPort} (SMTP: {MailPitSettings.SmtpPort})");
|
||||||
_NetworkProvider = networkProvider;
|
_NetworkProvider = networkProvider;
|
||||||
ExplorerNode = new RPCClient(RPCCredentialString.Parse(GetEnvironment("TESTS_BTCRPCCONNECTION", "server=http://127.0.0.1:43782;ceiwHEbqWI83:DwubwWsoo3")), NetworkProvider.GetNetwork<BTCPayNetwork>("BTC").NBitcoinNetwork);
|
ExplorerNode = new RPCClient(RPCCredentialString.Parse(GetEnvironment("TESTS_BTCRPCCONNECTION", "server=http://127.0.0.1:43782;ceiwHEbqWI83:DwubwWsoo3")), NetworkProvider.GetNetwork<BTCPayNetwork>("BTC").NBitcoinNetwork);
|
||||||
ExplorerNode.ScanRPCCapabilities();
|
ExplorerNode.ScanRPCCapabilities();
|
||||||
@@ -198,6 +199,8 @@ namespace BTCPayServer.Tests
|
|||||||
public async Task<T> WaitForEvent<T>(Func<Task> action, Func<T, bool> correctEvent = null)
|
public async Task<T> WaitForEvent<T>(Func<Task> action, Func<T, bool> correctEvent = null)
|
||||||
{
|
{
|
||||||
var tcs = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
|
var tcs = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||||
|
await using var register = cts.Token.Register(() => tcs.TrySetCanceled());
|
||||||
var sub = PayTester.GetService<EventAggregator>().SubscribeAny<T>(evt =>
|
var sub = PayTester.GetService<EventAggregator>().SubscribeAny<T>(evt =>
|
||||||
{
|
{
|
||||||
if (correctEvent is null)
|
if (correctEvent is null)
|
||||||
@@ -300,6 +303,12 @@ namespace BTCPayServer.Tests
|
|||||||
return await mailPitClient.GetMessage(sent.ServerResponse.Split(' ').Last());
|
return await mailPitClient.GetMessage(sent.ServerResponse.Split(' ').Last());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<MailPitClient.Message> AssertHasEmail(Func<Task> action)
|
||||||
|
{
|
||||||
|
var sent = await WaitForEvent<EmailSentEvent>(action);
|
||||||
|
return await AssertHasEmail(sent);
|
||||||
|
}
|
||||||
|
|
||||||
public MailPitClient GetMailPitClient()
|
public MailPitClient GetMailPitClient()
|
||||||
{
|
{
|
||||||
var http = PayTester.GetService<IHttpClientFactory>().CreateClient("MAIL_PIT");
|
var http = PayTester.GetService<IHttpClientFactory>().CreateClient("MAIL_PIT");
|
||||||
|
|||||||
@@ -1601,7 +1601,7 @@ namespace BTCPayServer.Tests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
[Trait("UnitTest", "UnitTest")]
|
[Trait("FastTest", "FastTest")]
|
||||||
public void TestMailTemplate()
|
public void TestMailTemplate()
|
||||||
{
|
{
|
||||||
var template = new TextTemplate("Hello mister {Name.Firstname} {Name.Lastname} !");
|
var template = new TextTemplate("Hello mister {Name.Firstname} {Name.Lastname} !");
|
||||||
@@ -1639,7 +1639,7 @@ namespace BTCPayServer.Tests
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
result = template.Render(model);
|
result = template.Render(model);
|
||||||
Assert.Equal("Hello mister John <NotFound(Name.Lastname)> !", result);
|
Assert.Equal("Hello mister John [NotFound(Name.Lastname)] !", result);
|
||||||
|
|
||||||
// Is Case insensitive
|
// Is Case insensitive
|
||||||
model = new()
|
model = new()
|
||||||
@@ -1650,7 +1650,7 @@ namespace BTCPayServer.Tests
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
result = template.Render(model);
|
result = template.Render(model);
|
||||||
Assert.Equal("Hello mister John <NotFound(Name.Lastname)> !", result);
|
Assert.Equal("Hello mister John [NotFound(Name.Lastname)] !", result);
|
||||||
|
|
||||||
model = new()
|
model = new()
|
||||||
{
|
{
|
||||||
@@ -3352,26 +3352,47 @@ namespace BTCPayServer.Tests
|
|||||||
Assert.Equal("admin@admin.com", (await Assert.IsType<ServerEmailSender>(await emailSenderFactory.GetEmailSender()).GetEmailSettings()).Login);
|
Assert.Equal("admin@admin.com", (await Assert.IsType<ServerEmailSender>(await emailSenderFactory.GetEmailSender()).GetEmailSettings()).Login);
|
||||||
Assert.Null(await Assert.IsType<StoreEmailSender>(await emailSenderFactory.GetEmailSender(acc.StoreId)).GetEmailSettings());
|
Assert.Null(await Assert.IsType<StoreEmailSender>(await emailSenderFactory.GetEmailSender(acc.StoreId)).GetEmailSettings());
|
||||||
|
|
||||||
Assert.IsType<RedirectToActionResult>(await acc.GetController<UIStoresEmailController>().StoreEmailSettings(acc.StoreId, new EmailsViewModel(new EmailSettings
|
Assert.IsType<RedirectToActionResult>(await acc.GetController<UIStoresEmailController>().StoreEmailSettings(acc.StoreId, new(new()
|
||||||
{
|
{
|
||||||
From = "store@store.com",
|
From = "store@store.com",
|
||||||
Login = "store@store.com",
|
Login = "store@store.com",
|
||||||
Password = "store@store.com",
|
Password = "store@store.com",
|
||||||
Port = tester.MailPitSettings.SmtpPort,
|
Port = tester.MailPitSettings.SmtpPort,
|
||||||
Server = tester.MailPitSettings.Hostname
|
Server = tester.MailPitSettings.Hostname
|
||||||
}), ""));
|
})
|
||||||
|
{
|
||||||
|
IsCustomSMTP = true
|
||||||
|
}, ""));
|
||||||
|
|
||||||
Assert.Equal("store@store.com", (await Assert.IsType<StoreEmailSender>(await emailSenderFactory.GetEmailSender(acc.StoreId)).GetEmailSettings()).Login);
|
Assert.Equal("store@store.com", (await Assert.IsType<StoreEmailSender>(await emailSenderFactory.GetEmailSender(acc.StoreId)).GetEmailSettings()).Login);
|
||||||
|
|
||||||
var sent = await tester.WaitForEvent<Events.EmailSentEvent>(
|
var message = await tester.AssertHasEmail(async () =>
|
||||||
async () =>
|
|
||||||
{
|
{
|
||||||
var sender = await emailSenderFactory.GetEmailSender(acc.StoreId);
|
var sender = await emailSenderFactory.GetEmailSender(acc.StoreId);
|
||||||
sender.SendEmail(MailboxAddress.Parse("destination@test.com"), "test", "hello world");
|
sender.SendEmail(MailboxAddress.Parse("destination@test.com"), "test", "hello world");
|
||||||
});
|
});
|
||||||
var message = await tester.AssertHasEmail(sent);
|
|
||||||
Assert.Equal("test", message.Subject);
|
Assert.Equal("test", message.Subject);
|
||||||
Assert.Equal("hello world", message.Text);
|
Assert.Equal("hello world", message.Text);
|
||||||
|
|
||||||
|
// Configure at server level
|
||||||
|
Assert.IsType<RedirectToActionResult>(await acc.GetController<UIServerEmailController>().ServerEmailSettings(new(new()
|
||||||
|
{
|
||||||
|
From = "server@server.com",
|
||||||
|
Login = "server@server.com",
|
||||||
|
Password = "server@server.com",
|
||||||
|
Port = tester.MailPitSettings.SmtpPort,
|
||||||
|
Server = tester.MailPitSettings.Hostname
|
||||||
|
})
|
||||||
|
{
|
||||||
|
EnableStoresToUseServerEmailSettings = true
|
||||||
|
}, ""));
|
||||||
|
|
||||||
|
// The store should now use it
|
||||||
|
Assert.IsType<RedirectToActionResult>(await acc.GetController<UIStoresEmailController>().StoreEmailSettings(acc.StoreId, new(new())
|
||||||
|
{
|
||||||
|
IsCustomSMTP = false
|
||||||
|
}, ""));
|
||||||
|
Assert.Equal("server@server.com", (await Assert.IsType<StoreEmailSender>(await emailSenderFactory.GetEmailSender(acc.StoreId)).GetEmailSettings()).Login);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(Timeout = TestUtils.TestTimeout)]
|
[Fact(Timeout = TestUtils.TestTimeout)]
|
||||||
|
|||||||
@@ -51,6 +51,10 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="BTCPayServer.NTag424" Version="1.0.25" />
|
<PackageReference Include="BTCPayServer.NTag424" Version="1.0.25" />
|
||||||
|
<PackageReference Include="JetBrains.Annotations.Sources" Version="2025.2.2">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
<PackageReference Include="NBitcoin" Version="9.0.0" />
|
<PackageReference Include="NBitcoin" Version="9.0.0" />
|
||||||
<PackageReference Include="YamlDotNet" Version="8.0.0" />
|
<PackageReference Include="YamlDotNet" Version="8.0.0" />
|
||||||
<PackageReference Include="BIP78.Sender" Version="0.2.5" />
|
<PackageReference Include="BIP78.Sender" Version="0.2.5" />
|
||||||
@@ -193,5 +197,12 @@
|
|||||||
</Content>
|
</Content>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<_ContentIncludedByDefault Remove="Plugins\Emails\Views\UIStoreEmailRules\StoreEmailRulesList.cshtml" />
|
||||||
|
<_ContentIncludedByDefault Remove="Plugins\Emails\Views\UIStoreEmailRules\StoreEmailRulesManage.cshtml" />
|
||||||
|
<_ContentIncludedByDefault Remove="Plugins\Emails\Views\UIServerEmail\ServerEmailSettings.cshtml" />
|
||||||
|
<_ContentIncludedByDefault Remove="Plugins\Emails\Views\UIStoresEmail\StoreEmailSettings.cshtml" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ProjectExtensions><VisualStudio><UserProperties wwwroot_4swagger_4v1_4swagger_1template_1invoices_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1misc_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1pull-payments_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1serverinfo_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1stores_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1stores-payment-methods_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1stores-users_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1users_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1webhooks_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" /></VisualStudio></ProjectExtensions>
|
<ProjectExtensions><VisualStudio><UserProperties wwwroot_4swagger_4v1_4swagger_1template_1invoices_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1misc_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1pull-payments_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1serverinfo_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1stores_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1stores-payment-methods_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1stores-users_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1users_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1webhooks_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" /></VisualStudio></ProjectExtensions>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -314,7 +314,7 @@
|
|||||||
<a layout-menu-item="@nameof(ServerNavPages.Roles)" asp-controller="UIServer" asp-action="ListRoles" text-translate="true">Roles</a>
|
<a layout-menu-item="@nameof(ServerNavPages.Roles)" asp-controller="UIServer" asp-action="ListRoles" text-translate="true">Roles</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item nav-item-sub" permission="@Policies.CanModifyServerSettings">
|
<li class="nav-item nav-item-sub" permission="@Policies.CanModifyServerSettings">
|
||||||
<a layout-menu-item="Server-@nameof(ServerNavPages.Emails)" asp-controller="UIServer" asp-action="Emails" text-translate="true">Email</a>
|
<a layout-menu-item="Server-@nameof(ServerNavPages.Emails)" asp-area="@EmailsPlugin.Area" asp-controller="UIServerEmail" asp-action="ServerEmailSettings" text-translate="true">Emails</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item nav-item-sub" permission="@Policies.CanModifyServerSettings">
|
<li class="nav-item nav-item-sub" permission="@Policies.CanModifyServerSettings">
|
||||||
<a layout-menu-item="@nameof(ServerNavPages.Services)" asp-controller="UIServer" asp-action="Services" text-translate="true">Services</a>
|
<a layout-menu-item="@nameof(ServerNavPages.Services)" asp-controller="UIServer" asp-action="Services" text-translate="true">Services</a>
|
||||||
|
|||||||
@@ -701,7 +701,6 @@ namespace BTCPayServer.Controllers
|
|||||||
Severity = StatusMessageModel.StatusSeverity.Success,
|
Severity = StatusMessageModel.StatusSeverity.Success,
|
||||||
Message = StringLocalizer["Your email has been confirmed."].Value
|
Message = StringLocalizer["Your email has been confirmed."].Value
|
||||||
});
|
});
|
||||||
await FinalizeInvitationIfApplicable(user);
|
|
||||||
return RedirectToAction(nameof(Login), new { email = user.Email });
|
return RedirectToAction(nameof(Login), new { email = user.Email });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -809,8 +808,6 @@ namespace BTCPayServer.Controllers
|
|||||||
: StringLocalizer["Account successfully created."].Value
|
: StringLocalizer["Account successfully created."].Value
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!hasPassword) await FinalizeInvitationIfApplicable(user);
|
|
||||||
|
|
||||||
// see if we can sign in user after accepting an invitation and setting the password
|
// see if we can sign in user after accepting an invitation and setting the password
|
||||||
if (needsInitialPassword && UserService.TryCanLogin(user, out _))
|
if (needsInitialPassword && UserService.TryCanLogin(user, out _))
|
||||||
{
|
{
|
||||||
@@ -866,22 +863,9 @@ namespace BTCPayServer.Controllers
|
|||||||
Severity = StatusMessageModel.StatusSeverity.Info,
|
Severity = StatusMessageModel.StatusSeverity.Info,
|
||||||
Message = StringLocalizer["Your password has been set by the user who invited you."].Value
|
Message = StringLocalizer["Your password has been set by the user who invited you."].Value
|
||||||
});
|
});
|
||||||
|
|
||||||
await FinalizeInvitationIfApplicable(user);
|
|
||||||
return RedirectToAction(nameof(Login), new { email = user.Email });
|
return RedirectToAction(nameof(Login), new { email = user.Email });
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task FinalizeInvitationIfApplicable(ApplicationUser user)
|
|
||||||
{
|
|
||||||
if (!_userManager.HasInvitationToken<ApplicationUser>(user)) return;
|
|
||||||
|
|
||||||
// This is a placeholder, the real storeIds will be set by the UserEventHostedService
|
|
||||||
var storeUsersLink = _callbackGenerator.StoreUsersLink("{0}", Request);
|
|
||||||
_eventAggregator.Publish(new UserEvent.InviteAccepted(user, storeUsersLink));
|
|
||||||
// unset used token
|
|
||||||
await _userManager.UnsetInvitationTokenAsync<ApplicationUser>(user.Id);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<IActionResult> RedirectToConfirmEmail(ApplicationUser user)
|
private async Task<IActionResult> RedirectToConfirmEmail(ApplicationUser user)
|
||||||
{
|
{
|
||||||
var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
|
var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ namespace BTCPayServer.Controllers
|
|||||||
}
|
}
|
||||||
|
|
||||||
var callbackUrl = await _callbackGenerator.ForEmailConfirmation(user, Request);
|
var callbackUrl = await _callbackGenerator.ForEmailConfirmation(user, Request);
|
||||||
(await _EmailSenderFactory.GetEmailSender()).SendEmailConfirmation(user.GetMailboxAddress(), callbackUrl);
|
_eventAggregator.Publish(new UserEvent.ConfirmationEmailRequested(user, callbackUrl));
|
||||||
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Verification email sent. Please check your email."].Value;
|
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Verification email sent. Please check your email."].Value;
|
||||||
return RedirectToAction(nameof(Index));
|
return RedirectToAction(nameof(Index));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -405,9 +405,7 @@ namespace BTCPayServer.Controllers
|
|||||||
}
|
}
|
||||||
|
|
||||||
var callbackUrl = await _callbackGenerator.ForEmailConfirmation(user, Request);
|
var callbackUrl = await _callbackGenerator.ForEmailConfirmation(user, Request);
|
||||||
|
_eventAggregator.Publish(new UserEvent.ConfirmationEmailRequested(user, callbackUrl));
|
||||||
(await _emailSenderFactory.GetEmailSender()).SendEmailConfirmation(user.GetMailboxAddress(), callbackUrl);
|
|
||||||
|
|
||||||
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Verification email sent"].Value;
|
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Verification email sent"].Value;
|
||||||
return RedirectToAction(nameof(ListUsers));
|
return RedirectToAction(nameof(ListUsers));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,9 +66,9 @@ namespace BTCPayServer.Controllers
|
|||||||
private readonly IEnumerable<IStorageProviderService> _StorageProviderServices;
|
private readonly IEnumerable<IStorageProviderService> _StorageProviderServices;
|
||||||
private readonly CallbackGenerator _callbackGenerator;
|
private readonly CallbackGenerator _callbackGenerator;
|
||||||
private readonly UriResolver _uriResolver;
|
private readonly UriResolver _uriResolver;
|
||||||
private readonly EmailSenderFactory _emailSenderFactory;
|
|
||||||
private readonly TransactionLinkProviders _transactionLinkProviders;
|
private readonly TransactionLinkProviders _transactionLinkProviders;
|
||||||
private readonly LocalizerService _localizer;
|
private readonly LocalizerService _localizer;
|
||||||
|
private readonly EmailSenderFactory _emailSenderFactory;
|
||||||
public IStringLocalizer StringLocalizer { get; }
|
public IStringLocalizer StringLocalizer { get; }
|
||||||
|
|
||||||
public UIServerController(
|
public UIServerController(
|
||||||
@@ -76,6 +76,7 @@ namespace BTCPayServer.Controllers
|
|||||||
UserService userService,
|
UserService userService,
|
||||||
StoredFileRepository storedFileRepository,
|
StoredFileRepository storedFileRepository,
|
||||||
IFileService fileService,
|
IFileService fileService,
|
||||||
|
EmailSenderFactory emailSenderFactory,
|
||||||
IEnumerable<IStorageProviderService> storageProviderServices,
|
IEnumerable<IStorageProviderService> storageProviderServices,
|
||||||
BTCPayServerOptions options,
|
BTCPayServerOptions options,
|
||||||
SettingsRepository settingsRepository,
|
SettingsRepository settingsRepository,
|
||||||
@@ -92,7 +93,6 @@ namespace BTCPayServer.Controllers
|
|||||||
Logs logs,
|
Logs logs,
|
||||||
CallbackGenerator callbackGenerator,
|
CallbackGenerator callbackGenerator,
|
||||||
UriResolver uriResolver,
|
UriResolver uriResolver,
|
||||||
EmailSenderFactory emailSenderFactory,
|
|
||||||
IHostApplicationLifetime applicationLifetime,
|
IHostApplicationLifetime applicationLifetime,
|
||||||
IHtmlHelper html,
|
IHtmlHelper html,
|
||||||
TransactionLinkProviders transactionLinkProviders,
|
TransactionLinkProviders transactionLinkProviders,
|
||||||
@@ -119,9 +119,9 @@ namespace BTCPayServer.Controllers
|
|||||||
_eventAggregator = eventAggregator;
|
_eventAggregator = eventAggregator;
|
||||||
_externalServiceOptions = externalServiceOptions;
|
_externalServiceOptions = externalServiceOptions;
|
||||||
Logs = logs;
|
Logs = logs;
|
||||||
|
_emailSenderFactory = emailSenderFactory;
|
||||||
_callbackGenerator = callbackGenerator;
|
_callbackGenerator = callbackGenerator;
|
||||||
_uriResolver = uriResolver;
|
_uriResolver = uriResolver;
|
||||||
_emailSenderFactory = emailSenderFactory;
|
|
||||||
ApplicationLifetime = applicationLifetime;
|
ApplicationLifetime = applicationLifetime;
|
||||||
Html = html;
|
Html = html;
|
||||||
_transactionLinkProviders = transactionLinkProviders;
|
_transactionLinkProviders = transactionLinkProviders;
|
||||||
@@ -1224,99 +1224,6 @@ namespace BTCPayServer.Controllers
|
|||||||
return View(vm);
|
return View(vm);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("server/emails")]
|
|
||||||
public async Task<IActionResult> Emails()
|
|
||||||
{
|
|
||||||
var email = await _emailSenderFactory.GetSettings() ?? new EmailSettings();
|
|
||||||
var vm = new ServerEmailsViewModel(email)
|
|
||||||
{
|
|
||||||
EnableStoresToUseServerEmailSettings = !_policiesSettings.DisableStoresToUseServerEmailSettings
|
|
||||||
};
|
|
||||||
return View(vm);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("server/emails")]
|
|
||||||
public async Task<IActionResult> Emails(ServerEmailsViewModel model, string command)
|
|
||||||
{
|
|
||||||
if (command == "Test")
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (model.PasswordSet)
|
|
||||||
{
|
|
||||||
var settings = await _emailSenderFactory.GetSettings() ?? new EmailSettings();
|
|
||||||
model.Settings.Password = settings.Password;
|
|
||||||
}
|
|
||||||
model.Settings.Validate("Settings.", ModelState);
|
|
||||||
if (string.IsNullOrEmpty(model.TestEmail))
|
|
||||||
ModelState.AddModelError(nameof(model.TestEmail), new RequiredAttribute().FormatErrorMessage(nameof(model.TestEmail)));
|
|
||||||
if (!ModelState.IsValid)
|
|
||||||
return View(model);
|
|
||||||
var serverSettings = await _SettingsRepository.GetSettingAsync<ServerSettings>();
|
|
||||||
var serverName = string.IsNullOrEmpty(serverSettings?.ServerName) ? "BTCPay Server" : serverSettings.ServerName;
|
|
||||||
using (var client = await model.Settings.CreateSmtpClient())
|
|
||||||
using (var message = model.Settings.CreateMailMessage(MailboxAddress.Parse(model.TestEmail), $"{serverName}: Email test", "You received it, the BTCPay Server SMTP settings work.", false))
|
|
||||||
{
|
|
||||||
await client.SendAsync(message);
|
|
||||||
await client.DisconnectAsync(true);
|
|
||||||
}
|
|
||||||
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email sent to {0}. Please verify you received it.", model.TestEmail].Value;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
TempData[WellKnownTempData.ErrorMessage] = ex.Message;
|
|
||||||
}
|
|
||||||
return View(model);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_policiesSettings.DisableStoresToUseServerEmailSettings == model.EnableStoresToUseServerEmailSettings)
|
|
||||||
{
|
|
||||||
_policiesSettings.DisableStoresToUseServerEmailSettings = !model.EnableStoresToUseServerEmailSettings;
|
|
||||||
await _SettingsRepository.UpdateSetting(_policiesSettings);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command == "ResetPassword")
|
|
||||||
{
|
|
||||||
var settings = await _SettingsRepository.GetSettingAsync<EmailSettings>() ?? new EmailSettings();
|
|
||||||
settings.Password = null;
|
|
||||||
await _SettingsRepository.UpdateSetting(settings);
|
|
||||||
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email server password reset"].Value;
|
|
||||||
}
|
|
||||||
else if (command == "mailpit")
|
|
||||||
{
|
|
||||||
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."
|
|
||||||
});
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Route("server/logs/{file?}")]
|
[Route("server/logs/{file?}")]
|
||||||
public async Task<IActionResult> LogsView(string? file = null, int offset = 0, bool download = false)
|
public async Task<IActionResult> LogsView(string? file = null, int offset = 0, bool download = false)
|
||||||
{
|
{
|
||||||
|
|||||||
13
BTCPayServer/Data/MigrationBase.cs
Normal file
13
BTCPayServer/Data/MigrationBase.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Data;
|
||||||
|
|
||||||
|
public abstract class MigrationBase<TDbContext>(string migrationId)
|
||||||
|
where TDbContext : DbContext
|
||||||
|
{
|
||||||
|
public string MigrationId { get; } = migrationId;
|
||||||
|
|
||||||
|
public abstract Task MigrateAsync(TDbContext dbContext, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
54
BTCPayServer/Data/MigrationExecutor.cs
Normal file
54
BTCPayServer/Data/MigrationExecutor.cs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Data;
|
||||||
|
|
||||||
|
public interface IMigrationExecutor
|
||||||
|
{
|
||||||
|
Task Execute(CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MigrationExecutor<TDbContext>(
|
||||||
|
ILoggerFactory loggerFactory,
|
||||||
|
IDbContextFactory<TDbContext> dbContextFactory,
|
||||||
|
IEnumerable<MigrationBase<TDbContext>> migrations) : IMigrationExecutor
|
||||||
|
where TDbContext : DbContext
|
||||||
|
{
|
||||||
|
ILogger logger = loggerFactory.CreateLogger($"BTCPayServer.Migrations.{typeof(TDbContext).Name}");
|
||||||
|
public async Task Execute(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
var history = dbContext.Database.GetService<IHistoryRepository>();
|
||||||
|
var appliedMigrations = (await history.GetAppliedMigrationsAsync(cancellationToken)).Select(m => m.MigrationId).ToHashSet();
|
||||||
|
var insertedRows = new List<HistoryRow>();
|
||||||
|
var pending = migrations
|
||||||
|
.Where(m => !appliedMigrations.Contains(m.MigrationId))
|
||||||
|
.OrderBy(m => m.MigrationId)
|
||||||
|
.ToArray();
|
||||||
|
if (pending.Length == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await dbContext.Database.CreateExecutionStrategy().ExecuteAsync(async () =>
|
||||||
|
{
|
||||||
|
foreach (var migration in pending)
|
||||||
|
{
|
||||||
|
logger.LogInformation("Applying migration '{MigrationId}'", migration.MigrationId);
|
||||||
|
await migration.MigrateAsync(dbContext, cancellationToken);
|
||||||
|
insertedRows.Add(new HistoryRow(migration.MigrationId, ProductInfo.GetVersion()));
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
var insertMigrations =
|
||||||
|
string.Concat(insertedRows
|
||||||
|
.Select(r => history.GetInsertScript(r))
|
||||||
|
.ToArray());
|
||||||
|
await dbContext.Database.ExecuteSqlRawAsync(insertMigrations, cancellationToken);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,14 +16,16 @@ public class UserEvent(ApplicationUser user)
|
|||||||
return $"{base.ToString()} has been deleted";
|
return $"{base.ToString()} has been deleted";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
public class InviteAccepted(ApplicationUser user, string storeUsersLink) : UserEvent(user)
|
|
||||||
{
|
|
||||||
public string StoreUsersLink { get; set; } = storeUsersLink;
|
|
||||||
}
|
|
||||||
public class PasswordResetRequested(ApplicationUser user, string resetLink) : UserEvent(user)
|
public class PasswordResetRequested(ApplicationUser user, string resetLink) : UserEvent(user)
|
||||||
{
|
{
|
||||||
public string ResetLink { get; } = resetLink;
|
public string ResetLink { get; } = resetLink;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class ConfirmationEmailRequested(ApplicationUser user, string confirmLink) : UserEvent(user)
|
||||||
|
{
|
||||||
|
public string ConfirmLink { get; } = confirmLink;
|
||||||
|
}
|
||||||
|
|
||||||
public class Registered(ApplicationUser user, string approvalLink, string confirmationEmail) : UserEvent(user)
|
public class Registered(ApplicationUser user, string approvalLink, string confirmationEmail) : UserEvent(user)
|
||||||
{
|
{
|
||||||
public string ApprovalLink { get; } = approvalLink;
|
public string ApprovalLink { get; } = approvalLink;
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ using BTCPayServer.Services.Wallets;
|
|||||||
using Microsoft.AspNetCore.DataProtection;
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
@@ -454,6 +455,15 @@ namespace BTCPayServer
|
|||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static IServiceCollection AddMigration<TDbContext, TMigration>(this IServiceCollection services)
|
||||||
|
where TDbContext : DbContext
|
||||||
|
where TMigration : MigrationBase<TDbContext>
|
||||||
|
{
|
||||||
|
services.TryAddSingleton<IMigrationExecutor, MigrationExecutor<TDbContext>>();
|
||||||
|
services.AddSingleton<MigrationBase<TDbContext>, TMigration>();
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
public static async Task CloseSocket(this WebSocket webSocket)
|
public static async Task CloseSocket(this WebSocket webSocket)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
using System.Text.Encodings.Web;
|
|
||||||
using BTCPayServer.Services.Mails;
|
|
||||||
using MimeKit;
|
|
||||||
using QRCoder;
|
|
||||||
|
|
||||||
namespace BTCPayServer.Services
|
|
||||||
{
|
|
||||||
public static class EmailSenderExtensions
|
|
||||||
{
|
|
||||||
private static string BODY_STYLE = "font-family: Open Sans, Helvetica Neue,Arial,sans-serif; font-color: #292929;";
|
|
||||||
private static string HEADER_HTML = "<h1 style='font-size:1.2rem'>BTCPay Server</h1><br/>";
|
|
||||||
private static string BUTTON_HTML = "<a href='{button_link}' type='submit' style='min-width: 2em;min-height: 20px;text-decoration-line: none;cursor: pointer;display: inline-block;font-weight: 400;color: #fff;text-align: center;vertical-align: middle;user-select: none;background-color: #51b13e;border-color: #51b13e;border: 1px solid transparent;padding: 0.375rem 0.75rem;font-size: 1rem;line-height: 1.5;border-radius: 0.25rem;transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;'>{button_description}</a>";
|
|
||||||
|
|
||||||
private static string CallToAction(string actionName, string actionLink)
|
|
||||||
{
|
|
||||||
var button = $"{BUTTON_HTML}".Replace("{button_description}", actionName, System.StringComparison.InvariantCulture);
|
|
||||||
return button.Replace("{button_link}", HtmlEncoder.Default.Encode(actionLink), System.StringComparison.InvariantCulture);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string CreateEmailBody(string body)
|
|
||||||
{
|
|
||||||
return $"<html><body style='{BODY_STYLE}'>{HEADER_HTML}{body}</body></html>";
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void SendEmailConfirmation(this IEmailSender emailSender, MailboxAddress address, string link)
|
|
||||||
{
|
|
||||||
emailSender.SendEmail(address, "Confirm your email", CreateEmailBody(
|
|
||||||
$"Please confirm your account.<br/><br/>{CallToAction("Confirm Email", link)}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void SendApprovalConfirmation(this IEmailSender emailSender, MailboxAddress address, string link)
|
|
||||||
{
|
|
||||||
emailSender.SendEmail(address, "Your account has been approved", CreateEmailBody(
|
|
||||||
$"Your account has been approved and you can now <a href='{HtmlEncoder.Default.Encode(link)}'>login here</a>."));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void SendResetPassword(this IEmailSender emailSender, MailboxAddress address, string link)
|
|
||||||
{
|
|
||||||
emailSender.SendEmail(address, "Update Password", CreateEmailBody(
|
|
||||||
$"A request has been made to reset your BTCPay Server password. Please set your password by clicking below.<br/><br/>{CallToAction("Update Password", link)}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void SendInvitation(this IEmailSender emailSender, MailboxAddress address, string link)
|
|
||||||
{
|
|
||||||
emailSender.SendEmail(address, "Invitation", CreateEmailBody(
|
|
||||||
$"<p>Please complete your account setup by clicking <a href='{HtmlEncoder.Default.Encode(link)}'>this link</a>.</p><p>You can also use the BTCPay Server app and scan this QR code when connecting:</p>{GetQrCodeImg(link)}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void SendNewUserInfo(this IEmailSender emailSender, MailboxAddress address, string newUserInfo, string link)
|
|
||||||
{
|
|
||||||
emailSender.SendEmail(address, newUserInfo, CreateEmailBody(
|
|
||||||
$"{newUserInfo}. You can verify and approve the account here: <a href='{HtmlEncoder.Default.Encode(link)}'>User details</a>"));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void SendUserInviteAcceptedInfo(this IEmailSender emailSender, MailboxAddress address, string userInfo, string link)
|
|
||||||
{
|
|
||||||
emailSender.SendEmail(address, userInfo, CreateEmailBody(
|
|
||||||
$"{userInfo}. You can view the store users here: <a href='{HtmlEncoder.Default.Encode(link)}'>Store users</a>"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetQrCodeImg(string data)
|
|
||||||
{
|
|
||||||
using var qrGenerator = new QRCodeGenerator();
|
|
||||||
using var qrCodeData = qrGenerator.CreateQrCode(data, QRCodeGenerator.ECCLevel.Q);
|
|
||||||
using var qrCode = new Base64QRCode(qrCodeData);
|
|
||||||
var base64 = qrCode.GetGraphic(20);
|
|
||||||
return $"<img src='data:image/png;base64,{base64}' alt='{data}' width='320' height='320'/>";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using BTCPayServer.Data;
|
|
||||||
using BTCPayServer.Events;
|
|
||||||
using BTCPayServer.Logging;
|
|
||||||
using BTCPayServer.Services;
|
|
||||||
using BTCPayServer.Services.Mails;
|
|
||||||
using BTCPayServer.Services.Notifications;
|
|
||||||
using BTCPayServer.Services.Notifications.Blobs;
|
|
||||||
using BTCPayServer.Services.Stores;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace BTCPayServer.HostedServices;
|
|
||||||
|
|
||||||
public class UserEventHostedService(
|
|
||||||
EventAggregator eventAggregator,
|
|
||||||
UserManager<ApplicationUser> userManager,
|
|
||||||
CallbackGenerator callbackGenerator,
|
|
||||||
EmailSenderFactory emailSenderFactory,
|
|
||||||
NotificationSender notificationSender,
|
|
||||||
StoreRepository storeRepository,
|
|
||||||
Logs logs)
|
|
||||||
: EventHostedServiceBase(eventAggregator, logs)
|
|
||||||
{
|
|
||||||
public UserManager<ApplicationUser> UserManager { get; } = userManager;
|
|
||||||
public CallbackGenerator CallbackGenerator { get; } = callbackGenerator;
|
|
||||||
|
|
||||||
protected override void SubscribeToEvents()
|
|
||||||
{
|
|
||||||
Subscribe<UserEvent.Registered>();
|
|
||||||
Subscribe<UserEvent.Invited>();
|
|
||||||
Subscribe<UserEvent.Approved>();
|
|
||||||
Subscribe<UserEvent.ConfirmedEmail>();
|
|
||||||
Subscribe<UserEvent.PasswordResetRequested>();
|
|
||||||
Subscribe<UserEvent.InviteAccepted>();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
ApplicationUser user = (evt as UserEvent).User;
|
|
||||||
IEmailSender emailSender;
|
|
||||||
switch (evt)
|
|
||||||
{
|
|
||||||
case UserEvent.Registered ev:
|
|
||||||
// can be either a self-registration or by invite from another user
|
|
||||||
var type = await UserManager.IsInRoleAsync(user, Roles.ServerAdmin) ? "admin" : "user";
|
|
||||||
var info = ev switch
|
|
||||||
{
|
|
||||||
UserEvent.Invited { InvitedByUser: { } invitedBy } => $"invited by {invitedBy.Email}",
|
|
||||||
UserEvent.Invited => "invited",
|
|
||||||
_ => "registered"
|
|
||||||
};
|
|
||||||
var requiresApproval = user.RequiresApproval && !user.Approved;
|
|
||||||
var requiresEmailConfirmation = user.RequiresEmailConfirmation && !user.EmailConfirmed;
|
|
||||||
|
|
||||||
// log registration info
|
|
||||||
var newUserInfo = $"New {type} {user.Email} {info}";
|
|
||||||
Logs.PayServer.LogInformation(newUserInfo);
|
|
||||||
|
|
||||||
// send notification if the user does not require email confirmation.
|
|
||||||
// inform admins only about qualified users and not annoy them with bot registrations.
|
|
||||||
if (requiresApproval && !requiresEmailConfirmation)
|
|
||||||
{
|
|
||||||
await NotifyAdminsAboutUserRequiringApproval(user, ev.ApprovalLink, newUserInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
// set callback result and send email to user
|
|
||||||
emailSender = await emailSenderFactory.GetEmailSender();
|
|
||||||
if (ev is UserEvent.Invited invited)
|
|
||||||
{
|
|
||||||
if (invited.SendInvitationEmail)
|
|
||||||
emailSender.SendInvitation(user.GetMailboxAddress(), invited.InvitationLink);
|
|
||||||
}
|
|
||||||
else if (requiresEmailConfirmation)
|
|
||||||
{
|
|
||||||
emailSender.SendEmailConfirmation(user.GetMailboxAddress(), ev.ConfirmationEmailLink);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case UserEvent.PasswordResetRequested pwResetEvent:
|
|
||||||
Logs.PayServer.LogInformation("User {Email} requested a password reset", user.Email);
|
|
||||||
emailSender = await emailSenderFactory.GetEmailSender();
|
|
||||||
emailSender.SendResetPassword(user.GetMailboxAddress(), pwResetEvent.ResetLink);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case UserEvent.Approved approvedEvent:
|
|
||||||
if (!user.Approved) break;
|
|
||||||
emailSender = await emailSenderFactory.GetEmailSender();
|
|
||||||
emailSender.SendApprovalConfirmation(user.GetMailboxAddress(), approvedEvent.LoginLink);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case UserEvent.ConfirmedEmail confirmedEvent:
|
|
||||||
if (!user.EmailConfirmed) break;
|
|
||||||
var confirmedUserInfo = $"User {user.Email} confirmed their email address";
|
|
||||||
Logs.PayServer.LogInformation(confirmedUserInfo);
|
|
||||||
await NotifyAdminsAboutUserRequiringApproval(user, confirmedEvent.ApprovalLink, confirmedUserInfo);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case UserEvent.InviteAccepted inviteAcceptedEvent:
|
|
||||||
Logs.PayServer.LogInformation("User {Email} accepted the invite", user.Email);
|
|
||||||
await NotifyAboutUserAcceptingInvite(user, inviteAcceptedEvent.StoreUsersLink);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task NotifyAdminsAboutUserRequiringApproval(ApplicationUser user, string approvalLink, string newUserInfo)
|
|
||||||
{
|
|
||||||
if (!user.RequiresApproval || user.Approved) return;
|
|
||||||
// notification
|
|
||||||
await notificationSender.SendNotification(new AdminScope(), new NewUserRequiresApprovalNotification(user));
|
|
||||||
// email
|
|
||||||
var admins = await UserManager.GetUsersInRoleAsync(Roles.ServerAdmin);
|
|
||||||
var emailSender = await emailSenderFactory.GetEmailSender();
|
|
||||||
foreach (var admin in admins)
|
|
||||||
{
|
|
||||||
emailSender.SendNewUserInfo(admin.GetMailboxAddress(), newUserInfo, approvalLink);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task NotifyAboutUserAcceptingInvite(ApplicationUser user, string storeUsersLink)
|
|
||||||
{
|
|
||||||
var stores = await storeRepository.GetStoresByUserId(user.Id);
|
|
||||||
var notifyRoles = new[] { StoreRoleId.Owner, StoreRoleId.Manager };
|
|
||||||
foreach (var store in stores)
|
|
||||||
{
|
|
||||||
// notification
|
|
||||||
await notificationSender.SendNotification(new StoreScope(store.Id, notifyRoles), new InviteAcceptedNotification(user, store));
|
|
||||||
// email
|
|
||||||
var notifyUsers = await storeRepository.GetStoreUsers(store.Id, notifyRoles);
|
|
||||||
var link = string.Format(storeUsersLink, store.Id);
|
|
||||||
var emailSender = await emailSenderFactory.GetEmailSender(store.Id);
|
|
||||||
foreach (var storeUser in notifyUsers)
|
|
||||||
{
|
|
||||||
if (storeUser.Id == user.Id) continue; // do not notify the user itself (if they were added as owner or manager)
|
|
||||||
var notifyUser = await UserManager.FindByIdOrEmail(storeUser.Id);
|
|
||||||
var info = $"User {user.Email} accepted the invite to {store.StoreName}";
|
|
||||||
emailSender.SendUserInviteAcceptedInfo(notifyUser.GetMailboxAddress(), info, link);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -74,6 +74,7 @@ using ExchangeSharp;
|
|||||||
using Microsoft.Extensions.Localization;
|
using Microsoft.Extensions.Localization;
|
||||||
using Microsoft.AspNetCore.Mvc.Localization;
|
using Microsoft.AspNetCore.Mvc.Localization;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace BTCPayServer.Hosting
|
namespace BTCPayServer.Hosting
|
||||||
{
|
{
|
||||||
@@ -96,6 +97,9 @@ namespace BTCPayServer.Hosting
|
|||||||
|
|
||||||
services.AddSingleton<MvcNewtonsoftJsonOptions>(o => o.GetRequiredService<IOptions<MvcNewtonsoftJsonOptions>>().Value);
|
services.AddSingleton<MvcNewtonsoftJsonOptions>(o => o.GetRequiredService<IOptions<MvcNewtonsoftJsonOptions>>().Value);
|
||||||
services.AddSingleton<JsonSerializerSettings>(o => o.GetRequiredService<IOptions<MvcNewtonsoftJsonOptions>>().Value.SerializerSettings);
|
services.AddSingleton<JsonSerializerSettings>(o => o.GetRequiredService<IOptions<MvcNewtonsoftJsonOptions>>().Value.SerializerSettings);
|
||||||
|
|
||||||
|
services.AddSingleton<IDbContextFactory<ApplicationDbContext>, ApplicationDbContextFactory>((provider) => provider.GetRequiredService<ApplicationDbContextFactory>());
|
||||||
|
services.AddSingleton<IMigrationExecutor, MigrationExecutor<ApplicationDbContext>>();
|
||||||
services.AddDbContext<ApplicationDbContext>((provider, o) =>
|
services.AddDbContext<ApplicationDbContext>((provider, o) =>
|
||||||
{
|
{
|
||||||
var factory = provider.GetRequiredService<ApplicationDbContextFactory>();
|
var factory = provider.GetRequiredService<ApplicationDbContextFactory>();
|
||||||
@@ -159,6 +163,7 @@ namespace BTCPayServer.Hosting
|
|||||||
//
|
//
|
||||||
AddSettingsAccessor<PoliciesSettings>(services);
|
AddSettingsAccessor<PoliciesSettings>(services);
|
||||||
AddSettingsAccessor<ThemeSettings>(services);
|
AddSettingsAccessor<ThemeSettings>(services);
|
||||||
|
AddSettingsAccessor<ServerSettings>(services);
|
||||||
//
|
//
|
||||||
|
|
||||||
AddOnchainWalletParsers(services);
|
AddOnchainWalletParsers(services);
|
||||||
@@ -435,7 +440,6 @@ o.GetRequiredService<IEnumerable<IPaymentLinkExtension>>().ToDictionary(o => o.P
|
|||||||
services.AddSingleton<IHostedService, AppInventoryUpdaterHostedService>();
|
services.AddSingleton<IHostedService, AppInventoryUpdaterHostedService>();
|
||||||
services.AddSingleton<IHostedService, TransactionLabelMarkerHostedService>();
|
services.AddSingleton<IHostedService, TransactionLabelMarkerHostedService>();
|
||||||
services.AddSingleton<IHostedService, OnChainRateTrackerHostedService>();
|
services.AddSingleton<IHostedService, OnChainRateTrackerHostedService>();
|
||||||
services.AddSingleton<IHostedService, UserEventHostedService>();
|
|
||||||
services.AddSingleton<IHostedService, DynamicDnsHostedService>();
|
services.AddSingleton<IHostedService, DynamicDnsHostedService>();
|
||||||
services.AddSingleton<PaymentRequestStreamer>();
|
services.AddSingleton<PaymentRequestStreamer>();
|
||||||
services.AddSingleton<IHostedService>(s => s.GetRequiredService<PaymentRequestStreamer>());
|
services.AddSingleton<IHostedService>(s => s.GetRequiredService<PaymentRequestStreamer>());
|
||||||
@@ -445,7 +449,6 @@ o.GetRequiredService<IEnumerable<IPaymentLinkExtension>>().ToDictionary(o => o.P
|
|||||||
|
|
||||||
services.AddSingleton<INotificationHandler, NewVersionNotification.Handler>();
|
services.AddSingleton<INotificationHandler, NewVersionNotification.Handler>();
|
||||||
services.AddSingleton<INotificationHandler, NewUserRequiresApprovalNotification.Handler>();
|
services.AddSingleton<INotificationHandler, NewUserRequiresApprovalNotification.Handler>();
|
||||||
services.AddSingleton<INotificationHandler, InviteAcceptedNotification.Handler>();
|
|
||||||
services.AddSingleton<INotificationHandler, PluginUpdateNotification.Handler>();
|
services.AddSingleton<INotificationHandler, PluginUpdateNotification.Handler>();
|
||||||
services.AddSingleton<INotificationHandler, InvoiceEventNotification.Handler>();
|
services.AddSingleton<INotificationHandler, InvoiceEventNotification.Handler>();
|
||||||
services.AddSingleton<INotificationHandler, PayoutNotification.Handler>();
|
services.AddSingleton<INotificationHandler, PayoutNotification.Handler>();
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ namespace BTCPayServer.Hosting
|
|||||||
|
|
||||||
private readonly ApplicationDbContextFactory _DBContextFactory;
|
private readonly ApplicationDbContextFactory _DBContextFactory;
|
||||||
private readonly StoreRepository _StoreRepository;
|
private readonly StoreRepository _StoreRepository;
|
||||||
|
private readonly IEnumerable<IMigrationExecutor> _migrationExecutors;
|
||||||
private readonly PaymentMethodHandlerDictionary _handlers;
|
private readonly PaymentMethodHandlerDictionary _handlers;
|
||||||
private readonly SettingsRepository _Settings;
|
private readonly SettingsRepository _Settings;
|
||||||
private readonly AppService _appService;
|
private readonly AppService _appService;
|
||||||
@@ -54,6 +55,7 @@ namespace BTCPayServer.Hosting
|
|||||||
public IOptions<LightningNetworkOptions> LightningOptions { get; }
|
public IOptions<LightningNetworkOptions> LightningOptions { get; }
|
||||||
|
|
||||||
public MigrationStartupTask(
|
public MigrationStartupTask(
|
||||||
|
IEnumerable<IMigrationExecutor> migrationExecutors,
|
||||||
PaymentMethodHandlerDictionary handlers,
|
PaymentMethodHandlerDictionary handlers,
|
||||||
StoreRepository storeRepository,
|
StoreRepository storeRepository,
|
||||||
ApplicationDbContextFactory dbContextFactory,
|
ApplicationDbContextFactory dbContextFactory,
|
||||||
@@ -67,6 +69,7 @@ namespace BTCPayServer.Hosting
|
|||||||
IFileService fileService,
|
IFileService fileService,
|
||||||
LightningClientFactoryService lightningClientFactoryService)
|
LightningClientFactoryService lightningClientFactoryService)
|
||||||
{
|
{
|
||||||
|
_migrationExecutors = migrationExecutors;
|
||||||
_handlers = handlers;
|
_handlers = handlers;
|
||||||
_DBContextFactory = dbContextFactory;
|
_DBContextFactory = dbContextFactory;
|
||||||
_StoreRepository = storeRepository;
|
_StoreRepository = storeRepository;
|
||||||
@@ -230,6 +233,11 @@ namespace BTCPayServer.Hosting
|
|||||||
settings.MigrateOldDerivationSchemes = true;
|
settings.MigrateOldDerivationSchemes = true;
|
||||||
await _Settings.UpdateSetting(settings);
|
await _Settings.UpdateSetting(settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (var executor in _migrationExecutors)
|
||||||
|
{
|
||||||
|
await executor.Execute(cancellationToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -45,16 +45,14 @@ namespace BTCPayServer.Hosting
|
|||||||
{
|
{
|
||||||
public class Startup
|
public class Startup
|
||||||
{
|
{
|
||||||
public Startup(IConfiguration conf, IWebHostEnvironment env, ILoggerFactory loggerFactory)
|
public Startup(IConfiguration conf, ILoggerFactory loggerFactory)
|
||||||
{
|
{
|
||||||
Configuration = conf;
|
Configuration = conf;
|
||||||
_Env = env;
|
|
||||||
LoggerFactory = loggerFactory;
|
LoggerFactory = loggerFactory;
|
||||||
Logs = new Logs();
|
Logs = new Logs();
|
||||||
Logs.Configure(loggerFactory);
|
Logs.Configure(loggerFactory);
|
||||||
}
|
}
|
||||||
|
|
||||||
readonly IWebHostEnvironment _Env;
|
|
||||||
public IConfiguration Configuration
|
public IConfiguration Configuration
|
||||||
{
|
{
|
||||||
get; set;
|
get; set;
|
||||||
@@ -178,7 +176,6 @@ namespace BTCPayServer.Hosting
|
|||||||
o.AreaViewLocationFormats.Add("/Plugins/{2}/Views/{0}.cshtml");
|
o.AreaViewLocationFormats.Add("/Plugins/{2}/Views/{0}.cshtml");
|
||||||
o.AreaViewLocationFormats.Add("/Plugins/{2}/Views/Shared/{0}.cshtml");
|
o.AreaViewLocationFormats.Add("/Plugins/{2}/Views/Shared/{0}.cshtml");
|
||||||
|
|
||||||
|
|
||||||
o.AreaViewLocationFormats.Add("/{0}.cshtml");
|
o.AreaViewLocationFormats.Add("/{0}.cshtml");
|
||||||
})
|
})
|
||||||
.AddNewtonsoftJson()
|
.AddNewtonsoftJson()
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using BTCPayServer.Services.Mails;
|
|
||||||
|
|
||||||
namespace BTCPayServer.Models.ServerViewModels;
|
|
||||||
|
|
||||||
public class ServerEmailsViewModel : EmailsViewModel
|
|
||||||
{
|
|
||||||
[Display(Name = "Allow Stores use the Server's SMTP email settings as their default")]
|
|
||||||
public bool EnableStoresToUseServerEmailSettings { get; set; }
|
|
||||||
|
|
||||||
public ServerEmailsViewModel()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public ServerEmailsViewModel(EmailSettings settings) : base(settings)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
127
BTCPayServer/Plugins/Emails/Controllers/UIEmailControllerBase.cs
Normal file
127
BTCPayServer/Plugins/Emails/Controllers/UIEmailControllerBase.cs
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
#nullable enable
|
||||||
|
using System;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using BTCPayServer.Abstractions.Constants;
|
||||||
|
using BTCPayServer.Abstractions.Extensions;
|
||||||
|
using BTCPayServer.Abstractions.Models;
|
||||||
|
using BTCPayServer.Client;
|
||||||
|
using BTCPayServer.Plugins.Emails.Views;
|
||||||
|
using BTCPayServer.Services;
|
||||||
|
using BTCPayServer.Services.Mails;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Localization;
|
||||||
|
using MimeKit;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Plugins.Emails.Controllers;
|
||||||
|
|
||||||
|
public abstract class UIEmailControllerBase(IStringLocalizer stringLocalizer) : Controller
|
||||||
|
{
|
||||||
|
public IStringLocalizer StringLocalizer { get; } = stringLocalizer;
|
||||||
|
|
||||||
|
protected class Context
|
||||||
|
{
|
||||||
|
public Func<EmailSettings, EmailsViewModel> CreateEmailViewModel { get; set; } = null!;
|
||||||
|
public string StoreId { get; set; } = null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract Task<EmailSettings> GetEmailSettings(Context ctx);
|
||||||
|
|
||||||
|
protected virtual Task<EmailSettings> GetEmailSettingsForTest(Context ctx, EmailsViewModel viewModel)
|
||||||
|
=> Task.FromResult(viewModel.Settings);
|
||||||
|
|
||||||
|
protected abstract Task SaveEmailSettings(Context ctx, EmailSettings settings, EmailsViewModel? viewModel = null);
|
||||||
|
protected abstract IActionResult RedirectToEmailSettings(Context ctx);
|
||||||
|
protected abstract Task<(string Subject, string Body)> GetTestMessage(Context ctx);
|
||||||
|
|
||||||
|
protected async Task<IActionResult> EmailSettingsCore(Context ctx)
|
||||||
|
{
|
||||||
|
var email = await GetEmailSettings(ctx);
|
||||||
|
var vm = ctx.CreateEmailViewModel(email);
|
||||||
|
return View("EmailSettings", vm);
|
||||||
|
}
|
||||||
|
protected async Task<IActionResult> EmailSettingsCore(Context ctx, EmailsViewModel model, string command)
|
||||||
|
{
|
||||||
|
if (command == "Test")
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (model.PasswordSet)
|
||||||
|
{
|
||||||
|
var settings = await GetEmailSettings(ctx);
|
||||||
|
model.Settings.Password = settings.Password;
|
||||||
|
}
|
||||||
|
|
||||||
|
model.Settings.Validate("Settings.", ModelState);
|
||||||
|
if (string.IsNullOrEmpty(model.TestEmail))
|
||||||
|
ModelState.AddModelError(nameof(model.TestEmail), new RequiredAttribute().FormatErrorMessage(nameof(model.TestEmail)));
|
||||||
|
if (!ModelState.IsValid)
|
||||||
|
return await EmailSettingsCore(ctx);
|
||||||
|
var mess = await GetTestMessage(ctx);
|
||||||
|
var settingsForTest = await GetEmailSettingsForTest(ctx, model);
|
||||||
|
if (!settingsForTest.IsComplete())
|
||||||
|
{
|
||||||
|
ModelState.AddModelError(nameof(model.TestEmail), "Email settings are not complete.");
|
||||||
|
return await EmailSettingsCore(ctx);
|
||||||
|
}
|
||||||
|
using (var client = await settingsForTest.CreateSmtpClient())
|
||||||
|
using (var message = settingsForTest.CreateMailMessage(MailboxAddress.Parse(model.TestEmail), mess.Subject,
|
||||||
|
mess.Body, false))
|
||||||
|
{
|
||||||
|
await client.SendAsync(message);
|
||||||
|
await client.DisconnectAsync(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email sent to {0}. Please verify you received it.", model.TestEmail].Value;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
TempData[WellKnownTempData.ErrorMessage] = ex.Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await EmailSettingsCore(ctx);
|
||||||
|
}
|
||||||
|
else if (command == "ResetPassword")
|
||||||
|
{
|
||||||
|
var settings = await GetEmailSettings(ctx);
|
||||||
|
settings.Password = null;
|
||||||
|
await SaveEmailSettings(ctx, settings);
|
||||||
|
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email server password reset"].Value;
|
||||||
|
}
|
||||||
|
else if (command == "mailpit")
|
||||||
|
{
|
||||||
|
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 SaveEmailSettings(ctx, 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."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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 await EmailSettingsCore(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
var oldSettings = await GetEmailSettings(ctx);
|
||||||
|
if (!string.IsNullOrEmpty(oldSettings.Password))
|
||||||
|
model.Settings.Password = oldSettings.Password;
|
||||||
|
|
||||||
|
await SaveEmailSettings(ctx, model.Settings, model);
|
||||||
|
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email settings saved"].Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return RedirectToEmailSettings(ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
#nullable enable
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.Encodings.Web;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using BTCPayServer.Abstractions.Extensions;
|
||||||
|
using BTCPayServer.Abstractions.Models;
|
||||||
|
using BTCPayServer.Client;
|
||||||
|
using BTCPayServer.Data;
|
||||||
|
using BTCPayServer.Plugins.Emails.Views;
|
||||||
|
using BTCPayServer.Plugins.Emails.Views.Shared;
|
||||||
|
using BTCPayServer.Services.Mails;
|
||||||
|
using Dapper;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Localization;
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Plugins.Emails.Controllers;
|
||||||
|
|
||||||
|
public class UIEmailRuleControllerBase(
|
||||||
|
ApplicationDbContextFactory dbContextFactory,
|
||||||
|
IStringLocalizer stringLocalizer,
|
||||||
|
EmailSenderFactory emailSenderFactory) : Controller
|
||||||
|
{
|
||||||
|
public class EmailsRuleControllerContext
|
||||||
|
{
|
||||||
|
public string? StoreId { get; set; }
|
||||||
|
public string EmailSettingsLink { get; set; } = "";
|
||||||
|
public List<EmailTriggerViewModel> Triggers { get; set; } = new();
|
||||||
|
public Func<ApplicationDbContext, Task<List<EmailRuleData>>>? Rules { get; set; }
|
||||||
|
public Action<EmailRulesListViewModel>? ModifyViewModel { get; set; }
|
||||||
|
|
||||||
|
public Func<ApplicationDbContext, long, Task<EmailRuleData?>>? GetRule { get; set; }
|
||||||
|
|
||||||
|
public Func<string?, IActionResult> RedirectToRuleList = null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ApplicationDbContextFactory DbContextFactory { get; } = dbContextFactory;
|
||||||
|
public IStringLocalizer StringLocalizer { get; } = stringLocalizer;
|
||||||
|
|
||||||
|
protected async Task<IActionResult> EmailRulesListCore(EmailsRuleControllerContext emailCtx)
|
||||||
|
{
|
||||||
|
await using var ctx = DbContextFactory.CreateContext();
|
||||||
|
var configured = await emailSenderFactory.IsComplete(emailCtx.StoreId);
|
||||||
|
if (!configured && !TempData.HasStatusMessage())
|
||||||
|
{
|
||||||
|
TempData.SetStatusMessageModel(new StatusMessageModel
|
||||||
|
{
|
||||||
|
Severity = StatusMessageModel.StatusSeverity.Warning,
|
||||||
|
Html = StringLocalizer["You need to configure email settings before this feature works. <a class='alert-link configure-email' href='{0}'>Configure email settings</a>.", HtmlEncoder.Default.Encode(emailCtx.EmailSettingsLink)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var rules = emailCtx.Rules is null ? new() : await emailCtx.Rules(ctx);
|
||||||
|
var vm = new EmailRulesListViewModel()
|
||||||
|
{
|
||||||
|
StoreId = emailCtx.StoreId,
|
||||||
|
ModifyPermission = Policies.CanModifyStoreSettings,
|
||||||
|
Rules = rules.Select(r => new StoreEmailRuleViewModel(r, emailCtx.Triggers)).ToList()
|
||||||
|
};
|
||||||
|
if (emailCtx.ModifyViewModel is not null)
|
||||||
|
emailCtx.ModifyViewModel(vm);
|
||||||
|
return View("EmailRulesList", vm);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected IActionResult EmailRulesCreateCore(
|
||||||
|
EmailsRuleControllerContext ctx,
|
||||||
|
string? offeringId = null,
|
||||||
|
string? trigger = null,
|
||||||
|
string? condition = null,
|
||||||
|
string? to = null,
|
||||||
|
string? redirectUrl = null)
|
||||||
|
{
|
||||||
|
return View("EmailRulesManage", new StoreEmailRuleViewModel(null, ctx.Triggers)
|
||||||
|
{
|
||||||
|
StoreId = ctx.StoreId,
|
||||||
|
CanChangeTrigger = trigger is null,
|
||||||
|
CanChangeCondition = offeringId is null,
|
||||||
|
Condition = condition,
|
||||||
|
Trigger = trigger,
|
||||||
|
OfferingId = offeringId,
|
||||||
|
RedirectUrl = redirectUrl,
|
||||||
|
To = to
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async Task<IActionResult> EmailRulesCreateCore(
|
||||||
|
EmailsRuleControllerContext emailCtx,
|
||||||
|
StoreEmailRuleViewModel model)
|
||||||
|
{
|
||||||
|
await ValidateCondition(model);
|
||||||
|
if (!ModelState.IsValid)
|
||||||
|
return EmailRulesCreateCore(emailCtx,
|
||||||
|
model.OfferingId,
|
||||||
|
model.CanChangeTrigger ? null : model.Trigger);
|
||||||
|
|
||||||
|
await using var ctx = DbContextFactory.CreateContext();
|
||||||
|
var c = new EmailRuleData()
|
||||||
|
{
|
||||||
|
StoreId = emailCtx.StoreId,
|
||||||
|
Trigger = model.Trigger,
|
||||||
|
Body = model.Body,
|
||||||
|
Subject = model.Subject,
|
||||||
|
Condition = string.IsNullOrWhiteSpace(model.Condition) ? null : model.Condition,
|
||||||
|
OfferingId = model.OfferingId,
|
||||||
|
To = model.AsArray(model.To),
|
||||||
|
CC = model.AsArray(model.CC),
|
||||||
|
BCC = model.AsArray(model.BCC),
|
||||||
|
};
|
||||||
|
c.SetBTCPayAdditionalData(model.AdditionalData);
|
||||||
|
ctx.EmailRules.Add(c);
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
this.TempData.SetStatusSuccess(StringLocalizer["Email rule successfully created"]);
|
||||||
|
return emailCtx.RedirectToRuleList(model.RedirectUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> EmailRulesEditCore(EmailsRuleControllerContext emailCtx, long ruleId, string? redirectUrl = null)
|
||||||
|
{
|
||||||
|
await using var ctx = DbContextFactory.CreateContext();
|
||||||
|
var r = emailCtx.GetRule is null ? null : await emailCtx.GetRule(ctx, ruleId);
|
||||||
|
if (r is null)
|
||||||
|
return NotFound();
|
||||||
|
return View("EmailRulesManage", new StoreEmailRuleViewModel(r, emailCtx.Triggers)
|
||||||
|
{
|
||||||
|
CanChangeTrigger = r.OfferingId is null,
|
||||||
|
CanChangeCondition = r.OfferingId is null,
|
||||||
|
RedirectUrl = redirectUrl
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> EmailRulesEditCore(EmailsRuleControllerContext emailCtx, long ruleId, StoreEmailRuleViewModel model)
|
||||||
|
{
|
||||||
|
await ValidateCondition(model);
|
||||||
|
if (!ModelState.IsValid)
|
||||||
|
return await EmailRulesEditCore(emailCtx, ruleId);
|
||||||
|
|
||||||
|
await using var ctx = DbContextFactory.CreateContext();
|
||||||
|
var rule = emailCtx.GetRule is null ? null : await emailCtx.GetRule(ctx, ruleId);
|
||||||
|
if (rule is null) return NotFound();
|
||||||
|
|
||||||
|
rule.Trigger = model.Trigger;
|
||||||
|
rule.SetBTCPayAdditionalData(model.AdditionalData);
|
||||||
|
rule.To = model.AsArray(model.To);
|
||||||
|
rule.CC = model.AsArray(model.CC);
|
||||||
|
rule.BCC = model.AsArray(model.BCC);
|
||||||
|
rule.Subject = model.Subject;
|
||||||
|
rule.Condition = model.Condition;
|
||||||
|
rule.Body = model.Body;
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
this.TempData.SetStatusSuccess(StringLocalizer["Email rule successfully updated"]);
|
||||||
|
return emailCtx.RedirectToRuleList(model.RedirectUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async Task<IActionResult> EmailRulesDeleteCore(EmailsRuleControllerContext emailCtx, long ruleId, string? redirectUrl)
|
||||||
|
{
|
||||||
|
await using var ctx = DbContextFactory.CreateContext();
|
||||||
|
var r = emailCtx.GetRule is null ? null : await emailCtx.GetRule(ctx, ruleId);
|
||||||
|
if (r is not null)
|
||||||
|
{
|
||||||
|
ctx.EmailRules.Remove(r);
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
this.TempData.SetStatusSuccess(StringLocalizer["Email rule successfully deleted"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return emailCtx.RedirectToRuleList(redirectUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
protected async Task ValidateCondition(StoreEmailRuleViewModel model)
|
||||||
|
{
|
||||||
|
string[] modelKeys = [nameof(model.To), nameof(model.CC), nameof(model.BCC)];
|
||||||
|
string[] values = [model.To, model.CC, model.BCC];
|
||||||
|
for (int i = 0; i < modelKeys.Length; i++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
model.AsArray(values[i]);
|
||||||
|
}
|
||||||
|
catch (FormatException)
|
||||||
|
{
|
||||||
|
ModelState.AddModelError(modelKeys[i], StringLocalizer["Invalid email address or placeholder detected"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
using System;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using BTCPayServer.Abstractions.Constants;
|
||||||
|
using BTCPayServer.Abstractions.Extensions;
|
||||||
|
using BTCPayServer.Abstractions.Models;
|
||||||
|
using BTCPayServer.Client;
|
||||||
|
using BTCPayServer.Plugins.Emails.Views;
|
||||||
|
using BTCPayServer.Services;
|
||||||
|
using BTCPayServer.Services.Mails;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Localization;
|
||||||
|
using MimeKit;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Plugins.Emails.Controllers;
|
||||||
|
|
||||||
|
[Area(EmailsPlugin.Area)]
|
||||||
|
[Authorize(Policy = Client.Policies.CanModifyServerSettings,
|
||||||
|
AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||||
|
[AutoValidateAntiforgeryToken]
|
||||||
|
public class UIServerEmailController(
|
||||||
|
EmailSenderFactory emailSenderFactory,
|
||||||
|
PoliciesSettings policiesSettings,
|
||||||
|
SettingsRepository settingsRepository,
|
||||||
|
IStringLocalizer stringLocalizer
|
||||||
|
) : UIEmailControllerBase(stringLocalizer)
|
||||||
|
{
|
||||||
|
protected override async Task<EmailSettings> GetEmailSettings(Context ctx)
|
||||||
|
=> await emailSenderFactory.GetSettings() ?? new EmailSettings();
|
||||||
|
|
||||||
|
protected override async Task SaveEmailSettings(Context ctx, EmailSettings settings, EmailsViewModel viewModel = null)
|
||||||
|
{
|
||||||
|
await settingsRepository.UpdateSetting(settings);
|
||||||
|
if (viewModel is not null)
|
||||||
|
{
|
||||||
|
if (policiesSettings.DisableStoresToUseServerEmailSettings == viewModel.EnableStoresToUseServerEmailSettings)
|
||||||
|
{
|
||||||
|
policiesSettings.DisableStoresToUseServerEmailSettings = !viewModel.EnableStoresToUseServerEmailSettings;
|
||||||
|
await settingsRepository.UpdateSetting(policiesSettings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override IActionResult RedirectToEmailSettings(Context ctx)
|
||||||
|
=> RedirectToAction(nameof(ServerEmailSettings));
|
||||||
|
|
||||||
|
protected override async Task<(string Subject, string Body)> GetTestMessage(Context ctx)
|
||||||
|
{
|
||||||
|
var serverSettings = await settingsRepository.GetSettingAsync<ServerSettings>();
|
||||||
|
var serverName = string.IsNullOrEmpty(serverSettings?.ServerName) ? "BTCPay Server" : serverSettings.ServerName;
|
||||||
|
return ($"{serverName}: Email test",
|
||||||
|
"You received it, the BTCPay Server SMTP settings work.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Context CreateContext()
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
CreateEmailViewModel = (email) => new EmailsViewModel(email)
|
||||||
|
{
|
||||||
|
EnableStoresToUseServerEmailSettings = !policiesSettings.DisableStoresToUseServerEmailSettings,
|
||||||
|
ModifyPermission = Policies.CanModifyServerSettings,
|
||||||
|
ViewPermission = Policies.CanModifyServerSettings
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
[HttpGet("server/emails")]
|
||||||
|
public Task<IActionResult> ServerEmailSettings()
|
||||||
|
=> EmailSettingsCore(CreateContext());
|
||||||
|
|
||||||
|
[HttpPost("server/emails")]
|
||||||
|
public Task<IActionResult> ServerEmailSettings(EmailsViewModel model, string command)
|
||||||
|
=> EmailSettingsCore(CreateContext(), model, command);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
using System;
|
||||||
|
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.Controllers;
|
||||||
|
using BTCPayServer.Data;
|
||||||
|
using BTCPayServer.Plugins.Emails.Views;
|
||||||
|
using BTCPayServer.Plugins.Emails.Views.Shared;
|
||||||
|
using BTCPayServer.Services.Mails;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Localization;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Plugins.Emails.Controllers;
|
||||||
|
|
||||||
|
[Area(EmailsPlugin.Area)]
|
||||||
|
[Route("server/rules")]
|
||||||
|
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||||
|
[Authorize(Policy = Policies.CanModifyServerSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||||
|
[AutoValidateAntiforgeryToken]
|
||||||
|
public class UIServerEmailRulesController(
|
||||||
|
EmailSenderFactory emailSenderFactory,
|
||||||
|
IEnumerable<EmailTriggerViewModel> triggers,
|
||||||
|
ApplicationDbContextFactory dbContextFactory,
|
||||||
|
IStringLocalizer stringLocalizer
|
||||||
|
) : UIEmailRuleControllerBase(dbContextFactory, stringLocalizer, emailSenderFactory)
|
||||||
|
{
|
||||||
|
|
||||||
|
[HttpGet("")]
|
||||||
|
public Task<IActionResult> ServerEmailRulesList()
|
||||||
|
=> EmailRulesListCore(CreateContext());
|
||||||
|
|
||||||
|
private EmailsRuleControllerContext CreateContext()
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
EmailSettingsLink = Url.Action(nameof(UIServerEmailController.ServerEmailSettings), "UIServerEmail") ?? throw new InvalidOperationException("Bug 1928"),
|
||||||
|
Rules = (ctx) => ctx.EmailRules.GetServerRules().ToListAsync(),
|
||||||
|
Triggers = triggers.Where(t => t.ServerTrigger).ToList(),
|
||||||
|
ModifyViewModel = (vm) =>
|
||||||
|
{
|
||||||
|
vm.ShowCustomerEmailColumn = false;
|
||||||
|
vm.ModifyPermission = Policies.CanModifyServerSettings;
|
||||||
|
},
|
||||||
|
GetRule = (ctx, ruleId) => ctx.EmailRules.GetServerRule(ruleId),
|
||||||
|
RedirectToRuleList = GoToServerRulesList
|
||||||
|
};
|
||||||
|
|
||||||
|
private IActionResult GoToServerRulesList(string redirectUrl)
|
||||||
|
{
|
||||||
|
if (redirectUrl != null)
|
||||||
|
return LocalRedirect(redirectUrl);
|
||||||
|
return RedirectToAction(nameof(ServerEmailRulesList));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("create")]
|
||||||
|
public IActionResult ServerEmailRulesCreate(
|
||||||
|
string trigger = null,
|
||||||
|
string condition = null,
|
||||||
|
string to = null,
|
||||||
|
string redirectUrl = null)
|
||||||
|
=> EmailRulesCreateCore(CreateContext(), null, trigger, condition, to, redirectUrl);
|
||||||
|
|
||||||
|
|
||||||
|
[HttpPost("create")]
|
||||||
|
public Task<IActionResult> ServerEmailRulesCreate(StoreEmailRuleViewModel model)
|
||||||
|
=> EmailRulesCreateCore(CreateContext(), model);
|
||||||
|
|
||||||
|
[HttpGet("{ruleId}/edit")]
|
||||||
|
public Task<IActionResult> ServerEmailRulesEdit(long ruleId, string redirectUrl = null)
|
||||||
|
=> EmailRulesEditCore(CreateContext(), ruleId, redirectUrl);
|
||||||
|
|
||||||
|
[HttpPost("{ruleId}/edit")]
|
||||||
|
public Task<IActionResult> ServerEmailRulesEdit(long ruleId, StoreEmailRuleViewModel model)
|
||||||
|
=> EmailRulesEditCore(CreateContext(), ruleId, model);
|
||||||
|
|
||||||
|
[HttpPost("{ruleId}/delete")]
|
||||||
|
public Task<IActionResult> ServerEmailRulesDelete(long ruleId, string redirectUrl = null)
|
||||||
|
=> EmailRulesDeleteCore(CreateContext(), ruleId, redirectUrl);
|
||||||
|
}
|
||||||
@@ -3,19 +3,15 @@ using System.Linq;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using BTCPayServer.Abstractions.Constants;
|
using BTCPayServer.Abstractions.Constants;
|
||||||
using BTCPayServer.Abstractions.Extensions;
|
using BTCPayServer.Abstractions.Extensions;
|
||||||
using BTCPayServer.Abstractions.Models;
|
|
||||||
using BTCPayServer.Client;
|
using BTCPayServer.Client;
|
||||||
using BTCPayServer.Data;
|
using BTCPayServer.Data;
|
||||||
using BTCPayServer.Plugins.Emails.Views;
|
using BTCPayServer.Plugins.Emails.Views;
|
||||||
using BTCPayServer.Plugins.Subscriptions.Controllers;
|
|
||||||
using BTCPayServer.Services.Mails;
|
using BTCPayServer.Services.Mails;
|
||||||
using Dapper;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Routing;
|
using Microsoft.AspNetCore.Routing;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Localization;
|
using Microsoft.Extensions.Localization;
|
||||||
using Npgsql;
|
|
||||||
|
|
||||||
namespace BTCPayServer.Plugins.Emails.Controllers;
|
namespace BTCPayServer.Plugins.Emails.Controllers;
|
||||||
|
|
||||||
@@ -29,28 +25,26 @@ public class UIStoreEmailRulesController(
|
|||||||
LinkGenerator linkGenerator,
|
LinkGenerator linkGenerator,
|
||||||
ApplicationDbContextFactory dbContextFactory,
|
ApplicationDbContextFactory dbContextFactory,
|
||||||
IEnumerable<EmailTriggerViewModel> triggers,
|
IEnumerable<EmailTriggerViewModel> triggers,
|
||||||
IStringLocalizer stringLocalizer) : Controller
|
IStringLocalizer stringLocalizer) : UIEmailRuleControllerBase(dbContextFactory, stringLocalizer, emailSenderFactory)
|
||||||
{
|
{
|
||||||
public IStringLocalizer StringLocalizer { get; set; } = stringLocalizer;
|
|
||||||
[HttpGet("")]
|
[HttpGet("")]
|
||||||
public async Task<IActionResult> StoreEmailRulesList(string storeId)
|
public Task<IActionResult> StoreEmailRulesList(string storeId)
|
||||||
{
|
=> EmailRulesListCore(CreateContext(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();
|
private EmailsRuleControllerContext CreateContext(string storeId)
|
||||||
return View("StoreEmailRulesList", rules.Select(r => new StoreEmailRuleViewModel(r, triggers)).ToList());
|
=> new()
|
||||||
}
|
{
|
||||||
|
StoreId = storeId,
|
||||||
|
EmailSettingsLink = linkGenerator.GetStoreEmailSettingsLink(storeId, Request.GetRequestBaseUrl()),
|
||||||
|
Rules = (ctx) => ctx.EmailRules.GetRules(storeId).ToListAsync(),
|
||||||
|
Triggers = triggers.Where(t => !t.ServerTrigger).ToList(),
|
||||||
|
ModifyViewModel = (vm) =>
|
||||||
|
{
|
||||||
|
vm.ShowCustomerEmailColumn = true;
|
||||||
|
},
|
||||||
|
GetRule = (ctx, ruleId) => ctx.EmailRules.GetRule(storeId, ruleId),
|
||||||
|
RedirectToRuleList = (redirectUrl) => GoToStoreEmailRulesList(storeId, redirectUrl)
|
||||||
|
};
|
||||||
|
|
||||||
[HttpGet("create")]
|
[HttpGet("create")]
|
||||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||||
@@ -61,50 +55,13 @@ public class UIStoreEmailRulesController(
|
|||||||
string condition = null,
|
string condition = null,
|
||||||
string to = null,
|
string to = null,
|
||||||
string redirectUrl = null)
|
string redirectUrl = null)
|
||||||
{
|
=> EmailRulesCreateCore(CreateContext(storeId), offeringId, trigger, condition, to, redirectUrl);
|
||||||
return View("StoreEmailRulesManage", new StoreEmailRuleViewModel(null, triggers)
|
|
||||||
{
|
|
||||||
CanChangeTrigger = trigger is null,
|
|
||||||
CanChangeCondition = offeringId is null,
|
|
||||||
Condition = condition,
|
|
||||||
Trigger = trigger,
|
|
||||||
OfferingId = offeringId,
|
|
||||||
RedirectUrl = redirectUrl,
|
|
||||||
To = to
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("create")]
|
[HttpPost("create")]
|
||||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||||
public async Task<IActionResult> StoreEmailRulesCreate(string storeId, StoreEmailRuleViewModel model)
|
public Task<IActionResult> StoreEmailRulesCreate(string storeId, StoreEmailRuleViewModel model)
|
||||||
{
|
=> EmailRulesCreateCore(CreateContext(storeId), model);
|
||||||
await ValidateCondition(model);
|
|
||||||
if (!ModelState.IsValid)
|
|
||||||
return StoreEmailRulesCreate(storeId,
|
|
||||||
model.OfferingId,
|
|
||||||
model.CanChangeTrigger ? null : model.Trigger);
|
|
||||||
|
|
||||||
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,
|
|
||||||
OfferingId = model.OfferingId,
|
|
||||||
To = model.ToAsArray()
|
|
||||||
};
|
|
||||||
c.SetBTCPayAdditionalData(model.AdditionalData);
|
|
||||||
ctx.EmailRules.Add(c);
|
|
||||||
await ctx.SaveChangesAsync();
|
|
||||||
|
|
||||||
this.TempData.SetStatusSuccess(StringLocalizer["Email rule successfully created"]);
|
|
||||||
return GoToStoreEmailRulesList(storeId, model);
|
|
||||||
}
|
|
||||||
|
|
||||||
private IActionResult GoToStoreEmailRulesList(string storeId, StoreEmailRuleViewModel model)
|
|
||||||
=> GoToStoreEmailRulesList(storeId, model.RedirectUrl);
|
|
||||||
private IActionResult GoToStoreEmailRulesList(string storeId, string redirectUrl)
|
private IActionResult GoToStoreEmailRulesList(string storeId, string redirectUrl)
|
||||||
{
|
{
|
||||||
if (redirectUrl != null)
|
if (redirectUrl != null)
|
||||||
@@ -114,78 +71,16 @@ public class UIStoreEmailRulesController(
|
|||||||
|
|
||||||
[HttpGet("{ruleId}/edit")]
|
[HttpGet("{ruleId}/edit")]
|
||||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||||
public async Task<IActionResult> StoreEmailRulesEdit(string storeId, long ruleId, string redirectUrl = null)
|
public Task<IActionResult> StoreEmailRulesEdit(string storeId, long ruleId, string redirectUrl = null)
|
||||||
{
|
=> EmailRulesEditCore(CreateContext(storeId), ruleId, redirectUrl);
|
||||||
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)
|
|
||||||
{
|
|
||||||
CanChangeTrigger = r.OfferingId is null,
|
|
||||||
CanChangeCondition = r.OfferingId is null,
|
|
||||||
RedirectUrl = redirectUrl
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("{ruleId}/edit")]
|
[HttpPost("{ruleId}/edit")]
|
||||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||||
public async Task<IActionResult> StoreEmailRulesEdit(string storeId, long ruleId, StoreEmailRuleViewModel model)
|
public Task<IActionResult> StoreEmailRulesEdit(string storeId, long ruleId, StoreEmailRuleViewModel model)
|
||||||
{
|
=> EmailRulesEditCore(CreateContext(storeId), ruleId, 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 GoToStoreEmailRulesList(storeId, model);
|
|
||||||
}
|
|
||||||
|
|
||||||
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")]
|
[HttpPost("{ruleId}/delete")]
|
||||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||||
public async Task<IActionResult> StoreEmailRulesDelete(string storeId, long ruleId, string redirectUrl = null)
|
public Task<IActionResult> StoreEmailRulesDelete(string storeId, long ruleId, string redirectUrl = null)
|
||||||
{
|
=> EmailRulesDeleteCore(CreateContext(storeId), ruleId, redirectUrl);
|
||||||
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 GoToStoreEmailRulesList(storeId, redirectUrl);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,16 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.Globalization;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using BTCPayServer.Abstractions.Constants;
|
using BTCPayServer.Abstractions.Constants;
|
||||||
using BTCPayServer.Abstractions.Extensions;
|
using BTCPayServer.Abstractions.Extensions;
|
||||||
using BTCPayServer.Abstractions.Models;
|
using BTCPayServer.Abstractions.Models;
|
||||||
using BTCPayServer.Client;
|
using BTCPayServer.Client;
|
||||||
using BTCPayServer.Data;
|
using BTCPayServer.Data;
|
||||||
using BTCPayServer.Models;
|
using BTCPayServer.Plugins.Emails.Views;
|
||||||
using BTCPayServer.Plugins.Emails;
|
|
||||||
using BTCPayServer.Services.Mails;
|
using BTCPayServer.Services.Mails;
|
||||||
using BTCPayServer.Services.Stores;
|
using BTCPayServer.Services.Stores;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Http;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Routing;
|
|
||||||
using Microsoft.Extensions.Localization;
|
using Microsoft.Extensions.Localization;
|
||||||
using MimeKit;
|
using MimeKit;
|
||||||
|
|
||||||
@@ -30,24 +24,33 @@ namespace BTCPayServer.Plugins.Emails.Controllers;
|
|||||||
public class UIStoresEmailController(
|
public class UIStoresEmailController(
|
||||||
EmailSenderFactory emailSenderFactory,
|
EmailSenderFactory emailSenderFactory,
|
||||||
StoreRepository storeRepository,
|
StoreRepository storeRepository,
|
||||||
IStringLocalizer stringLocalizer) : Controller
|
IStringLocalizer stringLocalizer) : UIEmailControllerBase(stringLocalizer)
|
||||||
{
|
{
|
||||||
public IStringLocalizer StringLocalizer { get; set; } = stringLocalizer;
|
private async Task<Context> CreateContext(string storeId)
|
||||||
[HttpGet("email-settings")]
|
|
||||||
public async Task<IActionResult> StoreEmailSettings(string storeId)
|
|
||||||
{
|
{
|
||||||
var store = HttpContext.GetStoreData();
|
var settings = await GetCustomSettings(storeId);
|
||||||
if (store == null)
|
return new()
|
||||||
return NotFound();
|
{
|
||||||
|
StoreId = storeId,
|
||||||
var settings = await GetCustomSettings(store.Id);
|
CreateEmailViewModel = (email) => new EmailsViewModel(email)
|
||||||
|
|
||||||
return View(new EmailsViewModel(settings.Custom ?? new())
|
|
||||||
{
|
{
|
||||||
IsFallbackSetup = settings.Fallback is not null,
|
IsFallbackSetup = settings.Fallback is not null,
|
||||||
IsCustomSMTP = settings.Custom is not null || settings.Fallback is null
|
IsCustomSMTP = settings.Custom is not null || settings.Fallback is null,
|
||||||
});
|
StoreId = storeId,
|
||||||
|
ModifyPermission = Policies.CanModifyStoreSettings,
|
||||||
|
ViewPermission = Policies.CanViewStoreSettings,
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("email-settings")]
|
||||||
|
public async Task<IActionResult> StoreEmailSettings(string storeId)
|
||||||
|
=> await EmailSettingsCore(await CreateContext(storeId));
|
||||||
|
|
||||||
|
[HttpPost("email-settings")]
|
||||||
|
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||||
|
public async Task<IActionResult> StoreEmailSettings(string storeId, EmailsViewModel model, string command)
|
||||||
|
=> await EmailSettingsCore(await CreateContext(storeId), model, command);
|
||||||
|
|
||||||
record AllEmailSettings(EmailSettings Custom, EmailSettings Fallback);
|
record AllEmailSettings(EmailSettings Custom, EmailSettings Fallback);
|
||||||
private async Task<AllEmailSettings> GetCustomSettings(string storeId)
|
private async Task<AllEmailSettings> GetCustomSettings(string storeId)
|
||||||
@@ -61,96 +64,35 @@ public class UIStoresEmailController(
|
|||||||
return new(await sender.GetCustomSettings(), fallback);
|
return new(await sender.GetCustomSettings(), fallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("email-settings")]
|
protected override async Task<EmailSettings> GetEmailSettings(Context ctx)
|
||||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
|
||||||
public async Task<IActionResult> StoreEmailSettings(string storeId, EmailsViewModel model, string command)
|
|
||||||
{
|
{
|
||||||
var store = HttpContext.GetStoreData();
|
var store = await storeRepository.FindStore(ctx.StoreId);
|
||||||
if (store == null)
|
return store?.GetStoreBlob().EmailSettings ?? new();
|
||||||
return NotFound();
|
|
||||||
var settings = await GetCustomSettings(store.Id);
|
|
||||||
model.IsFallbackSetup = settings.Fallback is not null;
|
|
||||||
if (!model.IsFallbackSetup)
|
|
||||||
model.IsCustomSMTP = true;
|
|
||||||
if (model.IsCustomSMTP)
|
|
||||||
{
|
|
||||||
model.Settings.Validate("Settings.", ModelState);
|
|
||||||
if (model.Settings.From is not null && !MailboxAddressValidator.IsMailboxAddress(model.Settings.From))
|
|
||||||
{
|
|
||||||
ModelState.AddModelError("Settings.From", StringLocalizer["Invalid email"]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var storeBlob = store.GetStoreBlob();
|
protected override async Task<EmailSettings> GetEmailSettingsForTest(Context ctx, EmailsViewModel model)
|
||||||
var currentSettings = store.GetStoreBlob().EmailSettings;
|
{
|
||||||
if (model is { IsCustomSMTP: true, Settings: { Password: null } })
|
var settings = await GetCustomSettings(ctx.StoreId);
|
||||||
model.Settings.Password = currentSettings?.Password;
|
return (model.IsCustomSMTP ? model.Settings : settings.Fallback) ?? new();
|
||||||
|
}
|
||||||
|
|
||||||
if (!ModelState.IsValid && command is not ("ResetPassword" or "mailpit"))
|
protected override async Task SaveEmailSettings(Context ctx, EmailSettings settings, EmailsViewModel viewModel = null)
|
||||||
return View(model);
|
{
|
||||||
|
var store = await storeRepository.FindStore(ctx.StoreId);
|
||||||
|
var blob = store?.GetStoreBlob();
|
||||||
|
if (blob is null)
|
||||||
|
return;
|
||||||
|
blob.EmailSettings = viewModel?.IsCustomSMTP is false ? null : settings;
|
||||||
|
store.SetStoreBlob(blob);
|
||||||
|
await storeRepository.UpdateStore(store);
|
||||||
|
}
|
||||||
|
|
||||||
if (command == "Test")
|
protected override IActionResult RedirectToEmailSettings(Context ctx)
|
||||||
{
|
=> RedirectToAction(nameof(StoreEmailSettings), new { storeId = ctx.StoreId });
|
||||||
try
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(model.TestEmail))
|
|
||||||
ModelState.AddModelError(nameof(model.TestEmail), new RequiredAttribute().FormatErrorMessage(nameof(model.TestEmail)));
|
|
||||||
if (!ModelState.IsValid)
|
|
||||||
return View(model);
|
|
||||||
var clientSettings = (model.IsCustomSMTP ? model.Settings : settings.Fallback) ?? new();
|
|
||||||
using var client = await clientSettings.CreateSmtpClient();
|
|
||||||
var message = clientSettings.CreateMailMessage(MailboxAddress.Parse(model.TestEmail), $"{store.StoreName}: Email test", StringLocalizer["You received it, the BTCPay Server SMTP settings work."], false);
|
|
||||||
await client.SendAsync(message);
|
|
||||||
await client.DisconnectAsync(true);
|
|
||||||
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email sent to {0}. Please verify you received it.", model.TestEmail].Value;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["Error: {0}", ex.Message].Value;
|
|
||||||
}
|
|
||||||
return View(model);
|
|
||||||
}
|
|
||||||
else if (command == "mailpit")
|
|
||||||
{
|
|
||||||
|
|
||||||
storeBlob.EmailSettings = model.Settings;
|
protected override async Task<(string Subject, string Body)> GetTestMessage(Context ctx)
|
||||||
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,
|
var store = await storeRepository.FindStore(ctx.StoreId);
|
||||||
AllowDismiss = true,
|
return ($"{store?.StoreName}: Email test", "You received it, the BTCPay Server SMTP settings work.");
|
||||||
Html = "Mailpit is now running on <a href=\"http://localhost:34218\" target=\"_blank\" class=\"alert-link\">localhost</a>. You can use it to test your SMTP settings."
|
|
||||||
});
|
|
||||||
}
|
|
||||||
else if (command == "ResetPassword")
|
|
||||||
{
|
|
||||||
if (storeBlob.EmailSettings is not null)
|
|
||||||
storeBlob.EmailSettings.Password = null;
|
|
||||||
store.SetStoreBlob(storeBlob);
|
|
||||||
await storeRepository.UpdateStore(store);
|
|
||||||
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email server password reset"].Value;
|
|
||||||
}
|
|
||||||
else if (!model.IsCustomSMTP && currentSettings is not null)
|
|
||||||
{
|
|
||||||
storeBlob.EmailSettings = null;
|
|
||||||
store.SetStoreBlob(storeBlob);
|
|
||||||
await storeRepository.UpdateStore(store);
|
|
||||||
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["You are now using server's email settings"].Value;
|
|
||||||
}
|
|
||||||
else if (model.IsCustomSMTP)
|
|
||||||
{
|
|
||||||
storeBlob.EmailSettings = model.Settings;
|
|
||||||
store.SetStoreBlob(storeBlob);
|
|
||||||
await storeRepository.UpdateStore(store);
|
|
||||||
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Email settings saved"].Value;
|
|
||||||
}
|
|
||||||
return RedirectToAction(nameof(StoreEmailSettings), new { storeId });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using BTCPayServer.Data;
|
||||||
|
using BTCPayServer.Plugins.Emails.Views;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Plugins.Emails;
|
||||||
|
|
||||||
|
public class DefaultServerEmailRulesMigration(IEnumerable<EmailTriggerViewModel> vms) : MigrationBase<ApplicationDbContext>("20251109_defaultserverrules")
|
||||||
|
{
|
||||||
|
public override Task MigrateAsync(ApplicationDbContext dbContext, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var defaultRules = new string[] {
|
||||||
|
ServerMailTriggers.PasswordReset,
|
||||||
|
ServerMailTriggers.InvitePending,
|
||||||
|
ServerMailTriggers.ApprovalConfirmed,
|
||||||
|
ServerMailTriggers.ApprovalPending,
|
||||||
|
ServerMailTriggers.EmailConfirm,
|
||||||
|
ServerMailTriggers.ApprovalRequest,
|
||||||
|
ServerMailTriggers.InviteConfirmed
|
||||||
|
}.ToHashSet();
|
||||||
|
foreach (var vm in vms.Where(v => defaultRules.Contains(v.Trigger)))
|
||||||
|
{
|
||||||
|
dbContext.EmailRules.Add(new()
|
||||||
|
{
|
||||||
|
To = vm.DefaultEmail.To,
|
||||||
|
CC = vm.DefaultEmail.CC,
|
||||||
|
BCC = vm.DefaultEmail.BCC,
|
||||||
|
Trigger = vm.Trigger,
|
||||||
|
Subject = vm.DefaultEmail.Subject,
|
||||||
|
Body = vm.DefaultEmail.Body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
using BTCPayServer.Abstractions.Models;
|
using BTCPayServer.Abstractions.Models;
|
||||||
|
using BTCPayServer.Data;
|
||||||
|
using BTCPayServer.Plugins.Emails.HostedServices;
|
||||||
|
using BTCPayServer.Plugins.Emails.Views;
|
||||||
using BTCPayServer.Plugins.Webhooks;
|
using BTCPayServer.Plugins.Webhooks;
|
||||||
using BTCPayServer.Services;
|
using BTCPayServer.Services;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
@@ -17,5 +21,126 @@ public class EmailsPlugin : BaseBTCPayServerPlugin
|
|||||||
{
|
{
|
||||||
services.AddSingleton<IDefaultTranslationProvider, EmailsTranslationProvider>();
|
services.AddSingleton<IDefaultTranslationProvider, EmailsTranslationProvider>();
|
||||||
services.AddSingleton<IHostedService, StoreEmailRuleProcessorSender>();
|
services.AddSingleton<IHostedService, StoreEmailRuleProcessorSender>();
|
||||||
|
services.AddSingleton<IHostedService, UserEventHostedService>();
|
||||||
|
services.AddMigration<ApplicationDbContext, DefaultServerEmailRulesMigration>();
|
||||||
|
RegisterServerEmailTriggers(services);
|
||||||
|
}
|
||||||
|
private static string BODY_STYLE = "font-family: Open Sans, Helvetica Neue,Arial,sans-serif; font-color: #292929;";
|
||||||
|
private static string HEADER_HTML = "<h1 style='font-size:1.2rem'>{Branding.ServerName}</h1><br/>";
|
||||||
|
private static string BUTTON_HTML = "<a href='{button_link}' type='submit' style='min-width: 2em;min-height: 20px;text-decoration-line: none;cursor: pointer;display: inline-block;font-weight: 400;color: #fff;text-align: center;vertical-align: middle;user-select: none;background-color: #51b13e;border-color: #51b13e;border: 1px solid transparent;padding: 0.375rem 0.75rem;font-size: 1rem;line-height: 1.5;border-radius: 0.25rem;transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;'>{button_description}</a>";
|
||||||
|
|
||||||
|
private static string CallToAction(string actionName, string actionLink)
|
||||||
|
{
|
||||||
|
var button = $"{BUTTON_HTML}".Replace("{button_description}", actionName, System.StringComparison.InvariantCulture);
|
||||||
|
return button.Replace("{button_link}", actionLink, System.StringComparison.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CreateEmailBody(string body) => $"<html><body style='{BODY_STYLE}'>{HEADER_HTML}{body}</body></html>";
|
||||||
|
private void RegisterServerEmailTriggers(IServiceCollection services)
|
||||||
|
{
|
||||||
|
|
||||||
|
List<EmailTriggerViewModel> vms = new();
|
||||||
|
|
||||||
|
var vm = new EmailTriggerViewModel()
|
||||||
|
{
|
||||||
|
Trigger = ServerMailTriggers.PasswordReset,
|
||||||
|
DefaultEmail = new()
|
||||||
|
{
|
||||||
|
To = ["{User.MailboxAddress}"],
|
||||||
|
Subject = "Update Password",
|
||||||
|
Body = CreateEmailBody($"A request has been made to reset your {{Branding.ServerName}} password. Please set your password by clicking below.<br/><br/>{CallToAction("Update Password", "{ResetLink}")}"),
|
||||||
|
},
|
||||||
|
PlaceHolders = new()
|
||||||
|
{
|
||||||
|
new ("{ResetLink}", "The link to the password reset page")
|
||||||
|
},
|
||||||
|
Description = "User: Password Reset Requested",
|
||||||
|
};
|
||||||
|
vms.Add(vm);
|
||||||
|
|
||||||
|
vm = new EmailTriggerViewModel()
|
||||||
|
{
|
||||||
|
Trigger = ServerMailTriggers.EmailConfirm,
|
||||||
|
DefaultEmail = new()
|
||||||
|
{
|
||||||
|
To = ["{User.MailboxAddress}"],
|
||||||
|
Subject = "Confirm your email address",
|
||||||
|
Body = CreateEmailBody($"Please confirm your account.<br/><br/>{CallToAction("Confirm Email", "{ConfirmLink}")}"),
|
||||||
|
},
|
||||||
|
PlaceHolders = new()
|
||||||
|
{
|
||||||
|
new ("{ConfirmLink}", "The link used to confirm the user's email address")
|
||||||
|
},
|
||||||
|
Description = "User: Email confirmation",
|
||||||
|
};
|
||||||
|
vms.Add(vm);
|
||||||
|
|
||||||
|
vm = new EmailTriggerViewModel()
|
||||||
|
{
|
||||||
|
Trigger = ServerMailTriggers.InvitePending,
|
||||||
|
DefaultEmail = new()
|
||||||
|
{
|
||||||
|
To = ["{User.MailboxAddress}"],
|
||||||
|
Subject = "Invitation to join {Branding.ServerName}",
|
||||||
|
Body = CreateEmailBody($"<p>Please complete your account setup by clicking <a href='{{InvitationLink}}'>this link</a>.</p><p>You can also use the BTCPay Server app and scan this QR code when connecting:</p>{{InvitationLinkQR}}"),
|
||||||
|
},
|
||||||
|
PlaceHolders = new()
|
||||||
|
{
|
||||||
|
new ("{InvitationLink}", "The link where the invited user can set up their account"),
|
||||||
|
new ("{InvitationLinkQR}", "The QR code representation of the invitation link")
|
||||||
|
},
|
||||||
|
Description = "User: Invitation",
|
||||||
|
};
|
||||||
|
vms.Add(vm);
|
||||||
|
|
||||||
|
vm = new EmailTriggerViewModel()
|
||||||
|
{
|
||||||
|
Trigger = ServerMailTriggers.ApprovalConfirmed,
|
||||||
|
DefaultEmail = new()
|
||||||
|
{
|
||||||
|
To = ["{User.MailboxAddress}"],
|
||||||
|
Subject = "Your account has been approved",
|
||||||
|
Body = CreateEmailBody($"Your account has been approved and you can now log in.<br/><br/>{CallToAction("Login here", "{LoginLink}")}"),
|
||||||
|
},
|
||||||
|
PlaceHolders = new()
|
||||||
|
{
|
||||||
|
new ("{LoginLink}", "The link that the user can use to login"),
|
||||||
|
},
|
||||||
|
Description = "User: Account approved",
|
||||||
|
};
|
||||||
|
vms.Add(vm);
|
||||||
|
|
||||||
|
vm = new EmailTriggerViewModel()
|
||||||
|
{
|
||||||
|
Trigger = ServerMailTriggers.ApprovalRequest,
|
||||||
|
DefaultEmail = new()
|
||||||
|
{
|
||||||
|
To = ["{Admins.MailboxAddresses}"],
|
||||||
|
Subject = "Approval request to access the server for {User.Email}",
|
||||||
|
Body = CreateEmailBody($"A new user ({{User.MailboxAddress}}), is awaiting approval to access the server.<br/><br/>{CallToAction("Approve", "{ApprovalLink}")}"),
|
||||||
|
},
|
||||||
|
PlaceHolders = new()
|
||||||
|
{
|
||||||
|
new ("{ApprovalLink}", "The link that the admin needs to use to approve the user"),
|
||||||
|
},
|
||||||
|
Description = "Admin: Approval request",
|
||||||
|
};
|
||||||
|
vms.Add(vm);
|
||||||
|
|
||||||
|
var commonPlaceholders = new List<EmailTriggerViewModel.PlaceHolder>()
|
||||||
|
{
|
||||||
|
new("{Admins.MailboxAddresses}", "The email addresses of the admins separated by a comma"),
|
||||||
|
new("{User.Name}", "The name of the user (eg. John Doe)"),
|
||||||
|
new("{User.Email}", "The email of the user (eg. john.doe@example.com)"),
|
||||||
|
new("{User.MailboxAddress}", "The formatted mailbox address to use when sending an email. (eg. \"John Doe\" <john.doe@example.com>)"),
|
||||||
|
new("{Branding.ServerName}", "The name of the server (You can configure this in Server Settings ➡ Branding)"),
|
||||||
|
new("{Branding.ContactUrl}", "The contact URL of the server (You can configure this in Server Settings ➡ Branding)"),
|
||||||
|
};
|
||||||
|
foreach (var v in vms)
|
||||||
|
{
|
||||||
|
v.ServerTrigger = true;
|
||||||
|
v.PlaceHolders.InsertRange(0, commonPlaceholders);
|
||||||
|
services.AddSingleton(v);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
#nullable enable
|
#nullable enable
|
||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Net.Mail;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using BTCPayServer.Data;
|
using BTCPayServer.Data;
|
||||||
@@ -10,7 +12,7 @@ using Microsoft.Extensions.Logging;
|
|||||||
using MimeKit;
|
using MimeKit;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
namespace BTCPayServer.Plugins.Emails;
|
namespace BTCPayServer.Plugins.Emails.HostedServices;
|
||||||
|
|
||||||
public interface ITriggerOwner
|
public interface ITriggerOwner
|
||||||
{
|
{
|
||||||
@@ -30,9 +32,9 @@ public class EmailRuleMatchContext(
|
|||||||
public TriggerEvent TriggerEvent { get; } = triggerEvent;
|
public TriggerEvent TriggerEvent { get; } = triggerEvent;
|
||||||
public EmailRuleData MatchedRule { get; } = matchedRule;
|
public EmailRuleData MatchedRule { get; } = matchedRule;
|
||||||
|
|
||||||
public List<MailboxAddress> Recipients { get; set; } = new();
|
public List<MailboxAddress> To { get; set; } = new();
|
||||||
public List<MailboxAddress> Cc { get; set; } = new();
|
public List<MailboxAddress> CC { get; set; } = new();
|
||||||
public List<MailboxAddress> Bcc { get; set; } = new();
|
public List<MailboxAddress> BCC { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
public class StoreEmailRuleProcessorSender(
|
public class StoreEmailRuleProcessorSender(
|
||||||
@@ -52,8 +54,7 @@ public class StoreEmailRuleProcessorSender(
|
|||||||
if (evt is TriggerEvent triggEvent)
|
if (evt is TriggerEvent triggEvent)
|
||||||
{
|
{
|
||||||
await using var ctx = dbContextFactory.CreateContext();
|
await using var ctx = dbContextFactory.CreateContext();
|
||||||
var actionableRules = await ctx.EmailRules
|
var actionableRules = await ctx.EmailRules.GetMatches(triggEvent.StoreId, triggEvent.Trigger, triggEvent.Model);
|
||||||
.GetMatches(triggEvent.StoreId, triggEvent.Trigger, triggEvent.Model);
|
|
||||||
|
|
||||||
if (actionableRules.Length > 0)
|
if (actionableRules.Length > 0)
|
||||||
{
|
{
|
||||||
@@ -64,25 +65,45 @@ public class StoreEmailRuleProcessorSender(
|
|||||||
|
|
||||||
var body = new TextTemplate(actionableRule.Body ?? "");
|
var body = new TextTemplate(actionableRule.Body ?? "");
|
||||||
var subject = new TextTemplate(actionableRule.Subject ?? "");
|
var subject = new TextTemplate(actionableRule.Subject ?? "");
|
||||||
matchedContext.Recipients.AddRange(
|
AddToMatchedContext(triggEvent.Model, matchedContext.To, actionableRule.To);
|
||||||
actionableRule.To
|
AddToMatchedContext(triggEvent.Model, matchedContext.CC, actionableRule.CC);
|
||||||
.Select(o =>
|
AddToMatchedContext(triggEvent.Model, matchedContext.BCC, actionableRule.BCC);
|
||||||
{
|
|
||||||
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)
|
if (triggEvent.Owner is not null)
|
||||||
await triggEvent.Owner.BeforeSending(matchedContext);
|
await triggEvent.Owner.BeforeSending(matchedContext);
|
||||||
if (matchedContext.Recipients.Count == 0)
|
if (matchedContext.To.Count == 0)
|
||||||
continue;
|
continue;
|
||||||
sender.SendEmail(matchedContext.Recipients.ToArray(), matchedContext.Cc.ToArray(), matchedContext.Bcc.ToArray(), subject.Render(triggEvent.Model), body.Render(triggEvent.Model));
|
sender.SendEmail(matchedContext.To.ToArray(), matchedContext.CC.ToArray(), matchedContext.BCC.ToArray(), subject.Render(triggEvent.Model), body.Render(triggEvent.Model));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void AddToMatchedContext(JObject model, List<MailboxAddress> mailboxAddresses, string[] rulesAddresses)
|
||||||
|
{
|
||||||
|
mailboxAddresses.AddRange(
|
||||||
|
rulesAddresses
|
||||||
|
.SelectMany(o =>
|
||||||
|
{
|
||||||
|
var emails = new TextTemplate(o).Render(model);
|
||||||
|
MailAddressCollection mailCollection = new();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
mailCollection.Add(emails);
|
||||||
|
}
|
||||||
|
catch (FormatException)
|
||||||
|
{
|
||||||
|
return Array.Empty<MailboxAddress>();
|
||||||
|
}
|
||||||
|
|
||||||
|
return mailCollection.Select(a =>
|
||||||
|
{
|
||||||
|
MailboxAddressValidator.TryParse(a.ToString(), out var oo);
|
||||||
|
return oo;
|
||||||
|
})
|
||||||
|
.Where(a => a != null)
|
||||||
|
.ToArray();
|
||||||
|
})
|
||||||
|
.Where(o => o != null)!);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
#nullable enable
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.Encodings.Web;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using BTCPayServer.Data;
|
||||||
|
using BTCPayServer.Events;
|
||||||
|
using BTCPayServer.HostedServices;
|
||||||
|
using BTCPayServer.Logging;
|
||||||
|
using BTCPayServer.Services;
|
||||||
|
using BTCPayServer.Services.Mails;
|
||||||
|
using BTCPayServer.Services.Notifications;
|
||||||
|
using BTCPayServer.Services.Notifications.Blobs;
|
||||||
|
using BTCPayServer.Services.Stores;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using QRCoder;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Plugins.Emails.HostedServices;
|
||||||
|
|
||||||
|
public class UserEventHostedService(
|
||||||
|
EventAggregator eventAggregator,
|
||||||
|
UserManager<ApplicationUser> userManager,
|
||||||
|
ISettingsAccessor<ServerSettings> serverSettings,
|
||||||
|
NotificationSender notificationSender,
|
||||||
|
Logs logs)
|
||||||
|
: EventHostedServiceBase(eventAggregator, logs)
|
||||||
|
{
|
||||||
|
public UserManager<ApplicationUser> UserManager { get; } = userManager;
|
||||||
|
|
||||||
|
protected override void SubscribeToEvents()
|
||||||
|
{
|
||||||
|
SubscribeAny<UserEvent>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetQrCodeImg(string data)
|
||||||
|
{
|
||||||
|
using var qrGenerator = new QRCodeGenerator();
|
||||||
|
using var qrCodeData = qrGenerator.CreateQrCode(data, QRCodeGenerator.ECCLevel.Q);
|
||||||
|
using var qrCode = new Base64QRCode(qrCodeData);
|
||||||
|
var base64 = qrCode.GetGraphic(20);
|
||||||
|
return $"<img src='data:image/png;base64,{base64}' alt='{HtmlEncoder.Default.Encode(data)}' width='320' height='320'/>";
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var user = (evt as UserEvent)?.User;
|
||||||
|
if (user is null) return;
|
||||||
|
switch (evt)
|
||||||
|
{
|
||||||
|
case UserEvent.Registered ev:
|
||||||
|
var requiresApproval = user is { RequiresApproval: true, Approved: false };
|
||||||
|
var requiresEmailConfirmation = user is { RequiresEmailConfirmation: true, EmailConfirmed: false };
|
||||||
|
|
||||||
|
// send notification if the user does not require email confirmation.
|
||||||
|
// inform admins only about qualified users and not annoy them with bot registrations.
|
||||||
|
if (requiresApproval && !requiresEmailConfirmation)
|
||||||
|
{
|
||||||
|
await NotifyAdminsAboutUserRequiringApproval(user, ev.ApprovalLink);
|
||||||
|
}
|
||||||
|
|
||||||
|
// set callback result and send email to user
|
||||||
|
if (ev is UserEvent.Invited invited)
|
||||||
|
{
|
||||||
|
if (invited.SendInvitationEmail)
|
||||||
|
EventAggregator.Publish(await CreateTriggerEvent(ServerMailTriggers.InvitePending,
|
||||||
|
new JObject()
|
||||||
|
{
|
||||||
|
["InvitationLink"] = HtmlEncoder.Default.Encode(invited.InvitationLink),
|
||||||
|
["InvitationLinkQR"] = GetQrCodeImg(invited.InvitationLink)
|
||||||
|
}, user));
|
||||||
|
}
|
||||||
|
else if (requiresEmailConfirmation)
|
||||||
|
{
|
||||||
|
EventAggregator.Publish(new UserEvent.ConfirmationEmailRequested(user, ev.ConfirmationEmailLink));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case UserEvent.ConfirmationEmailRequested confReq:
|
||||||
|
EventAggregator.Publish(await CreateTriggerEvent(ServerMailTriggers.EmailConfirm,
|
||||||
|
new JObject()
|
||||||
|
{
|
||||||
|
["ConfirmLink"] = HtmlEncoder.Default.Encode(confReq.ConfirmLink)
|
||||||
|
}, user));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case UserEvent.PasswordResetRequested pwResetEvent:
|
||||||
|
EventAggregator.Publish(await CreateTriggerEvent(ServerMailTriggers.PasswordReset,
|
||||||
|
new JObject()
|
||||||
|
{
|
||||||
|
["ResetLink"] = HtmlEncoder.Default.Encode(pwResetEvent.ResetLink)
|
||||||
|
}, user));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case UserEvent.Approved approvedEvent:
|
||||||
|
if (!user.Approved) break;
|
||||||
|
EventAggregator.Publish(await CreateTriggerEvent(ServerMailTriggers.ApprovalConfirmed,
|
||||||
|
new JObject()
|
||||||
|
{
|
||||||
|
["LoginLink"] = HtmlEncoder.Default.Encode(approvedEvent.LoginLink)
|
||||||
|
}, user));
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case UserEvent.ConfirmedEmail confirmedEvent when user is { RequiresApproval: true, Approved: false, EmailConfirmed: true }:
|
||||||
|
await NotifyAdminsAboutUserRequiringApproval(user, confirmedEvent.ApprovalLink);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task NotifyAdminsAboutUserRequiringApproval(ApplicationUser user, string approvalLink)
|
||||||
|
{
|
||||||
|
await notificationSender.SendNotification(new AdminScope(), new NewUserRequiresApprovalNotification(user));
|
||||||
|
EventAggregator.Publish(await CreateTriggerEvent(ServerMailTriggers.ApprovalRequest,
|
||||||
|
new JObject()
|
||||||
|
{
|
||||||
|
["ApprovalLink"] = HtmlEncoder.Default.Encode(approvalLink)
|
||||||
|
}, user));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<TriggerEvent> CreateTriggerEvent(string trigger, JObject model, ApplicationUser user)
|
||||||
|
{
|
||||||
|
var admins = await UserManager.GetUsersInRoleAsync(Roles.ServerAdmin);
|
||||||
|
var adminMailboxes = string.Join(", ", admins.Select(a => a.GetMailboxAddress().ToString()).ToArray());
|
||||||
|
model["Admins"] = new JObject()
|
||||||
|
{
|
||||||
|
["MailboxAddresses"] = adminMailboxes,
|
||||||
|
};
|
||||||
|
model["User"] = new JObject()
|
||||||
|
{
|
||||||
|
["Name"] = user.UserName,
|
||||||
|
["Email"] = user.Email,
|
||||||
|
["MailboxAddress"] = user.GetMailboxAddress().ToString(),
|
||||||
|
};
|
||||||
|
model["Branding"] = new JObject()
|
||||||
|
{
|
||||||
|
["ServerName"] = serverSettings.Settings.ServerName ?? "BTCPay Server",
|
||||||
|
["ContactUrl"] = serverSettings.Settings.ContactUrl,
|
||||||
|
};
|
||||||
|
var evt = new TriggerEvent(null, trigger, model, null);
|
||||||
|
return evt;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
BTCPayServer/Plugins/Emails/ServerMailTriggers.cs
Normal file
12
BTCPayServer/Plugins/Emails/ServerMailTriggers.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
namespace BTCPayServer.Plugins.Emails;
|
||||||
|
|
||||||
|
public class ServerMailTriggers
|
||||||
|
{
|
||||||
|
public const string PasswordReset = "SRV-PasswordReset";
|
||||||
|
public const string InvitePending = "SRV-InvitePending";
|
||||||
|
public const string InviteConfirmed = "SRV-InviteConfirmed";
|
||||||
|
public const string ApprovalConfirmed = "SRV-ApprovalConfirmed";
|
||||||
|
public const string ApprovalPending = "SRV-ApprovalPending";
|
||||||
|
public const string EmailConfirm = "SRV-EmailConfirmation";
|
||||||
|
public const string ApprovalRequest = "SRV-ApprovalRequest";
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
using System.Collections.Generic;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
namespace BTCPayServer.Plugins.Emails.Views;
|
namespace BTCPayServer.Plugins.Emails.Views;
|
||||||
|
|
||||||
@@ -7,11 +9,23 @@ namespace BTCPayServer.Plugins.Emails.Views;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class EmailTriggerViewModel
|
public class EmailTriggerViewModel
|
||||||
{
|
{
|
||||||
public string Trigger { get; set; }
|
public class Default
|
||||||
public string Description { get; set; }
|
{
|
||||||
public string SubjectExample { get; set; }
|
public string Subject { get; set; }
|
||||||
public string BodyExample { get; set; }
|
public string Body { get; set; }
|
||||||
|
public string[] To { get; set; } = Array.Empty<string>();
|
||||||
|
[JsonProperty("cc")]
|
||||||
|
public string[] CC { get; set; } = Array.Empty<string>();
|
||||||
|
[JsonProperty("bcc")]
|
||||||
|
public string[] BCC { get; set; } = Array.Empty<string>();
|
||||||
public bool CanIncludeCustomerEmail { get; set; }
|
public bool CanIncludeCustomerEmail { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Trigger { get; set; }
|
||||||
|
|
||||||
|
public string Description { get; set; }
|
||||||
|
|
||||||
|
public Default DefaultEmail { get; set; }
|
||||||
|
|
||||||
public class PlaceHolder(string name, string description)
|
public class PlaceHolder(string name, string description)
|
||||||
{
|
{
|
||||||
@@ -20,4 +34,5 @@ public class EmailTriggerViewModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
public List<PlaceHolder> PlaceHolders { get; set; } = new();
|
public List<PlaceHolder> PlaceHolders { get; set; } = new();
|
||||||
|
public bool ServerTrigger { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,19 @@ using System.ComponentModel.DataAnnotations;
|
|||||||
using BTCPayServer.Services.Mails;
|
using BTCPayServer.Services.Mails;
|
||||||
using BTCPayServer.Validation;
|
using BTCPayServer.Validation;
|
||||||
|
|
||||||
namespace BTCPayServer.Models;
|
namespace BTCPayServer.Plugins.Emails.Views;
|
||||||
|
|
||||||
public class EmailsViewModel
|
public class EmailsViewModel
|
||||||
{
|
{
|
||||||
|
public string ModifyPermission { get; set; }
|
||||||
|
public string ViewPermission { get; set; }
|
||||||
|
public string StoreId { get; set; }
|
||||||
public EmailSettings Settings { get; set; }
|
public EmailSettings Settings { get; set; }
|
||||||
public bool PasswordSet { get; set; }
|
public bool PasswordSet { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "Allow Stores use the Server's SMTP email settings as their default")]
|
||||||
|
public bool EnableStoresToUseServerEmailSettings { get; set; }
|
||||||
|
|
||||||
[MailboxAddress]
|
[MailboxAddress]
|
||||||
[Display(Name = "Test Email")]
|
[Display(Name = "Test Email")]
|
||||||
public string TestEmail { get; set; }
|
public string TestEmail { get; set; }
|
||||||
120
BTCPayServer/Plugins/Emails/Views/Shared/EmailRulesList.cshtml
Normal file
120
BTCPayServer/Plugins/Emails/Views/Shared/EmailRulesList.cshtml
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
@using BTCPayServer.Views.Server
|
||||||
|
@model EmailRulesListViewModel
|
||||||
|
|
||||||
|
@{
|
||||||
|
var storeId = Model.StoreId;
|
||||||
|
|
||||||
|
if (storeId is null)
|
||||||
|
{
|
||||||
|
ViewData.SetLayoutModel(new LayoutModel("Server-" + nameof(ServerNavPages.Emails), StringLocalizer["Email Rules"])
|
||||||
|
.SetCategory(WellKnownCategories.Server));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ViewData.SetLayoutModel(new LayoutModel(nameof(StoreNavPages.Emails), StringLocalizer["Email Rules"])
|
||||||
|
.SetCategory(WellKnownCategories.Store));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<div class="sticky-header">
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item">
|
||||||
|
@if (storeId is not null)
|
||||||
|
{
|
||||||
|
<a asp-controller="UIStoresEmail" asp-action="StoreEmailSettings" asp-route-storeId="@storeId" text-translate="true">Emails</a>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<a asp-controller="UIServerEmail" asp-action="ServerEmailSettings" text-translate="true">Server Emails</a>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">@ViewData.GetTitle()</li>
|
||||||
|
</ol>
|
||||||
|
<h2>@ViewData.GetTitle()</h2>
|
||||||
|
</nav>
|
||||||
|
@if (storeId is not null)
|
||||||
|
{
|
||||||
|
<a id="CreateEmailRule" permission="@Model.ModifyPermission" asp-action="StoreEmailRulesCreate" asp-route-storeId="@storeId"
|
||||||
|
class="btn btn-primary" role="button" text-translate="true">
|
||||||
|
Create Email Rule
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<a id="CreateEmailRule" permission="@Model.ModifyPermission" asp-action="ServerEmailRulesCreate"
|
||||||
|
class="btn btn-primary" role="button" text-translate="true">
|
||||||
|
Create Email Rule
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<partial name="_StatusMessage" />
|
||||||
|
@if (storeId is not null)
|
||||||
|
{
|
||||||
|
<p text-translate="true">
|
||||||
|
Email rules allow BTCPay Server to send customized emails from your store based on events.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p text-translate="true">
|
||||||
|
Email rules allow BTCPay Server to send customized emails from your server based on events.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (Model.Rules.Any())
|
||||||
|
{
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th text-translate="true">Trigger</th>
|
||||||
|
@if (Model.ShowCustomerEmailColumn)
|
||||||
|
{
|
||||||
|
<th text-translate="true">Customer Email</th>
|
||||||
|
}
|
||||||
|
<th text-translate="true">To</th>
|
||||||
|
<th text-translate="true">Subject</th>
|
||||||
|
<th class="actions-col" permission="@Model.ModifyPermission"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var rule in Model.Rules)
|
||||||
|
{
|
||||||
|
<tr data-trigger="@rule.Trigger">
|
||||||
|
<td>@rule.Trigger</td>
|
||||||
|
@if (Model.ShowCustomerEmailColumn)
|
||||||
|
{
|
||||||
|
<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="@Model.ModifyPermission">
|
||||||
|
<div class="d-inline-flex align-items-center gap-3">
|
||||||
|
@if (storeId is not null)
|
||||||
|
{
|
||||||
|
|
||||||
|
<a asp-action="StoreEmailRulesEdit" asp-route-storeId="@storeId" asp-route-ruleId="@rule.Data.Id" text-translate="true">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["Delete"]" text-translate="true">Remove</a>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<a asp-action="ServerEmailRulesEdit" asp-route-ruleId="@rule.Data.Id" text-translate="true">Edit</a>
|
||||||
|
<a asp-action="ServerEmailRulesDelete" 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["Delete"]" text-translate="true">Remove</a>
|
||||||
|
}
|
||||||
|
</div >
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p class="text-secondary" text-translate="true">
|
||||||
|
There are no rules yet.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<partial name="_Confirm" model="@(new ConfirmModel(StringLocalizer["Remove email rule"], StringLocalizer["This action will remove this rule. Are you sure?"], StringLocalizer["Delete"]))" permission="@Model.ModifyPermission" />
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Plugins.Emails.Views.Shared;
|
||||||
|
|
||||||
|
public class EmailRulesListViewModel
|
||||||
|
{
|
||||||
|
public string StoreId { get; set; }
|
||||||
|
public string ModifyPermission { get; set; }
|
||||||
|
public bool ShowCustomerEmailColumn { get; set; }
|
||||||
|
public List<StoreEmailRuleViewModel> Rules { get; set; }
|
||||||
|
}
|
||||||
@@ -1,29 +1,52 @@
|
|||||||
|
@using BTCPayServer.Views.Server
|
||||||
@model StoreEmailRuleViewModel
|
@model StoreEmailRuleViewModel
|
||||||
|
|
||||||
@{
|
@{
|
||||||
var storeId = Context.GetStoreData().Id;
|
var storeId = Model.StoreId;
|
||||||
bool isEdit = Model.Trigger != null;
|
bool isEdit = Model.Trigger != null;
|
||||||
|
if (storeId is not null)
|
||||||
|
{
|
||||||
ViewData.SetLayoutModel(new LayoutModel(nameof(StoreNavPages.Emails), StringLocalizer[isEdit ? "Edit Email Rule" : "Create Email Rule"])
|
ViewData.SetLayoutModel(new LayoutModel(nameof(StoreNavPages.Emails), StringLocalizer[isEdit ? "Edit Email Rule" : "Create Email Rule"])
|
||||||
.SetCategory(WellKnownCategories.Store));
|
.SetCategory(WellKnownCategories.Store));
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ViewData.SetLayoutModel(new LayoutModel("Server-" + nameof(ServerNavPages.Emails), StringLocalizer[isEdit ? "Edit Email Rule" : "Create Email Rule"])
|
||||||
|
.SetCategory(WellKnownCategories.Server));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@section PageHeadContent {
|
@section PageHeadContent {
|
||||||
<link href="~/vendor/summernote/summernote-bs5.css" rel="stylesheet" asp-append-version="true" />
|
<link href="~/vendor/summernote/summernote-bs5.css" rel="stylesheet" asp-append-version="true" />
|
||||||
}
|
}
|
||||||
|
|
||||||
<form method="post">
|
<form id="save-form" method="post">
|
||||||
<div class="sticky-header">
|
<div class="sticky-header">
|
||||||
<nav aria-label="breadcrumb">
|
<nav aria-label="breadcrumb">
|
||||||
<ol class="breadcrumb">
|
<ol class="breadcrumb">
|
||||||
<li class="breadcrumb-item">
|
<li class="breadcrumb-item">
|
||||||
|
@if (storeId is not null)
|
||||||
|
{
|
||||||
<a asp-controller="UIStoresEmail" 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>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<a asp-controller="UIServerEmail" asp-action="ServerEmailSettings" text-translate="true">Server Emails</a>
|
||||||
|
}
|
||||||
</li>
|
</li>
|
||||||
<li class="breadcrumb-item">
|
<li class="breadcrumb-item">
|
||||||
|
@if (storeId is not null)
|
||||||
|
{
|
||||||
<a asp-action="StoreEmailRulesList" asp-route-storeId="@storeId" text-translate="true">Email Rules</a>
|
<a asp-action="StoreEmailRulesList" asp-route-storeId="@storeId" text-translate="true">Email Rules</a>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<a asp-action="ServerEmailRulesList" text-translate="true">Email Rules</a>
|
||||||
|
}
|
||||||
</li>
|
</li>
|
||||||
<li class="breadcrumb-item active" aria-current="page">@ViewData["Title"]</li>
|
<li class="breadcrumb-item active" aria-current="page">@ViewData.GetTitle()</li>
|
||||||
</ol>
|
</ol>
|
||||||
<h2>@ViewData["Title"]</h2>
|
<h2>@ViewData.GetTitle()</h2>
|
||||||
</nav>
|
</nav>
|
||||||
<div>
|
<div>
|
||||||
<button id="page-primary" type="submit" class="btn btn-primary" text-translate="true">Save</button>
|
<button id="page-primary" type="submit" class="btn btn-primary" text-translate="true">Save</button>
|
||||||
@@ -57,7 +80,7 @@
|
|||||||
<input type="hidden" asp-for="CanChangeCondition" ></input>
|
<input type="hidden" asp-for="CanChangeCondition" ></input>
|
||||||
@if (Model.CanChangeCondition)
|
@if (Model.CanChangeCondition)
|
||||||
{
|
{
|
||||||
<input asp-for="Condition" class="form-control" placeholder="@StringLocalizer["A Postgres compatible JSON Path (eg. $.Offering.Id == \"john\")"]" />
|
<input asp-for="Condition" class="form-control" placeholder="@StringLocalizer["A Postgres compatible JSON Path (eg. $.Customer.Name == \"john\")"]" />
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -68,12 +91,25 @@
|
|||||||
<div class="form-text" text-translate="true">Only send email when the specified JSON Path exists</div>
|
<div class="form-text" text-translate="true">Only send email when the specified JSON Path exists</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@{
|
||||||
|
var placeholder = "\"John Smith\" <john.smith@example.com>, john.smith@example.com, {Placeholder}";
|
||||||
|
}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label asp-for="To" class="form-label" text-translate="true">Recipients</label>
|
<label asp-for="To" class="form-label" text-translate="true">To</label>
|
||||||
<input type="text" asp-for="To" class="form-control email-rule-to" />
|
<input type="text" asp-for="To" placeholder="@placeholder" class="form-control email-rule-to" />
|
||||||
<span asp-validation-for="To" class="text-danger"></span>
|
<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 class="form-text" text-translate="true">Who to send the email to. For multiple emails, separate with a comma.</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label asp-for="CC" class="form-label" text-translate="true">CC</label>
|
||||||
|
<input type="text" asp-for="CC" placeholder="@placeholder" class="form-control email-rule-cc" />
|
||||||
|
<span asp-validation-for="CC" class="text-danger"></span>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label asp-for="BCC" class="form-label" text-translate="true">BCC</label>
|
||||||
|
<input type="text" asp-for="BCC" placeholder="@placeholder" class="form-control email-rule-bcc" />
|
||||||
|
<span asp-validation-for="BCC" class="text-danger"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-check mb-4 customer-email-container">
|
<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" />
|
<input asp-for="AdditionalData.CustomerEmail" type="checkbox" class="form-check-input email-rule-customer-email customer-email-checkbox" />
|
||||||
@@ -104,6 +140,16 @@
|
|||||||
@section PageFootContent {
|
@section PageFootContent {
|
||||||
<partial name="_ValidationScriptsPartial" />
|
<partial name="_ValidationScriptsPartial" />
|
||||||
<script src="~/vendor/summernote/summernote-bs5.js" asp-append-version="true"></script>
|
<script src="~/vendor/summernote/summernote-bs5.js" asp-append-version="true"></script>
|
||||||
|
<script>
|
||||||
|
// Fix annoying summernote bug (need to go back to preview after changing codeview): https://github.com/summernote/summernote/issues/94
|
||||||
|
document.querySelector('#save-form').addEventListener('submit', function (e) {
|
||||||
|
const codeView = document.querySelector(".note-codeview-keep.active");
|
||||||
|
if (codeView) {
|
||||||
|
codeView.click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
<script>
|
<script>
|
||||||
var triggers = @Safe.Json(Model.Triggers);
|
var triggers = @Safe.Json(Model.Triggers);
|
||||||
var triggersByType = {};
|
var triggersByType = {};
|
||||||
@@ -115,33 +161,37 @@
|
|||||||
|
|
||||||
const triggerSelect = document.querySelector('.email-rule-trigger') ?? document.querySelector('.email-rule-trigger-hidden');
|
const triggerSelect = document.querySelector('.email-rule-trigger') ?? document.querySelector('.email-rule-trigger-hidden');
|
||||||
const subjectInput = document.querySelector('.email-rule-subject');
|
const subjectInput = document.querySelector('.email-rule-subject');
|
||||||
|
const toInput = document.querySelector('.email-rule-to');
|
||||||
|
const ccInput = document.querySelector('.email-rule-cc');
|
||||||
|
const bccInput = document.querySelector('.email-rule-bcc');
|
||||||
const bodyTextarea = document.querySelector('.email-rule-body');
|
const bodyTextarea = document.querySelector('.email-rule-body');
|
||||||
const placeholdersTd = document.querySelector('#placeholders');
|
const placeholdersTd = document.querySelector('#placeholders');
|
||||||
|
|
||||||
const isEmptyOrDefault = (value, type) => {
|
function join(arr) {
|
||||||
const val = value.replace(/<.*?>/gi, '').trim();
|
return arr ? arr.join(', ') : '';
|
||||||
if (!val) return true;
|
}
|
||||||
return Object.values(triggersByType).some(t => t[type] === val);
|
|
||||||
};
|
|
||||||
|
|
||||||
function applyTemplate() {
|
function applyTemplate() {
|
||||||
const selectedTrigger = triggerSelect.value;
|
const selectedTrigger = triggerSelect.value;
|
||||||
console.log(selectedTrigger);
|
|
||||||
if (triggersByType[selectedTrigger]) {
|
if (triggersByType[selectedTrigger]) {
|
||||||
if (isEmptyOrDefault(subjectInput.value, 'subjectExample')) {
|
subjectInput.value = triggersByType[selectedTrigger].defaultEmail.subject;
|
||||||
subjectInput.value = triggersByType[selectedTrigger].subjectExample;
|
toInput.value = join(triggersByType[selectedTrigger].defaultEmail.to);
|
||||||
}
|
ccInput.value = join(triggersByType[selectedTrigger].defaultEmail.cc);
|
||||||
var body = triggersByType[selectedTrigger].bodyExample;
|
bccInput.value = join(triggersByType[selectedTrigger].defaultEmail.bcc);
|
||||||
|
var body = triggersByType[selectedTrigger].defaultEmail.body;
|
||||||
|
|
||||||
if (isEmptyOrDefault(bodyTextarea.value, 'bodyExample')) {
|
|
||||||
if ($(bodyTextarea).summernote) {
|
if ($(bodyTextarea).summernote) {
|
||||||
$(bodyTextarea).summernote('reset');
|
$(bodyTextarea).summernote('reset');
|
||||||
$(bodyTextarea).summernote('code', body.replace(/\n/g, '<br/>'));
|
$(bodyTextarea).summernote('code', body.replace(/\n/g, '<br/>'));
|
||||||
} else {
|
} else {
|
||||||
bodyTextarea.value = body;
|
bodyTextarea.value = body;
|
||||||
}
|
}
|
||||||
|
applyPlaceholders();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
function applyPlaceholders()
|
||||||
|
{
|
||||||
|
const selectedTrigger = triggerSelect.value;
|
||||||
|
if (triggersByType[selectedTrigger]) {
|
||||||
placeholdersTd.innerHTML = '';
|
placeholdersTd.innerHTML = '';
|
||||||
triggersByType[selectedTrigger].placeHolders.forEach(p => {
|
triggersByType[selectedTrigger].placeHolders.forEach(p => {
|
||||||
const tr = document.createElement('tr');
|
const tr = document.createElement('tr');
|
||||||
@@ -155,7 +205,6 @@
|
|||||||
tr.appendChild(td2);
|
tr.appendChild(td2);
|
||||||
placeholdersTd.appendChild(tr);
|
placeholdersTd.appendChild(tr);
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,7 +212,7 @@
|
|||||||
const customerEmailContainer = document.querySelector('.customer-email-container');
|
const customerEmailContainer = document.querySelector('.customer-email-container');
|
||||||
const customerEmailCheckbox = document.querySelector('.customer-email-checkbox');
|
const customerEmailCheckbox = document.querySelector('.customer-email-checkbox');
|
||||||
const selectedTrigger = triggerSelect.value;
|
const selectedTrigger = triggerSelect.value;
|
||||||
if (triggersByType[selectedTrigger].canIncludeCustomerEmail) {
|
if (triggersByType[selectedTrigger].defaultEmail.canIncludeCustomerEmail) {
|
||||||
customerEmailContainer.style.display = 'block';
|
customerEmailContainer.style.display = 'block';
|
||||||
} else {
|
} else {
|
||||||
customerEmailContainer.style.display = 'none';
|
customerEmailContainer.style.display = 'none';
|
||||||
@@ -176,7 +225,14 @@
|
|||||||
|
|
||||||
// Apply template on page load if a trigger is selected
|
// Apply template on page load if a trigger is selected
|
||||||
if (triggerSelect.value) {
|
if (triggerSelect.value) {
|
||||||
|
if (@Safe.Json(!isEdit))
|
||||||
|
{
|
||||||
applyTemplate();
|
applyTemplate();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
applyPlaceholders();
|
||||||
|
}
|
||||||
toggleCustomerEmailVisibility();
|
toggleCustomerEmailVisibility();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
192
BTCPayServer/Plugins/Emails/Views/Shared/EmailSettings.cshtml
Normal file
192
BTCPayServer/Plugins/Emails/Views/Shared/EmailSettings.cshtml
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
@using BTCPayServer.Views.Server
|
||||||
|
@model EmailsViewModel
|
||||||
|
@{
|
||||||
|
if (Model.StoreId is null)
|
||||||
|
{
|
||||||
|
ViewData.SetLayoutModel(new LayoutModel("Server-" + nameof(ServerNavPages.Emails), StringLocalizer["Emails"]).SetCategory(WellKnownCategories.Server));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ViewData.SetLayoutModel(new LayoutModel(nameof(StoreNavPages.Emails), StringLocalizer["Email Rules"])
|
||||||
|
.SetCategory(WellKnownCategories.Store));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<form method="post" autocomplete="off">
|
||||||
|
<div class="sticky-header">
|
||||||
|
<h2>@ViewData.GetTitle()</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>
|
||||||
|
@if (Model.StoreId is null)
|
||||||
|
{
|
||||||
|
<a id="ConfigureEmailRules" class="btn btn-secondary" asp-area="@EmailsPlugin.Area" asp-controller="UIServerEmailRules"
|
||||||
|
asp-action="ServerEmailRulesList"
|
||||||
|
permission="@Model.ModifyPermission" text-translate="true">
|
||||||
|
Go to email rules
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<a id="ConfigureEmailRules" class="btn btn-secondary" asp-area="@EmailsPlugin.Area" asp-controller="UIStoreEmailRules"
|
||||||
|
asp-action="StoreEmailRulesList"
|
||||||
|
asp-route-storeId="@Model.StoreId"
|
||||||
|
permission="@Model.ViewPermission" text-translate="true">
|
||||||
|
Go to email rules
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
<button id="page-primary" type="submit" class="btn btn-primary" name="command" value="Save" permission="@Model.ModifyPermission">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<partial name="_StatusMessage" />
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-xl-10 col-xxl-constrain">
|
||||||
|
@if (!ViewContext.ModelState.IsValid)
|
||||||
|
{
|
||||||
|
<div asp-validation-summary="All"></div>
|
||||||
|
}
|
||||||
|
@if (Model.StoreId is null)
|
||||||
|
{
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<input asp-for="EnableStoresToUseServerEmailSettings" type="checkbox" class="btcpay-toggle me-3" />
|
||||||
|
<div>
|
||||||
|
<label asp-for="EnableStoresToUseServerEmailSettings" class="form-check-label"></label>
|
||||||
|
<div class="text-muted">
|
||||||
|
<span text-translate="true">This can be overridden at the Store level.</span>
|
||||||
|
<a href="https://docs.btcpayserver.org/Notifications/#server-emails" target="_blank" rel="noreferrer noopener">
|
||||||
|
<vc:icon symbol="info" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@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>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<input type="hidden" id="IsCustomSMTPHidden" asp-for="IsCustomSMTP" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="collapse @(Model.IsCustomSMTP || Model.StoreId is null ? "show" : "")" id="SmtpSettings">
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="d-flex flex-wrap gap-2 align-items-center justify-content-between">
|
||||||
|
<label asp-for="Settings.Server" class="form-label" text-translate="true">SMTP Server</label>
|
||||||
|
<div class="dropdown only-for-js mt-n2" id="quick-fill">
|
||||||
|
<button class="btn btn-link p-0 dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"
|
||||||
|
id="QuickFillDropdownToggle" text-translate="true">
|
||||||
|
Quick Fill
|
||||||
|
</button>
|
||||||
|
<div class="dropdown-menu" aria-labelledby="QuickFillDropdownToggle">
|
||||||
|
<a class="dropdown-item" href="" data-server="smtp.gmail.com" data-port="587">Gmail.com</a>
|
||||||
|
<a class="dropdown-item" href="" data-server="mail.yahoo.com" data-port="587">Yahoo.com</a>
|
||||||
|
<a class="dropdown-item" href="" data-server="smtp.mailgun.org" data-port="587">Mailgun</a>
|
||||||
|
<a class="dropdown-item" href="" data-server="smtp.office365.com" data-port="587">Office365</a>
|
||||||
|
<a class="dropdown-item" href="" data-server="smtp.sendgrid.net" data-port="587">SendGrid</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input asp-for="Settings.Server" data-fill="server" class="form-control" />
|
||||||
|
<span asp-validation-for="Settings.Server" class="text-danger"></span>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label asp-for="Settings.Port" class="form-label" text-translate="true">Port</label>
|
||||||
|
<input asp-for="Settings.Port" data-fill="port" class="form-control" />
|
||||||
|
<span asp-validation-for="Settings.Port" class="text-danger"></span>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label asp-for="Settings.From" class="form-label" text-translate="true">Sender's Email Address</label>
|
||||||
|
<input asp-for="Settings.From" class="form-control" placeholder="@StringLocalizer["Firstname Lastname <email@example.com>"]" />
|
||||||
|
<span asp-validation-for="Settings.From" class="text-danger"></span>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label asp-for="Settings.Login" class="form-label" text-translate="true">Login</label>
|
||||||
|
<input asp-for="Settings.Login" class="form-control" />
|
||||||
|
<div class="form-text" text-translate="true">For many email providers (like Gmail) your login is your email address.</div>
|
||||||
|
<span asp-validation-for="Settings.Login" class="text-danger"></span>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" permission="@Model.ModifyPermission">
|
||||||
|
<label asp-for="Settings.Password" class="form-label" text-translate="true">Password</label>
|
||||||
|
@if (!Model.PasswordSet)
|
||||||
|
{
|
||||||
|
<input asp-for="Settings.Password" type="password" value="@Model.Settings.Password" class="form-control" />
|
||||||
|
<span asp-validation-for="Settings.Password" class="text-danger"></span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="input-group">
|
||||||
|
<input value="@StringLocalizer["Configured"]" type="text" readonly class="form-control" />
|
||||||
|
<button type="submit" class="btn btn-danger" name="command" value="ResetPassword" id="ResetPassword" text-translate="true">Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<input asp-for="PasswordSet" type="hidden" />
|
||||||
|
<div class="my-4">
|
||||||
|
<button class="d-inline-flex align-items-center btn btn-link text-primary fw-semibold p-0" type="button" id="AdvancedSettingsButton"
|
||||||
|
data-bs-toggle="collapse" data-bs-target="#AdvancedSettings" aria-expanded="false" aria-controls="AdvancedSettings">
|
||||||
|
<vc:icon symbol="caret-down" />
|
||||||
|
<span class="ms-1" text-translate="true">Advanced settings</span>
|
||||||
|
</button>
|
||||||
|
<div id="AdvancedSettings" class="collapse">
|
||||||
|
<div class="pt-3 pb-1">
|
||||||
|
<div class="d-flex">
|
||||||
|
<input asp-for="Settings.EnabledCertificateCheck" type="checkbox" class="btcpay-toggle me-3" />
|
||||||
|
<label asp-for="Settings.EnabledCertificateCheck" class="form-check-label" text-translate="true">TLS certificate security
|
||||||
|
checks</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 class="my-3" text-translate="true">Testing</h3>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-xl-10 col-xxl-constrain">
|
||||||
|
<div class="form-group">
|
||||||
|
<label asp-for="TestEmail" class="form-label" text-translate="true">To test your settings, enter an email address</label>
|
||||||
|
<input asp-for="TestEmail" placeholder="@StringLocalizer["Firstname Lastname <email@example.com>"]" class="form-control" />
|
||||||
|
<span asp-validation-for="TestEmail" class="text-danger"></span>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-secondary mt-2" name="command" value="Test" id="Test" text-translate="true">Send Test Email</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
@section PageFootContent {
|
||||||
|
<partial name="_ValidationScriptsPartial" />
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
delegate('click', '#quick-fill .dropdown-menu a', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const data = e.target.dataset;
|
||||||
|
Object.keys(data).forEach(function (key) {
|
||||||
|
const value = data[key];
|
||||||
|
const input = document.querySelector('input[data-fill="' + key + '"]');
|
||||||
|
if (input) {
|
||||||
|
const type = input.getAttribute('type');
|
||||||
|
if (type === 'checkbox') {
|
||||||
|
input.checked = value;
|
||||||
|
} else {
|
||||||
|
input.value = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Net.Mail;
|
||||||
using BTCPayServer.Data;
|
using BTCPayServer.Data;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
namespace BTCPayServer.Plugins.Emails.Views;
|
namespace BTCPayServer.Plugins.Emails.Views;
|
||||||
|
|
||||||
@@ -16,6 +18,7 @@ public class StoreEmailRuleViewModel
|
|||||||
{
|
{
|
||||||
if (data is not null)
|
if (data is not null)
|
||||||
{
|
{
|
||||||
|
StoreId = data.StoreId;
|
||||||
Data = data;
|
Data = data;
|
||||||
OfferingId = data.OfferingId;
|
OfferingId = data.OfferingId;
|
||||||
AdditionalData = data.GetBTCPayAdditionalData() ?? new();
|
AdditionalData = data.GetBTCPayAdditionalData() ?? new();
|
||||||
@@ -24,6 +27,8 @@ public class StoreEmailRuleViewModel
|
|||||||
Condition = data.Condition ?? "";
|
Condition = data.Condition ?? "";
|
||||||
Body = data.Body;
|
Body = data.Body;
|
||||||
To = string.Join(",", data.To);
|
To = string.Join(",", data.To);
|
||||||
|
CC = string.Join(",", data.CC);
|
||||||
|
BCC = string.Join(",", data.BCC);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -45,15 +50,39 @@ public class StoreEmailRuleViewModel
|
|||||||
public EmailRuleData Data { get; set; }
|
public EmailRuleData Data { get; set; }
|
||||||
public EmailRuleData.BTCPayAdditionalData AdditionalData { get; set; }
|
public EmailRuleData.BTCPayAdditionalData AdditionalData { get; set; }
|
||||||
public string To { get; set; }
|
public string To { get; set; }
|
||||||
|
public string CC { get; set; }
|
||||||
|
public string BCC { get; set; }
|
||||||
|
|
||||||
public List<EmailTriggerViewModel> Triggers { get; set; }
|
public List<EmailTriggerViewModel> Triggers { get; set; }
|
||||||
public string RedirectUrl { get; set; }
|
public string RedirectUrl { get; set; }
|
||||||
public bool CanChangeTrigger { get; set; } = true;
|
public bool CanChangeTrigger { get; set; } = true;
|
||||||
public bool CanChangeCondition { get; set; } = true;
|
public bool CanChangeCondition { get; set; } = true;
|
||||||
public string OfferingId { get; set; }
|
public string OfferingId { get; set; }
|
||||||
|
public string StoreId { get; set; }
|
||||||
|
|
||||||
public string[] ToAsArray()
|
public string[] AsArray(string values)
|
||||||
=> (To ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries)
|
{
|
||||||
.Select(t => t.Trim())
|
// This replace the placeholders with random email addresses
|
||||||
.ToArray();
|
// We can't just split input by comma, because display names of people can contain commas.
|
||||||
|
// "John, Jr. Smith" <jon@example.com>,{User.Email},"Nicolas D." <nico@example.com>
|
||||||
|
values ??= "";
|
||||||
|
|
||||||
|
// We replace the placeholders with dummy email addresses
|
||||||
|
var template = new TextTemplate(values);
|
||||||
|
var dummy = $"{Random.Shared.Next()}@example.com";
|
||||||
|
template.NotFoundReplacement = o => $"\"{o}\" <{dummy}>";
|
||||||
|
values = template.Render(new JObject());
|
||||||
|
if (string.IsNullOrWhiteSpace(values))
|
||||||
|
return Array.Empty<string>();
|
||||||
|
// We use MailAddressCollection to parse the addresses
|
||||||
|
MailAddressCollection mailCollection = new();
|
||||||
|
mailCollection.Add(values);
|
||||||
|
foreach (var mail in mailCollection)
|
||||||
|
{
|
||||||
|
// Let it throw if the address is invalid
|
||||||
|
MailboxAddressValidator.Parse(mail.ToString());
|
||||||
|
}
|
||||||
|
// We replace the dummies with the former placeholders
|
||||||
|
return mailCollection.Select(a => a.Address == dummy ? $"{{{a.DisplayName}}}" : a.ToString()).ToArray();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
@model List<StoreEmailRuleViewModel>
|
|
||||||
|
|
||||||
@{
|
|
||||||
var storeId = Context.GetStoreData().Id;
|
|
||||||
ViewData.SetLayoutModel(new LayoutModel(nameof(StoreNavPages.Emails), StringLocalizer["Email Rules"])
|
|
||||||
.SetCategory(WellKnownCategories.Store));
|
|
||||||
}
|
|
||||||
<div class="sticky-header">
|
|
||||||
<nav aria-label="breadcrumb">
|
|
||||||
<ol class="breadcrumb">
|
|
||||||
<li class="breadcrumb-item">
|
|
||||||
<a asp-controller="UIStoresEmail" asp-action="StoreEmailSettings" asp-route-storeId="@storeId" text-translate="true">Emails</a>
|
|
||||||
</li>
|
|
||||||
<li class="breadcrumb-item active" aria-current="page">@ViewData["Title"]</li>
|
|
||||||
</ol>
|
|
||||||
<h2>@ViewData["Title"]</h2>
|
|
||||||
</nav>
|
|
||||||
<a id="CreateEmailRule" permission="@Policies.CanModifyStoreSettings" asp-action="StoreEmailRulesCreate" asp-route-storeId="@storeId"
|
|
||||||
class="btn btn-primary" role="button" text-translate="true">
|
|
||||||
Create Email Rule
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<partial name="_StatusMessage" />
|
|
||||||
<p text-translate="true">
|
|
||||||
Email rules allow BTCPay Server to send customized emails from your store based on events.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
@if (Model.Any())
|
|
||||||
{
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-hover">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th text-translate="true">Trigger</th>
|
|
||||||
<th text-translate="true">Customer Email</th>
|
|
||||||
<th text-translate="true">To</th>
|
|
||||||
<th text-translate="true">Subject</th>
|
|
||||||
<th class="actions-col" permission="@Policies.CanModifyStoreSettings"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@foreach (var rule in Model)
|
|
||||||
{
|
|
||||||
<tr>
|
|
||||||
<td>@rule.Trigger</td>
|
|
||||||
<td>@(rule.AdditionalData.CustomerEmail is true ? StringLocalizer["Yes"] : StringLocalizer["No"])</td>
|
|
||||||
<td>@string.Join(", ", rule.To)</td>
|
|
||||||
<td>@rule.Subject</td>
|
|
||||||
<td class="actions-col" permission="@Policies.CanModifyStoreSettings">
|
|
||||||
<div class="d-inline-flex align-items-center gap-3">
|
|
||||||
<a asp-action="StoreEmailRulesEdit" asp-route-storeId="@storeId" asp-route-ruleId="@rule.Data.Id">Edit</a>
|
|
||||||
<a asp-action="StoreEmailRulesDelete" asp-route-storeId="@storeId" asp-route-ruleId="@rule.Data.Id" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-description="@ViewLocalizer["This action will remove the rule with the trigger <b>{0}</b>.", Html.Encode(rule.Trigger)]" data-confirm-input="@StringLocalizer["Delete"]" text-translate="true">Remove</a>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<p class="text-secondary" text-translate="true">
|
|
||||||
There are no rules yet.
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
<partial name="_Confirm" model="@(new ConfirmModel(StringLocalizer["Remove email rule"], StringLocalizer["This action will remove this rule. Are you sure?"], StringLocalizer["Delete"]))" permission="@Policies.CanModifyStoreSettings" />
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
@using BTCPayServer.Client
|
|
||||||
@using BTCPayServer.Plugins.Emails
|
|
||||||
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
|
||||||
@model BTCPayServer.Models.EmailsViewModel
|
|
||||||
@{
|
|
||||||
var storeId = Context.GetStoreData().Id;
|
|
||||||
ViewData.SetLayoutModel(new LayoutModel(nameof(StoreNavPages.Emails), StringLocalizer["Email Rules"])
|
|
||||||
.SetCategory(WellKnownCategories.Store));
|
|
||||||
}
|
|
||||||
|
|
||||||
<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" />
|
|
||||||
}
|
|
||||||
@@ -54,7 +54,6 @@ public class SubscriptionsPlugin : BaseBTCPayServerPlugin
|
|||||||
new("{Customer.ExternalRef}", "Customer external reference"),
|
new("{Customer.ExternalRef}", "Customer external reference"),
|
||||||
new("{Customer.Name}", "Customer name"),
|
new("{Customer.Name}", "Customer name"),
|
||||||
new("{Customer.Metadata}*", "Customer metadata")
|
new("{Customer.Metadata}*", "Customer metadata")
|
||||||
|
|
||||||
}.AddStoresPlaceHolders();
|
}.AddStoresPlaceHolders();
|
||||||
|
|
||||||
var viewModels = new List<EmailTriggerViewModel>()
|
var viewModels = new List<EmailTriggerViewModel>()
|
||||||
@@ -63,72 +62,99 @@ public class SubscriptionsPlugin : BaseBTCPayServerPlugin
|
|||||||
{
|
{
|
||||||
Trigger = WebhookSubscriptionEvent.SubscriberCreated,
|
Trigger = WebhookSubscriptionEvent.SubscriberCreated,
|
||||||
Description = "Subscription - New subscriber",
|
Description = "Subscription - New subscriber",
|
||||||
SubjectExample = "Welcome {Customer.Name}!",
|
DefaultEmail = new()
|
||||||
BodyExample = "Hello {Customer.Name},\n\nThank you for subscribing to our service.\n\nRegards,\n{Store.Name}",
|
{
|
||||||
|
Subject = "Welcome {Customer.Name}!",
|
||||||
|
Body = "Hello {Customer.Name},\n\nThank you for subscribing to our service.\n\nRegards,\n{Store.Name}"
|
||||||
|
},
|
||||||
PlaceHolders = placeHolders
|
PlaceHolders = placeHolders
|
||||||
},
|
},
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Trigger = WebhookSubscriptionEvent.SubscriberCredited,
|
Trigger = WebhookSubscriptionEvent.SubscriberCredited,
|
||||||
Description = "Subscription - Subscriber credited",
|
Description = "Subscription - Subscriber credited",
|
||||||
SubjectExample = "Your subscription has been credited",
|
DefaultEmail = new()
|
||||||
BodyExample = "Hello {Customer.Name},\n\nYour subscription has been credited.\n\nRegards,\n{Store.Name}",
|
{
|
||||||
|
Subject = "Your subscription has been credited",
|
||||||
|
Body = "Hello {Customer.Name},\n\nYour subscription has been credited.\n\nRegards,\n{Store.Name}"
|
||||||
|
},
|
||||||
PlaceHolders = placeHolders
|
PlaceHolders = placeHolders
|
||||||
},
|
},
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Trigger = WebhookSubscriptionEvent.SubscriberCharged,
|
Trigger = WebhookSubscriptionEvent.SubscriberCharged,
|
||||||
Description = "Subscription - Subscriber charged",
|
Description = "Subscription - Subscriber charged",
|
||||||
SubjectExample = "Your subscription payment has been processed",
|
DefaultEmail = new()
|
||||||
BodyExample = "Hello {Customer.Name},\n\nYour subscription payment for {Plan.Name} has been processed.\n\nRegards,\n{Store.Name}",
|
{
|
||||||
|
Subject = "Your subscription payment has been processed",
|
||||||
|
Body = "Hello {Customer.Name},\n\nYour subscription payment for {Plan.Name} has been processed.\n\nRegards,\n{Store.Name}"
|
||||||
|
},
|
||||||
PlaceHolders = placeHolders
|
PlaceHolders = placeHolders
|
||||||
},
|
},
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Trigger = WebhookSubscriptionEvent.SubscriberActivated,
|
Trigger = WebhookSubscriptionEvent.SubscriberActivated,
|
||||||
Description = "Subscription - Subscriber activated",
|
Description = "Subscription - Subscriber activated",
|
||||||
SubjectExample = "Your subscription is now active",
|
DefaultEmail = new()
|
||||||
BodyExample = "Hello {Customer.Name},\n\nYour subscription to {Plan.Name} is now active.\n\nRegards,\n{Store.Name}",
|
{
|
||||||
|
Subject = "Your subscription is now active",
|
||||||
|
Body = "Hello {Customer.Name},\n\nYour subscription to {Plan.Name} is now active.\n\nRegards,\n{Store.Name}"
|
||||||
|
},
|
||||||
PlaceHolders = placeHolders
|
PlaceHolders = placeHolders
|
||||||
},
|
},
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Trigger = WebhookSubscriptionEvent.SubscriberPhaseChanged,
|
Trigger = WebhookSubscriptionEvent.SubscriberPhaseChanged,
|
||||||
Description = "Subscription - Subscriber phase changed",
|
Description = "Subscription - Subscriber phase changed",
|
||||||
SubjectExample = "Your subscription phase has changed",
|
DefaultEmail = new()
|
||||||
BodyExample = "Hello {Customer.Name},\n\nYour subscription phase has been updated to {Subscriber.Phase}.\n\nRegards,\n{Store.Name}",
|
{
|
||||||
|
Subject = "Your subscription phase has changed",
|
||||||
|
Body = "Hello {Customer.Name},\n\nYour subscription phase has been updated to {Subscriber.Phase}.\n\nRegards,\n{Store.Name}"
|
||||||
|
},
|
||||||
PlaceHolders = placeHolders
|
PlaceHolders = placeHolders
|
||||||
},
|
},
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Trigger = WebhookSubscriptionEvent.SubscriberDisabled,
|
Trigger = WebhookSubscriptionEvent.SubscriberDisabled,
|
||||||
Description = "Subscription - Subscriber disabled",
|
Description = "Subscription - Subscriber disabled",
|
||||||
SubjectExample = "Your subscription has been disabled",
|
DefaultEmail = new()
|
||||||
BodyExample = "Hello {Customer.Name},\n\nYour subscription has been disabled.\n\nRegards,\n{Store.Name}",
|
{
|
||||||
|
Subject = "Your subscription has been disabled",
|
||||||
|
Body = "Hello {Customer.Name},\n\nYour subscription has been disabled.\n\nRegards,\n{Store.Name}"
|
||||||
|
},
|
||||||
PlaceHolders = placeHolders
|
PlaceHolders = placeHolders
|
||||||
},
|
},
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Trigger = WebhookSubscriptionEvent.PaymentReminder,
|
Trigger = WebhookSubscriptionEvent.PaymentReminder,
|
||||||
Description = "Subscription - Payment reminder",
|
Description = "Subscription - Payment reminder",
|
||||||
SubjectExample = "Payment reminder for your subscription",
|
DefaultEmail = new()
|
||||||
BodyExample = "Hello {Customer.Name},\n\nThis is a reminder about your upcoming subscription payment.\n\nRegards,\n{Store.Name}",
|
{
|
||||||
|
Subject = "Payment reminder for your subscription",
|
||||||
|
Body = "Hello {Customer.Name},\n\nThis is a reminder about your upcoming subscription payment.\n\nRegards,\n{Store.Name}"
|
||||||
|
},
|
||||||
PlaceHolders = placeHolders
|
PlaceHolders = placeHolders
|
||||||
},
|
},
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Trigger = WebhookSubscriptionEvent.PlanStarted,
|
Trigger = WebhookSubscriptionEvent.PlanStarted,
|
||||||
Description = "Subscription - Plan started",
|
Description = "Subscription - Plan started",
|
||||||
SubjectExample = "Your subscription plan has started",
|
DefaultEmail = new()
|
||||||
BodyExample = "Hello {Customer.Name},\n\nYour subscription plan {Plan.Name} has started.\n\nRegards,\n{Store.Name}",
|
{
|
||||||
|
Subject = "Your subscription plan has started",
|
||||||
|
Body = "Hello {Customer.Name},\n\nYour subscription plan {Plan.Name} has started.\n\nRegards,\n{Store.Name}"
|
||||||
|
},
|
||||||
PlaceHolders = placeHolders
|
PlaceHolders = placeHolders
|
||||||
},
|
},
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Trigger = WebhookSubscriptionEvent.SubscriberNeedUpgrade,
|
Trigger = WebhookSubscriptionEvent.SubscriberNeedUpgrade,
|
||||||
Description = "Subscription - Need upgrade",
|
Description = "Subscription - Need upgrade",
|
||||||
SubjectExample = "Your subscription needs to be upgraded",
|
DefaultEmail = new()
|
||||||
BodyExample = "Hello {Customer.Name},\n\nYour subscription needs to be upgraded to continue using our service.\n\nRegards,\n{Store.Name}",
|
{
|
||||||
|
Subject = "Your subscription needs to be upgraded",
|
||||||
|
Body = "Hello {Customer.Name},\n\nYour subscription needs to be upgraded to continue using our service.\n\nRegards,\n{Store.Name}"
|
||||||
|
},
|
||||||
PlaceHolders = placeHolders
|
PlaceHolders = placeHolders
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -141,6 +167,7 @@ public class SubscriptionsAppType(
|
|||||||
IOptions<BTCPayServerOptions> btcPayServerOptions) : AppBaseType(AppType)
|
IOptions<BTCPayServerOptions> btcPayServerOptions) : AppBaseType(AppType)
|
||||||
{
|
{
|
||||||
public const string AppType = "Subscriptions";
|
public const string AppType = "Subscriptions";
|
||||||
|
|
||||||
public class AppConfig
|
public class AppConfig
|
||||||
{
|
{
|
||||||
public string OfferingId { get; set; } = null!;
|
public string OfferingId { get; set; } = null!;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using BTCPayServer.Client.Models;
|
|||||||
using BTCPayServer.Data;
|
using BTCPayServer.Data;
|
||||||
using BTCPayServer.HostedServices;
|
using BTCPayServer.HostedServices;
|
||||||
using BTCPayServer.Plugins.Emails;
|
using BTCPayServer.Plugins.Emails;
|
||||||
|
using BTCPayServer.Plugins.Emails.HostedServices;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using StoreData = BTCPayServer.Data.StoreData;
|
using StoreData = BTCPayServer.Data.StoreData;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using BTCPayServer.Client.Models;
|
|||||||
using BTCPayServer.Controllers.Greenfield;
|
using BTCPayServer.Controllers.Greenfield;
|
||||||
using BTCPayServer.Events;
|
using BTCPayServer.Events;
|
||||||
using BTCPayServer.Plugins.Emails;
|
using BTCPayServer.Plugins.Emails;
|
||||||
|
using BTCPayServer.Plugins.Emails.HostedServices;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Routing;
|
using Microsoft.AspNetCore.Routing;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
@@ -41,7 +42,7 @@ public class InvoiceTriggerProvider(LinkGenerator linkGenerator)
|
|||||||
context.MatchedRule.GetBTCPayAdditionalData()?.CustomerEmail is true &&
|
context.MatchedRule.GetBTCPayAdditionalData()?.CustomerEmail is true &&
|
||||||
MailboxAddressValidator.TryParse(email, out var mb))
|
MailboxAddressValidator.TryParse(email, out var mb))
|
||||||
{
|
{
|
||||||
context.Recipients.Insert(0, mb);
|
context.To.Insert(0, mb);
|
||||||
}
|
}
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using BTCPayServer.Client.Models;
|
|||||||
using BTCPayServer.Data;
|
using BTCPayServer.Data;
|
||||||
using BTCPayServer.HostedServices;
|
using BTCPayServer.HostedServices;
|
||||||
using BTCPayServer.Plugins.Emails;
|
using BTCPayServer.Plugins.Emails;
|
||||||
|
using BTCPayServer.Plugins.Emails.HostedServices;
|
||||||
using BTCPayServer.Services.PaymentRequests;
|
using BTCPayServer.Services.PaymentRequests;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Routing;
|
using Microsoft.AspNetCore.Routing;
|
||||||
@@ -42,7 +43,7 @@ public class PaymentRequestTriggerProvider(LinkGenerator linkGenerator)
|
|||||||
context.MatchedRule.GetBTCPayAdditionalData()?.CustomerEmail is true &&
|
context.MatchedRule.GetBTCPayAdditionalData()?.CustomerEmail is true &&
|
||||||
MailboxAddressValidator.TryParse(email, out var mb))
|
MailboxAddressValidator.TryParse(email, out var mb))
|
||||||
{
|
{
|
||||||
context.Recipients.Insert(0, mb);
|
context.To.Insert(0, mb);
|
||||||
}
|
}
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Threading.Tasks;
|
|||||||
using BTCPayServer.Client.Models;
|
using BTCPayServer.Client.Models;
|
||||||
using BTCPayServer.HostedServices;
|
using BTCPayServer.HostedServices;
|
||||||
using BTCPayServer.Plugins.Emails;
|
using BTCPayServer.Plugins.Emails;
|
||||||
|
using BTCPayServer.Plugins.Emails.HostedServices;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
using StoreData = BTCPayServer.Data.StoreData;
|
using StoreData = BTCPayServer.Data.StoreData;
|
||||||
|
|||||||
@@ -73,32 +73,45 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
|
|||||||
{
|
{
|
||||||
Trigger = PendingTransactionTriggerProvider.PendingTransactionCreated,
|
Trigger = PendingTransactionTriggerProvider.PendingTransactionCreated,
|
||||||
Description = "Pending Transaction - Created",
|
Description = "Pending Transaction - Created",
|
||||||
SubjectExample = "Pending Transaction {PendingTransaction.TrimmedId} Created",
|
DefaultEmail = new()
|
||||||
BodyExample = "Review the transaction {PendingTransaction.Id} and sign it on: {PendingTransaction.Link}",
|
{
|
||||||
|
Subject = "Pending Transaction {PendingTransaction.TrimmedId} Created",
|
||||||
|
Body = "Review the transaction {PendingTransaction.Id} and sign it on: {PendingTransaction.Link}"
|
||||||
|
},
|
||||||
PlaceHolders = pendingTransactionsPlaceholders
|
PlaceHolders = pendingTransactionsPlaceholders
|
||||||
},
|
},
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Trigger = PendingTransactionTriggerProvider.PendingTransactionSignatureCollected,
|
Trigger = PendingTransactionTriggerProvider.PendingTransactionSignatureCollected,
|
||||||
Description = "Pending Transaction - Signature Collected",
|
Description = "Pending Transaction - Signature Collected",
|
||||||
SubjectExample = "Signature Collected for Pending Transaction {PendingTransaction.TrimmedId}",
|
DefaultEmail = new()
|
||||||
BodyExample = "So far {PendingTransaction.SignaturesCollected} signatures collected out of {PendingTransaction.SignaturesNeeded} signatures needed. ",
|
{
|
||||||
|
Subject = "Signature Collected for Pending Transaction {PendingTransaction.TrimmedId}",
|
||||||
|
Body =
|
||||||
|
"So far {PendingTransaction.SignaturesCollected} signatures collected out of {PendingTransaction.SignaturesNeeded} signatures needed. "
|
||||||
|
},
|
||||||
PlaceHolders = pendingTransactionsPlaceholders
|
PlaceHolders = pendingTransactionsPlaceholders
|
||||||
},
|
},
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Trigger = PendingTransactionTriggerProvider.PendingTransactionBroadcast,
|
Trigger = PendingTransactionTriggerProvider.PendingTransactionBroadcast,
|
||||||
Description = "Pending Transaction - Broadcast",
|
Description = "Pending Transaction - Broadcast",
|
||||||
SubjectExample = "Transaction {PendingTransaction.TrimmedId} has been Broadcast",
|
DefaultEmail = new()
|
||||||
BodyExample = "Transaction is visible in mempool on: https://mempool.space/tx/{PendingTransaction.Id}. ",
|
{
|
||||||
|
Subject = "Transaction {PendingTransaction.TrimmedId} has been Broadcast",
|
||||||
|
Body = "Transaction is visible in mempool on: https://mempool.space/tx/{PendingTransaction.Id}. "
|
||||||
|
},
|
||||||
PlaceHolders = pendingTransactionsPlaceholders
|
PlaceHolders = pendingTransactionsPlaceholders
|
||||||
},
|
},
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Trigger = PendingTransactionTriggerProvider.PendingTransactionCancelled,
|
Trigger = PendingTransactionTriggerProvider.PendingTransactionCancelled,
|
||||||
Description = "Pending Transaction - Cancelled",
|
Description = "Pending Transaction - Cancelled",
|
||||||
SubjectExample = "Pending Transaction {PendingTransaction.TrimmedId} Cancelled",
|
DefaultEmail = new()
|
||||||
BodyExample = "Transaction {PendingTransaction.Id} is cancelled and signatures are no longer being collected. ",
|
{
|
||||||
|
Subject = "Pending Transaction {PendingTransaction.TrimmedId} Cancelled",
|
||||||
|
Body = "Transaction {PendingTransaction.Id} is cancelled and signatures are no longer being collected. "
|
||||||
|
},
|
||||||
PlaceHolders = pendingTransactionsPlaceholders
|
PlaceHolders = pendingTransactionsPlaceholders
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -130,46 +143,61 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
|
|||||||
{
|
{
|
||||||
Trigger = WebhookEventType.PaymentRequestCreated,
|
Trigger = WebhookEventType.PaymentRequestCreated,
|
||||||
Description = "Payment Request - Created",
|
Description = "Payment Request - Created",
|
||||||
SubjectExample = "Payment Request {PaymentRequest.Id} created",
|
DefaultEmail = new()
|
||||||
BodyExample = "Payment Request {PaymentRequest.Id} ({PaymentRequest.Title}) created.",
|
{
|
||||||
PlaceHolders = paymentRequestPlaceholders,
|
Subject = "Payment Request {PaymentRequest.Id} created",
|
||||||
|
Body = "Payment Request {PaymentRequest.Id} ({PaymentRequest.Title}) created.",
|
||||||
CanIncludeCustomerEmail = true
|
CanIncludeCustomerEmail = true
|
||||||
},
|
},
|
||||||
|
PlaceHolders = paymentRequestPlaceholders
|
||||||
|
},
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Trigger = WebhookEventType.PaymentRequestUpdated,
|
Trigger = WebhookEventType.PaymentRequestUpdated,
|
||||||
Description = "Payment Request - Updated",
|
Description = "Payment Request - Updated",
|
||||||
SubjectExample = "Payment Request {PaymentRequest.Id} updated",
|
DefaultEmail = new()
|
||||||
BodyExample = "Payment Request {PaymentRequest.Id} ({PaymentRequest.Title}) updated.",
|
{
|
||||||
PlaceHolders = paymentRequestPlaceholders,
|
Subject = "Payment Request {PaymentRequest.Id} updated",
|
||||||
|
Body = "Payment Request {PaymentRequest.Id} ({PaymentRequest.Title}) updated.",
|
||||||
CanIncludeCustomerEmail = true
|
CanIncludeCustomerEmail = true
|
||||||
},
|
},
|
||||||
|
PlaceHolders = paymentRequestPlaceholders
|
||||||
|
},
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Trigger = WebhookEventType.PaymentRequestArchived,
|
Trigger = WebhookEventType.PaymentRequestArchived,
|
||||||
Description = "Payment Request - Archived",
|
Description = "Payment Request - Archived",
|
||||||
SubjectExample = "Payment Request {PaymentRequest.Id} archived",
|
DefaultEmail = new()
|
||||||
BodyExample = "Payment Request {PaymentRequest.Id} ({PaymentRequest.Title}) archived.",
|
{
|
||||||
PlaceHolders = paymentRequestPlaceholders,
|
Subject = "Payment Request {PaymentRequest.Id} archived",
|
||||||
|
Body = "Payment Request {PaymentRequest.Id} ({PaymentRequest.Title}) archived.",
|
||||||
CanIncludeCustomerEmail = true
|
CanIncludeCustomerEmail = true
|
||||||
},
|
},
|
||||||
|
PlaceHolders = paymentRequestPlaceholders
|
||||||
|
},
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Trigger = WebhookEventType.PaymentRequestStatusChanged,
|
Trigger = WebhookEventType.PaymentRequestStatusChanged,
|
||||||
Description = "Payment Request - Status Changed",
|
Description = "Payment Request - Status Changed",
|
||||||
SubjectExample = "Payment Request {PaymentRequest.Id} status changed",
|
DefaultEmail = new()
|
||||||
BodyExample = "Payment Request {PaymentRequest.Id} ({PaymentRequest.Title}) status changed to {PaymentRequest.Status}.",
|
{
|
||||||
PlaceHolders = paymentRequestPlaceholders,
|
Subject = "Payment Request {PaymentRequest.Id} status changed",
|
||||||
|
Body = "Payment Request {PaymentRequest.Id} ({PaymentRequest.Title}) status changed to {PaymentRequest.Status}.",
|
||||||
CanIncludeCustomerEmail = true
|
CanIncludeCustomerEmail = true
|
||||||
},
|
},
|
||||||
|
PlaceHolders = paymentRequestPlaceholders
|
||||||
|
},
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Trigger = WebhookEventType.PaymentRequestCompleted,
|
Trigger = WebhookEventType.PaymentRequestCompleted,
|
||||||
Description = "Payment Request - Completed",
|
Description = "Payment Request - Completed",
|
||||||
SubjectExample = "Payment Request {PaymentRequest.Title} {PaymentRequest.ReferenceId} Completed",
|
DefaultEmail = new()
|
||||||
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,
|
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 request: {PaymentRequest.Link}",
|
||||||
CanIncludeCustomerEmail = true
|
CanIncludeCustomerEmail = true
|
||||||
|
},
|
||||||
|
PlaceHolders = paymentRequestPlaceholders
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
services.AddWebhookTriggerViewModels(paymentRequestTriggers);
|
services.AddWebhookTriggerViewModels(paymentRequestTriggers);
|
||||||
@@ -192,24 +220,33 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
|
|||||||
{
|
{
|
||||||
Trigger = WebhookEventType.PayoutCreated,
|
Trigger = WebhookEventType.PayoutCreated,
|
||||||
Description = "Payout - Created",
|
Description = "Payout - Created",
|
||||||
SubjectExample = "Payout {Payout.Id} created",
|
DefaultEmail = new()
|
||||||
BodyExample = "Payout {Payout.Id} (Pull Payment Id: {Payout.PullPaymentId}) created.",
|
{
|
||||||
|
Subject = "Payout {Payout.Id} created",
|
||||||
|
Body = "Payout {Payout.Id} (Pull Payment Id: {Payout.PullPaymentId}) created."
|
||||||
|
},
|
||||||
PlaceHolders = payoutPlaceholders
|
PlaceHolders = payoutPlaceholders
|
||||||
},
|
},
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Trigger = WebhookEventType.PayoutApproved,
|
Trigger = WebhookEventType.PayoutApproved,
|
||||||
Description = "Payout - Approved",
|
Description = "Payout - Approved",
|
||||||
SubjectExample = "Payout {Payout.Id} approved",
|
DefaultEmail = new()
|
||||||
BodyExample = "Payout {Payout.Id} (Pull Payment Id: {Payout.PullPaymentId}) approved.",
|
{
|
||||||
|
Subject = "Payout {Payout.Id} approved",
|
||||||
|
Body = "Payout {Payout.Id} (Pull Payment Id: {Payout.PullPaymentId}) approved."
|
||||||
|
},
|
||||||
PlaceHolders = payoutPlaceholders
|
PlaceHolders = payoutPlaceholders
|
||||||
},
|
},
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Trigger = WebhookEventType.PayoutUpdated,
|
Trigger = WebhookEventType.PayoutUpdated,
|
||||||
Description = "Payout - Updated",
|
Description = "Payout - Updated",
|
||||||
SubjectExample = "Payout {Payout.Id} updated",
|
DefaultEmail = new()
|
||||||
BodyExample = "Payout {Payout.Id} (Pull Payment Id: {Payout.PullPaymentId}) updated.",
|
{
|
||||||
|
Subject = "Payout {Payout.Id} updated",
|
||||||
|
Body = "Payout {Payout.Id} (Pull Payment Id: {Payout.PullPaymentId}) updated."
|
||||||
|
},
|
||||||
PlaceHolders = payoutPlaceholders
|
PlaceHolders = payoutPlaceholders
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -238,82 +275,110 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
|
|||||||
{
|
{
|
||||||
Trigger = WebhookEventType.InvoiceCreated,
|
Trigger = WebhookEventType.InvoiceCreated,
|
||||||
Description = "Invoice - Created",
|
Description = "Invoice - Created",
|
||||||
SubjectExample = "Invoice {Invoice.Id} created",
|
DefaultEmail = new()
|
||||||
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) created.",
|
{
|
||||||
PlaceHolders = invoicePlaceholders,
|
Subject = "Invoice {Invoice.Id} created",
|
||||||
|
Body = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) created.",
|
||||||
CanIncludeCustomerEmail = true
|
CanIncludeCustomerEmail = true
|
||||||
},
|
},
|
||||||
|
PlaceHolders = invoicePlaceholders
|
||||||
|
},
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Trigger = WebhookEventType.InvoiceReceivedPayment,
|
Trigger = WebhookEventType.InvoiceReceivedPayment,
|
||||||
Description = "Invoice - Received Payment",
|
Description = "Invoice - Received Payment",
|
||||||
SubjectExample = "Invoice {Invoice.Id} received payment",
|
DefaultEmail = new()
|
||||||
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) received payment.",
|
{
|
||||||
PlaceHolders = invoicePlaceholders,
|
Subject = "Invoice {Invoice.Id} received payment",
|
||||||
|
Body = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) received payment.",
|
||||||
CanIncludeCustomerEmail = true
|
CanIncludeCustomerEmail = true
|
||||||
},
|
},
|
||||||
|
PlaceHolders = invoicePlaceholders
|
||||||
|
},
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Trigger = WebhookEventType.InvoiceProcessing,
|
Trigger = WebhookEventType.InvoiceProcessing,
|
||||||
Description = "Invoice - Is Processing",
|
Description = "Invoice - Is Processing",
|
||||||
SubjectExample = "Invoice {Invoice.Id} processing",
|
DefaultEmail = new()
|
||||||
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) is processing.",
|
{
|
||||||
PlaceHolders = invoicePlaceholders,
|
Subject = "Invoice {Invoice.Id} processing",
|
||||||
|
Body = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) is processing.",
|
||||||
CanIncludeCustomerEmail = true
|
CanIncludeCustomerEmail = true
|
||||||
},
|
},
|
||||||
|
PlaceHolders = invoicePlaceholders
|
||||||
|
},
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Trigger = WebhookEventType.InvoiceExpired,
|
Trigger = WebhookEventType.InvoiceExpired,
|
||||||
Description = "Invoice - Expired",
|
Description = "Invoice - Expired",
|
||||||
SubjectExample = "Invoice {Invoice.Id} expired",
|
DefaultEmail = new()
|
||||||
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) expired.",
|
{
|
||||||
PlaceHolders = invoicePlaceholders,
|
Subject = "Invoice {Invoice.Id} expired",
|
||||||
|
Body = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) expired.",
|
||||||
CanIncludeCustomerEmail = true
|
CanIncludeCustomerEmail = true
|
||||||
},
|
},
|
||||||
|
PlaceHolders = invoicePlaceholders
|
||||||
|
},
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Trigger = WebhookEventType.InvoiceSettled,
|
Trigger = WebhookEventType.InvoiceSettled,
|
||||||
Description = "Invoice - Is Settled",
|
Description = "Invoice - Is Settled",
|
||||||
SubjectExample = "Invoice {Invoice.Id} settled",
|
DefaultEmail = new()
|
||||||
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) is settled.",
|
{
|
||||||
PlaceHolders = invoicePlaceholders,
|
Subject = "Invoice {Invoice.Id} settled",
|
||||||
|
Body = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) is settled.",
|
||||||
CanIncludeCustomerEmail = true
|
CanIncludeCustomerEmail = true
|
||||||
},
|
},
|
||||||
|
PlaceHolders = invoicePlaceholders
|
||||||
|
},
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Trigger = WebhookEventType.InvoiceInvalid,
|
Trigger = WebhookEventType.InvoiceInvalid,
|
||||||
Description = "Invoice - Became Invalid",
|
Description = "Invoice - Became Invalid",
|
||||||
SubjectExample = "Invoice {Invoice.Id} invalid",
|
DefaultEmail = new()
|
||||||
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) invalid.",
|
{
|
||||||
PlaceHolders = invoicePlaceholders,
|
Subject = "Invoice {Invoice.Id} invalid",
|
||||||
|
Body = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) invalid.",
|
||||||
CanIncludeCustomerEmail = true
|
CanIncludeCustomerEmail = true
|
||||||
},
|
},
|
||||||
|
PlaceHolders = invoicePlaceholders
|
||||||
|
},
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Trigger = WebhookEventType.InvoicePaymentSettled,
|
Trigger = WebhookEventType.InvoicePaymentSettled,
|
||||||
Description = "Invoice - Payment Settled",
|
Description = "Invoice - Payment Settled",
|
||||||
SubjectExample = "Invoice {Invoice.Id} payment settled",
|
DefaultEmail = new()
|
||||||
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) payment settled.",
|
{
|
||||||
PlaceHolders = invoicePlaceholders,
|
Subject = "Invoice {Invoice.Id} payment settled",
|
||||||
|
Body = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) payment settled.",
|
||||||
CanIncludeCustomerEmail = true
|
CanIncludeCustomerEmail = true
|
||||||
},
|
},
|
||||||
|
PlaceHolders = invoicePlaceholders
|
||||||
|
},
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Trigger = WebhookEventType.InvoiceExpiredPaidPartial,
|
Trigger = WebhookEventType.InvoiceExpiredPaidPartial,
|
||||||
Description = "Invoice - Expired Paid Partial",
|
Description = "Invoice - Expired Paid Partial",
|
||||||
SubjectExample = "Invoice {Invoice.Id} expired with partial payment",
|
DefaultEmail = new()
|
||||||
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) expired with partial payment. \nPlease review and take appropriate action: {Invoice.Link}",
|
{
|
||||||
PlaceHolders = invoicePlaceholders,
|
Subject = "Invoice {Invoice.Id} expired with partial payment",
|
||||||
|
Body = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) expired with partial payment. \nPlease review and take appropriate action: {Invoice.Link}",
|
||||||
CanIncludeCustomerEmail = true
|
CanIncludeCustomerEmail = true
|
||||||
},
|
},
|
||||||
|
PlaceHolders = invoicePlaceholders,
|
||||||
|
|
||||||
|
},
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Trigger = WebhookEventType.InvoicePaidAfterExpiration,
|
Trigger = WebhookEventType.InvoicePaidAfterExpiration,
|
||||||
Description = "Invoice - Expired Paid Late",
|
Description = "Invoice - Expired Paid Late",
|
||||||
SubjectExample = "Invoice {Invoice.Id} paid after expiration",
|
DefaultEmail = new()
|
||||||
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) paid after expiration.",
|
{
|
||||||
PlaceHolders = invoicePlaceholders,
|
Subject = "Invoice {Invoice.Id} paid after expiration",
|
||||||
|
Body = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) paid after expiration.",
|
||||||
CanIncludeCustomerEmail = true
|
CanIncludeCustomerEmail = true
|
||||||
|
},
|
||||||
|
PlaceHolders = invoicePlaceholders
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
services.AddWebhookTriggerViewModels(emailTriggers);
|
services.AddWebhookTriggerViewModels(emailTriggers);
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ using Microsoft.Extensions.Logging;
|
|||||||
|
|
||||||
[assembly: InternalsVisibleTo("BTCPayServer.Tests")]
|
[assembly: InternalsVisibleTo("BTCPayServer.Tests")]
|
||||||
|
|
||||||
|
// This help JetBrains to find partial views referenced by views in plugins
|
||||||
|
[assembly: JetBrains.Annotations.AspMvcAreaPartialViewLocationFormat("/Plugins/{2}/Views/Shared/{0}.cshtml")]
|
||||||
|
|
||||||
namespace BTCPayServer
|
namespace BTCPayServer
|
||||||
{
|
{
|
||||||
class Program
|
class Program
|
||||||
@@ -55,6 +58,7 @@ namespace BTCPayServer
|
|||||||
// Uncomment this to see EF queries
|
// Uncomment this to see EF queries
|
||||||
//l.AddFilter("Microsoft.EntityFrameworkCore.Database.Command", LogLevel.Trace);
|
//l.AddFilter("Microsoft.EntityFrameworkCore.Database.Command", LogLevel.Trace);
|
||||||
l.AddFilter("Microsoft.EntityFrameworkCore.Migrations", LogLevel.Information);
|
l.AddFilter("Microsoft.EntityFrameworkCore.Migrations", LogLevel.Information);
|
||||||
|
l.AddFilter("BTCPayServer.Migrations", LogLevel.Information);
|
||||||
l.AddFilter("System.Net.Http.HttpClient", LogLevel.Critical);
|
l.AddFilter("System.Net.Http.HttpClient", LogLevel.Critical);
|
||||||
l.AddFilter("Microsoft.AspNetCore.Antiforgery.Internal", LogLevel.Critical);
|
l.AddFilter("Microsoft.AspNetCore.Antiforgery.Internal", LogLevel.Critical);
|
||||||
l.AddFilter("Fido2NetLib.DistributedCacheMetadataService", LogLevel.Error);
|
l.AddFilter("Fido2NetLib.DistributedCacheMetadataService", LogLevel.Error);
|
||||||
|
|||||||
@@ -47,11 +47,7 @@ namespace BTCPayServer.Services.Mails
|
|||||||
EmailSettings? GetCustomSettings(StoreData store)
|
EmailSettings? GetCustomSettings(StoreData store)
|
||||||
{
|
{
|
||||||
var emailSettings = store.GetStoreBlob().EmailSettings;
|
var emailSettings = store.GetStoreBlob().EmailSettings;
|
||||||
if (emailSettings?.IsComplete() is true)
|
return emailSettings?.IsComplete() is true ? emailSettings : null;
|
||||||
{
|
|
||||||
return emailSettings;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
using BTCPayServer.Abstractions.Contracts;
|
|
||||||
using BTCPayServer.Configuration;
|
|
||||||
using BTCPayServer.Controllers;
|
|
||||||
using BTCPayServer.Data;
|
|
||||||
using Microsoft.AspNetCore.Routing;
|
|
||||||
using Microsoft.Extensions.Localization;
|
|
||||||
|
|
||||||
namespace BTCPayServer.Services.Notifications.Blobs;
|
|
||||||
|
|
||||||
internal class InviteAcceptedNotification : BaseNotification
|
|
||||||
{
|
|
||||||
private const string TYPE = "inviteaccepted";
|
|
||||||
public string UserId { get; set; }
|
|
||||||
public string UserEmail { get; set; }
|
|
||||||
public string StoreId { get; set; }
|
|
||||||
public string StoreName { get; set; }
|
|
||||||
public override string Identifier => TYPE;
|
|
||||||
public override string NotificationType => TYPE;
|
|
||||||
|
|
||||||
public InviteAcceptedNotification()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public InviteAcceptedNotification(ApplicationUser user, StoreData store)
|
|
||||||
{
|
|
||||||
UserId = user.Id;
|
|
||||||
UserEmail = user.Email;
|
|
||||||
StoreId = store.Id;
|
|
||||||
StoreName = store.StoreName;
|
|
||||||
}
|
|
||||||
|
|
||||||
internal class Handler(LinkGenerator linkGenerator, BTCPayServerOptions options, IStringLocalizer stringLocalizer)
|
|
||||||
: NotificationHandler<InviteAcceptedNotification>
|
|
||||||
{
|
|
||||||
private IStringLocalizer StringLocalizer { get; } = stringLocalizer;
|
|
||||||
public override string NotificationType => TYPE;
|
|
||||||
public override (string identifier, string name)[] Meta
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
return [(TYPE, StringLocalizer["User accepted invitation"])];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void FillViewModel(InviteAcceptedNotification notification, NotificationViewModel vm)
|
|
||||||
{
|
|
||||||
vm.Identifier = notification.Identifier;
|
|
||||||
vm.Type = notification.NotificationType;
|
|
||||||
vm.StoreId = notification.StoreId;
|
|
||||||
vm.Body = StringLocalizer["User {0} accepted the invite to {1}.", notification.UserEmail, notification.StoreName];
|
|
||||||
vm.ActionLink = linkGenerator.GetPathByAction(nameof(UIStoresController.StoreUsers),
|
|
||||||
"UIStores",
|
|
||||||
new { storeId = notification.StoreId }, options.RootPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -48,10 +48,7 @@ namespace BTCPayServer.Services.Notifications
|
|||||||
}
|
}
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
foreach (string user in users)
|
_notificationManager.InvalidateNotificationCache(users);
|
||||||
{
|
|
||||||
_notificationManager.InvalidateNotificationCache(user);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public BaseNotification GetBaseNotification(NotificationData notificationData)
|
public BaseNotification GetBaseNotification(NotificationData notificationData)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ using MimeKit;
|
|||||||
namespace BTCPayServer
|
namespace BTCPayServer
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Validate address in the format "Firstname Lastname <blah@example.com>" See rfc822
|
/// Validate address in the format "Firstname Lastname <blah@example.com>" See rfc5322
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class MailboxAddressValidator
|
public class MailboxAddressValidator
|
||||||
{
|
{
|
||||||
@@ -25,7 +25,7 @@ namespace BTCPayServer
|
|||||||
public static MailboxAddress Parse(string? str)
|
public static MailboxAddress Parse(string? str)
|
||||||
{
|
{
|
||||||
if (!TryParse(str, out var mb))
|
if (!TryParse(str, out var mb))
|
||||||
throw new FormatException("Invalid mailbox address (rfc822)");
|
throw new FormatException("Invalid mailbox address (rfc5322)");
|
||||||
return mb;
|
return mb;
|
||||||
}
|
}
|
||||||
public static bool TryParse(string? str, [MaybeNullWhen(false)] out MailboxAddress mailboxAddress)
|
public static bool TryParse(string? str, [MaybeNullWhen(false)] out MailboxAddress mailboxAddress)
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
@using BTCPayServer.Client
|
|
||||||
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
|
||||||
@using BTCPayServer.Abstractions.TagHelpers
|
|
||||||
@using BTCPayServer.Models.ServerViewModels
|
|
||||||
@model BTCPayServer.Models.EmailsViewModel
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-xl-10 col-xxl-constrain">
|
|
||||||
@if (!ViewContext.ModelState.IsValid)
|
|
||||||
{
|
|
||||||
<div asp-validation-summary="All"></div>
|
|
||||||
}
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="d-flex flex-wrap gap-2 align-items-center justify-content-between">
|
|
||||||
<label asp-for="Settings.Server" class="form-label" text-translate="true">SMTP Server</label>
|
|
||||||
<div class="dropdown only-for-js mt-n2" id="quick-fill">
|
|
||||||
<button class="btn btn-link p-0 dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" id="QuickFillDropdownToggle" text-translate="true">
|
|
||||||
Quick Fill
|
|
||||||
</button>
|
|
||||||
<div class="dropdown-menu" aria-labelledby="QuickFillDropdownToggle">
|
|
||||||
<a class="dropdown-item" href="" data-server="smtp.gmail.com" data-port="587">Gmail.com</a>
|
|
||||||
<a class="dropdown-item" href="" data-server="mail.yahoo.com" data-port="587">Yahoo.com</a>
|
|
||||||
<a class="dropdown-item" href="" data-server="smtp.mailgun.org" data-port="587">Mailgun</a>
|
|
||||||
<a class="dropdown-item" href="" data-server="smtp.office365.com" data-port="587">Office365</a>
|
|
||||||
<a class="dropdown-item" href="" data-server="smtp.sendgrid.net" data-port="587">SendGrid</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<input asp-for="Settings.Server" data-fill="server" class="form-control" />
|
|
||||||
<span asp-validation-for="Settings.Server" class="text-danger"></span>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label asp-for="Settings.Port" class="form-label" text-translate="true">Port</label>
|
|
||||||
<input asp-for="Settings.Port" data-fill="port" class="form-control"/>
|
|
||||||
<span asp-validation-for="Settings.Port" class="text-danger"></span>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label asp-for="Settings.From" class="form-label" text-translate="true">Sender's Email Address</label>
|
|
||||||
<input asp-for="Settings.From" class="form-control" placeholder="@StringLocalizer["Firstname Lastname <email@example.com>"]" />
|
|
||||||
<span asp-validation-for="Settings.From" class="text-danger"></span>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label asp-for="Settings.Login" class="form-label" text-translate="true">Login</label>
|
|
||||||
<input asp-for="Settings.Login" class="form-control"/>
|
|
||||||
<div class="form-text" text-translate="true">For many email providers (like Gmail) your login is your email address.</div>
|
|
||||||
<span asp-validation-for="Settings.Login" class="text-danger"></span>
|
|
||||||
</div>
|
|
||||||
<div class="form-group" permission="@(Model is ServerEmailsViewModel ? Policies.CanModifyServerSettings : Policies.CanModifyStoreSettings)">
|
|
||||||
<label asp-for="Settings.Password" class="form-label" text-translate="true">Password</label>
|
|
||||||
@if (!Model.PasswordSet)
|
|
||||||
{
|
|
||||||
<input asp-for="Settings.Password" type="password" value="@Model.Settings.Password" class="form-control" />
|
|
||||||
<span asp-validation-for="Settings.Password" class="text-danger"></span>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<div class="input-group">
|
|
||||||
<input value="@StringLocalizer["Configured"]" type="text" readonly class="form-control"/>
|
|
||||||
<button type="submit" class="btn btn-danger" name="command" value="ResetPassword" id="ResetPassword" text-translate="true">Reset</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<input asp-for="PasswordSet" type="hidden"/>
|
|
||||||
<div class="my-4">
|
|
||||||
<button class="d-inline-flex align-items-center btn btn-link text-primary fw-semibold p-0" type="button" id="AdvancedSettingsButton" data-bs-toggle="collapse" data-bs-target="#AdvancedSettings" aria-expanded="false" aria-controls="AdvancedSettings">
|
|
||||||
<vc:icon symbol="caret-down"/>
|
|
||||||
<span class="ms-1" text-translate="true">Advanced settings</span>
|
|
||||||
</button>
|
|
||||||
<div id="AdvancedSettings" class="collapse">
|
|
||||||
<div class="pt-3 pb-1">
|
|
||||||
<div class="d-flex">
|
|
||||||
<input asp-for="Settings.EnabledCertificateCheck" type="checkbox" class="btcpay-toggle me-3" />
|
|
||||||
<label asp-for="Settings.EnabledCertificateCheck" class="form-check-label" text-translate="true">TLS certificate security checks</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
|
||||||
delegate('click', '#quick-fill .dropdown-menu a', function (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const data = e.target.dataset;
|
|
||||||
Object.keys(data).forEach(function (key) {
|
|
||||||
const value = data[key];
|
|
||||||
const input = document.querySelector('input[data-fill="' + key + '"]');
|
|
||||||
if (input) {
|
|
||||||
const type = input.getAttribute('type');
|
|
||||||
if (type === 'checkbox') {
|
|
||||||
input.checked = value;
|
|
||||||
} else {
|
|
||||||
input.value = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
@model BTCPayServer.Models.EmailsViewModel
|
|
||||||
|
|
||||||
<h3 class="my-3" text-translate="true">Testing</h3>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-xl-10 col-xxl-constrain">
|
|
||||||
<div class="form-group">
|
|
||||||
<label asp-for="TestEmail" class="form-label" text-translate="true">To test your settings, enter an email address</label>
|
|
||||||
<input asp-for="TestEmail" placeholder="@StringLocalizer["Firstname Lastname <email@example.com>"]" class="form-control" />
|
|
||||||
<span asp-validation-for="TestEmail" class="text-danger"></span>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-secondary mt-2" name="command" value="Test" id="Test" text-translate="true">Send Test Email</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
<span asp-validation-for="Email" class="text-danger"></span>
|
<span asp-validation-for="Email" class="text-danger"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group mt-4">
|
<div class="form-group mt-4">
|
||||||
<button type="submit" class="btn btn-primary btn-lg w-100" text-translate="true">Submit</button>
|
<button id="page-primary" type="submit" class="btn btn-primary btn-lg w-100" text-translate="true">Submit</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
@model ServerEmailsViewModel
|
|
||||||
@{
|
|
||||||
ViewData.SetLayoutModel(new LayoutModel("Server-" + nameof(ServerNavPages.Emails), StringLocalizer["Emails"]).SetCategory(WellKnownCategories.Server));
|
|
||||||
}
|
|
||||||
|
|
||||||
<form method="post" autocomplete="off">
|
|
||||||
<div class="sticky-header">
|
|
||||||
<h2>@ViewData["Title"]</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" />
|
|
||||||
<div class="form-group mb-4">
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<input asp-for="EnableStoresToUseServerEmailSettings" type="checkbox" class="btcpay-toggle me-3"/>
|
|
||||||
<div>
|
|
||||||
<label asp-for="EnableStoresToUseServerEmailSettings" class="form-check-label"></label>
|
|
||||||
<div class="text-muted">
|
|
||||||
<span text-translate="true">This can be overridden at the Store level.</span>
|
|
||||||
<a href="https://docs.btcpayserver.org/Notifications/#server-emails" target="_blank" rel="noreferrer noopener">
|
|
||||||
<vc:icon symbol="info" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<partial name="EmailsBody" model="Model" />
|
|
||||||
<partial name="EmailsTest" model="Model" />
|
|
||||||
</form>
|
|
||||||
|
|
||||||
@section PageFootContent {
|
|
||||||
<partial name="_ValidationScriptsPartial" />
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user