Add unsubscribe link validation and related enhancements
- Introduced `ValidateUnsubscribeLink` endpoint in `MailingsController`. - Refactored `UnsubscribeListsController` to inherit from `BaseController`. - Added `Url` property to `UnsubscribeList` and updated related mappings. - Enhanced `IMailingService` and `MailingService` for unsubscribe link handling. - Implemented validation logic in `MailingEdit.tsx` with confirmation dialogs. - Updated `TemplateEdit.tsx` for inserting unsubscribe links in templates. - Improved sorting functionality in `CompletedMailings` and `ScheduledMailings`. - Added `UnsubscribeValidationResult` interface for validation results.
This commit is contained in:
parent
651b33171b
commit
9f6b1ae74c
@ -82,6 +82,15 @@ namespace Surge365.MassEmailReact.API.Controllers
|
||||
return template is not null ? Ok(template) : NotFound($"Mailing template with id '{id}' not found.");
|
||||
}
|
||||
|
||||
[HttpPost("validate-unsubscribe")]
|
||||
public async Task<IActionResult> ValidateUnsubscribeLink([FromBody] MailingUpdateDto mailingUpdateDto)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(mailingUpdateDto);
|
||||
|
||||
var validationResult = await _mailingService.ValidateUnsubscribeLinkAsync(mailingUpdateDto);
|
||||
return Ok(validationResult);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> CreateMailing([FromBody] MailingUpdateDto mailingUpdateDto)
|
||||
{
|
||||
@ -118,7 +127,7 @@ namespace Surge365.MassEmailReact.API.Controllers
|
||||
if (existingMailing == null)
|
||||
return NotFound($"Mailing with Id {id} not found");
|
||||
|
||||
var success = await _mailingService.UpdateAsync(mailingUpdateDto);
|
||||
var success = await _mailingService.UpdateAsync(mailingUpdateDto);
|
||||
if (!success)
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, "Failed to update mailing.");
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Surge365.Core.Controllers;
|
||||
using Surge365.MassEmailReact.Application.DTOs;
|
||||
using Surge365.MassEmailReact.Application.Interfaces;
|
||||
using System;
|
||||
@ -7,10 +8,7 @@ using System.Threading.Tasks;
|
||||
|
||||
namespace Surge365.MassEmailReact.API.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize]
|
||||
public class UnsubscribeListsController : ControllerBase
|
||||
public class UnsubscribeListsController : BaseController
|
||||
{
|
||||
private readonly IUnsubscribeListService _unsubscribeListService;
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ namespace Surge365.MassEmailReact.Application.DTOs
|
||||
public string UnsubscribeListCode { get; set; } = "";
|
||||
public string FriendlyName { get; set; } = "";
|
||||
public string? FriendlyDescription { get; set; }
|
||||
public string? Url { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
public short DisplayOrder { get; set; } = 0;
|
||||
}
|
||||
|
||||
@ -0,0 +1,21 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Surge365.MassEmailReact.Application.DTOs
|
||||
{
|
||||
public class UnsubscribeValidationResult
|
||||
{
|
||||
public bool IsValid { get; set; }
|
||||
public string ValidationMessage { get; set; } = "";
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public UnsubscribeValidationType ValidationType { get; set; }
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum UnsubscribeValidationType
|
||||
{
|
||||
Valid,
|
||||
HasUnsubscribeListButNoUrlInTemplate,
|
||||
HasUrlInTemplateButNoUnsubscribeList
|
||||
}
|
||||
}
|
||||
@ -23,5 +23,6 @@ namespace Surge365.MassEmailReact.Application.Interfaces
|
||||
Task<bool> UpdateAsync(MailingUpdateDto mailingDto);
|
||||
Task<bool> CancelMailingAsync(int id);
|
||||
Task<bool> TestMailing(MailingUpdateDto mailingUpdateDto, List<string> emails);
|
||||
Task<UnsubscribeValidationResult> ValidateUnsubscribeLinkAsync(MailingUpdateDto mailingDto);
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
using Surge365.MassEmailReact.Domain.Entities;
|
||||
using Surge365.MassEmailReact.Application.DTOs;
|
||||
using Surge365.MassEmailReact.Domain.Entities;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ namespace Surge365.MassEmailReact.Domain.Entities
|
||||
public string UnsubscribeListCode { get; private set; } = "";
|
||||
public string FriendlyName { get; set; } = "";
|
||||
public string? FriendlyDescription { get; set; }
|
||||
public string? Url { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
public short DisplayOrder { get; set; } = 0;
|
||||
public DateTime CreateDate { get; set; } = DateTime.Now;
|
||||
@ -15,11 +16,12 @@ namespace Surge365.MassEmailReact.Domain.Entities
|
||||
public UnsubscribeList() { }
|
||||
|
||||
private UnsubscribeList(string unsubscribeListCode, string friendlyName, string? friendlyDescription,
|
||||
bool isActive, short displayOrder, DateTime createDate, DateTime updateDate)
|
||||
string? url, bool isActive, short displayOrder, DateTime createDate, DateTime updateDate)
|
||||
{
|
||||
UnsubscribeListCode = unsubscribeListCode;
|
||||
FriendlyName = friendlyName;
|
||||
FriendlyDescription = friendlyDescription;
|
||||
Url = url;
|
||||
IsActive = isActive;
|
||||
DisplayOrder = displayOrder;
|
||||
CreateDate = createDate;
|
||||
@ -27,9 +29,9 @@ namespace Surge365.MassEmailReact.Domain.Entities
|
||||
}
|
||||
|
||||
public static UnsubscribeList Create(string unsubscribeListCode, string friendlyName, string? friendlyDescription,
|
||||
bool isActive, short displayOrder, DateTime createDate, DateTime updateDate)
|
||||
string? url, bool isActive, short displayOrder, DateTime createDate, DateTime updateDate)
|
||||
{
|
||||
return new UnsubscribeList(unsubscribeListCode, friendlyName, friendlyDescription, isActive,
|
||||
return new UnsubscribeList(unsubscribeListCode, friendlyName, friendlyDescription, url, isActive,
|
||||
displayOrder, createDate, updateDate);
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ namespace Surge365.MassEmailReact.Infrastructure.EntityMaps
|
||||
Map(u => u.UnsubscribeListCode).ToColumn("unsubscribe_list_code");
|
||||
Map(u => u.FriendlyName).ToColumn("friendly_name");
|
||||
Map(u => u.FriendlyDescription).ToColumn("friendly_description");
|
||||
Map(u => u.Url).ToColumn("url");
|
||||
Map(u => u.IsActive).ToColumn("is_active");
|
||||
Map(u => u.DisplayOrder).ToColumn("display_order");
|
||||
Map(u => u.CreateDate).ToColumn("create_date");
|
||||
|
||||
@ -79,6 +79,7 @@ namespace Surge365.MassEmailReact.Infrastructure.Repositories
|
||||
new SqlParameter("@unsubscribe_list_code", unsubscribeList.UnsubscribeListCode),
|
||||
new SqlParameter("@friendly_name", unsubscribeList.FriendlyName),
|
||||
new SqlParameter("@friendly_description", unsubscribeList.FriendlyDescription ?? (object)DBNull.Value),
|
||||
new SqlParameter("@url", unsubscribeList.Url ?? (object)DBNull.Value),
|
||||
new SqlParameter("@is_active", unsubscribeList.IsActive),
|
||||
new SqlParameter("@display_order", unsubscribeList.DisplayOrder),
|
||||
pmSuccess
|
||||
@ -107,6 +108,7 @@ namespace Surge365.MassEmailReact.Infrastructure.Repositories
|
||||
new SqlParameter("@unsubscribe_list_code", unsubscribeList.UnsubscribeListCode),
|
||||
new SqlParameter("@friendly_name", unsubscribeList.FriendlyName),
|
||||
new SqlParameter("@friendly_description", unsubscribeList.FriendlyDescription ?? (object)DBNull.Value),
|
||||
new SqlParameter("@url", unsubscribeList.Url ?? (object)DBNull.Value),
|
||||
new SqlParameter("@is_active", unsubscribeList.IsActive),
|
||||
new SqlParameter("@display_order", unsubscribeList.DisplayOrder),
|
||||
pmSuccess
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Surge365.MassEmailReact.Application.DTOs;
|
||||
using Surge365.MassEmailReact.Application.Interfaces;
|
||||
using Surge365.MassEmailReact.Domain.Entities;
|
||||
@ -20,13 +20,14 @@ namespace Surge365.MassEmailReact.Infrastructure.Services
|
||||
private readonly ITargetService _targetService;
|
||||
private readonly ITemplateService _templateService;
|
||||
private readonly IEmailDomainService _emailDomainService;
|
||||
private readonly IUnsubscribeListService _unsubscribeListService;
|
||||
private readonly IMailingRepository _mailingRepository;
|
||||
private readonly IConfiguration _config;
|
||||
private string DefaultUnsubscribeUrl
|
||||
{
|
||||
get
|
||||
{
|
||||
return _config["DefaultUnsubscribeUrl"] ?? "";
|
||||
return _config["MassEmail_UnsubscribeProcessingUrl"] ?? "";
|
||||
}
|
||||
}
|
||||
private bool SendGridTestMode
|
||||
@ -43,13 +44,14 @@ namespace Surge365.MassEmailReact.Infrastructure.Services
|
||||
return _config["RegularExpression_Email"] ?? "";
|
||||
}
|
||||
}
|
||||
public MailingService(IHttpClientFactory httpClientFactory, IMailingRepository mailingRepository, ITargetService targetService, ITemplateService templateService, IEmailDomainService emailDomainService, IConfiguration config)
|
||||
public MailingService(IHttpClientFactory httpClientFactory, IMailingRepository mailingRepository, ITargetService targetService, ITemplateService templateService, IEmailDomainService emailDomainService, IUnsubscribeListService unsubscribeListService, IConfiguration config)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_mailingRepository = mailingRepository;
|
||||
_targetService = targetService;
|
||||
_templateService = templateService;
|
||||
_emailDomainService = emailDomainService;
|
||||
_unsubscribeListService = unsubscribeListService;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
@ -177,11 +179,11 @@ namespace Surge365.MassEmailReact.Infrastructure.Services
|
||||
return false;
|
||||
|
||||
foreach (string email in emails)
|
||||
await SendTestEmailLocal(template, domain, targetSample, email);
|
||||
await SendTestEmailLocal(template, domain, targetSample, email, mailing.UnsubscribeListCode);
|
||||
|
||||
return true;
|
||||
}
|
||||
private async Task<bool> SendTestEmailLocal(Template template, EmailDomain emailDomain, TargetSample targetSample, string emailAddress)
|
||||
private async Task<bool> SendTestEmailLocal(Template template, EmailDomain emailDomain, TargetSample targetSample, string emailAddress, string? unsubscribeListCode = null)
|
||||
{
|
||||
string html = template.HtmlBody;
|
||||
string tokenTemplate = "(?i)##{0}##";
|
||||
@ -197,7 +199,7 @@ namespace Surge365.MassEmailReact.Infrastructure.Services
|
||||
toName = r.Replace(toName, targetSample.Rows[0][columnName]);
|
||||
}
|
||||
html = RemoveOpenTag(html);
|
||||
html = MergeWithUnsubscribe(html, template);
|
||||
html = await MergeWithUnsubscribeAsync(html, template, unsubscribeListCode, emailAddress, null);
|
||||
return await SendEmailLocal(emailDomain, emailAddress, toName, html, subject, template.FromName, template.FromEmail, template.ReplyToEmail, new List<string>(), null);
|
||||
}
|
||||
|
||||
@ -207,27 +209,64 @@ namespace Surge365.MassEmailReact.Infrastructure.Services
|
||||
html = r.Replace(html, "");
|
||||
return html;
|
||||
}
|
||||
private string MergeWithUnsubscribe(string html, Template template)
|
||||
private async Task<string> MergeWithUnsubscribeAsync(string html, Template template, string? unsubscribeListCode = null, string? emailAddress = null, int? blastEmailID = null)
|
||||
{
|
||||
string url = DefaultUnsubscribeUrl;
|
||||
//if (template.UnsubscribeUrl != null)
|
||||
//{
|
||||
// url = template.UnsubscribeUrl.Url;
|
||||
//}
|
||||
//else if (blast.BlastTemplate != null && blast.BlastTemplate.BlastUnsubscribeUrl != null)
|
||||
//{
|
||||
// url = blast.BlastTemplate.BlastUnsubscribeUrl.Url;
|
||||
//}
|
||||
//else if (blast.BlastTemplate != null && blast.BlastTemplate.UnsubscribeUrl != null)
|
||||
//{
|
||||
// url = blast.BlastTemplate.UnsubscribeUrl.Url;
|
||||
//}
|
||||
//else
|
||||
//{
|
||||
// url = Utilities.DefaultUnsubscribeUrl;
|
||||
//}
|
||||
Regex r = new Regex("(?i)##unsubscribeurl##", RegexOptions.IgnoreCase);
|
||||
html = r.Replace(html, url);
|
||||
string baseUrl = ""; // Default to empty, will use default only if list found without URL.
|
||||
|
||||
// If unsubscribe list is provided, try to get its URL
|
||||
if (!string.IsNullOrWhiteSpace(unsubscribeListCode))
|
||||
{
|
||||
var unsubscribeList = await _unsubscribeListService.GetByCodeAsync(unsubscribeListCode);
|
||||
if (unsubscribeList != null && !string.IsNullOrWhiteSpace(unsubscribeList.Url))
|
||||
{
|
||||
baseUrl = unsubscribeList.Url;
|
||||
}
|
||||
else if(unsubscribeList != null)
|
||||
{
|
||||
baseUrl = DefaultUnsubscribeUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// Format the URL with the required parameters: {baseUrl}?emailid={blastEmailID ?? 0}&emailaddress={emailAddress}
|
||||
string url = "";
|
||||
if (!string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
var uriBuilder = new UriBuilder(baseUrl);
|
||||
var query = System.Web.HttpUtility.ParseQueryString(uriBuilder.Query);
|
||||
|
||||
query["emailid"] = (blastEmailID ?? 0).ToString();
|
||||
query["emailaddress"] = emailAddress ?? "";
|
||||
|
||||
uriBuilder.Query = query.ToString();
|
||||
url = uriBuilder.ToString();
|
||||
}
|
||||
|
||||
var unsubscribeRegex = new Regex("(?i)##unsubscribe_url##|##unsubscribeurl##", RegexOptions.IgnoreCase);
|
||||
bool templateHasUnsubscribeToken = unsubscribeRegex.IsMatch(template.HtmlBody);
|
||||
|
||||
// Always replace any unsubscribe tokens that exist
|
||||
html = unsubscribeRegex.Replace(html, url);
|
||||
|
||||
// If the original template didn't have unsubscribe tokens, auto-append
|
||||
if (!templateHasUnsubscribeToken && !string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
string unsubscribeFooter = $"<p style=\"text-align: center; font-size: 12px; color: #666;\"><a href=\"{url}\">Unsubscribe</a></p>";
|
||||
|
||||
// Smart insertion logic: before </body>, then </html>, then at end
|
||||
if (Regex.IsMatch(html, @"</body\s*>", RegexOptions.IgnoreCase))
|
||||
{
|
||||
html = Regex.Replace(html, @"</body\s*>", $"\n{unsubscribeFooter}\n</body>", RegexOptions.IgnoreCase);
|
||||
}
|
||||
else if (Regex.IsMatch(html, @"</html\s*>", RegexOptions.IgnoreCase))
|
||||
{
|
||||
html = Regex.Replace(html, @"</html\s*>", $"\n{unsubscribeFooter}\n</html>", RegexOptions.IgnoreCase);
|
||||
}
|
||||
else
|
||||
{
|
||||
html += "\n" + unsubscribeFooter;
|
||||
}
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
private async Task<bool> SendEmailLocal(EmailDomain domain, string toAddress, string toName, string htmlBody, string subject, string fromName, string fromAddress, string replyToAddress, List<string> categories, int? blastEmailID)
|
||||
@ -314,5 +353,66 @@ namespace Surge365.MassEmailReact.Infrastructure.Services
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<UnsubscribeValidationResult> ValidateUnsubscribeLinkAsync(MailingUpdateDto mailingDto)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(mailingDto, nameof(mailingDto));
|
||||
|
||||
// Get the template to check for unsubscribe URL in HTML
|
||||
var template = await _templateService.GetByIdAsync(mailingDto.TemplateId);
|
||||
if (template == null)
|
||||
{
|
||||
return new UnsubscribeValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
ValidationMessage = "Template not found",
|
||||
ValidationType = UnsubscribeValidationType.Valid
|
||||
};
|
||||
}
|
||||
|
||||
// Check for ##unsubscribe_url## token in the HTML body
|
||||
bool hasUnsubscribeUrlInTemplate = ContainsUnsubscribeUrl(template.HtmlBody);
|
||||
bool hasUnsubscribeList = !string.IsNullOrWhiteSpace(mailingDto.UnsubscribeListCode);
|
||||
|
||||
// Case 1: Has unsubscribe list but no URL in template
|
||||
if (hasUnsubscribeList && !hasUnsubscribeUrlInTemplate)
|
||||
{
|
||||
return new UnsubscribeValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
ValidationMessage = "An unsubscribe list is selected, but the ##unsubscribe_url## merge field was not found in the email template. The unsubscribe link will be automatically added to the bottom of the email when sent.",
|
||||
ValidationType = UnsubscribeValidationType.HasUnsubscribeListButNoUrlInTemplate
|
||||
};
|
||||
}
|
||||
|
||||
// Case 2: Has URL in template but no unsubscribe list
|
||||
if (!hasUnsubscribeList && hasUnsubscribeUrlInTemplate)
|
||||
{
|
||||
return new UnsubscribeValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
ValidationMessage = "The template contains an ##unsubscribe_url## merge field, but no unsubscribe list is selected. Please either select an unsubscribe list or remove the ##unsubscribe_url## merge field from the template.",
|
||||
ValidationType = UnsubscribeValidationType.HasUrlInTemplateButNoUnsubscribeList
|
||||
};
|
||||
}
|
||||
|
||||
// Case 3: Both match or neither (valid scenarios)
|
||||
return new UnsubscribeValidationResult
|
||||
{
|
||||
IsValid = true,
|
||||
ValidationMessage = "",
|
||||
ValidationType = UnsubscribeValidationType.Valid
|
||||
};
|
||||
}
|
||||
|
||||
private bool ContainsUnsubscribeUrl(string htmlBody)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(htmlBody))
|
||||
return false;
|
||||
|
||||
// Check for both ##unsubscribe_url## and ##unsubscribeurl## tokens (case insensitive)
|
||||
var regex = new Regex(@"##unsubscribe_url##|##unsubscribeurl##", RegexOptions.IgnoreCase);
|
||||
return regex.IsMatch(htmlBody);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Surge365.MassEmailReact.Application.DTOs;
|
||||
using Surge365.MassEmailReact.Application.Interfaces;
|
||||
using Surge365.MassEmailReact.Domain.Entities;
|
||||
using System;
|
||||
|
||||
@ -39,6 +39,7 @@ namespace Surge365.MassEmailReact.Infrastructure.Services
|
||||
{
|
||||
FriendlyName = unsubscribeListDto.FriendlyName,
|
||||
FriendlyDescription = unsubscribeListDto.FriendlyDescription,
|
||||
Url = unsubscribeListDto.Url,
|
||||
IsActive = unsubscribeListDto.IsActive,
|
||||
DisplayOrder = unsubscribeListDto.DisplayOrder
|
||||
};
|
||||
@ -57,6 +58,7 @@ namespace Surge365.MassEmailReact.Infrastructure.Services
|
||||
|
||||
unsubscribeList.FriendlyName = unsubscribeListDto.FriendlyName;
|
||||
unsubscribeList.FriendlyDescription = unsubscribeListDto.FriendlyDescription;
|
||||
unsubscribeList.Url = unsubscribeListDto.Url;
|
||||
unsubscribeList.IsActive = unsubscribeListDto.IsActive;
|
||||
unsubscribeList.DisplayOrder = unsubscribeListDto.DisplayOrder;
|
||||
|
||||
|
||||
@ -9,7 +9,8 @@
|
||||
|
||||
|
||||
|
||||
|
||||
# to publish the react project, in command prompt, go to the directory where you cloned the project, then type: npm run build
|
||||
# This will create a "dist" folder in the same directory. That folder gets deployed to the web server.
|
||||
|
||||
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
@ -24,6 +24,7 @@ import Mailing from "@/types/mailing";
|
||||
import TargetSample from "@/types/targetSample";
|
||||
import Target from "@/types/target";
|
||||
import UnsubscribeList from "@/types/unsubscribeList";
|
||||
import UnsubscribeValidationResult from "@/types/unsubscribeValidationResult";
|
||||
//import MailingTemplate from "@/types/mailingTemplate";
|
||||
//import MailingTarget from "@/types/mailingTarget";
|
||||
import EmailList from "@/components/forms/EmailList";
|
||||
@ -84,6 +85,10 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => {
|
||||
const [targetSample, setTargetSample] = useState<TargetSample | null>(null);
|
||||
const [targetSampleLoading, setTargetSampleLoading] = useState(false);
|
||||
const [availableUnsubscribeLists, setAvailableUnsubscribeLists] = useState<UnsubscribeList[]>([]);
|
||||
const [showUnsubscribeConfirmation, setShowUnsubscribeConfirmation] = useState<boolean>(false);
|
||||
const [pendingFormData, setPendingFormData] = useState<Mailing | null>(null);
|
||||
const [showUnsubscribeValidationDialog, setShowUnsubscribeValidationDialog] = useState<boolean>(false);
|
||||
const [unsubscribeValidationResult, setUnsubscribeValidationResult] = useState<UnsubscribeValidationResult | null>(null);
|
||||
|
||||
const defaultMailing: Mailing = {
|
||||
id: 0,
|
||||
@ -357,6 +362,56 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => {
|
||||
}, [open, mailing, reset, setupData.testEmailLists, setupData.targets, setupData.templates, setupData.unsubscribeLists]);
|
||||
|
||||
const handleSave = async (formData: Mailing) => {
|
||||
// Check if no unsubscribe list is selected and confirmation hasn't been shown
|
||||
if (!formData.unsubscribeListCode && availableUnsubscribeLists.length > 0) {
|
||||
setPendingFormData(formData);
|
||||
setShowUnsubscribeConfirmation(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// If this is an approved mailing, validate unsubscribe link before saving
|
||||
if (approved) {
|
||||
const validationResult = await validateUnsubscribeLink(formData);
|
||||
if (!validationResult.isValid) {
|
||||
if (validationResult.validationType === 'HasUrlInTemplateButNoUnsubscribeList') {
|
||||
// Block save - show error dialog
|
||||
setUnsubscribeValidationResult(validationResult);
|
||||
setShowUnsubscribeValidationDialog(true);
|
||||
return;
|
||||
} else if (validationResult.validationType === 'HasUnsubscribeListButNoUrlInTemplate') {
|
||||
// Show warning but allow continue
|
||||
setUnsubscribeValidationResult(validationResult);
|
||||
setPendingFormData(formData);
|
||||
setShowUnsubscribeValidationDialog(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await performSave(formData);
|
||||
};
|
||||
|
||||
const validateUnsubscribeLink = async (formData: Mailing): Promise<UnsubscribeValidationResult> => {
|
||||
try {
|
||||
const response = await customFetch("/api/mailings/validate-unsubscribe", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to validate unsubscribe link");
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error("Validation error:", error);
|
||||
// Return valid result on error to not block the save
|
||||
return { isValid: true, validationMessage: "", validationType: "Valid" };
|
||||
}
|
||||
};
|
||||
|
||||
const performSave = async (formData: Mailing) => {
|
||||
const apiUrl = isNew ? "/api/mailings" : `/api/mailings/${formData.id}`;
|
||||
const method = isNew ? "POST" : "PUT";
|
||||
setLoading(true);
|
||||
@ -397,6 +452,35 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnsubscribeConfirmationYes = () => {
|
||||
setShowUnsubscribeConfirmation(false);
|
||||
if (pendingFormData) {
|
||||
performSave(pendingFormData);
|
||||
setPendingFormData(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnsubscribeConfirmationNo = () => {
|
||||
setShowUnsubscribeConfirmation(false);
|
||||
setPendingFormData(null);
|
||||
};
|
||||
|
||||
const handleUnsubscribeValidationOk = () => {
|
||||
setShowUnsubscribeValidationDialog(false);
|
||||
if (unsubscribeValidationResult?.validationType === 'HasUnsubscribeListButNoUrlInTemplate' && pendingFormData) {
|
||||
// User confirmed they want to continue with auto-append
|
||||
performSave(pendingFormData);
|
||||
setPendingFormData(null);
|
||||
}
|
||||
setUnsubscribeValidationResult(null);
|
||||
};
|
||||
|
||||
const handleUnsubscribeValidationCancel = () => {
|
||||
setShowUnsubscribeValidationDialog(false);
|
||||
setPendingFormData(null);
|
||||
setUnsubscribeValidationResult(null);
|
||||
};
|
||||
|
||||
const handleEmailsChange = (newEmails: string[]) => {
|
||||
setEmails(newEmails);
|
||||
};
|
||||
@ -844,6 +928,58 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => {
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Unsubscribe List Confirmation Dialog */}
|
||||
<Dialog open={showUnsubscribeConfirmation} onClose={handleUnsubscribeConfirmationNo}>
|
||||
<DialogTitle>No Unsubscribe List Selected</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>
|
||||
You have not selected an unsubscribe list for this mailing.
|
||||
Are you sure you want to continue without an unsubscribe list?
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleUnsubscribeConfirmationNo} color="secondary">
|
||||
No, Go Back
|
||||
</Button>
|
||||
<Button onClick={handleUnsubscribeConfirmationYes} color="primary" autoFocus>
|
||||
Yes, Continue
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Unsubscribe Link Validation Dialog */}
|
||||
<Dialog open={showUnsubscribeValidationDialog} onClose={handleUnsubscribeValidationCancel}>
|
||||
<DialogTitle>
|
||||
{unsubscribeValidationResult?.validationType === 'HasUrlInTemplateButNoUnsubscribeList'
|
||||
? 'Unsubscribe Link Error'
|
||||
: 'Unsubscribe Link Warning'
|
||||
}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>
|
||||
{unsubscribeValidationResult?.validationMessage}
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
{unsubscribeValidationResult?.validationType === 'HasUrlInTemplateButNoUnsubscribeList' ? (
|
||||
// Error case - only allow going back
|
||||
<Button onClick={handleUnsubscribeValidationCancel} color="primary" autoFocus>
|
||||
OK
|
||||
</Button>
|
||||
) : (
|
||||
// Warning case - allow continue or go back
|
||||
<>
|
||||
<Button onClick={handleUnsubscribeValidationCancel} color="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleUnsubscribeValidationOk} color="primary" autoFocus>
|
||||
Continue
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</LocalizationProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@ -120,6 +120,39 @@ const TemplateEdit = ({ open, template, onClose, onSave }: TemplateEditProps) =>
|
||||
}
|
||||
};
|
||||
|
||||
// Check if unsubscribe URL already exists in the HTML (case insensitive)
|
||||
const hasUnsubscribeUrl = (html: string): boolean => {
|
||||
return /##unsubscribe_url##/i.test(html);
|
||||
};
|
||||
|
||||
// Smart insertion of unsubscribe link
|
||||
const insertUnsubscribeLink = () => {
|
||||
const currentHtml = watch("htmlBody");
|
||||
|
||||
// Check if unsubscribe URL already exists
|
||||
if (hasUnsubscribeUrl(currentHtml)) {
|
||||
return; // Don't add if it already exists
|
||||
}
|
||||
|
||||
const unsubscribeHtml = '<p style="text-align: center; font-size: 12px; color: #666;"><a href="##unsubscribe_url##">Unsubscribe</a></p>';
|
||||
let newHtml = currentHtml;
|
||||
|
||||
// Try to insert before closing </body> tag (case insensitive)
|
||||
if (/<\/body\s*>/i.test(newHtml)) {
|
||||
newHtml = newHtml.replace(/<\/body\s*>/i, `\n${unsubscribeHtml}\n</body>`);
|
||||
}
|
||||
// If no </body>, try to insert before closing </html> tag (case insensitive)
|
||||
else if (/<\/html\s*>/i.test(newHtml)) {
|
||||
newHtml = newHtml.replace(/<\/html\s*>/i, `\n${unsubscribeHtml}\n</html>`);
|
||||
}
|
||||
// If no closing tags, just append to the end
|
||||
else {
|
||||
newHtml = currentHtml + '\n' + unsubscribeHtml;
|
||||
}
|
||||
|
||||
setValue("htmlBody", newHtml, { shouldValidate: true });
|
||||
};
|
||||
|
||||
const renderEditorOrPreview = (height: string) => (
|
||||
<Box sx={{ flexGrow: 1, overflow: "hidden" }}>
|
||||
{isPreviewMode ? (
|
||||
@ -144,6 +177,9 @@ const TemplateEdit = ({ open, template, onClose, onSave }: TemplateEditProps) =>
|
||||
);
|
||||
|
||||
const filteredEmailDomains = setupData.emailDomains.filter((domain) => domain.isActive);
|
||||
const currentHtml = watch("htmlBody") || "";
|
||||
const unsubscribeExists = hasUnsubscribeUrl(currentHtml);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
@ -170,9 +206,20 @@ const TemplateEdit = ({ open, template, onClose, onSave }: TemplateEditProps) =>
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Button onClick={() => setIsExpanded(false)}>Collapse</Button>
|
||||
<Button onClick={() => setIsPreviewMode(!isPreviewMode)}>
|
||||
{isPreviewMode ? "Edit" : "Preview"}
|
||||
<Box>
|
||||
<Button onClick={() => setIsExpanded(false)}>Collapse</Button>
|
||||
<Button onClick={() => setIsPreviewMode(!isPreviewMode)}>
|
||||
{isPreviewMode ? "Edit" : "Preview"}
|
||||
</Button>
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={insertUnsubscribeLink}
|
||||
disabled={isPreviewMode || unsubscribeExists}
|
||||
title={unsubscribeExists ? "Unsubscribe link already exists in template" : "Add unsubscribe link to template"}
|
||||
>
|
||||
{unsubscribeExists ? "Unsubscribe Link Exists" : "Add Unsubscribe Link"}
|
||||
</Button>
|
||||
</Box>
|
||||
{renderEditorOrPreview("calc(100vh - 120px)")}
|
||||
@ -290,16 +337,26 @@ const TemplateEdit = ({ open, template, onClose, onSave }: TemplateEditProps) =>
|
||||
</Grid>
|
||||
</Grid>
|
||||
{/* Bottom Section: Single Column for HTML Control */}
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Grid item xs={12}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", my: 1 }}>
|
||||
<Button onClick={() => setIsExpanded(true)}>Expand Editor</Button>
|
||||
<Button onClick={() => setIsPreviewMode(!isPreviewMode)}>
|
||||
{isPreviewMode ? "Edit" : "Preview"}
|
||||
</Button>
|
||||
<Box>
|
||||
<Button onClick={() => setIsExpanded(true)}>Expand Editor</Button>
|
||||
<Button onClick={() => setIsPreviewMode(!isPreviewMode)}>
|
||||
{isPreviewMode ? "Edit" : "Preview"}
|
||||
</Button>
|
||||
</Box>
|
||||
<Paper variant="outlined" sx={{ p: 2 }}>
|
||||
{renderEditorOrPreview("300px")}
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={insertUnsubscribeLink}
|
||||
disabled={isPreviewMode || unsubscribeExists}
|
||||
title={unsubscribeExists ? "Unsubscribe link already exists in template" : "Add unsubscribe link to template"}
|
||||
>
|
||||
{unsubscribeExists ? "Unsubscribe Link Exists" : "Add Unsubscribe Link"}
|
||||
</Button>
|
||||
</Box>
|
||||
<Paper variant="outlined" sx={{ p: 2 }}>
|
||||
{renderEditorOrPreview("300px")}
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
@ -54,7 +54,13 @@ function CompletedMailings() {
|
||||
<>
|
||||
{params.value ? new Date(params.value).toLocaleString() : ''}
|
||||
</>
|
||||
)},
|
||||
),
|
||||
sortComparator: (v1, v2) => {
|
||||
if (!v1 && !v2) return 0;
|
||||
if (!v1) return 1;
|
||||
if (!v2) return -1;
|
||||
return new Date(v1).getTime() - new Date(v2).getTime();
|
||||
}},
|
||||
{ field: "emailCount", headerName: "Emails", width: 70 },
|
||||
{ field: "sendCount", headerName: "Active", width: 70 },
|
||||
{ field: "deliveredCount", headerName: "Delivered", width: 90 },
|
||||
|
||||
@ -68,7 +68,17 @@ function ScheduleMailings() {
|
||||
headerName: "Schedule Date",
|
||||
flex: 1,
|
||||
minWidth: 200,
|
||||
valueGetter: (_: any, row: Mailing) => row.scheduleDate ? new Date(row.scheduleDate).toLocaleString() : 'N/A'
|
||||
valueGetter: (_: any, row: Mailing) => row.scheduleDate ? new Date(row.scheduleDate).toLocaleString() : 'N/A',
|
||||
sortComparator: (v1: any, v2: any, cellParams1: any, cellParams2: any) => {
|
||||
const date1 = cellParams1.api.getRow(cellParams1.id)?.scheduleDate;
|
||||
const date2 = cellParams2.api.getRow(cellParams2.id)?.scheduleDate;
|
||||
|
||||
if (!date1 && !date2) return 0;
|
||||
if (!date1) return 1;
|
||||
if (!date2) return -1;
|
||||
|
||||
return new Date(date1).getTime() - new Date(date2).getTime();
|
||||
}
|
||||
},
|
||||
{
|
||||
field: "recurring",
|
||||
|
||||
@ -2,6 +2,7 @@ export interface UnsubscribeList {
|
||||
unsubscribeListCode: string;
|
||||
friendlyName: string;
|
||||
friendlyDescription: string | null;
|
||||
url: string | null;
|
||||
isActive: boolean;
|
||||
displayOrder: number;
|
||||
createDate: string;
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
export interface UnsubscribeValidationResult {
|
||||
isValid: boolean;
|
||||
validationMessage: string;
|
||||
validationType: 'Valid' | 'HasUnsubscribeListButNoUrlInTemplate' | 'HasUrlInTemplateButNoUnsubscribeList';
|
||||
}
|
||||
|
||||
export default UnsubscribeValidationResult;
|
||||
Loading…
x
Reference in New Issue
Block a user