Starting dedicated pages for Email Rules

This commit is contained in:
rockstardev
2025-02-25 01:31:04 -06:00
parent 0c8b0d1dd3
commit f0b0aa0c89
5 changed files with 396 additions and 146 deletions

View File

@@ -19,154 +19,15 @@ namespace BTCPayServer.Controllers;
public partial class UIStoresController
{
[HttpGet("{storeId}/emails")]
public async Task<IActionResult> StoreEmails(string storeId)
{
var store = HttpContext.GetStoreData();
if (store == null)
return NotFound();
var configured = await _emailSenderFactory.IsComplete(store.Id);
if (!configured && !TempData.HasStatusMessage())
{
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Warning,
Html = "You need to configure email settings before this feature works." +
$" <a class='alert-link' href='{Url.Action("StoreEmailSettings", new { storeId })}'>Configure store email settings</a>."
});
}
var vm = new StoreEmailRuleViewModel { Rules = store.GetStoreBlob().EmailRules ?? [] };
return View(vm);
}
[HttpPost("{storeId}/emails")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> StoreEmails(string storeId, StoreEmailRuleViewModel vm, string command)
{
vm.Rules ??= [];
int commandIndex = 0;
var indSep = command.Split(':', StringSplitOptions.RemoveEmptyEntries);
if (indSep.Length > 1)
{
commandIndex = int.Parse(indSep[1], CultureInfo.InvariantCulture);
}
if (command.StartsWith("remove", StringComparison.InvariantCultureIgnoreCase))
{
vm.Rules.RemoveAt(commandIndex);
}
else if (command == "add")
{
vm.Rules.Add(new StoreEmailRule());
return View(vm);
}
for (var i = 0; i < vm.Rules.Count; i++)
{
var rule = vm.Rules[i];
if (!string.IsNullOrEmpty(rule.To) && rule.To.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Any(s => !MailboxAddressValidator.TryParse(s, out _)))
{
ModelState.AddModelError($"{nameof(vm.Rules)}[{i}].{nameof(rule.To)}",
StringLocalizer["Invalid mailbox address provided. Valid formats are: '{0}' or '{1}'", "test@example.com", "Firstname Lastname <test@example.com>"]);
}
else if (!rule.CustomerEmail && string.IsNullOrEmpty(rule.To))
ModelState.AddModelError($"{nameof(vm.Rules)}[{i}].{nameof(rule.To)}",
StringLocalizer["Either recipient or \"Send the email to the buyer\" is required"]);
}
if (!ModelState.IsValid)
{
return View(vm);
}
var store = HttpContext.GetStoreData();
if (store == null)
return NotFound();
string message = "";
// update rules
var blob = store.GetStoreBlob();
blob.EmailRules = vm.Rules;
if (store.SetStoreBlob(blob))
{
await _storeRepo.UpdateStore(store);
message += StringLocalizer["Store email rules saved."] + " ";
}
if (command.StartsWith("test", StringComparison.InvariantCultureIgnoreCase))
{
try
{
var rule = vm.Rules[commandIndex];
if (await _emailSenderFactory.IsComplete(store.Id))
{
var recipients = rule.To.Split(",", StringSplitOptions.RemoveEmptyEntries)
.Select(o =>
{
MailboxAddressValidator.TryParse(o, out var mb);
return mb;
})
.Where(o => o != null)
.ToArray();
var emailSender = await _emailSenderFactory.GetEmailSender(store.Id);
emailSender.SendEmail(recipients.ToArray(), null, null, $"[TEST] {rule.Subject}", rule.Body);
message += StringLocalizer["Test email sent — please verify you received it."];
}
else
{
message += StringLocalizer["Complete the email setup to send test emails."];
}
}
catch (Exception ex)
{
TempData[WellKnownTempData.ErrorMessage] = message + StringLocalizer["Error sending test email: {0}", ex.Message].Value;
return RedirectToAction("StoreEmails", new { storeId });
}
}
if (!string.IsNullOrEmpty(message))
{
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Success,
Message = message
});
}
return RedirectToAction("StoreEmails", new { storeId });
}
public class StoreEmailRuleViewModel
{
public List<StoreEmailRule> Rules { get; set; }
}
public class StoreEmailRule
{
[Required]
public string Trigger { get; set; }
public bool CustomerEmail { get; set; }
public string To { get; set; }
[Required]
public string Subject { get; set; }
[Required]
public string Body { get; set; }
}
// public class StoreEmailRuleViewModel
// {
// public List<StoreEmailRule> Rules { get; set; }
// }
[HttpGet("{storeId}/email-settings")]
public async Task<IActionResult> StoreEmailSettings(string storeId)
{

View File

@@ -0,0 +1,169 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Client;
using BTCPayServer.Data;
using BTCPayServer.Models;
using BTCPayServer.Services.Mails;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace BTCPayServer.Controllers
{
[Authorize(Policy = Policies.CanModifyStoreSettings)]
public partial class UIStoresController
{
[HttpGet("{storeId}/emails/rules")]
public IActionResult EmailRulesIndex(string storeId)
{
var store = HttpContext.GetStoreData();
if (store == null) return NotFound();
var rules = store.GetStoreBlob().EmailRules ?? new List<StoreEmailRule>();
return View("StoreEmailRulesList", rules);
}
[HttpGet("{storeId}/emails/rules/create")]
public IActionResult EmailRulesCreate(string storeId)
{
return View("StoreEmailRulesManage", new StoreEmailRuleViewModel { StoreId = storeId });
}
[HttpPost("{storeId}/emails/rules/create")]
public async Task<IActionResult> EmailRulesCreate(string storeId, StoreEmailRuleViewModel model)
{
if (!ModelState.IsValid)
return View("StoreEmailRulesManage", model);
var store = await _storeRepo.FindStore(storeId);
if (store == null) return NotFound();
var blob = store.GetStoreBlob();
var rulesList = blob.EmailRules ?? new List<StoreEmailRule>();
rulesList.Add(new StoreEmailRule
{
Trigger = model.Trigger,
CustomerEmail = model.CustomerEmail,
To = model.To,
Subject = model.Subject,
Body = model.Body
});
blob.EmailRules = rulesList;
store.SetStoreBlob(blob);
await _storeRepo.UpdateStore(store);
return RedirectToAction(nameof(EmailRulesIndex), new { storeId });
}
[HttpGet("{storeId}/emails/rules/{ruleIndex}/edit")]
public IActionResult EmailRulesEdit(string storeId, int ruleIndex)
{
var store = HttpContext.GetStoreData();
if (store == null) return NotFound();
var rules = store.GetStoreBlob().EmailRules;
if (rules == null || ruleIndex >= rules.Count) return NotFound();
var rule = rules[ruleIndex];
return View("StoreEmailRulesManage", new StoreEmailRuleViewModel
{
StoreId = storeId,
Trigger = rule.Trigger,
CustomerEmail = rule.CustomerEmail,
To = rule.To,
Subject = rule.Subject,
Body = rule.Body
});
}
[HttpPost("{storeId}/emails/rules/{ruleIndex}/edit")]
public async Task<IActionResult> EmailRulesEdit(string storeId, int ruleIndex, StoreEmailRuleViewModel model)
{
if (!ModelState.IsValid)
return View("StoreEmailRulesManage", model);
var store = await _storeRepo.FindStore(storeId);
if (store == null) return NotFound();
var blob = store.GetStoreBlob();
if (blob.EmailRules == null || ruleIndex >= blob.EmailRules.Count) return NotFound();
var rule = blob.EmailRules[ruleIndex];
rule.Trigger = model.Trigger;
rule.CustomerEmail = model.CustomerEmail;
rule.To = model.To;
rule.Subject = model.Subject;
rule.Body = model.Body;
store.SetStoreBlob(blob);
await _storeRepo.UpdateStore(store);
return RedirectToAction(nameof(EmailRulesIndex), new { storeId });
}
[HttpPost("{storeId}/emails/rules/{ruleIndex}/delete")]
public async Task<IActionResult> EmailRulesDelete(string storeId, int ruleIndex)
{
var store = await _storeRepo.FindStore(storeId);
if (store == null) return NotFound();
var blob = store.GetStoreBlob();
if (blob.EmailRules == null || ruleIndex >= blob.EmailRules.Count) return NotFound();
blob.EmailRules.RemoveAt(ruleIndex);
store.SetStoreBlob(blob);
await _storeRepo.UpdateStore(store);
return RedirectToAction(nameof(Index), new { storeId });
}
public class StoreEmailRuleViewModel
{
public string StoreId { get; set; }
[Required]
public string Trigger { get; set; }
public bool CustomerEmail { get; set; }
public string To { get; set; }
[Required]
public string Subject { get; set; }
[Required]
public string Body { get; set; }
}
public class StoreEmailRule
{
[Required]
public string Trigger { get; set; }
public bool CustomerEmail { get; set; }
public string To { get; set; }
[Required]
public string Subject { get; set; }
[Required]
public string Body { get; set; }
}
}
}

View File

@@ -0,0 +1,60 @@
@using BTCPayServer.Client
@using BTCPayServer.TagHelpers
@using Microsoft.AspNetCore.Mvc.TagHelpers
@model List<BTCPayServer.Controllers.UIStoresController.StoreEmailRule>
@{
var storeId = Context.GetStoreData().Id;
ViewData.SetActivePage(StoreNavPages.Emails, StringLocalizer["Email Rules"], storeId);
}
<div class="sticky-header">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a asp-action="StoreEmailSettings" asp-route-storeId="@storeId" text-translate="true">Emails</a>
</li>
<li class="breadcrumb-item active" aria-current="page">@ViewData["Title"]</li>
</ol>
<h2>@ViewData["Title"]</h2>
</nav>
<a id="page-primary" permission="@Policies.CanCreateNonApprovedPullPayments" asp-action="EmailRulesCreate" asp-route-storeId="@storeId"
class="btn btn-primary" role="button">
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>
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Trigger</th>
<th>Customer Email</th>
<th>To</th>
<th>Subject</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var rule in Model.Select((value, index) => new { value, index }))
{
<tr>
<td>@rule.value.Trigger</td>
<td>@(rule.value.CustomerEmail ? "Yes" : "No")</td>
<td>@rule.value.To</td>
<td>@rule.value.Subject</td>
<td>
<a asp-action="EmailRulesEdit" asp-route-storeId="@ViewBag.StoreId" asp-route-ruleIndex="@rule.index" class="btn btn-primary">Edit</a>
<form asp-action="EmailRulesDelete" asp-route-storeId="@ViewBag.StoreId" asp-route-ruleIndex="@rule.index" method="post" style="display:inline;">
<button type="submit" class="btn btn-danger">Delete</button>
</form>
</td>
</tr>
}
</tbody>
</table>
</div>

View File

@@ -0,0 +1,160 @@
@using BTCPayServer.HostedServices.Webhooks
@using Microsoft.AspNetCore.Mvc.TagHelpers
@model BTCPayServer.Controllers.UIStoresController.StoreEmailRuleViewModel
@inject WebhookSender WebhookSender
@{
bool isEdit = Model.Trigger != null;
ViewData["Title"] = isEdit ? "Edit Store Email Rule" : "Create Store Email Rule";
}
@section PageHeadContent {
<link href="~/vendor/summernote/summernote-bs5.css" rel="stylesheet" asp-append-version="true" />
}
<form asp-action="@(isEdit ? "EmailRulesEdit" : "EmailRulesCreate")" asp-route-storeId="@Model.StoreId" method="post">
<div class="sticky-header">
<h2 text-translate="true">@ViewData["Title"]</h2>
<div>
<button id="page-primary" type="submit" class="btn btn-primary">Save</button>
<a asp-action="EmailRulesIndex" asp-route-storeId="@Model.StoreId" class="btn btn-secondary">Cancel</a>
</div>
</div>
<partial name="_StatusMessage" />
<div class="form-group">
<label asp-for="Trigger" class="form-label" data-required></label>
<select asp-for="Trigger" asp-items="@WebhookSender.GetSupportedWebhookTypes().Select(s => new SelectListItem(s.Value, s.Key))" class="form-select email-rule-trigger" required></select>
<span asp-validation-for="Trigger" class="text-danger"></span>
<div class="form-text" text-translate="true">Choose what event sends the email.</div>
</div>
<div class="form-group">
<label asp-for="To" class="form-label">Recipients</label>
<input type="text" asp-for="To" class="form-control email-rule-to" />
<span asp-validation-for="To" class="text-danger"></span>
<div class="form-text" text-translate="true">Who to send the email to. For multiple emails, separate with a comma.</div>
</div>
<div class="form-check mb-4">
<input asp-for="CustomerEmail" type="checkbox" class="form-check-input email-rule-customer-email" />
<label asp-for="CustomerEmail" class="form-check-label" text-translate="true">Send the email to the buyer, if email was provided to the invoice</label>
<span asp-validation-for="CustomerEmail" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Subject" class="form-label" data-required></label>
<input type="text" asp-for="Subject" class="form-control email-rule-subject" />
<span asp-validation-for="Subject" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Body" class="form-label" data-required></label>
<textarea asp-for="Body" class="form-control richtext email-rule-body" rows="4"></textarea>
<span asp-validation-for="Body" class="text-danger"></span>
<div class="form-text rounded bg-light p-2">
<table class="table table-sm caption-top m-0">
<caption class="text-muted p-0" text-translate="true">Placeholders</caption>
<tr>
<th text-translate="true">Invoice</th>
<td>
<code>{Invoice.Id}</code>,
<code>{Invoice.StoreId}</code>,
<code>{Invoice.Price}</code>,
<code>{Invoice.Currency}</code>,
<code>{Invoice.Status}</code>,
<code>{Invoice.AdditionalStatus}</code>,
<code>{Invoice.OrderId}</code>
<code>{Invoice.Metadata}*</code>
</td>
</tr>
<tr>
<th text-translate="true">Request</th>
<td>
<code>{PaymentRequest.Id}</code>,
<code>{PaymentRequest.Price}</code>,
<code>{PaymentRequest.Currency}</code>,
<code>{PaymentRequest.Title}</code>,
<code>{PaymentRequest.Description}</code>,
<code>{PaymentRequest.Status}</code>
<code>{PaymentRequest.FormResponse}*</code>
</td>
</tr>
<tr>
<th text-translate="true">Payout</th>
<td>
<code>{Payout.Id}</code>,
<code>{Payout.PullPaymentId}</code>,
<code>{Payout.Destination}</code>,
<code>{Payout.State}</code>
<code>{Payout.Metadata}*</code>
</td>
</tr>
<tr><td colspan="2">* These fields are JSON objects. You can access properties within them using <a href="https://www.newtonsoft.com/json/help/html/SelectToken.htm#SelectTokenJSONPath" rel="noreferrer noopener" target="_blank">this syntax</a>. One example is <code>{Invoice.Metadata.itemCode}</code></td></tr>
</table>
</div>
</div>
</form>
@section PageFootContent {
<partial name="_ValidationScriptsPartial" />
<script src="~/vendor/summernote/summernote-bs5.js" asp-append-version="true"></script>
<script>
(function () {
const templates = {
InvoiceCreated: {
subject: 'Invoice {Invoice.Id} created',
body: 'Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) created.'
},
InvoiceReceivedPayment: {
subject: 'Invoice {Invoice.Id} received payment',
body: 'Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) received payment.'
},
InvoiceProcessing: {
subject: 'Invoice {Invoice.Id} processing',
body: 'Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) is processing.'
},
InvoiceExpired: {
subject: 'Invoice {Invoice.Id} expired',
body: 'Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) expired.'
},
InvoiceSettled: {
subject: 'Invoice {Invoice.Id} settled',
body: 'Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) is settled.'
},
InvoiceInvalid: {
subject: 'Invoice {Invoice.Id} invalid',
body: 'Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) invalid.'
},
InvoicePaymentSettled: {
subject: 'Invoice {Invoice.Id} payment settled',
body: 'Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) payment settled.'
},
};
const isEmptyOrDefault = (value, type) => {
const val = value.replace(/<.*?>/gi, '').trim()
if (!val) return true;
return Object.values(templates).find(t => t[type] === val) != null;
}
const applyDefault = $trigger => {
const $emailRule = $trigger.closest('.email-rule');
const $subject = $emailRule.querySelector('.email-rule-subject');
const $body = $emailRule.querySelector('.email-rule-body');
const rule = $trigger.querySelector(`option[value='${$trigger.value}']`).innerText;
const { subject, body } = templates[rule];
if (isEmptyOrDefault($subject.value, 'subject') && subject) {
$subject.value = subject;
}
if (isEmptyOrDefault($body.value, 'body') && body) {
$($body).summernote('reset');
$($body).summernote('insertText', body);
}
}
delegate('change', '.email-rule-trigger', (e) => { applyDefault(e.target); })
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.email-rule-trigger').forEach(applyDefault);
});
})();
</script>
}

View File

@@ -38,7 +38,7 @@
<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 class="btn btn-secondary" asp-action="StoreEmails" asp-controller="UIStores" asp-route-storeId="@storeId" id="ConfigureEmailRules" permission="@Policies.CanModifyStoreSettings" text-translate="true">
<a class="btn btn-secondary" asp-action="EmailRulesIndex" asp-controller="UIStores" asp-route-storeId="@storeId" id="ConfigureEmailRules" permission="@Policies.CanModifyStoreSettings" text-translate="true">
Configure
</a>
</div>